diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts index b4e2364720..a27ac0c094 100644 --- a/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.d.ts @@ -62,6 +62,7 @@ type CoreCommandNames = | 'backspaceNextToRun' | 'backspaceAcrossRuns' | 'backspaceAtomBefore' + | 'selectInlineSdtBeforeRunStart' | 'deleteBlockSdtAtTextBlockStart' | 'deleteSkipEmptyRun' | 'deleteNextToRun' diff --git a/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts new file mode 100644 index 0000000000..1ec288f6eb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +describe('core command map types', () => { + it('lists inline SDT Backspace selection in CoreCommandNames', () => { + const declarationPath = join(dirname(fileURLToPath(import.meta.url)), 'core-command-map.d.ts'); + const declaration = readFileSync(declarationPath, 'utf8'); + + expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/commands/index.js b/packages/super-editor/src/editors/v1/core/commands/index.js index a286741b72..541c2c15af 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -52,6 +52,7 @@ export * from './backspaceSkipEmptyRun.js'; export * from './backspaceNextToRun.js'; export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; +export * from './selectInlineSdtBeforeRunStart.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; export * from './deleteSkipEmptyRun.js'; export * from './deleteNextToRun.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js new file mode 100644 index 0000000000..b92c100b65 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js @@ -0,0 +1,40 @@ +import { NodeSelection } from 'prosemirror-state'; + +export const SELECT_INLINE_SDT_BEFORE_RUN_START_META = 'selectInlineSdtBeforeRunStart'; + +function blocksWrapperDelete(node) { + return node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked'; +} + +/** + * Selects an inline SDT wrapper when Backspace is pressed at the start of the + * following run. Without this, run-aware Backspace scans into the SDT content. + * + * @returns {import('@core/commands/types').Command} + */ +export const selectInlineSdtBeforeRunStart = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + if ($from.parent.type.name !== 'run') return false; + if ($from.parentOffset !== 0) return false; + + const runStart = $from.before($from.depth); + const previousSibling = state.doc.resolve(runStart).nodeBefore; + if (previousSibling?.type.name !== 'structuredContent') return false; + + if (blocksWrapperDelete(previousSibling)) return true; + + if (dispatch) { + dispatch( + state.tr + .setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true) + .setSelection(NodeSelection.create(state.doc, runStart - previousSibling.nodeSize)), + ); + } + + return true; + }; diff --git a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js new file mode 100644 index 0000000000..7a4bbb7f4c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; +import { selectInlineSdtBeforeRunStart } from './selectInlineSdtBeforeRunStart.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + structuredContent: { + inline: true, + group: 'inline', + content: 'inline*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + run: { inline: true, group: 'inline', content: 'inline*' }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const makeDoc = (schema, lockMode = 'contentLocked') => { + const sdtRun = schema.nodes.run.create(null, schema.text('Locked content')); + const sdt = schema.nodes.structuredContent.create({ lockMode }, sdtRun); + const followingRun = schema.nodes.run.create(null, schema.text('Adding text')); + return schema.node('doc', null, [schema.node('paragraph', null, [sdt, followingRun])]); +}; + +const findNode = (doc, typeName, predicate = () => true) => { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name === typeName && predicate(node)) { + result = { node, pos, end: pos + node.nodeSize }; + return false; + } + return true; + }); + return result; +}; + +describe('selectInlineSdtBeforeRunStart', () => { + it.each(['unlocked', 'contentLocked'])('selects the %s inline SDT wrapper before the current run', (lockMode) => { + const schema = makeSchema(); + const doc = makeDoc(schema, lockMode); + const sdt = findNode(doc, 'structuredContent'); + const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, followingRun.pos + 1), + }); + + let dispatched; + const ok = selectInlineSdtBeforeRunStart()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(sdt.pos); + expect(dispatched.selection.to).toBe(sdt.end); + }); + + it.each(['sdtLocked', 'sdtContentLocked'])('consumes Backspace without selecting %s wrappers', (lockMode) => { + const schema = makeSchema(); + const doc = makeDoc(schema, lockMode); + const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, followingRun.pos + 1), + }); + const dispatch = vi.fn(); + + const ok = selectInlineSdtBeforeRunStart()({ state, dispatch }); + + expect(ok).toBe(true); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the cursor is not at the start of a run', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, followingRun.pos + 2), + }); + const dispatch = vi.fn(); + + const ok = selectInlineSdtBeforeRunStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the previous sibling is not an inline SDT', () => { + const schema = makeSchema(); + const doc = schema.node('doc', null, [ + schema.node('paragraph', null, [ + schema.nodes.run.create(null, schema.text('Before')), + schema.nodes.run.create(null, schema.text('Adding text')), + ]), + ]); + const followingRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Adding')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, followingRun.pos + 1), + }); + const dispatch = vi.fn(); + + const ok = selectInlineSdtBeforeRunStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js index cd9d480fcc..09034a6b9d 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap-backspace-chain.test.js @@ -37,6 +37,7 @@ describe('handleBackspace chain ordering', () => { const commands = { undoInputRule: make('undoInputRule'), deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), + selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'), backspaceAtomBefore: make('backspaceAtomBefore'), @@ -73,6 +74,7 @@ describe('handleBackspace chain ordering', () => { 'undoInputRule', // step 2 sets inputType meta and returns false (no command call) 'deleteBlockSdtAtTextBlockStart', + 'selectInlineSdtBeforeRunStart', 'backspaceEmptyRunParagraph', 'backspaceSkipEmptyRun', 'backspaceAtomBefore', @@ -102,6 +104,7 @@ describe('handleBackspace chain ordering', () => { // walk (undoInputRule at 0, meta-setter at 1, then SDT at 2). expect(callLog[0]).toBe('undoInputRule'); expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); + expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { @@ -109,10 +112,13 @@ describe('handleBackspace chain ordering', () => { handleBackspace(editor); const acrossRunsIndex = callLog.indexOf('backspaceAcrossRuns'); + const inlineSdtIndex = callLog.indexOf('selectInlineSdtBeforeRunStart'); const mixedIndex = callLog.indexOf('mixedBidiBackspace'); const deleteSelectionIndex = callLog.indexOf('deleteSelection'); + expect(inlineSdtIndex).toBeGreaterThanOrEqual(0); expect(acrossRunsIndex).toBeGreaterThanOrEqual(0); + expect(acrossRunsIndex).toBeGreaterThan(inlineSdtIndex); expect(mixedIndex).toBeGreaterThan(acrossRunsIndex); expect(deleteSelectionIndex).toBeGreaterThan(mixedIndex); }); diff --git a/packages/super-editor/src/editors/v1/core/extensions/keymap.js b/packages/super-editor/src/editors/v1/core/extensions/keymap.js index f094d10b7e..0eec4b8979 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -38,6 +38,7 @@ export const handleBackspace = (editor) => { return false; }, () => commands.deleteBlockSdtAtTextBlockStart(), + () => commands.selectInlineSdtBeforeRunStart(), () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), () => commands.backspaceAtomBefore(), diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js index 9bf4a08c6a..abe7d253b6 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.test.js @@ -3,6 +3,7 @@ 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 { handleBackspace } from '@core/extensions/keymap.js'; import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js'; /** @@ -504,6 +505,40 @@ describe('StructuredContentLockPlugin', () => { const finalState = afterSelectState.apply(deletionTr); expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false); }); + + it('contentLocked + Backspace at the start of the following run selects, then deletes the SDT wrapper', () => { + const sdtRun = schema.nodes.run.create(null, schema.text('Locked content')); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode: 'contentLocked' }, sdtRun); + const followingRun = schema.nodes.run.create(null, schema.text('Adding some additional text here.')); + const paragraph = schema.nodes.paragraph.create(null, [sdt, followingRun]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + let followingRunPos = null; + state.doc.descendants((node, pos) => { + if (node.type.name === 'run' && node.textContent.startsWith('Adding')) { + followingRunPos = pos; + return false; + } + return true; + }); + expect(followingRunPos).not.toBeNull(); + + const caretBeforeAdding = followingRunPos + 1; + placeCaretAt(state, caretBeforeAdding); + + handleBackspace(editor); + + const selection = editor.state.selection; + expect(selection).toBeInstanceOf(NodeSelection); + expect(selection.from).toBe(sdtInfo.pos); + expect(selection.to).toBe(sdtInfo.end); + + handleBackspace(editor); + + expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); + }); }); describe('Path 1 — selection covers SDT content (triple-click / first-click select-all)', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js index 9cc202ec46..886c804f5c 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.js @@ -1,6 +1,7 @@ import { Plugin, TextSelection } from 'prosemirror-state'; import { applyEditableSlotAtInlineBoundary } from '@helpers/ensure-editable-slot-inline-boundary.js'; +import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js'; const INLINE_LEAF_TEXT = '\ufffc'; @@ -93,6 +94,8 @@ export function createStructuredContentSelectPlugin(editor) { if (transactions.some((tr) => tr.docChanged)) return null; if (!selection.empty) { + if (transactions.some((tr) => tr.getMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META))) return null; + let selectedSdt = null; newState.doc.descendants((node, pos) => { if (node.type.name !== 'structuredContent') return true; diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js index daf8cf7321..334ea6068d 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-select-plugin.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { initTestEditor } from '@tests/helpers/helpers.js'; +import { SELECT_INLINE_SDT_BEFORE_RUN_START_META } from '@core/commands/selectInlineSdtBeforeRunStart.js'; function findNode(doc, nodeType) { let result = null; @@ -68,6 +69,28 @@ describe('StructuredContentSelectPlugin', () => { expect(editor.state.selection.to).toBe(contentTo); }); + it('keeps Backspace-created inline SDT node selections when the meta escape is set', () => { + const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); + const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]); + applyDoc(schema.nodes.doc.create(null, [paragraph])); + + const sdt = findNode(editor.state.doc, 'structuredContent'); + expect(sdt).not.toBeNull(); + + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt))); + + editor.view.dispatch( + editor.state.tr + .setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true) + .setSelection(NodeSelection.create(editor.state.doc, sdt.pos)), + ); + + expect(editor.state.selection).toBeInstanceOf(NodeSelection); + expect(editor.state.selection.from).toBe(sdt.pos); + expect(editor.state.selection.to).toBe(sdt.pos + sdt.node.nodeSize); + }); + it('does not auto-select inline SDT content in viewing mode', () => { const inlineSdt = schema.nodes.structuredContent.create({ id: 'inline-1' }, schema.text('Field')); const paragraph = schema.nodes.paragraph.create(null, [schema.text('A '), inlineSdt, schema.text(' Z')]);