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 b00fc248f1..b4e2364720 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' + | 'deleteBlockSdtAtTextBlockStart' | 'deleteSkipEmptyRun' | 'deleteNextToRun' | 'deleteAtomAfter' diff --git a/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js new file mode 100644 index 0000000000..0776e15c0f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js @@ -0,0 +1,51 @@ +import { Selection } from 'prosemirror-state'; + +function isSdtContentFullyLocked(node) { + return node.attrs.lockMode === 'sdtContentLocked'; +} + +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return null; +} + +/** + * Deletes the block SDT wrapper from the start of its first paragraph. + * + * @returns {import('@core/commands/types').Command} + */ +export const deleteBlockSdtAtTextBlockStart = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const { $from } = selection; + const sdtDepth = findAncestorDepth($from, (node) => node.type.name === 'structuredContentBlock'); + if (sdtDepth == null) return false; + + const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); + if (textblockDepth !== sdtDepth + 1) return false; + if ($from.node(textblockDepth).type.name !== 'paragraph') return false; + if ($from.pos !== $from.start(textblockDepth)) return false; + if ($from.before(textblockDepth) !== $from.start(sdtDepth)) return false; + + const sdtNode = $from.node(sdtDepth); + const lockMode = sdtNode.attrs.lockMode; + // Wrapper deletion is blocked for sdtLocked / sdtContentLocked (see createStructuredContentLockPlugin). + // For sdtLocked, content edits must still work — returning true here consumed Delete without + // dispatching, so the first character of the first paragraph was undeletable at this caret. + if (lockMode === 'sdtLocked') return false; + if (isSdtContentFullyLocked(sdtNode)) return true; + + if (dispatch) { + const from = $from.before(sdtDepth); + const tr = state.tr.delete(from, from + sdtNode.nodeSize); + const selectionPos = Math.min(from, tr.doc.content.size); + dispatch(tr.setSelection(Selection.near(tr.doc.resolve(selectionPos), -1)).scrollIntoView()); + } + + return true; + }; diff --git a/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js new file mode 100644 index 0000000000..25efae50eb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js @@ -0,0 +1,153 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { deleteBlockSdtAtTextBlockStart } from './deleteBlockSdtAtTextBlockStart.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + structuredContentBlock: { + group: 'block', + content: 'block*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const makeDoc = (schema, lockMode = 'unlocked', sdtContent = [schema.node('paragraph', null, schema.text('Inner'))]) => + schema.node('doc', null, [ + schema.node('paragraph', null, schema.text('Before')), + schema.node('structuredContentBlock', { lockMode }, sdtContent), + schema.node('paragraph', null, schema.text('After')), + ]); + +const findBlockSdt = (doc) => { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name === 'structuredContentBlock') { + result = { node, pos, end: pos + node.nodeSize }; + return false; + } + return true; + }); + return result; +}; + +const paragraphStartInSdt = (doc, index = 0) => { + const sdt = findBlockSdt(doc); + expect(sdt).not.toBeNull(); + + let seen = 0; + let start = null; + sdt.node.descendants((node, offset) => { + if (node.type.name !== 'paragraph') return true; + if (seen === index) { + start = sdt.pos + 1 + offset + 1; + return false; + } + seen += 1; + return true; + }); + + expect(start).not.toBeNull(); + return start; +}; + +describe('deleteBlockSdtAtTextBlockStart', () => { + it('deletes an unlocked block SDT when the caret is at the start of its first paragraph', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) }); + + let dispatched; + const ok = deleteBlockSdtAtTextBlockStart()({ + state, + dispatch: (tr) => { + dispatched = tr; + }, + }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(findBlockSdt(dispatched.doc)).toBeNull(); + expect(dispatched.doc.textContent).toBe('BeforeAfter'); + }); + + it('returns false for sdtLocked so Delete can fall through for in-SDT content edits', () => { + const schema = makeSchema(); + const doc = makeDoc(schema, 'sdtLocked'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) }); + const dispatch = vi.fn(); + + const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('consumes sdtContentLocked block SDT wrapper delete without dispatching', () => { + const schema = makeSchema(); + const doc = makeDoc(schema, 'sdtContentLocked'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, paragraphStartInSdt(doc)) }); + const dispatch = vi.fn(); + + const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(true); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the caret is not at the paragraph start', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, paragraphStartInSdt(doc) + 1), + }); + const dispatch = vi.fn(); + + const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false from later paragraphs inside the same block SDT', () => { + const schema = makeSchema(); + const doc = makeDoc(schema, 'unlocked', [ + schema.node('paragraph', null, schema.text('First')), + schema.node('paragraph', null, schema.text('Second')), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, paragraphStartInSdt(doc, 1)), + }); + const dispatch = vi.fn(); + + const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false outside a block SDT', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) }); + const dispatch = vi.fn(); + + const ok = deleteBlockSdtAtTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); 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 1e590c8c06..db86551d31 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -51,6 +51,7 @@ export * from './backspaceSkipEmptyRun.js'; export * from './backspaceNextToRun.js'; export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; +export * from './deleteBlockSdtAtTextBlockStart.js'; export * from './deleteSkipEmptyRun.js'; export * from './deleteNextToRun.js'; export * from './deleteAtomAfter.js'; 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 d30fd62e62..3cd3c93eb1 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -37,6 +37,7 @@ export const handleBackspace = (editor) => { tr.setMeta('inputType', 'deleteContentBackward'); return false; }, + () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), () => commands.backspaceAtomBefore(), @@ -55,6 +56,7 @@ export const handleDelete = (editor) => { dispatchHistoryBoundary(view); return editor.commands.first(({ commands }) => [ + () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.deleteSkipEmptyRun(), () => commands.deleteAtomAfter(), () => commands.deleteNextToRun(),