Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Plugin, PluginKey } from 'prosemirror-state';
import { NodeSelection, Plugin, PluginKey } from 'prosemirror-state';
import { ySyncPluginKey } from 'y-prosemirror';

export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock');
Expand Down Expand Up @@ -106,6 +106,27 @@ export function createStructuredContentLockPlugin() {
return false;
}

// Path 1 — non-collapsed selection that exactly covers the editable
// content of an SDT (e.g., the select-plugin's first-click select-all,
// a triple-click that lands on the content range, or precise keyboard
// selection). For wrapper-deletable lock modes, promote to a
// NodeSelection on the wrapper so the user sees the whole field
// highlighted and the next destructive press deletes it (matches
// Word's "click to select, key to delete").
if (from !== to && !(selection instanceof NodeSelection)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repro: open any unlocked inline field, triple-click to highlight the text inside, press Cmd+X. clipboard stays unchanged, the field's text is still there, and the highlight jumps to the whole field. paste anywhere to confirm "controlled text" is not on the clipboard. only Backspace and Delete should go through this branch:

Suggested change
if (from !== to && !(selection instanceof NodeSelection)) {
if ((isBackspace || isDelete) && from !== to && !(selection instanceof NodeSelection)) {

const exactContentSDT = sdtNodes.find((s) => from === s.pos + 1 && to === s.end - 1);
if (exactContentSDT) {
const isSdtLocked =
exactContentSDT.lockMode === 'sdtLocked' || exactContentSDT.lockMode === 'sdtContentLocked';
if (!isSdtLocked) {
const tr = state.tr.setSelection(NodeSelection.create(state.doc, exactContentSDT.pos));
view.dispatch(tr);
event.preventDefault();
return true;
}
}
}

// Calculate the range that would be affected
let affectedFrom = from;
let affectedTo = to;
Expand All @@ -117,8 +138,25 @@ export function createStructuredContentLockPlugin() {
if (from === to) {
if (isBackspace && from > 0) {
affectedFrom = from - 1;
// Path 2 — caret is exactly at the trailing wrapper boundary of an
// SDT. Backspace here is a wrapper-touching action (PM's keymap
// chains through to selectNodeBackward, which produces a
// NodeSelection on the wrapper). Expand the affected range so
// contentLocked alone — which only locks content edits — doesn't
// mistake this for an in-content edit and block it.
const adjacentSDT = sdtNodes.find((s) => s.end === from);
if (adjacentSDT) {
affectedFrom = adjacentSDT.pos;
affectedTo = adjacentSDT.end;
}
} else if (isDelete && to < state.doc.content.size) {
affectedTo = to + 1;
// Symmetric: caret immediately before an SDT.
const adjacentSDT = sdtNodes.find((s) => s.pos === to);
if (adjacentSDT) {
affectedFrom = adjacentSDT.pos;
affectedTo = adjacentSDT.end;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { EditorState, TextSelection } from 'prosemirror-state';
import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state';
import { Slice } from 'prosemirror-model';
import { ySyncPluginKey } from 'y-prosemirror';
import { initTestEditor } from '@tests/helpers/helpers.js';
import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js';

/**
* Test suite for StructuredContentLockPlugin
Expand Down Expand Up @@ -412,6 +413,177 @@ describe('StructuredContentLockPlugin', () => {
});
});

describe('Word-style deletion via keyboard (SD-2678)', () => {
// Drive the lock plugin's handleKeyDown directly so tests check the
// plugin's own decision (block vs let through) without other plugins
// (e.g. the keymap plugin) running real Backspace commands and mutating
// the document mid-test.
function invokeLockHandleKeyDown(key) {
const view = editor.view;
const lockPlugin = view.state.plugins.find((p) => p.spec.key === STRUCTURED_CONTENT_LOCK_KEY);
let prevented = false;
const event = {
key,
metaKey: false,
ctrlKey: false,
preventDefault() {
prevented = true;
},
};
const handled = lockPlugin?.props?.handleKeyDown?.(view, event) === true;
return { handled, prevented };
}

// Build a fresh state with the desired selection without going through
// applyTransaction — this bypasses other plugins' appendTransaction (e.g.
// the select-plugin's first-click select-all and ZWSP-slot adjustments)
// so each test can pin the exact selection it wants to exercise. Use
// editor.setState so both editor._state and view.state stay in sync —
// editor._state is what subsequent dispatchTransaction calls read.
function setSelection(state, selection) {
const newState = EditorState.create({
schema,
doc: state.doc,
selection,
plugins: state.plugins,
});
editor.setState(newState);
return newState;
}

function placeCaretAt(state, pos) {
return setSelection(state, TextSelection.create(state.doc, pos));
}

describe('Path 2 — caret immediately adjacent to inline SDT', () => {
const adjacencyCases = [
// [lockMode, key, shouldConsume, description]
['unlocked', 'Backspace', false, 'unlocked + Backspace at trailing boundary: lets PM run (selectNodeBackward)'],
['contentLocked', 'Backspace', false, 'contentLocked + Backspace at trailing boundary: lets PM run'],
['sdtLocked', 'Backspace', true, 'sdtLocked + Backspace at trailing boundary: blocked'],
['sdtContentLocked', 'Backspace', true, 'sdtContentLocked + Backspace at trailing boundary: blocked'],
['unlocked', 'Delete', false, 'unlocked + Delete at leading boundary: lets PM run (selectNodeForward)'],
['contentLocked', 'Delete', false, 'contentLocked + Delete at leading boundary: lets PM run'],
['sdtLocked', 'Delete', true, 'sdtLocked + Delete at leading boundary: blocked'],
['sdtContentLocked', 'Delete', true, 'sdtContentLocked + Delete at leading boundary: blocked'],
];

it.each(adjacencyCases)('%s + %s', (lockMode, key, shouldConsume) => {
const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent');
const state = applyDocToEditor(doc);
const sdtInfo = findSDTNode(state.doc, 'structuredContent');
const caretPos = key === 'Backspace' ? sdtInfo.end : sdtInfo.pos;

placeCaretAt(state, caretPos);

const { handled, prevented } = invokeLockHandleKeyDown(key);

expect(handled).toBe(shouldConsume);
expect(prevented).toBe(shouldConsume);
});

it('contentLocked + Backspace then Backspace deletes the SDT (two-stage Word UX)', () => {
const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContent');
const initialState = applyDocToEditor(doc);
const sdtInfo = findSDTNode(initialState.doc, 'structuredContent');

// Stage 1: caret at trailing boundary, Backspace lets PM run.
placeCaretAt(initialState, sdtInfo.end);
const stage1 = invokeLockHandleKeyDown('Backspace');
expect(stage1.handled).toBe(false);

// Simulate PM's selectNodeBackward outcome (it's what the keymap
// chain produces for an isolating inline node before the caret).
const afterSelectState = setSelection(editor.state, NodeSelection.create(editor.state.doc, sdtInfo.pos));

// Stage 2: NodeSelection on the wrapper, Backspace deletes it.
const stage2 = invokeLockHandleKeyDown('Backspace');
expect(stage2.handled).toBe(false);

const deletionTr = afterSelectState.tr.delete(sdtInfo.pos, sdtInfo.end);
const finalState = afterSelectState.apply(deletionTr);
expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false);
});
});

describe('Path 1 — selection covers SDT content (triple-click / first-click select-all)', () => {
const selectAllCases = [
// [lockMode, shouldPromote, description]
['unlocked', true, 'unlocked: promotes content selection to NodeSelection on wrapper'],
['contentLocked', true, 'contentLocked: promotes content selection to NodeSelection on wrapper'],
['sdtLocked', false, 'sdtLocked: leaves selection alone (content edit allowed by sdtLocked semantics)'],
['sdtContentLocked', false, 'sdtContentLocked: leaves selection alone (and original block path applies)'],
];

it.each(selectAllCases)('%s — Backspace on (contentFrom, contentTo)', (lockMode, shouldPromote) => {
const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent');
const state = applyDocToEditor(doc);
const sdtInfo = findSDTNode(state.doc, 'structuredContent');

const contentFrom = sdtInfo.pos + 1;
const contentTo = sdtInfo.end - 1;
setSelection(state, TextSelection.create(state.doc, contentFrom, contentTo));

const { handled, prevented } = invokeLockHandleKeyDown('Backspace');

if (shouldPromote) {
expect(handled).toBe(true);
expect(prevented).toBe(true);
// The plugin promoted to a NodeSelection covering the wrapper.
const sel = editor.state.selection;
expect(sel).toBeInstanceOf(NodeSelection);
expect(sel.from).toBe(sdtInfo.pos);
expect(sel.to).toBe(sdtInfo.end);
} else {
// No promotion: selection unchanged.
const sel = editor.state.selection;
expect(sel).not.toBeInstanceOf(NodeSelection);
expect(sel.from).toBe(contentFrom);
expect(sel.to).toBe(contentTo);
}
});

it('contentLocked: select-all then Backspace twice deletes the wrapper', () => {
const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContent');
const state = applyDocToEditor(doc);
const sdtInfo = findSDTNode(state.doc, 'structuredContent');

// Stage 1: select-all-content + Backspace promotes to NodeSelection.
setSelection(state, TextSelection.create(state.doc, sdtInfo.pos + 1, sdtInfo.end - 1));
expect(invokeLockHandleKeyDown('Backspace').handled).toBe(true);
expect(editor.state.selection).toBeInstanceOf(NodeSelection);

// Stage 2: NodeSelection + Backspace lets PM delete the wrapper.
expect(invokeLockHandleKeyDown('Backspace').handled).toBe(false);

// Apply the corresponding delete (what PM's deleteSelection would do).
const tr = editor.state.tr.delete(sdtInfo.pos, sdtInfo.end);
const finalState = editor.state.apply(tr);
expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false);
});

it('sdtLocked: select-all + Backspace still allows content deletion (no promotion)', () => {
const doc = createDocWithSDTAndSurroundingText('sdtLocked', 'structuredContent');
const state = applyDocToEditor(doc);
const sdtInfo = findSDTNode(state.doc, 'structuredContent');
const originalContent = state.doc.textContent;

const contentFrom = sdtInfo.pos + 1;
const contentTo = sdtInfo.end - 1;
setSelection(state, TextSelection.create(state.doc, contentFrom, contentTo));

// Plugin does not promote and does not block — content edit is allowed.
expect(invokeLockHandleKeyDown('Backspace').handled).toBe(false);

// The corresponding deletion goes through filterTransaction unchanged.
const tr = editor.state.tr.delete(contentFrom, contentTo);
const finalState = editor.state.apply(tr);
expect(finalState.doc.textContent).not.toBe(originalContent);
expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(true);
});
});
});

describe('lock mode attribute validation', () => {
it('treats missing lockMode as unlocked', () => {
// Arrange: create SDT without explicit lockMode (defaults to unlocked)
Expand Down
Loading