From fec665d0557b98c756ee48b63641b022f649ceaf Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 12 May 2026 16:22:15 +0300 Subject: [PATCH 1/2] fix: remove block sdt on delete press when cursor is on the first position --- .../v1/core/commands/core-command-map.d.ts | 1 + .../deleteBlockSdtAtTextBlockStart.js | 46 ++++++ .../deleteBlockSdtAtTextBlockStart.test.js | 141 ++++++++++++++++++ .../src/editors/v1/core/commands/index.js | 1 + .../src/editors/v1/core/extensions/keymap.js | 2 + 5 files changed, 191 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js 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..e4bfcb405f --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js @@ -0,0 +1,46 @@ +import { Selection } from 'prosemirror-state'; + +function isSdtWrapperLocked(node) { + return node.attrs.lockMode === 'sdtLocked' || 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); + if (isSdtWrapperLocked(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..73445ecb70 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js @@ -0,0 +1,141 @@ +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.each(['sdtLocked', 'sdtContentLocked'])('consumes %s block SDT deletion without dispatching', (lockMode) => { + const schema = makeSchema(); + const doc = makeDoc(schema, lockMode); + 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(), From faf150bf72dac29b72618de456aa064084498a58 Mon Sep 17 00:00:00 2001 From: VladaHarbour Date: Tue, 12 May 2026 18:48:36 +0300 Subject: [PATCH 2/2] fix: return false from command for sdtLocked --- .../commands/deleteBlockSdtAtTextBlockStart.js | 11 ++++++++--- .../deleteBlockSdtAtTextBlockStart.test.js | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js index e4bfcb405f..0776e15c0f 100644 --- a/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.js @@ -1,7 +1,7 @@ import { Selection } from 'prosemirror-state'; -function isSdtWrapperLocked(node) { - return node.attrs.lockMode === 'sdtLocked' || node.attrs.lockMode === 'sdtContentLocked'; +function isSdtContentFullyLocked(node) { + return node.attrs.lockMode === 'sdtContentLocked'; } function findAncestorDepth($pos, predicate) { @@ -33,7 +33,12 @@ export const deleteBlockSdtAtTextBlockStart = if ($from.before(textblockDepth) !== $from.start(sdtDepth)) return false; const sdtNode = $from.node(sdtDepth); - if (isSdtWrapperLocked(sdtNode)) return true; + 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); 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 index 73445ecb70..25efae50eb 100644 --- a/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/deleteBlockSdtAtTextBlockStart.test.js @@ -80,9 +80,21 @@ describe('deleteBlockSdtAtTextBlockStart', () => { expect(dispatched.doc.textContent).toBe('BeforeAfter'); }); - it.each(['sdtLocked', 'sdtContentLocked'])('consumes %s block SDT deletion without dispatching', (lockMode) => { + it('returns false for sdtLocked so Delete can fall through for in-SDT content edits', () => { const schema = makeSchema(); - const doc = makeDoc(schema, lockMode); + 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();