From a35c636b88af64dc864c70264da9d46fcd25e284 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:18:58 -0300 Subject: [PATCH 1/7] feat(super-editor): select adjacent block SDT content at textblock boundaries Add selectBlockSdtBeforeTextBlockStart / selectBlockSdtAfterTextBlockEnd commands and wire them into the Backspace/Delete keymaps. When the caret sits at the start of the textblock following a block SDT (Backspace) or the end of the one preceding it (Delete), the first keypress selects the SDT's content rather than deleting across the boundary, giving the user an explicit confirm-then-delete step. Teach the lock plugin's exact-content-selection check to recognize this content span via the first/last content cursor positions (not just pos+1/end-1), so the follow-up keypress deletes the wrapper according to the SDT's lock mode. Skipping zero-width markers keeps boundary detection robust. --- .../v1/core/commands/core-command-map.d.ts | 2 + .../v1/core/commands/core-command-map.test.ts | 2 + .../src/editors/v1/core/commands/index.js | 1 + .../selectBlockSdtAtTextBlockBoundary.js | 88 +++++++++++++ .../selectBlockSdtAtTextBlockBoundary.test.js | 124 ++++++++++++++++++ .../src/editors/v1/core/extensions/keymap.js | 2 + .../structured-content-lock-plugin.js | 17 ++- .../structured-content-lock-plugin.test.js | 56 ++++++++ 8 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.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 1a2e13e361..1f2bedffa0 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 @@ -64,6 +64,8 @@ type CoreCommandNames = | 'backspaceAtomBefore' | 'selectInlineSdtBeforeRunStart' | 'selectInlineSdtAfterRunEnd' + | 'selectBlockSdtBeforeTextBlockStart' + | 'selectBlockSdtAfterTextBlockEnd' | 'deleteBlockSdtAtTextBlockStart' | 'moveIntoBlockSdtBeforeTextBlockStart' | 'moveIntoBlockSdtAfterTextBlockEnd' 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 index 29aeb75e6a..e621cfd2ea 100644 --- 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 @@ -11,6 +11,8 @@ describe('core command map types', () => { expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'"); + expect(declaration).toContain("| 'selectBlockSdtBeforeTextBlockStart'"); + expect(declaration).toContain("| 'selectBlockSdtAfterTextBlockEnd'"); expect(declaration).toContain("| 'moveIntoBlockSdtBeforeTextBlockStart'"); expect(declaration).toContain("| 'moveIntoBlockSdtAfterTextBlockEnd'"); }); 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 82ee55614d..8e8a325d8d 100644 --- a/packages/super-editor/src/editors/v1/core/commands/index.js +++ b/packages/super-editor/src/editors/v1/core/commands/index.js @@ -53,6 +53,7 @@ export * from './backspaceNextToRun.js'; export * from './backspaceAcrossRuns.js'; export * from './backspaceAtomBefore.js'; export * from './selectInlineSdtBeforeRunStart.js'; +export * from './selectBlockSdtAtTextBlockBoundary.js'; export * from './deleteBlockSdtAtTextBlockStart.js'; export * from './moveIntoBlockSdtBeforeTextBlockStart.js'; export * from './moveIntoBlockSdtAfterTextBlockEnd.js'; diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js new file mode 100644 index 0000000000..d71f1b5b39 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js @@ -0,0 +1,88 @@ +import { TextSelection } from 'prosemirror-state'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, + isZeroWidthMarker, +} from './helpers/textPositions.js'; + +function findAncestorDepth($pos, predicate) { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if (predicate($pos.node(depth))) return depth; + } + return null; +} + +function findSiblingAcrossHiddenMarkers(doc, pos, direction) { + let currentPos = pos; + let node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; + + while (node && isZeroWidthMarker(node)) { + currentPos += direction === 'before' ? -node.nodeSize : node.nodeSize; + node = direction === 'before' ? doc.resolve(currentPos).nodeBefore : doc.resolve(currentPos).nodeAfter; + } + + return { + node, + nodePos: direction === 'before' && node ? currentPos - node.nodeSize : currentPos, + }; +} + +function isAtTextBlockBoundary($from, direction) { + const textblockDepth = findAncestorDepth($from, (node) => node.isTextblock); + if (textblockDepth == null) return null; + + const textblock = $from.node(textblockDepth); + const textblockPos = $from.before(textblockDepth); + const boundary = + direction === 'before' + ? (findFirstContentCursorPosInNode(textblock, textblockPos) ?? $from.start(textblockDepth)) + : (findLastContentCursorPosInNode(textblock, textblockPos) ?? $from.end(textblockDepth)); + if ($from.pos !== boundary) return null; + + return { + textblockDepth, + textblockPos, + }; +} + +function selectAdjacentBlockSdtContent(direction) { + return ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; + + const boundary = isAtTextBlockBoundary(selection.$from, direction); + if (!boundary) return false; + + const siblingBoundaryPos = + direction === 'before' ? boundary.textblockPos : selection.$from.after(boundary.textblockDepth); + const { node, nodePos } = findSiblingAcrossHiddenMarkers(state.doc, siblingBoundaryPos, direction); + if (node?.type.name !== 'structuredContentBlock') return false; + if (node.content.size === 0) return false; + + const contentStart = findFirstContentCursorPosInNode(node, nodePos); + const contentEnd = findLastContentCursorPosInNode(node, nodePos); + if (contentStart == null || contentEnd == null) return false; + + if (dispatch) { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, contentStart, contentEnd)).scrollIntoView()); + } + + return true; + }; +} + +/** + * Selects previous block SDT content when Backspace is pressed at the start of + * the following textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const selectBlockSdtBeforeTextBlockStart = () => selectAdjacentBlockSdtContent('before'); + +/** + * Selects next block SDT content when Delete is pressed at the end of the + * preceding textblock. + * + * @returns {import('@core/commands/types').Command} + */ +export const selectBlockSdtAfterTextBlockEnd = () => selectAdjacentBlockSdtContent('after'); diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js new file mode 100644 index 0000000000..7d5898157c --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Schema } from 'prosemirror-model'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { + selectBlockSdtAfterTextBlockEnd, + selectBlockSdtBeforeTextBlockStart, +} from './selectBlockSdtAtTextBlockBoundary.js'; +import { findFirstContentCursorPosInNode, findLastContentCursorPosInNode } from './helpers/textPositions.js'; + +const makeSchema = () => + new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { group: 'block', content: 'inline*' }, + run: { inline: true, group: 'inline', content: 'inline*' }, + structuredContentBlock: { + group: 'block', + content: 'block*', + isolating: true, + attrs: { + lockMode: { default: 'unlocked' }, + }, + }, + image: { inline: true, group: 'inline', atom: true }, + text: { group: 'inline' }, + }, + marks: {}, + }); + +const run = (schema, text) => schema.nodes.run.create(null, schema.text(text)); +const paragraph = (schema, text) => schema.nodes.paragraph.create(null, run(schema, text)); + +const findTextPos = (doc, text, offset = 0) => { + let found = null; + doc.descendants((node, pos) => { + if (!node.isText || found != null) return found == null; + const index = node.text.indexOf(text); + if (index === -1) return true; + found = pos + index + offset; + return false; + }); + expect(found).not.toBeNull(); + return found; +}; + +const findBlockSdt = (doc) => { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name !== 'structuredContentBlock') return true; + result = { node, pos, end: pos + node.nodeSize }; + return false; + }); + expect(result).not.toBeNull(); + return result; +}; + +const makeDoc = (schema, lockMode = 'contentLocked') => { + const imageRun = schema.nodes.run.create(null, schema.nodes.image.create()); + const sdt = schema.nodes.structuredContentBlock.create({ lockMode }, [ + paragraph(schema, 'Inner text'), + schema.nodes.paragraph.create(null, imageRun), + ]); + return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); +}; + +describe('selectBlockSdtBeforeTextBlockStart', () => { + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + 'selects the previous %s block SDT content from the following textblock start', + (lockMode) => { + const schema = makeSchema(); + const doc = makeDoc(schema, lockMode); + const sdt = findBlockSdt(doc); + const afterStart = findTextPos(doc, 'After'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + + let dispatched; + const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos)); + expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos)); + }, + ); + + it('returns false away from the following textblock start', () => { + const schema = makeSchema(); + const doc = makeDoc(schema); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, findTextPos(doc, 'After', 1)), + }); + const dispatch = vi.fn(); + + const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); + +describe('selectBlockSdtAfterTextBlockEnd', () => { + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + 'selects the next %s block SDT content from the preceding textblock end', + (lockMode) => { + const schema = makeSchema(); + const doc = makeDoc(schema, lockMode); + const sdt = findBlockSdt(doc); + const beforeEnd = findTextPos(doc, 'Before', 'Before'.length); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + + let dispatched; + const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(sdt.node, sdt.pos)); + expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos)); + }, + ); +}); 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 20422b5383..2867757f1a 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -39,6 +39,7 @@ export const handleBackspace = (editor) => { }, () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtBeforeRunStart(), + () => commands.selectBlockSdtBeforeTextBlockStart(), () => commands.moveIntoBlockSdtBeforeTextBlockStart(), () => commands.backspaceEmptyRunParagraph(), () => commands.backspaceSkipEmptyRun(), @@ -61,6 +62,7 @@ export const handleDelete = (editor) => { return editor.commands.first(({ commands }) => [ () => commands.deleteBlockSdtAtTextBlockStart(), () => commands.selectInlineSdtAfterRunEnd(), + () => commands.selectBlockSdtAfterTextBlockEnd(), () => commands.moveIntoBlockSdtAfterTextBlockEnd(), () => commands.deleteSkipEmptyRun(), () => commands.deleteAtomAfter(), diff --git a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js index b999f0ddc4..5f7d265ed3 100644 --- a/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js +++ b/packages/super-editor/src/editors/v1/extensions/structured-content/structured-content-lock-plugin.js @@ -1,5 +1,9 @@ import { NodeSelection, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { ySyncPluginKey } from 'y-prosemirror'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, +} from '@core/commands/helpers/textPositions.js'; import { BLOCK_NODE_METADATA_UPDATE_META } from '../block-node/block-node.js'; export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); @@ -27,6 +31,7 @@ function collectSDTNodes(doc) { if (node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') { sdtNodes.push({ type: node.type.name, + node, lockMode: node.attrs.lockMode, pos, end: pos + node.nodeSize, @@ -91,6 +96,16 @@ function isAtBlockSdtWrapperDeletePosition(state, sdt, pos) { return $pos.before(textblockDepth) === $pos.start(sdtDepth); } +function selectionCoversSdtContent(sdt, from, to) { + if (from === sdt.pos + 1 && to === sdt.end - 1) return true; + + if (sdt.type !== 'structuredContentBlock') return false; + + const contentStart = findFirstContentCursorPosInNode(sdt.node, sdt.pos); + const contentEnd = findLastContentCursorPosInNode(sdt.node, sdt.pos); + return contentStart != null && contentEnd != null && from === contentStart && to === contentEnd; +} + export function createStructuredContentLockPlugin() { return new Plugin({ key: STRUCTURED_CONTENT_LOCK_KEY, @@ -138,7 +153,7 @@ export function createStructuredContentLockPlugin() { // modes, let the normal command chain delete the selected content while // preserving the SDT wrapper. if (from !== to && !(selection instanceof NodeSelection)) { - const exactContentSDT = sdtNodes.find((s) => from === s.pos + 1 && to === s.end - 1); + const exactContentSDT = sdtNodes.find((s) => selectionCoversSdtContent(s, from, to)); if (exactContentSDT) { const isContentLocked = exactContentSDT.lockMode === 'contentLocked' || exactContentSDT.lockMode === 'sdtContentLocked'; 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 f2c9b31974..c1d0c2830b 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 @@ -4,6 +4,10 @@ import { Slice } from 'prosemirror-model'; import { ySyncPluginKey } from 'y-prosemirror'; import { initTestEditor } from '@tests/helpers/helpers.js'; import { handleBackspace, handleDelete } from '@core/extensions/keymap.js'; +import { + findFirstContentCursorPosInNode, + findLastContentCursorPosInNode, +} from '@core/commands/helpers/textPositions.js'; import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js'; /** @@ -33,6 +37,18 @@ function sdtNodeExists(doc, nodeType = 'structuredContent') { return findSDTNode(doc, nodeType) !== null; } +function findTextPos(doc, text, offset = 0) { + let found = null; + doc.descendants((node, pos) => { + if (!node.isText || found != null) return found == null; + const index = node.text.indexOf(text); + if (index === -1) return true; + found = pos + index + offset; + return false; + }); + return found; +} + describe('StructuredContentLockPlugin', () => { let editor; let schema; @@ -636,6 +652,46 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); + it('contentLocked + Backspace at the start of the following paragraph selects block SDT content, then deletes the wrapper', () => { + const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const afterStart = findTextPos(state.doc, ' After'); + expect(afterStart).not.toBeNull(); + + placeCaretAt(state, afterStart); + + handleBackspace(editor); + + let selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + expect(selection.to).toBe(findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + + expect(invokeLockHandleKeyDown('Backspace').handled).toBe(true); + expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(false); + }); + + it('contentLocked + Delete at the end of the preceding paragraph selects block SDT content, then deletes the wrapper', () => { + const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const beforeEnd = findTextPos(state.doc, 'Before ', 'Before '.length); + expect(beforeEnd).not.toBeNull(); + + placeCaretAt(state, beforeEnd); + + handleDelete(editor); + + let selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + expect(selection.to).toBe(findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + + expect(invokeLockHandleKeyDown('Delete').handled).toBe(true); + expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(false); + }); + it.each([ ['unlocked', 'Backspace', true], ['unlocked', 'Delete', true], From fb0114741893051e88e278c30f78a5ecb328bb58 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:39:18 -0300 Subject: [PATCH 2/7] test(super-editor): update SDT keymap chain coverage --- .../v1/core/extensions/keymap-backspace-chain.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 98538477e7..40ba420e99 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 @@ -38,6 +38,7 @@ describe('handleBackspace chain ordering', () => { undoInputRule: make('undoInputRule'), deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtBeforeRunStart: make('selectInlineSdtBeforeRunStart'), + selectBlockSdtBeforeTextBlockStart: make('selectBlockSdtBeforeTextBlockStart'), moveIntoBlockSdtBeforeTextBlockStart: make('moveIntoBlockSdtBeforeTextBlockStart'), backspaceEmptyRunParagraph: make('backspaceEmptyRunParagraph'), backspaceSkipEmptyRun: make('backspaceSkipEmptyRun'), @@ -76,6 +77,7 @@ describe('handleBackspace chain ordering', () => { // step 2 sets inputType meta and returns false (no command call) 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtBeforeRunStart', + 'selectBlockSdtBeforeTextBlockStart', 'moveIntoBlockSdtBeforeTextBlockStart', 'backspaceEmptyRunParagraph', 'backspaceSkipEmptyRun', @@ -107,7 +109,8 @@ describe('handleBackspace chain ordering', () => { expect(callLog[0]).toBe('undoInputRule'); expect(callLog[1]).toBe('deleteBlockSdtAtTextBlockStart'); expect(callLog[2]).toBe('selectInlineSdtBeforeRunStart'); - expect(callLog[3]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); + expect(callLog[3]).toBe('selectBlockSdtBeforeTextBlockStart'); + expect(callLog[4]).toBe('moveIntoBlockSdtBeforeTextBlockStart'); }); it('places mixedBidiBackspace after backspaceAcrossRuns and before deleteSelection', () => { @@ -179,6 +182,7 @@ describe('handleDelete chain ordering', () => { const commands = { deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), + selectBlockSdtAfterTextBlockEnd: make('selectBlockSdtAfterTextBlockEnd'), moveIntoBlockSdtAfterTextBlockEnd: make('moveIntoBlockSdtAfterTextBlockEnd'), deleteSkipEmptyRun: make('deleteSkipEmptyRun'), deleteAtomAfter: make('deleteAtomAfter'), @@ -212,6 +216,7 @@ describe('handleDelete chain ordering', () => { expect(callLog).toEqual([ 'deleteBlockSdtAtTextBlockStart', 'selectInlineSdtAfterRunEnd', + 'selectBlockSdtAfterTextBlockEnd', 'moveIntoBlockSdtAfterTextBlockEnd', 'deleteSkipEmptyRun', 'deleteAtomAfter', From a752dd2b9065b09ed07ccb7f5e401235a875e716 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:42:54 -0300 Subject: [PATCH 3/7] test(behavior): expect block SDT boundary selection --- .../tests/sdt/sd-3237-sdt-interactions.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts index 64904e85c8..cb3596aefd 100644 --- a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts +++ b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts @@ -515,10 +515,10 @@ test.describe('SD-3237 structured content interactions', () => { }); }); - test('Backspace at paragraph after block SDT table moves into SDT without deleting following text', async ({ + test('Backspace at paragraph after block SDT table selects SDT content without deleting following text', async ({ superdoc, }) => { - const { afterStart, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + const { afterStart, a1Start, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); await superdoc.waitForStable(); await superdoc.setTextSelection(afterStart); @@ -544,17 +544,17 @@ test.describe('SD-3237 structured content interactions', () => { expect(result).toMatchObject({ text: 'BeforeA1B1A2B2After', - from: b2End, + from: a1Start, to: b2End, - empty: true, + empty: false, }); expect(result.parentTypes).toContain('structuredContentBlock'); }); - test('Delete at paragraph before block SDT table moves into SDT without deleting preceding text', async ({ + test('Delete at paragraph before block SDT table selects SDT content without deleting preceding text', async ({ superdoc, }) => { - const { beforeEnd, a1Start } = await loadBlockSdtTableBackspaceFixture(superdoc.page); + const { beforeEnd, a1Start, b2End } = await loadBlockSdtTableBackspaceFixture(superdoc.page); await superdoc.waitForStable(); await superdoc.setTextSelection(beforeEnd); @@ -581,8 +581,8 @@ test.describe('SD-3237 structured content interactions', () => { expect(result).toMatchObject({ text: 'BeforeA1B1A2B2After', from: a1Start, - to: a1Start, - empty: true, + to: b2End, + empty: false, }); expect(result.parentTypes).toContain('structuredContentBlock'); }); From 6761ddb07a21109b4ee43295ce2c013413e0756a Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:43:33 -0300 Subject: [PATCH 4/7] test(super-editor): cover nested block SDT boundary selection --- .../selectBlockSdtAtTextBlockBoundary.test.js | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js index 7d5898157c..895376ca1b 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js @@ -54,6 +54,17 @@ const findBlockSdt = (doc) => { return result; }; +const findBlockSdtByText = (doc, text) => { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name !== 'structuredContentBlock' || node.textContent !== text) return true; + result = { node, pos, end: pos + node.nodeSize }; + return false; + }); + expect(result).not.toBeNull(); + return result; +}; + const makeDoc = (schema, lockMode = 'contentLocked') => { const imageRun = schema.nodes.run.create(null, schema.nodes.image.create()); const sdt = schema.nodes.structuredContentBlock.create({ lockMode }, [ @@ -63,6 +74,16 @@ const makeDoc = (schema, lockMode = 'contentLocked') => { return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); }; +const makeNestedDoc = (schema, lockMode = 'contentLocked') => { + const innerSdt = schema.nodes.structuredContentBlock.create({ lockMode }, [paragraph(schema, 'Nested text')]); + const outerSdt = schema.nodes.structuredContentBlock.create({ lockMode: 'unlocked' }, [ + paragraph(schema, 'Outer before'), + innerSdt, + paragraph(schema, 'Outer after'), + ]); + return schema.node('doc', null, [paragraph(schema, 'Before'), outerSdt, paragraph(schema, 'After')]); +}; + describe('selectBlockSdtBeforeTextBlockStart', () => { it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( 'selects the previous %s block SDT content from the following textblock start', @@ -99,6 +120,21 @@ describe('selectBlockSdtBeforeTextBlockStart', () => { expect(ok).toBe(false); expect(dispatch).not.toHaveBeenCalled(); }); + + it('selects nested previous block SDT content from the following nested textblock start', () => { + const schema = makeSchema(); + const doc = makeNestedDoc(schema); + const nestedSdt = findBlockSdtByText(doc, 'Nested text'); + const outerAfterStart = findTextPos(doc, 'Outer after'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerAfterStart) }); + + let dispatched; + const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); + expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); + }); }); describe('selectBlockSdtAfterTextBlockEnd', () => { @@ -121,4 +157,19 @@ describe('selectBlockSdtAfterTextBlockEnd', () => { expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(sdt.node, sdt.pos)); }, ); + + it('selects nested next block SDT content from the preceding nested textblock end', () => { + const schema = makeSchema(); + const doc = makeNestedDoc(schema); + const nestedSdt = findBlockSdtByText(doc, 'Nested text'); + const outerBeforeEnd = findTextPos(doc, 'Outer before', 'Outer before'.length); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, outerBeforeEnd) }); + + let dispatched; + const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched.selection.from).toBe(findFirstContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); + expect(dispatched.selection.to).toBe(findLastContentCursorPosInNode(nestedSdt.node, nestedSdt.pos)); + }); }); From 2013eb209e68873a1fab91ea9b46b01d5d3195cf Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:48:28 -0300 Subject: [PATCH 5/7] test(super-editor): cover locked block SDT Delete selection --- .../structured-content-lock-plugin.test.js | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) 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 c1d0c2830b..f5759ca8d6 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 @@ -672,21 +672,40 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(false); }); - it('contentLocked + Delete at the end of the preceding paragraph selects block SDT content, then deletes the wrapper', () => { + it.each(['contentLocked', 'sdtLocked', 'sdtContentLocked'])( + '%s + Delete at the end of the preceding paragraph lets keymap select block SDT content', + (lockMode) => { + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + const beforeEnd = findTextPos(state.doc, 'Before ', 'Before '.length); + expect(beforeEnd).not.toBeNull(); + + placeCaretAt(state, beforeEnd); + + const lockResult = invokeLockHandleKeyDown('Delete'); + expect(lockResult.handled).toBe(false); + expect(lockResult.prevented).toBe(false); + + handleDelete(editor); + + const selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + expect(selection.to).toBe(findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + }, + ); + + it('contentLocked + Delete at selected block SDT content deletes the wrapper', () => { const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContentBlock'); const state = applyDocToEditor(doc); const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); - const beforeEnd = findTextPos(state.doc, 'Before ', 'Before '.length); - expect(beforeEnd).not.toBeNull(); - - placeCaretAt(state, beforeEnd); - - handleDelete(editor); + const contentStart = findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos); + const contentEnd = findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos); + expect(contentStart).not.toBeNull(); + expect(contentEnd).not.toBeNull(); - let selection = editor.state.selection; - expect(selection).toBeInstanceOf(TextSelection); - expect(selection.from).toBe(findFirstContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); - expect(selection.to).toBe(findLastContentCursorPosInNode(sdtInfo.node, sdtInfo.pos)); + setSelection(state, TextSelection.create(state.doc, contentStart, contentEnd)); expect(invokeLockHandleKeyDown('Delete').handled).toBe(true); expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(false); From b9ebc98698bb4b1df6873004b67255d78eea84a1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:49:23 -0300 Subject: [PATCH 6/7] test(super-editor): resolve handleBase64 source path from package or repo root The url-validation source-check test hardcoded a package-relative path, so it failed when the suite ran from the repo root. Probe the package-relative path first and fall back to the root-relative one, picking whichever exists before reading the file. --- .../extensions/image/imageHelpers/handleBase64.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js index c5e3afb21e..2dcfc34f53 100644 --- a/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js +++ b/packages/super-editor/src/editors/v1/extensions/image/imageHelpers/handleBase64.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { base64ToFile, getBase64FileMeta } from './handleBase64.js'; @@ -26,10 +26,10 @@ describe('handleBase64', () => { }); it('uses shared url-validation helpers directly instead of converter helpers', () => { - const source = readFileSync( - resolve(process.cwd(), 'src/editors/v1/extensions/image/imageHelpers/handleBase64.js'), - 'utf8', - ); + const packageRelativePath = 'src/editors/v1/extensions/image/imageHelpers/handleBase64.js'; + const rootRelativePath = `packages/super-editor/${packageRelativePath}`; + const sourcePath = existsSync(resolve(process.cwd(), packageRelativePath)) ? packageRelativePath : rootRelativePath; + const source = readFileSync(resolve(process.cwd(), sourcePath), 'utf8'); expect(source).toContain("from '@superdoc/url-validation'"); expect(source).not.toContain("from '@converter/helpers/mediaHelpers.js'"); From f4d5b204e87abe59c8c3718f8614aa14f4b5f664 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 20:52:13 -0300 Subject: [PATCH 7/7] fix(super-editor): skip empty block SDT content selection --- .../selectBlockSdtAtTextBlockBoundary.js | 1 + .../selectBlockSdtAtTextBlockBoundary.test.js | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js index d71f1b5b39..6d0941b41d 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.js @@ -62,6 +62,7 @@ function selectAdjacentBlockSdtContent(direction) { const contentStart = findFirstContentCursorPosInNode(node, nodePos); const contentEnd = findLastContentCursorPosInNode(node, nodePos); if (contentStart == null || contentEnd == null) return false; + if (contentStart >= contentEnd) return false; if (dispatch) { dispatch(state.tr.setSelection(TextSelection.create(state.doc, contentStart, contentEnd)).scrollIntoView()); diff --git a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js index 895376ca1b..6e21d81266 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectBlockSdtAtTextBlockBoundary.test.js @@ -21,6 +21,7 @@ const makeSchema = () => lockMode: { default: 'unlocked' }, }, }, + mathBlock: { group: 'block', atom: true }, image: { inline: true, group: 'inline', atom: true }, text: { group: 'inline' }, }, @@ -84,6 +85,20 @@ const makeNestedDoc = (schema, lockMode = 'contentLocked') => { return schema.node('doc', null, [paragraph(schema, 'Before'), outerSdt, paragraph(schema, 'After')]); }; +const makeEmptyBlockSdtDoc = (schema) => { + const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [ + schema.nodes.paragraph.create(), + ]); + return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); +}; + +const makeAtomBlockSdtDoc = (schema) => { + const sdt = schema.nodes.structuredContentBlock.create({ lockMode: 'contentLocked' }, [ + schema.nodes.mathBlock.create(), + ]); + return schema.node('doc', null, [paragraph(schema, 'Before'), sdt, paragraph(schema, 'After')]); +}; + describe('selectBlockSdtBeforeTextBlockStart', () => { it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( 'selects the previous %s block SDT content from the following textblock start', @@ -121,6 +136,22 @@ describe('selectBlockSdtBeforeTextBlockStart', () => { expect(dispatch).not.toHaveBeenCalled(); }); + it.each([ + ['empty paragraph', makeEmptyBlockSdtDoc], + ['block atom', makeAtomBlockSdtDoc], + ])('returns false for previous block SDT with %s content', (_, makeAdjacentDoc) => { + const schema = makeSchema(); + const doc = makeAdjacentDoc(schema); + const afterStart = findTextPos(doc, 'After'); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, afterStart) }); + const dispatch = vi.fn(); + + const ok = selectBlockSdtBeforeTextBlockStart()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('selects nested previous block SDT content from the following nested textblock start', () => { const schema = makeSchema(); const doc = makeNestedDoc(schema); @@ -158,6 +189,22 @@ describe('selectBlockSdtAfterTextBlockEnd', () => { }, ); + it.each([ + ['empty paragraph', makeEmptyBlockSdtDoc], + ['block atom', makeAtomBlockSdtDoc], + ])('returns false for next block SDT with %s content', (_, makeAdjacentDoc) => { + const schema = makeSchema(); + const doc = makeAdjacentDoc(schema); + const beforeEnd = findTextPos(doc, 'Before', 'Before'.length); + const state = EditorState.create({ schema, doc, selection: TextSelection.create(doc, beforeEnd) }); + const dispatch = vi.fn(); + + const ok = selectBlockSdtAfterTextBlockEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + it('selects nested next block SDT content from the preceding nested textblock end', () => { const schema = makeSchema(); const doc = makeNestedDoc(schema);