From c439e245480e2631137a0717bd4b7bbf505d3603 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 19 May 2026 12:36:31 -0300 Subject: [PATCH 001/135] fix(super-editor): select inline SDT on Backspace at start of following run (SD-3165) Adds a new selectInlineSdtBeforeRunStart command in the Backspace chain. When the caret is at the start of a run whose previous sibling is an inline structuredContent wrapper, Backspace now selects the wrapper as a NodeSelection (for unlocked / contentLocked modes) so a subsequent Backspace deletes it. For sdtLocked / sdtContentLocked wrappers the command consumes the keystroke without changing the selection. The structured-content select plugin ignores selections produced via the new meta flag so it does not collapse the NodeSelection back to a TextSelection. --- .../v1/core/commands/core-command-map.d.ts | 1 + .../v1/core/commands/core-command-map.test.ts | 14 ++ .../src/editors/v1/core/commands/index.js | 1 + .../commands/selectInlineSdtBeforeRunStart.js | 40 ++++++ .../selectInlineSdtBeforeRunStart.test.js | 122 ++++++++++++++++++ .../extensions/keymap-backspace-chain.test.js | 6 + .../src/editors/v1/core/extensions/keymap.js | 1 + .../structured-content-lock-plugin.test.js | 31 +++++ .../structured-content-select-plugin.js | 3 + 9 files changed, 219 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/commands/core-command-map.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js create mode 100644 packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.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 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 d5fde35e4a..7e5a75f161 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,36 @@ 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 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); + }); }); describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { 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 a9381b0249..9ff9997977 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 } 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'; @@ -88,6 +89,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; From 1bb1ca84ad71bb53f487eca47354870c624bd804 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 19 May 2026 13:42:57 -0300 Subject: [PATCH 002/135] test(super-editor): cover inline SDT selection meta escape --- .../structured-content-select-plugin.test.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 5168ceb009..d1586c2c8e 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; @@ -67,6 +68,28 @@ describe('StructuredContentSelectPlugin', () => { expect(editor.state.selection.to).toBe(contentFrom + 1); }); + 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')]); From 2e869010020bca8158e47b3be197a8a702bec1eb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Tue, 19 May 2026 13:43:23 -0300 Subject: [PATCH 003/135] test(super-editor): cover two-step inline SDT Backspace --- .../structured-content-lock-plugin.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 7e5a75f161..9decf93f0c 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 @@ -506,7 +506,7 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false); }); - it('contentLocked + Backspace at the start of the following run selects the SDT wrapper', () => { + 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.')); @@ -534,6 +534,10 @@ describe('StructuredContentLockPlugin', () => { 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); }); }); From 8bc1594c77f154b8d3ae9be17140dc8788b18810 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 10:48:22 -0300 Subject: [PATCH 004/135] fix(super-editor): select inline SDT content as text on Backspace Switch selectInlineSdtBeforeRunStart from a NodeSelection over the whole wrapper to a TextSelection over the inner content, and apply it uniformly across all lock modes. This avoids selecting the SDT chrome and keeps Backspace inside the field boundary. --- .../commands/selectInlineSdtBeforeRunStart.js | 35 +++-- .../selectInlineSdtBeforeRunStart.test.js | 75 +++++++--- .../structured-content-lock-plugin.js | 29 ++-- .../structured-content-lock-plugin.test.js | 133 +++++++++++++----- 4 files changed, 183 insertions(+), 89 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js index b92c100b65..1ff3046bb6 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js @@ -1,13 +1,24 @@ -import { NodeSelection } from 'prosemirror-state'; +import { TextSelection } 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'; +function getPreviousInlineSdt(state) { + const { $from } = state.selection; + + if ($from.parent.type.name === 'run' && $from.parentOffset === 0) { + const runStart = $from.before($from.depth); + const node = state.doc.resolve(runStart).nodeBefore; + if (node?.type.name !== 'structuredContent') return null; + return { node, pos: runStart - node.nodeSize }; + } + + const node = $from.nodeBefore; + if (node?.type.name !== 'structuredContent') return null; + return { node, pos: $from.pos - node.nodeSize }; } /** - * Selects an inline SDT wrapper when Backspace is pressed at the start of the + * Selects inline SDT content 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} @@ -18,21 +29,17 @@ export const selectInlineSdtBeforeRunStart = 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; + const previousSdt = getPreviousInlineSdt(state); + if (!previousSdt) return false; if (dispatch) { + const contentStart = previousSdt.pos + 1; + const contentEnd = previousSdt.pos + previousSdt.node.nodeSize - 1; + dispatch( state.tr .setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true) - .setSelection(NodeSelection.create(state.doc, runStart - previousSibling.nodeSize)), + .setSelection(TextSelection.create(state.doc, contentStart, contentEnd)), ); } 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 index 7a4bbb7f4c..eace887010 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js @@ -43,42 +43,71 @@ const findNode = (doc, typeName, predicate = () => true) => { }; 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), - }); + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + 'selects only the %s inline SDT content 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) }); + 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); - }); + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection).not.toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(sdt.pos + 1); + expect(dispatched.selection.to).toBe(sdt.end - 1); + expect(dispatched.selection.content().content.textBetween(0, dispatched.selection.content().content.size)).toBe( + 'Locked content', + ); + }, + ); + + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + 'selects only the %s inline SDT content from the trailing boundary', + (lockMode) => { + const schema = makeSchema(); + const doc = makeDoc(schema, lockMode); + const sdt = findNode(doc, 'structuredContent'); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, sdt.end), + }); + + let dispatched; + const ok = selectInlineSdtBeforeRunStart()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection).not.toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(sdt.pos + 1); + expect(dispatched.selection.to).toBe(sdt.end - 1); + }, + ); - it.each(['sdtLocked', 'sdtContentLocked'])('consumes Backspace without selecting %s wrappers', (lockMode) => { + it('returns true without dispatching when no dispatch is provided', () => { const schema = makeSchema(); - const doc = makeDoc(schema, lockMode); + 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 + 1), }); - const dispatch = vi.fn(); - const ok = selectInlineSdtBeforeRunStart()({ state, dispatch }); + const ok = selectInlineSdtBeforeRunStart()({ state }); expect(ok).toBe(true); - expect(dispatch).not.toHaveBeenCalled(); }); it('returns false when the cursor is not at the start of a run', () => { 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 4a73e15798..bc98910ba8 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 @@ -109,19 +109,19 @@ export function createStructuredContentLockPlugin() { // Path 1 — non-collapsed selection that exactly covers the editable // content of an SDT (e.g., a label/handle selection, a triple-click // that lands on the content range, or precise keyboard selection). - // For wrapper-deletable lock modes, promote to a + // For wrapper-deletable but content-locked modes, promote to a // NodeSelection on the wrapper so the next operation targets the whole - // field. For Backspace/Delete we stop here — the user sees the wrapper - // highlighted and presses again to confirm (matches Word's "click to - // select, key to delete"). For Cut we let the event continue so PM's - // clipboard handler runs against the just-installed NodeSelection and - // the wrapper is cut in a single keystroke. + // field instead of trying to edit locked content. For content-editable + // 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); if (exactContentSDT) { - const isSdtLocked = - exactContentSDT.lockMode === 'sdtLocked' || exactContentSDT.lockMode === 'sdtContentLocked'; - if (!isSdtLocked) { + const isContentLocked = + exactContentSDT.lockMode === 'contentLocked' || exactContentSDT.lockMode === 'sdtContentLocked'; + const isWrapperDeletable = + exactContentSDT.lockMode !== 'sdtLocked' && exactContentSDT.lockMode !== 'sdtContentLocked'; + if (isContentLocked && isWrapperDeletable) { const tr = state.tr.setSelection(NodeSelection.create(state.doc, exactContentSDT.pos)); view.dispatch(tr); if (isCut) { @@ -145,15 +145,12 @@ export function createStructuredContentLockPlugin() { 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. + // SDT. The Backspace keymap has a specialized command that selects + // the inline SDT content, so let that run instead of treating this + // as an attempted wrapper deletion. const adjacentSDT = sdtNodes.find((s) => s.end === from); if (adjacentSDT) { - affectedFrom = adjacentSDT.pos; - affectedTo = adjacentSDT.end; + return false; } } else if (isDelete && to < state.doc.content.size) { affectedTo = to + 1; 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 9decf93f0c..4b56a36d44 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 @@ -459,10 +459,20 @@ describe('StructuredContentLockPlugin', () => { 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', 'Backspace', false, 'unlocked + Backspace at trailing boundary: lets keymap select content'], + [ + 'contentLocked', + 'Backspace', + false, + 'contentLocked + Backspace at trailing boundary: lets keymap select content', + ], + ['sdtLocked', 'Backspace', false, 'sdtLocked + Backspace at trailing boundary: lets keymap select content'], + [ + 'sdtContentLocked', + 'Backspace', + false, + 'sdtContentLocked + Backspace at trailing boundary: lets keymap select content', + ], ['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'], @@ -483,6 +493,24 @@ describe('StructuredContentLockPlugin', () => { expect(prevented).toBe(shouldConsume); }); + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + '%s + Backspace at the trailing boundary selects inline SDT content', + (lockMode) => { + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + placeCaretAt(state, sdtInfo.end); + + handleBackspace(editor); + + const selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(sdtInfo.pos + 1); + expect(selection.to).toBe(sdtInfo.end - 1); + }, + ); + it('contentLocked + Backspace then Backspace deletes the SDT (two-stage Word UX)', () => { const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContent'); const initialState = applyDocToEditor(doc); @@ -506,7 +534,7 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false); }); - it('contentLocked + Backspace at the start of the following run selects, then deletes the SDT wrapper', () => { + it('contentLocked + Backspace at the start of the following run selects content, promotes, then deletes the 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.')); @@ -530,11 +558,18 @@ describe('StructuredContentLockPlugin', () => { handleBackspace(editor); - const selection = editor.state.selection; + let selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(sdtInfo.pos + 1); + expect(selection.to).toBe(sdtInfo.end - 1); + + expect(invokeLockHandleKeyDown('Backspace').handled).toBe(true); + selection = editor.state.selection; expect(selection).toBeInstanceOf(NodeSelection); expect(selection.from).toBe(sdtInfo.pos); expect(selection.to).toBe(sdtInfo.end); + expect(invokeLockHandleKeyDown('Backspace').handled).toBe(false); handleBackspace(editor); expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); @@ -543,40 +578,66 @@ describe('StructuredContentLockPlugin', () => { describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { 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)'], + // [lockMode, shouldPromote, shouldConsume, description] + ['unlocked', false, false, 'unlocked: leaves content selection for deletion'], + ['sdtLocked', false, false, 'sdtLocked: leaves content selection for deletion'], + ['contentLocked', true, true, 'contentLocked: promotes content selection to NodeSelection on wrapper'], + ['sdtContentLocked', false, true, 'sdtContentLocked: blocks content deletion'], ]; - 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'); + it.each(selectAllCases)( + '%s — Backspace on (contentFrom, contentTo)', + (lockMode, shouldPromote, shouldConsume) => { + 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 contentFrom = sdtInfo.pos + 1; + const contentTo = sdtInfo.end - 1; + setSelection(state, TextSelection.create(state.doc, contentFrom, contentTo)); + + const { handled, prevented } = invokeLockHandleKeyDown('Backspace'); + + expect(handled).toBe(shouldConsume || shouldPromote); + expect(prevented).toBe(shouldConsume || shouldPromote); + + if (shouldPromote) { + // 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); + } + }, + ); - const { handled, prevented } = invokeLockHandleKeyDown('Backspace'); + it.each([['unlocked'], ['sdtLocked']])( + '%s: Backspace deletes selected content and preserves an empty inline SDT', + (lockMode) => { + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); - 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); - } - }); + setSelection(state, TextSelection.create(state.doc, sdtInfo.pos + 1, sdtInfo.end - 1)); + + expect(invokeLockHandleKeyDown('Backspace').handled).toBe(false); + handleBackspace(editor); + + const sdtAfter = findSDTNode(editor.state.doc, 'structuredContent'); + expect(sdtAfter).not.toBeNull(); + expect(sdtAfter.node.attrs.lockMode).toBe(lockMode); + expect(sdtAfter.node.textContent).toBe(''); + expect(editor.state.selection).toBeInstanceOf(TextSelection); + expect(editor.state.selection.empty).toBe(true); + expect(editor.state.selection.from).toBe(sdtAfter.pos + 1); + }, + ); it('contentLocked: select-all then Backspace twice deletes the wrapper', () => { const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContent'); @@ -597,7 +658,7 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false); }); - it.each([['unlocked'], ['contentLocked']])( + it.each([['contentLocked']])( '%s: select-all + Cmd+X promotes to NodeSelection in one keystroke (no preventDefault)', (lockMode) => { const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); From a89bf1f4412f4bd4daba36c98907fd8042588dc0 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 11:32:42 -0300 Subject: [PATCH 005/135] fix(super-editor): bump sdBlockRev on ancestors of inline edits When text inside an inline structured-content field changes, the containing paragraph's sdBlockRev was not incremented because nodesBetween over the replace range did not always visit the ancestor block. Walk up the ancestor chain at each changed range's boundaries so the block-level paragraph gets a fresh revision. Tag the plugin's own metadata transaction with a meta key so it neither re-triggers the block-node appendTransaction nor gets filtered out by the structured-content lock plugin. --- .../v1/extensions/block-node/block-node.js | 49 +++++++--- .../extensions/block-node/block-node.test.js | 89 +++++++++++++++++++ .../structured-content-lock-plugin.js | 5 ++ 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/packages/super-editor/src/editors/v1/extensions/block-node/block-node.js b/packages/super-editor/src/editors/v1/extensions/block-node/block-node.js index 64fd1d203b..48ce111992 100644 --- a/packages/super-editor/src/editors/v1/extensions/block-node/block-node.js +++ b/packages/super-editor/src/editors/v1/extensions/block-node/block-node.js @@ -10,6 +10,7 @@ import { ySyncPluginKey } from 'y-prosemirror'; const { findChildren } = helpers; const SD_BLOCK_ID_ATTRIBUTE_NAME = 'sdBlockId'; const SD_BLOCK_REV_ATTRIBUTE_NAME = 'sdBlockRev'; +export const BLOCK_NODE_METADATA_UPDATE_META = 'blockNodeMetadataUpdate'; export const BlockNodePluginKey = new PluginKey('blockNodePlugin'); /** @@ -267,7 +268,10 @@ export const BlockNode = Extension.create({ new Plugin({ key: BlockNodePluginKey, appendTransaction: (transactions, oldState, newState) => { - const docChanges = transactions.some((tr) => tr.docChanged) && !oldState.doc.eq(newState.doc); + const hasContentDocChanges = transactions.some( + (tr) => tr.docChanged && !tr.getMeta(BLOCK_NODE_METADATA_UPDATE_META), + ); + const docChanges = hasContentDocChanges && !oldState.doc.eq(newState.doc); if (hasInitialized && !docChanges) { return; @@ -312,6 +316,7 @@ export const BlockNode = Extension.create({ let shouldFallbackToFullTraversal = false; transactions.forEach((transaction, txIndex) => { + if (transaction.getMeta(BLOCK_NODE_METADATA_UPDATE_META)) return; transaction.steps.forEach((step, stepIndex) => { const stepRanges = []; if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { @@ -363,6 +368,30 @@ export const BlockNode = Extension.create({ // (e.g., when tr.split() copies the original paragraph's sdBlockId to the new one). const seenBlockIds = new Set(); + const updateNodeAt = (node, pos) => { + if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; + if (updatedPositions.has(pos)) return; + const nextAttrs = { ...node.attrs }; + let nodeChanged = ensureUniqueSdBlockId(node, nextAttrs, seenBlockIds); + if (!isYjsOrigin && nodeAllowsSdBlockRevAttr(node)) { + nextAttrs.sdBlockRev = getNextBlockRev(node); + nodeChanged = true; + } + if (nodeChanged) { + applyNodeAttrs(tr, node, pos, nextAttrs); + updatedPositions.add(pos); + changed = true; + } + }; + + const updateBlockAncestorsAt = (pos) => { + const safePos = Math.max(0, Math.min(pos, docSize)); + const $pos = newState.doc.resolve(safePos); + for (let depth = $pos.depth; depth > 0; depth--) { + updateNodeAt($pos.node(depth), $pos.before(depth)); + } + }; + for (const { from, to } of mergedRanges) { const clampedRange = clampRange(from, to, docSize); @@ -373,20 +402,11 @@ export const BlockNode = Extension.create({ const { start: safeStart, end: safeEnd } = clampedRange; try { + updateBlockAncestorsAt(safeStart); + updateBlockAncestorsAt(Math.max(safeStart, safeEnd - 1)); + updateBlockAncestorsAt(safeEnd); newState.doc.nodesBetween(safeStart, safeEnd, (node, pos) => { - if (!nodeAllowsSdBlockIdAttr(node) && !nodeAllowsSdBlockRevAttr(node)) return; - if (updatedPositions.has(pos)) return; - const nextAttrs = { ...node.attrs }; - let nodeChanged = ensureUniqueSdBlockId(node, nextAttrs, seenBlockIds); - if (!isYjsOrigin && nodeAllowsSdBlockRevAttr(node)) { - nextAttrs.sdBlockRev = getNextBlockRev(node); - nodeChanged = true; - } - if (nodeChanged) { - applyNodeAttrs(tr, node, pos, nextAttrs); - updatedPositions.add(pos); - changed = true; - } + updateNodeAt(node, pos); }); } catch (error) { console.warn('Block node plugin: nodesBetween failed, falling back to full traversal', error); @@ -422,6 +442,7 @@ export const BlockNode = Extension.create({ // Restore marks since setNodeMarkup resets them tr.setStoredMarks(newState.tr.storedMarks); + tr.setMeta(BLOCK_NODE_METADATA_UPDATE_META, true); return changed ? tr : null; }, diff --git a/packages/super-editor/src/editors/v1/extensions/block-node/block-node.test.js b/packages/super-editor/src/editors/v1/extensions/block-node/block-node.test.js index 01bb96162b..b4208f1f64 100644 --- a/packages/super-editor/src/editors/v1/extensions/block-node/block-node.test.js +++ b/packages/super-editor/src/editors/v1/extensions/block-node/block-node.test.js @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { EditorState } from 'prosemirror-state'; import { ReplaceStep } from 'prosemirror-transform'; import { nodeAllowsSdBlockIdAttr, nodeNeedsSdBlockId, checkForNewBlockNodesInTrs } from './block-node.js'; import { initTestEditor, loadTestDataForEditorTests } from '../../tests/helpers/helpers.js'; @@ -491,6 +492,94 @@ describe('BlockNode helpers', () => { }); }); +describe('BlockNode sdBlockRev updates', () => { + let editor; + let schema; + + beforeEach(() => { + ({ editor } = initTestEditor()); + ({ schema } = editor); + }); + + afterEach(() => { + editor?.destroy(); + editor = null; + schema = null; + }); + + function applyDocToEditor(doc) { + const state = EditorState.create({ schema, doc, plugins: editor.state.plugins }); + editor.setState(state); + } + + function findParagraphByParaId(paraId) { + let result = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && node.attrs.paraId === paraId) { + result = { node, pos }; + return false; + } + }); + return result; + } + + function findInlineSdt() { + let result = null; + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + result = { node, pos }; + return false; + } + }); + return result; + } + + function createInlineSdtParagraphDoc() { + const paragraph = schema.nodes.paragraph.create({ paraId: 'p-inline-sdt', sdBlockRev: 0 }, [ + schema.text('Before '), + schema.nodes.structuredContent.create({ id: 'sdt-1', lockMode: 'sdtLocked' }, schema.text('Field')), + schema.text(' After'), + ]); + return schema.nodes.doc.create(null, [paragraph]); + } + + it('increments the containing paragraph revision when text is inserted inside an inline SDT', () => { + applyDocToEditor(createInlineSdtParagraphDoc()); + const sdt = findInlineSdt(); + expect(sdt).toBeTruthy(); + + editor.view.dispatch(editor.state.tr.insertText('!', sdt.pos + 1)); + + const paragraph = findParagraphByParaId('p-inline-sdt'); + expect(paragraph.node.attrs.sdBlockRev).toBeGreaterThan(0); + expect(paragraph.node.textContent).toBe('Before !Field After'); + }); + + it('increments the containing paragraph revision when inline SDT content is deleted to empty', () => { + applyDocToEditor(createInlineSdtParagraphDoc()); + const sdt = findInlineSdt(); + expect(sdt).toBeTruthy(); + + editor.view.dispatch(editor.state.tr.delete(sdt.pos + 1, sdt.pos + sdt.node.nodeSize - 1)); + + const paragraph = findParagraphByParaId('p-inline-sdt'); + const emptySdt = findInlineSdt(); + expect(paragraph.node.attrs.sdBlockRev).toBeGreaterThan(0); + expect(emptySdt.node.textContent).toBe(''); + expect(paragraph.node.textContent).toBe('Before After'); + }); + + it('still increments the paragraph revision for ordinary text edits', () => { + const paragraph = schema.nodes.paragraph.create({ paraId: 'p-plain', sdBlockRev: 0 }, schema.text('Plain')); + applyDocToEditor(schema.nodes.doc.create(null, [paragraph])); + + const plainParagraph = findParagraphByParaId('p-plain'); + editor.view.dispatch(editor.state.tr.insertText('!', plainParagraph.pos + 1)); + + expect(findParagraphByParaId('p-plain').node.attrs.sdBlockRev).toBe(1); + }); +}); + describe('BlockNode commands', () => { const filename = 'doc_with_spaces_from_styles.docx'; let docx, media, mediaFiles, fonts, editor; 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 bc98910ba8..6a769904bd 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,6 @@ import { NodeSelection, Plugin, PluginKey } from 'prosemirror-state'; import { ySyncPluginKey } from 'y-prosemirror'; +import { BLOCK_NODE_METADATA_UPDATE_META } from '../block-node/block-node.js'; export const STRUCTURED_CONTENT_LOCK_KEY = new PluginKey('structuredContentLock'); @@ -208,6 +209,10 @@ export function createStructuredContentLockPlugin() { return true; } + if (tr.getMeta?.(BLOCK_NODE_METADATA_UPDATE_META)) { + return true; + } + const sdtNodes = STRUCTURED_CONTENT_LOCK_KEY.getState(state); if (sdtNodes.length === 0) { return true; From a74e60ec901bd1c2829c7e975ea04bd827af1ba1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 11:44:39 -0300 Subject: [PATCH 006/135] fix(super-editor): delete contentLocked SDT wrapper in one step When an exact content selection covers a contentLocked structured content field and the user presses Backspace/Delete, delete the wrapper directly instead of first promoting to a NodeSelection that required a second keystroke. Cut still promotes to NodeSelection so the browser can serialize the wrapper. Tests are updated to assert single-step deletion and now cover Delete in addition to Backspace. --- .../structured-content-lock-plugin.js | 6 ++- .../structured-content-lock-plugin.test.js | 47 ++++++------------- 2 files changed, 19 insertions(+), 34 deletions(-) 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 6a769904bd..ce2aba190b 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 @@ -123,11 +123,13 @@ export function createStructuredContentLockPlugin() { const isWrapperDeletable = exactContentSDT.lockMode !== 'sdtLocked' && exactContentSDT.lockMode !== 'sdtContentLocked'; if (isContentLocked && isWrapperDeletable) { - const tr = state.tr.setSelection(NodeSelection.create(state.doc, exactContentSDT.pos)); - view.dispatch(tr); if (isCut) { + const tr = state.tr.setSelection(NodeSelection.create(state.doc, exactContentSDT.pos)); + view.dispatch(tr); return false; } + const tr = state.tr.delete(exactContentSDT.pos, exactContentSDT.end); + view.dispatch(tr); event.preventDefault(); return true; } 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 4b56a36d44..9cf1550cc5 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 @@ -534,7 +534,7 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(finalState.doc, 'structuredContent')).toBe(false); }); - it('contentLocked + Backspace at the start of the following run selects content, promotes, then deletes the wrapper', () => { + it('contentLocked + Backspace at the start of the following run selects content, then deletes the 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.')); @@ -564,30 +564,22 @@ describe('StructuredContentLockPlugin', () => { expect(selection.to).toBe(sdtInfo.end - 1); expect(invokeLockHandleKeyDown('Backspace').handled).toBe(true); - selection = editor.state.selection; - expect(selection).toBeInstanceOf(NodeSelection); - expect(selection.from).toBe(sdtInfo.pos); - expect(selection.to).toBe(sdtInfo.end); - - expect(invokeLockHandleKeyDown('Backspace').handled).toBe(false); - handleBackspace(editor); - expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); }); describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { const selectAllCases = [ - // [lockMode, shouldPromote, shouldConsume, description] + // [lockMode, shouldConsume, shouldDeleteWrapper, description] ['unlocked', false, false, 'unlocked: leaves content selection for deletion'], ['sdtLocked', false, false, 'sdtLocked: leaves content selection for deletion'], - ['contentLocked', true, true, 'contentLocked: promotes content selection to NodeSelection on wrapper'], - ['sdtContentLocked', false, true, 'sdtContentLocked: blocks content deletion'], + ['contentLocked', true, true, 'contentLocked: deletes wrapper instead of locked content'], + ['sdtContentLocked', true, false, 'sdtContentLocked: blocks content deletion'], ]; it.each(selectAllCases)( '%s — Backspace on (contentFrom, contentTo)', - (lockMode, shouldPromote, shouldConsume) => { + (lockMode, shouldConsume, shouldDeleteWrapper) => { const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); const state = applyDocToEditor(doc); const sdtInfo = findSDTNode(state.doc, 'structuredContent'); @@ -598,17 +590,13 @@ describe('StructuredContentLockPlugin', () => { const { handled, prevented } = invokeLockHandleKeyDown('Backspace'); - expect(handled).toBe(shouldConsume || shouldPromote); - expect(prevented).toBe(shouldConsume || shouldPromote); + expect(handled).toBe(shouldConsume); + expect(prevented).toBe(shouldConsume); - if (shouldPromote) { - // 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); + if (shouldDeleteWrapper) { + expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); } else { - // No promotion: selection unchanged. + // No wrapper deletion: selection unchanged. const sel = editor.state.selection; expect(sel).not.toBeInstanceOf(NodeSelection); expect(sel.from).toBe(contentFrom); @@ -639,23 +627,18 @@ describe('StructuredContentLockPlugin', () => { }, ); - it('contentLocked: select-all then Backspace twice deletes the wrapper', () => { + it.each(['Backspace', 'Delete'])('contentLocked: exact content selection + %s deletes the wrapper', (key) => { 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); + const result = invokeLockHandleKeyDown(key); - // 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); + expect(result.handled).toBe(true); + expect(result.prevented).toBe(true); + expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); it.each([['contentLocked']])( From 7b34a314f02817fad799da3587bc4b691185ffd1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 12:16:11 -0300 Subject: [PATCH 007/135] feat(layout-engine): render empty inline SDTs as a visible placeholder Empty inline structured content used to be filtered out of the layout runs entirely, so the wrapper had no width, no caret target, and the field was effectively invisible. Introduce an `emptyInlineSdt` visual placeholder run that flows end-to-end through the pipeline: - contracts: `TextRun.visualPlaceholder` and an `isEmptyInlineSdtPlaceholderRun` guard; `sliceRunsForLine` preserves placeholders that have no chars. - pm-adapter: emit a placeholder run for inline `structuredContent` nodes with empty/missing content, and skip merging it into neighboring text. - measuring/dom: reserve an 8px inline box (0px for hidden-appearance) without taking the empty-paragraph code path. - painters/dom: render a `` inside the inline SDT wrapper, tagged `data-empty="true"`, and add styles so the wrapper gets a visible affordance without inflating line-box height. - DomSelectionGeometry: anchor the caret to the line's Y (the placeholder is height: 0) and always to its left edge. - structured-content lock plugin: Backspace/Delete inside an empty inline SDT deletes the wrapper when its lock mode allows it. --- packages/layout-engine/contracts/src/index.ts | 4 +- .../contracts/src/run-helpers.test.ts | 17 +++ .../contracts/src/run-helpers.ts | 13 +++ .../measuring/dom/src/index.test.ts | 32 ++++++ .../layout-engine/measuring/dom/src/index.ts | 75 ++++++++++++- .../painters/dom/src/index.test.ts | 68 ++++++++++++ .../painters/dom/src/renderer.ts | 40 +++++++ .../painters/dom/src/styles.test.ts | 30 +++++ .../layout-engine/painters/dom/src/styles.ts | 19 ++++ .../inline-converters/structured-content.ts | 26 ++++- .../src/converters/paragraph.test.ts | 105 ++++++++++++++++++ .../pm-adapter/src/converters/paragraph.ts | 5 +- .../tests/DomSelectionGeometry.test.ts | 32 ++++++ .../v1/dom-observer/DomSelectionGeometry.ts | 7 +- .../structured-content-lock-plugin.js | 13 +++ .../structured-content-lock-plugin.test.js | 24 ++++ 16 files changed, 502 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 165b379d95..39d229d2b1 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -342,6 +342,8 @@ export type TextRun = RunMarks & { */ dataAttrs?: Record; sdt?: SdtMetadata; + /** Layout-only placeholder for visual affordances that do not represent document text. */ + visualPlaceholder?: 'emptyInlineSdt'; link?: FlowRunLink; /** Token annotations for dynamic content (page numbers, etc.). */ token?: 'pageNumber' | 'totalPageCount' | 'pageReference'; @@ -2197,6 +2199,6 @@ export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from // Pure transformations on inline-run shapes (used by pm-adapter, layout-bridge, // and painter-dom). Located in contracts to avoid reverse stage dependencies. -export { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js'; +export { expandRunsForInlineNewlines, isEmptyInlineSdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js'; export * as Engines from './engines/index.js'; diff --git a/packages/layout-engine/contracts/src/run-helpers.test.ts b/packages/layout-engine/contracts/src/run-helpers.test.ts index aaae281c2b..afefce64c6 100644 --- a/packages/layout-engine/contracts/src/run-helpers.test.ts +++ b/packages/layout-engine/contracts/src/run-helpers.test.ts @@ -136,4 +136,21 @@ describe('sliceRunsForLine', () => { const line = makeLine({ fromRun: 0, fromChar: 2, toRun: 0, toChar: 2 }); expect(sliceRunsForLine(block, line)).toEqual([]); }); + + it('preserves empty inline SDT visual placeholders', () => { + const run: TextRun = { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 12, + pmStart: 10, + pmEnd: 10, + visualPlaceholder: 'emptyInlineSdt', + sdt: { type: 'structuredContent', scope: 'inline', id: 'sdt-1' }, + }; + const block = makeParagraph([run]); + const line = makeLine({ fromRun: 0, fromChar: 0, toRun: 0, toChar: 0 }); + + expect(sliceRunsForLine(block, line)).toEqual([run]); + }); }); diff --git a/packages/layout-engine/contracts/src/run-helpers.ts b/packages/layout-engine/contracts/src/run-helpers.ts index 13409e2b69..8b04fa638a 100644 --- a/packages/layout-engine/contracts/src/run-helpers.ts +++ b/packages/layout-engine/contracts/src/run-helpers.ts @@ -9,6 +9,14 @@ import type { FlowBlock, Line, Run, TextRun } from './index.js'; +export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } { + return ( + (run.kind === 'text' || run.kind === undefined) && + 'text' in run && + (run as TextRun).visualPlaceholder === 'emptyInlineSdt' + ); +} + /** * Expands text runs that contain inline newlines into multiple runs. * @@ -82,6 +90,11 @@ export function sliceRunsForLine(block: FlowBlock, line: Line): Run[] { } const text = run.text ?? ''; + if (isEmptyInlineSdtPlaceholderRun(run)) { + result.push(run); + continue; + } + const isFirstRun = runIndex === line.fromRun; const isLastRun = runIndex === line.toRun; diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index a2a0ee80f6..b8e993e1a4 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -684,6 +684,38 @@ describe('measureBlock', () => { expect(line.toRun).toBeGreaterThanOrEqual(line.fromRun); }); }); + + it('measures empty inline SDT placeholders as a small inline box', async () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'empty-inline-sdt', + runs: [ + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 10, + pmEnd: 10, + visualPlaceholder: 'emptyInlineSdt', + sdt: { type: 'structuredContent', scope: 'inline', id: 'sdt-empty' }, + }, + ], + attrs: {}, + }; + + const measure = expectParagraphMeasure(await measureBlock(block, 1000)); + + expect(measure.lines).toHaveLength(1); + expect(measure.lines[0]).toMatchObject({ + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 8, + segments: [{ runIndex: 0, fromChar: 0, toChar: 0, width: 8 }], + }); + }); }); describe('advanced styling', () => { diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 39e85716cf..1ae0f9395e 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -62,6 +62,7 @@ import { type TableBorders, type TableBorderValue, effectiveTableCellSpacing, + isEmptyInlineSdtPlaceholderRun, LeaderDecoration, resolveBaseFontSizeForVerticalText, } from '@superdoc/contracts'; @@ -208,6 +209,7 @@ const FIELD_ANNOTATION_VERTICAL_PADDING = 6; // Vertical padding/border for pill const DEFAULT_FIELD_ANNOTATION_FONT_SIZE = 16; // Default font size for field annotations const DEFAULT_PARAGRAPH_FONT_SIZE = 12; const DEFAULT_PARAGRAPH_FONT_FAMILY = 'Arial'; +const EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH = 8; const isValidFontSize = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0; @@ -1032,7 +1034,11 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P } const emptyParagraphRun = - normalizedRuns.length === 1 && isEmptyTextRun(normalizedRuns[0] as Run) ? (normalizedRuns[0] as TextRun) : null; + normalizedRuns.length === 1 && + isEmptyTextRun(normalizedRuns[0] as Run) && + !isEmptyInlineSdtPlaceholderRun(normalizedRuns[0] as Run) + ? (normalizedRuns[0] as TextRun) + : null; if (emptyParagraphRun) { const fontSize = emptyParagraphRun.fontSize ?? DEFAULT_PARAGRAPH_FONT_SIZE; const metrics = calculateEmptyParagraphMetrics(fontSize, spacing, getFontInfoFromRun(emptyParagraphRun)); @@ -2012,6 +2018,73 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P continue; } + if (isEmptyInlineSdtPlaceholderRun(run)) { + const placeholderWidth = + run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' + ? 0 + : EMPTY_INLINE_SDT_PLACEHOLDER_WIDTH; + + if (!currentLine) { + currentLine = { + fromRun: runIndex, + fromChar: 0, + toRun: runIndex, + toChar: 0, + width: placeholderWidth, + maxFontSize: lineHeightFontSize(run), + maxFontInfo: getFontInfoFromRun(run), + maxWidth: getEffectiveWidth(lines.length === 0 ? initialAvailableWidth : bodyContentWidth), + segments: [{ runIndex, fromChar: 0, toChar: 0, width: placeholderWidth }], + spaceCount: 0, + }; + } else { + const boundarySpacing = resolveBoundarySpacing(currentLine.width, true, run); + if ( + currentLine.width + boundarySpacing + placeholderWidth > currentLine.maxWidth - WIDTH_FUDGE_PX && + currentLine.width > 0 + ) { + trimTrailingWrapSpaces(currentLine); + const metrics = finalizeLineMetrics(currentLine, spacing); + const completedLine: Line = { + ...currentLine, + ...metrics, + }; + addBarTabsToLine(completedLine); + lines.push(completedLine); + tabStopCursor = 0; + pendingTabAlignment = null; + pendingLeader = null; + lastAppliedTabAlign = null; + activeTabGroup = null; + + currentLine = { + fromRun: runIndex, + fromChar: 0, + toRun: runIndex, + toChar: 0, + width: placeholderWidth, + maxFontSize: lineHeightFontSize(run), + maxFontInfo: getFontInfoFromRun(run), + maxWidth: getEffectiveWidth(bodyContentWidth), + segments: [{ runIndex, fromChar: 0, toChar: 0, width: placeholderWidth }], + spaceCount: 0, + }; + } else { + currentLine.toRun = runIndex; + currentLine.toChar = 0; + currentLine.width = roundValue(currentLine.width + boundarySpacing + placeholderWidth); + currentLine.maxFontInfo = updateMaxFontInfo(currentLine.maxFontSize, currentLine.maxFontInfo, run); + currentLine.maxFontSize = Math.max(currentLine.maxFontSize, lineHeightFontSize(run)); + appendSegment(currentLine.segments, runIndex, 0, 0, placeholderWidth); + } + } + + lastFontSize = run.fontSize; + hasSeenTextRun = true; + pendingRunSpacing = 0; + continue; + } + // Handle text runs lastFontSize = run.fontSize; hasSeenTextRun = true; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index dfb4f2470a..6d733a18f5 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2773,6 +2773,74 @@ describe('DomPainter', () => { expect(wrapper.textContent).not.toContain('Harvey citation'); }); + it('renders a visible wrapper for an empty inline SDT placeholder', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'inline-sc-empty', + runs: [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 }, + { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 8, + pmEnd: 8, + visualPlaceholder: 'emptyInlineSdt', + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-empty-1', + alias: 'Empty SDT', + }, + }, + { text: ' after', fontFamily: 'Arial', fontSize: 16, pmStart: 8, pmEnd: 14 }, + ], + attrs: {}, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 2, toChar: 6, width: 120, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-sc-empty', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 14, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="sc-empty-1"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + expect(wrapper?.dataset.empty).toBe('true'); + expect(wrapper?.dataset.pmStart).toBe('8'); + expect(wrapper?.dataset.pmEnd).toBe('8'); + expect(wrapper?.querySelector('.superdoc-empty-inline-sdt-placeholder')).toBeTruthy(); + expect(wrapper?.textContent).not.toContain('old content'); + }); + it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index e3e6133bc7..1488747529 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -66,6 +66,7 @@ import { expandRunsForInlineNewlines, getCellSpacingPx, getParagraphInlineDirection, + isEmptyInlineSdtPlaceholderRun, normalizeColumnLayout, normalizeBaselineShift, resolveBaseFontSizeForVerticalText, @@ -5528,6 +5529,18 @@ export class DomPainter { } } + private renderEmptyInlineSdtPlaceholderRun(run: TextRun): HTMLElement | null { + if (!this.doc) return null; + const elem = this.doc.createElement('span'); + elem.classList.add('superdoc-empty-inline-sdt-placeholder'); + elem.setAttribute('aria-hidden', 'true'); + elem.dataset.layoutEpoch = String(this.layoutEpoch); + if (run.pmStart != null) elem.dataset.pmStart = String(run.pmStart); + if (run.pmEnd != null) elem.dataset.pmEnd = String(run.pmEnd); + this.applySdtDataset(elem, run.sdt); + return elem; + } + private findLastTextRun(runs: Run[]): { run: TextRun; index: number } | null { for (let index = runs.length - 1; index >= 0; index -= 1) { const run = runs[index]; @@ -5665,6 +5678,10 @@ export class DomPainter { return null; } + if (isEmptyInlineSdtPlaceholderRun(run)) { + return this.renderEmptyInlineSdtPlaceholderRun(run); + } + // Handle TextRun if (!('text' in run) || !run.text || !this.doc) { return null; @@ -6721,6 +6738,9 @@ export class DomPainter { if (resolved && this.doc) { if (!geoSdtWrapper) { geoSdtWrapper = this.createInlineSdtWrapper(resolved.sdt); + if (isEmptyInlineSdtPlaceholderRun(runForSdt)) { + geoSdtWrapper.dataset.empty = 'true'; + } geoSdtId = thisRunSdtId; geoSdtWrapperLeft = elemLeftPx; geoSdtMaxRight = elemLeftPx; @@ -6881,6 +6901,23 @@ export class DomPainter { continue; } + if (isEmptyInlineSdtPlaceholderRun(baseRun)) { + const elem = this.renderRun(baseRun, context, trackedConfig); + if (elem) { + if (styleId) { + elem.setAttribute('styleid', styleId); + } + const segment = runSegments[0]!; + const baseX = segment.x !== undefined ? segment.x : cumulativeX; + const xPos = baseX + indentOffset; + elem.style.position = 'absolute'; + elem.style.left = `${xPos}px`; + appendToLineGeo(elem, baseRun, xPos, segment.width); + cumulativeX = baseX + segment.width; + } + continue; + } + // At this point, baseRun must be TextRun (has .text property) if (!('text' in baseRun)) { continue; @@ -7013,6 +7050,9 @@ export class DomPainter { if (resolved && this.doc) { if (!currentInlineSdtWrapper) { currentInlineSdtWrapper = this.createInlineSdtWrapper(resolved.sdt); + if (isEmptyInlineSdtPlaceholderRun(run)) { + currentInlineSdtWrapper.dataset.empty = 'true'; + } this.syncInlineSdtWrapperTypography(currentInlineSdtWrapper, run); currentInlineSdtId = runSdtId; } diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 14f084b300..f26a0f8ffd 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -25,6 +25,36 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain('border-color: var(--sd-content-controls-inline-hover-border, transparent);'); }); + it('gives empty inline SDTs a default visible affordance', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const emptyRule = cssText.match( + /\.superdoc-structured-content-inline\[data-empty='true'\]:not\(\[data-appearance='hidden'\]\)\s*\{([^}]*)\}/, + )?.[1]; + + expect(cssText).toContain(".superdoc-structured-content-inline[data-empty='true']:not([data-appearance='hidden'])"); + expect(cssText).toContain('border-color: var(--sd-content-controls-inline-border, #629be7);'); + expect(emptyRule).not.toContain('display: inline-block'); + expect(emptyRule).not.toContain('vertical-align'); + }); + + it('reserves empty inline SDT width without adding line-box height', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const placeholderRule = cssText.match(/\.superdoc-empty-inline-sdt-placeholder\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(placeholderRule).toContain('display: inline-block;'); + expect(placeholderRule).toContain('width: 8px;'); + expect(placeholderRule).toContain('height: 0;'); + expect(placeholderRule).toContain('line-height: 0;'); + expect(placeholderRule).toContain('vertical-align: baseline;'); + expect(placeholderRule).not.toContain('height: 1em;'); + }); + it('suppresses structured-content hover backgrounds in viewing mode, including grouped hover', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index ef605d8e4e..28a62683e6 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -613,6 +613,25 @@ const SDT_CONTAINER_STYLES = ` outline: none; background-color: transparent; } + +.superdoc-structured-content-inline[data-empty='true']:not([data-appearance='hidden']) { + border-color: var(--sd-content-controls-inline-border, #629be7); +} + +.superdoc-empty-inline-sdt-placeholder { + display: inline-block; + width: 8px; + height: 0; + line-height: 0; + vertical-align: baseline; + overflow: hidden; +} + +.superdoc-structured-content-inline[data-appearance='hidden'] .superdoc-empty-inline-sdt-placeholder { + width: 0; + min-width: 0; +} + /* Inline structured content label - shown when active */ .superdoc-structured-content-inline__label { position: absolute; diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts index 30ecfec2dd..87ed9ec1e1 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/structured-content.ts @@ -1,17 +1,39 @@ -import { type InlineConverterParams } from './common'; +import type { TextRun } from '@superdoc/contracts'; + +import { applyInlineRunProperties, type InlineConverterParams } from './common'; import { resolveNodeSdtMetadata } from '../../sdt/index.js'; export function structuredContentNodeToBlocks({ node, + positions, + defaultFont, + defaultSize, inheritedMarks, sdtMetadata, visitNode, runProperties, inlineRunProperties, -}: InlineConverterParams): void { + converterContext, +}: InlineConverterParams): TextRun | void { const inlineMetadata = resolveNodeSdtMetadata(node, 'structuredContent'); const nextSdt = inlineMetadata ?? sdtMetadata; + + if (inlineMetadata?.scope === 'inline' && (!node.content || node.content.length === 0)) { + const pos = positions.get(node); + const contentPos = pos ? pos.start + 1 : undefined; + const placeholder: TextRun = { + kind: 'text', + text: '', + fontFamily: defaultFont, + fontSize: defaultSize, + sdt: inlineMetadata, + visualPlaceholder: 'emptyInlineSdt', + ...(contentPos != null ? { pmStart: contentPos, pmEnd: contentPos } : {}), + }; + return applyInlineRunProperties(placeholder, runProperties, converterContext, inlineRunProperties); + } + // SD-2781: forward inlineRunProperties so children inside this SDT wrapper // preserve run-level bidi/script metadata. The SDT itself doesn't introduce a // new run boundary, so the parent run's inline source still applies. diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index 78d0862571..b4ed57350e 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -405,6 +405,38 @@ describe('paragraph converters', () => { }); }); + it('should not merge empty inline SDT placeholders into adjacent text runs', () => { + const run1: TextRun = { + text: 'before', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 0, + pmEnd: 6, + }; + const placeholder: TextRun = { + kind: 'text', + text: '', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 6, + pmEnd: 6, + visualPlaceholder: 'emptyInlineSdt', + sdt: { type: 'structuredContent', scope: 'inline', id: 'sdt-empty' }, + }; + const run2: TextRun = { + text: 'after', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 6, + pmEnd: 11, + }; + + vi.mocked(trackedChangesCompatible).mockReturnValue(true); + + const result = mergeAdjacentRuns([run1, placeholder, run2]); + expect(result).toEqual([run1, placeholder, run2]); + }); + it('should not merge runs with non-continuous PM positions', () => { const run1: TextRun = { text: 'hello', @@ -1592,6 +1624,79 @@ describe('paragraph converters', () => { ); }); + it('should emit a visual placeholder run for empty inline structuredContent', () => { + const sdtNode: PMNode = { + type: 'structuredContent', + content: [], + }; + const sdtMetadata: SdtMetadata = { + type: 'structuredContent', + scope: 'inline', + id: 'empty-inline-sdt', + alias: 'Empty SDT', + }; + positions.set(sdtNode, { start: 20, end: 22 }); + vi.mocked(resolveNodeSdtMetadata).mockReturnValue(sdtMetadata); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [sdtNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + ); + + const paraBlock = blocks[0] as ParagraphBlock; + expect(paraBlock.runs).toEqual([ + expect.objectContaining({ + kind: 'text', + text: '', + sdt: sdtMetadata, + visualPlaceholder: 'emptyInlineSdt', + pmStart: 21, + pmEnd: 21, + }), + ]); + }); + + it('should emit a visual placeholder run for inline structuredContent with omitted content', () => { + const sdtNode: PMNode = { + type: 'structuredContent', + }; + const sdtMetadata: SdtMetadata = { + type: 'structuredContent', + scope: 'inline', + id: 'contentless-inline-sdt', + }; + positions.set(sdtNode, { start: 30, end: 32 }); + vi.mocked(resolveNodeSdtMetadata).mockReturnValue(sdtMetadata); + + const blocks = paragraphToFlowBlocks( + { + type: 'paragraph', + content: [sdtNode], + }, + nextBlockId, + positions, + 'Arial', + 16, + ); + + const paraBlock = blocks[0] as ParagraphBlock; + expect(paraBlock.runs).toEqual([ + expect.objectContaining({ + text: '', + sdt: sdtMetadata, + visualPlaceholder: 'emptyInlineSdt', + pmStart: 31, + pmEnd: 31, + }), + ]); + }); + it('should render fieldAnnotation as FieldAnnotationRun with inner content as displayLabel', () => { const blocks = paragraphToFlowBlocks( { diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts index a3f5d964e8..15e9b607dd 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.ts @@ -18,7 +18,7 @@ import type { TrackedChangeMeta, SourceAnchor, } from '@superdoc/contracts'; -import { expandRunsForInlineNewlines } from '@superdoc/contracts'; +import { expandRunsForInlineNewlines, isEmptyInlineSdtPlaceholderRun } from '@superdoc/contracts'; import type { PMNode, PMMark, @@ -209,6 +209,8 @@ export function mergeAdjacentRuns(runs: Run[]): Run[] { isTextRun(next) && !current.token && !next.token && + !isEmptyInlineSdtPlaceholderRun(current) && + !isEmptyInlineSdtPlaceholderRun(next) && current.pmStart != null && current.pmEnd != null && next.pmStart != null && @@ -967,7 +969,6 @@ const INLINE_CONVERTERS_REGISTRY: Record = { }, structuredContent: { inlineConverter: structuredContentNodeToBlocks, - extraCheck: (node: PMNode) => Array.isArray(node.content), }, fieldAnnotation: { inlineConverter: fieldAnnotationNodeToRun, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts index 1731154f0e..b22985dc67 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/DomSelectionGeometry.test.ts @@ -1619,6 +1619,38 @@ describe('computeDomCaretPageLocal', () => { y: 20, }); }); + + it('positions caret at the left edge of an empty inline SDT placeholder', () => { + painterHost.innerHTML = ` +
+
+ + + +
+
+ `; + + domPositionIndex.rebuild(painterHost); + + const pageEl = painterHost.querySelector('.superdoc-page') as HTMLElement; + const lineEl = painterHost.querySelector('.superdoc-line') as HTMLElement; + const placeholderEl = painterHost.querySelector('.superdoc-empty-inline-sdt-placeholder') as HTMLElement; + + pageEl.getBoundingClientRect = vi.fn(() => createRect(0, 0, 612, 792)); + lineEl.getBoundingClientRect = vi.fn(() => createRect(10, 20, 100, 16)); + placeholderEl.getBoundingClientRect = vi.fn(() => createRect(10, 34, 8, 0)); + + const options = createCaretOptions(); + const caret = computeDomCaretPageLocal(options, 5); + + expect(caret).not.toBe(null); + expect(caret).toMatchObject({ + pageIndex: 0, + x: 10, + y: 20, + }); + }); }); describe('index rebuild for disconnected elements', () => { diff --git a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts index 9e32dfbe2d..d05c732615 100644 --- a/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts +++ b/packages/super-editor/src/editors/v1/dom-observer/DomSelectionGeometry.ts @@ -578,11 +578,14 @@ export function computeDomCaretPageLocal( const elRect = targetEl.getBoundingClientRect(); // For non-text elements (images, math), position caret at the right edge // when pos matches pmEnd (cursor after the element) - const atEnd = pos >= entry.pmEnd; + const isEmptyInlineSdtPlaceholder = targetEl.classList.contains('superdoc-empty-inline-sdt-placeholder'); + const atEnd = !isEmptyInlineSdtPlaceholder && pos >= entry.pmEnd; + const lineEl = isEmptyInlineSdtPlaceholder ? (targetEl.closest('.superdoc-line') as HTMLElement | null) : null; + const yRect = lineEl?.getBoundingClientRect() ?? elRect; return { pageIndex: Number(page.dataset.pageIndex ?? '0'), x: ((atEnd ? elRect.right : elRect.left) - pageRect.left) / zoom, - y: (elRect.top - pageRect.top) / zoom, + y: (yRect.top - pageRect.top) / zoom, }; } 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 ce2aba190b..a1aeb4638b 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 @@ -145,6 +145,19 @@ export function createStructuredContentLockPlugin() { // boundaries can span wider ranges, but filterTransaction catches the real // step range as a safety net (with a possible brief cursor jump). if (from === to) { + const emptyInlineSDT = sdtNodes.find( + (s) => s.type === 'structuredContent' && s.pos + 1 === from && s.end - 1 === from, + ); + if ((isBackspace || isDelete) && emptyInlineSDT) { + const isWrapperDeletable = + emptyInlineSDT.lockMode !== 'sdtLocked' && emptyInlineSDT.lockMode !== 'sdtContentLocked'; + event.preventDefault(); + if (isWrapperDeletable) { + view.dispatch(state.tr.delete(emptyInlineSDT.pos, emptyInlineSDT.end)); + } + return true; + } + if (isBackspace && from > 0) { affectedFrom = from - 1; // Path 2 — caret is exactly at the trailing wrapper boundary of an 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 9cf1550cc5..ac551164bd 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 @@ -566,6 +566,30 @@ describe('StructuredContentLockPlugin', () => { expect(invokeLockHandleKeyDown('Backspace').handled).toBe(true); expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); + + it.each([ + ['unlocked', 'Backspace', true], + ['unlocked', 'Delete', true], + ['contentLocked', 'Backspace', true], + ['sdtLocked', 'Backspace', false], + ['sdtContentLocked', 'Backspace', false], + ])('%s + %s inside an empty inline SDT', (lockMode, key, shouldDeleteWrapper) => { + const beforeText = schema.text('Before '); + const sdt = schema.nodes.structuredContent.create({ id: 'test-123', lockMode }); + const afterText = schema.text(' After'); + const paragraph = schema.nodes.paragraph.create(null, [beforeText, sdt, afterText]); + const doc = schema.nodes.doc.create(null, [paragraph]); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + placeCaretAt(state, sdtInfo.pos + 1); + + const result = invokeLockHandleKeyDown(key); + + expect(result.handled).toBe(true); + expect(result.prevented).toBe(true); + expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(!shouldDeleteWrapper); + }); }); describe('Path 1 — selection covers SDT content (label selection / triple-click)', () => { From 92f60e449400a15abd3e418372deae5939c5452b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 12:33:30 -0300 Subject: [PATCH 008/135] fix(super-editor): intercept beforeinput insertText at inline SDT boundaries When the caret sits directly before or after an inline structured content node, native beforeinput handling can drift the inserted character into the SDT. Force the manual insertText path for collapsed selections at those boundaries so the character lands on the outer side of the wrapper. --- .../editors/v1/core/extensions/editable.js | 19 +++- .../v1/core/extensions/editable.test.js | 89 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/extensions/editable.js b/packages/super-editor/src/editors/v1/core/extensions/editable.js index 9ed73fe43d..428e90eed3 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/editable.js +++ b/packages/super-editor/src/editors/v1/core/extensions/editable.js @@ -62,6 +62,20 @@ const recordStoryInputDebug = (view, event, editor, phase, extra = {}) => { }); }; +const isInlineStructuredContentNode = (node) => node?.type?.name === 'structuredContent' && node.isInline; + +const isInlineStructuredContentBoundary = (doc, pos) => { + if (typeof pos !== 'number' || pos < 0 || pos > doc.content.size) { + return false; + } + + const $pos = doc.resolve(pos); + const before = $pos.parent.childBefore($pos.parentOffset).node; + const after = $pos.parent.childAfter($pos.parentOffset).node; + + return isInlineStructuredContentNode(before) || isInlineStructuredContentNode(after); +}; + const handleInsertTextBeforeInput = (view, event, editor) => { const isInsertTextInput = event?.inputType === 'insertText'; const hasTextData = typeof event?.data === 'string' && event.data.length > 0; @@ -79,7 +93,10 @@ const handleInsertTextBeforeInput = (view, event, editor) => { } const selection = view.state.selection; - if (selection.empty && !isStorySurfaceEditor(editor)) { + const shouldHandleCollapsedSelection = + isStorySurfaceEditor(editor) || isInlineStructuredContentBoundary(view.state.doc, selection.from); + + if (selection.empty && !shouldHandleCollapsedSelection) { recordStoryInputDebug(view, event, editor, 'beforeinput:skip-empty-selection'); return false; } diff --git a/packages/super-editor/src/editors/v1/core/extensions/editable.test.js b/packages/super-editor/src/editors/v1/core/extensions/editable.test.js index 09e4d9d958..b3a4ecec70 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/editable.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/editable.test.js @@ -17,6 +17,36 @@ const findTextRange = (doc, text) => { return range; }; +const findStructuredContent = (doc) => { + let result = null; + doc.descendants((node, pos) => { + if (node.type.name === 'structuredContent') { + result = { node, pos }; + return false; + } + return true; + }); + return result; +}; + +const inlineStructuredContentDoc = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'run', content: [{ type: 'text', text: 'A ' }] }, + { + type: 'structuredContent', + attrs: { id: 'inline-sdt-1' }, + content: [{ type: 'run', content: [{ type: 'text', text: 'Field' }] }], + }, + { type: 'run', content: [{ type: 'text', text: ' Z' }] }, + ], + }, + ], +}; + /** * Test the handleKeyDown plugin handler directly via someProp. * Returns true if the handler blocked the key, false if allowed. @@ -42,6 +72,12 @@ describe('Editable extension insertText beforeinput handling', () => { editor = null; }); + const getInlineStructuredContent = () => { + const sdt = findStructuredContent(editor.state.doc); + expect(sdt).not.toBeNull(); + return sdt; + }; + it('replaces backward non-empty selection on beforeinput insertText', () => { ({ editor } = initTestEditor({ mode: 'text', @@ -112,6 +148,59 @@ describe('Editable extension insertText beforeinput handling', () => { expect(editor.state.doc.textContent).toBe('QA'); }); + it('inserts collapsed beforeinput insertText outside an inline SDT after its boundary', () => { + ({ editor } = initTestEditor({ + loadFromSchema: true, + content: structuredClone(inlineStructuredContentDoc), + })); + + const sdt = getInlineStructuredContent(); + const afterSdt = sdt.pos + sdt.node.nodeSize; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, afterSdt))); + + const beforeInputEvent = new InputEvent('beforeinput', { + data: 'a', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent); + + const updatedSdt = findStructuredContent(editor.state.doc); + expect(prevented).toBe(true); + expect(updatedSdt).not.toBeNull(); + expect(updatedSdt.node.textContent).toBe('Field'); + expect(editor.state.doc.textContent).toBe('A Fielda Z'); + expect(editor.state.doc.textBetween(0, editor.state.selection.from)).toBe('A Fielda'); + expect(editor.state.selection.empty).toBe(true); + }); + + it('inserts collapsed beforeinput insertText outside an inline SDT before its boundary', () => { + ({ editor } = initTestEditor({ + loadFromSchema: true, + content: structuredClone(inlineStructuredContentDoc), + })); + + const sdt = getInlineStructuredContent(); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, sdt.pos))); + + const beforeInputEvent = new InputEvent('beforeinput', { + data: 'a', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent); + + const updatedSdt = findStructuredContent(editor.state.doc); + expect(prevented).toBe(true); + expect(updatedSdt).not.toBeNull(); + expect(updatedSdt.node.textContent).toBe('Field'); + expect(editor.state.doc.textContent).toBe('A aField Z'); + expect(editor.state.doc.textBetween(0, editor.state.selection.from)).toBe('A a'); + expect(editor.state.selection.empty).toBe(true); + }); + it('intercepts collapsed beforeinput insertText for active footer editors', () => { ({ editor } = initTestEditor({ mode: 'text', From 236109faefcd4b5453975a7aaccabc5a613af98e Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 14:14:34 -0300 Subject: [PATCH 009/135] fix(super-editor): select inline SDT content as text on Delete Mirrors selectInlineSdtBeforeRunStart for forward deletion: when Delete is pressed at the end of a run preceding an inline SDT, select the SDT content as a TextSelection instead of falling through to generic deletion. The structured-content lock plugin no longer consumes Delete at the leading inline SDT boundary so the keymap can handle it. --- .../v1/core/commands/core-command-map.d.ts | 1 + .../v1/core/commands/core-command-map.test.ts | 1 + .../commands/selectInlineSdtBeforeRunStart.js | 57 +++++++-- .../selectInlineSdtBeforeRunStart.test.js | 117 +++++++++++++++++- .../extensions/keymap-backspace-chain.test.js | 58 ++++++++- .../src/editors/v1/core/extensions/keymap.js | 1 + .../structured-content-lock-plugin.js | 6 +- .../structured-content-lock-plugin.test.js | 33 ++++- 8 files changed, 256 insertions(+), 18 deletions(-) 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 a27ac0c094..bfd3b47778 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 @@ -63,6 +63,7 @@ type CoreCommandNames = | 'backspaceAcrossRuns' | 'backspaceAtomBefore' | 'selectInlineSdtBeforeRunStart' + | 'selectInlineSdtAfterRunEnd' | '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 index 1ec288f6eb..ef8c702ac0 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 @@ -10,5 +10,6 @@ describe('core command map types', () => { const declaration = readFileSync(declarationPath, 'utf8'); expect(declaration).toContain("| 'selectInlineSdtBeforeRunStart'"); + expect(declaration).toContain("| 'selectInlineSdtAfterRunEnd'"); }); }); diff --git a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js index 1ff3046bb6..f4a3a2843e 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.js @@ -17,6 +17,36 @@ function getPreviousInlineSdt(state) { return { node, pos: $from.pos - node.nodeSize }; } +function getNextInlineSdt(state) { + const { $from } = state.selection; + + if ($from.parent.type.name === 'run' && $from.parentOffset === $from.parent.content.size) { + const runEnd = $from.after($from.depth); + const node = state.doc.resolve(runEnd).nodeAfter; + if (node?.type.name !== 'structuredContent') return null; + return { node, pos: runEnd }; + } + + const node = $from.nodeAfter; + if (node?.type.name !== 'structuredContent') return null; + return { node, pos: $from.pos }; +} + +function selectInlineSdtContent(state, dispatch, sdt) { + if (dispatch) { + const contentStart = sdt.pos + 1; + const contentEnd = sdt.pos + sdt.node.nodeSize - 1; + + dispatch( + state.tr + .setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true) + .setSelection(TextSelection.create(state.doc, contentStart, contentEnd)), + ); + } + + return true; +} + /** * Selects inline SDT content when Backspace is pressed at the start of the * following run. Without this, run-aware Backspace scans into the SDT content. @@ -32,16 +62,23 @@ export const selectInlineSdtBeforeRunStart = const previousSdt = getPreviousInlineSdt(state); if (!previousSdt) return false; - if (dispatch) { - const contentStart = previousSdt.pos + 1; - const contentEnd = previousSdt.pos + previousSdt.node.nodeSize - 1; + return selectInlineSdtContent(state, dispatch, previousSdt); + }; + +/** + * Selects inline SDT content when Delete is pressed at the end of the + * previous run. Mirrors selectInlineSdtBeforeRunStart for forward deletion. + * + * @returns {import('@core/commands/types').Command} + */ +export const selectInlineSdtAfterRunEnd = + () => + ({ state, dispatch }) => { + const { selection } = state; + if (!selection.empty) return false; - dispatch( - state.tr - .setMeta(SELECT_INLINE_SDT_BEFORE_RUN_START_META, true) - .setSelection(TextSelection.create(state.doc, contentStart, contentEnd)), - ); - } + const nextSdt = getNextInlineSdt(state); + if (!nextSdt) return false; - return true; + return selectInlineSdtContent(state, dispatch, nextSdt); }; 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 index eace887010..f17799f9d2 100644 --- a/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js +++ b/packages/super-editor/src/editors/v1/core/commands/selectInlineSdtBeforeRunStart.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { Schema } from 'prosemirror-model'; import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; -import { selectInlineSdtBeforeRunStart } from './selectInlineSdtBeforeRunStart.js'; +import { selectInlineSdtAfterRunEnd, selectInlineSdtBeforeRunStart } from './selectInlineSdtBeforeRunStart.js'; const makeSchema = () => new Schema({ @@ -30,6 +30,13 @@ const makeDoc = (schema, lockMode = 'contentLocked') => { return schema.node('doc', null, [schema.node('paragraph', null, [sdt, followingRun])]); }; +const makeDocWithPreviousRun = (schema, lockMode = 'contentLocked') => { + const previousRun = schema.nodes.run.create(null, schema.text('Before text')); + const sdtRun = schema.nodes.run.create(null, schema.text('Locked content')); + const sdt = schema.nodes.structuredContent.create({ lockMode }, sdtRun); + return schema.node('doc', null, [schema.node('paragraph', null, [previousRun, sdt])]); +}; + const findNode = (doc, typeName, predicate = () => true) => { let result = null; doc.descendants((node, pos) => { @@ -149,3 +156,111 @@ describe('selectInlineSdtBeforeRunStart', () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +describe('selectInlineSdtAfterRunEnd', () => { + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + 'selects only the %s inline SDT content after the current run', + (lockMode) => { + const schema = makeSchema(); + const doc = makeDocWithPreviousRun(schema, lockMode); + const sdt = findNode(doc, 'structuredContent'); + const previousRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Before')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, previousRun.end - 1), + }); + + let dispatched; + const ok = selectInlineSdtAfterRunEnd()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection).not.toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(sdt.pos + 1); + expect(dispatched.selection.to).toBe(sdt.end - 1); + expect(dispatched.selection.content().content.textBetween(0, dispatched.selection.content().content.size)).toBe( + 'Locked content', + ); + }, + ); + + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + 'selects only the %s inline SDT content from the leading boundary', + (lockMode) => { + const schema = makeSchema(); + const doc = makeDocWithPreviousRun(schema, lockMode); + const sdt = findNode(doc, 'structuredContent'); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, sdt.pos), + }); + + let dispatched; + const ok = selectInlineSdtAfterRunEnd()({ state, dispatch: (tr) => (dispatched = tr) }); + + expect(ok).toBe(true); + expect(dispatched).toBeDefined(); + expect(dispatched.selection).toBeInstanceOf(TextSelection); + expect(dispatched.selection).not.toBeInstanceOf(NodeSelection); + expect(dispatched.selection.from).toBe(sdt.pos + 1); + expect(dispatched.selection.to).toBe(sdt.end - 1); + }, + ); + + it('returns true without dispatching when no dispatch is provided', () => { + const schema = makeSchema(); + const doc = makeDocWithPreviousRun(schema); + const previousRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Before')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, previousRun.end - 1), + }); + + const ok = selectInlineSdtAfterRunEnd()({ state }); + + expect(ok).toBe(true); + }); + + it('returns false when the cursor is not at the end of a run', () => { + const schema = makeSchema(); + const doc = makeDocWithPreviousRun(schema); + const previousRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Before')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, previousRun.end - 2), + }); + const dispatch = vi.fn(); + + const ok = selectInlineSdtAfterRunEnd()({ state, dispatch }); + + expect(ok).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('returns false when the next 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 text')), + schema.nodes.run.create(null, schema.text('After')), + ]), + ]); + const previousRun = findNode(doc, 'run', (node) => node.textContent.startsWith('Before')); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, previousRun.end - 1), + }); + const dispatch = vi.fn(); + + const ok = selectInlineSdtAfterRunEnd()({ 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 09034a6b9d..f653d6e360 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 @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { handleBackspace } from './keymap.js'; +import { handleBackspace, handleDelete } from './keymap.js'; /** * Pins the ordering of commands in the Backspace chain. @@ -161,3 +161,59 @@ describe('handleBackspace chain ordering', () => { expect(callLog).not.toContain('mixedBidiBackspace'); }); }); + +describe('handleDelete chain ordering', () => { + const makeEditor = () => { + const callLog = []; + const tr = { + setMeta: vi.fn(() => tr), + }; + const make = (name) => () => { + callLog.push(name); + return false; + }; + + const commands = { + deleteBlockSdtAtTextBlockStart: make('deleteBlockSdtAtTextBlockStart'), + selectInlineSdtAfterRunEnd: make('selectInlineSdtAfterRunEnd'), + deleteSkipEmptyRun: make('deleteSkipEmptyRun'), + deleteAtomAfter: make('deleteAtomAfter'), + deleteNextToRun: make('deleteNextToRun'), + deleteSelection: make('deleteSelection'), + joinForward: make('joinForward'), + selectNodeForward: make('selectNodeForward'), + }; + + const editor = { + view: { state: { tr }, dispatch: vi.fn() }, + commands: { + first: vi.fn((build) => { + const fns = build({ commands }); + for (const fn of fns) { + const result = fn(); + if (result) return result; + } + return false; + }), + }, + }; + + return { editor, callLog }; + }; + + it('runs inline SDT forward selection before generic Delete fallbacks', () => { + const { editor, callLog } = makeEditor(); + handleDelete(editor); + + expect(callLog).toEqual([ + 'deleteBlockSdtAtTextBlockStart', + 'selectInlineSdtAfterRunEnd', + 'deleteSkipEmptyRun', + 'deleteAtomAfter', + 'deleteNextToRun', + 'deleteSelection', + 'joinForward', + 'selectNodeForward', + ]); + }); +}); 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 0eec4b8979..c1873ff34f 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/keymap.js +++ b/packages/super-editor/src/editors/v1/core/extensions/keymap.js @@ -59,6 +59,7 @@ export const handleDelete = (editor) => { return editor.commands.first(({ commands }) => [ () => commands.deleteBlockSdtAtTextBlockStart(), + () => commands.selectInlineSdtAfterRunEnd(), () => commands.deleteSkipEmptyRun(), () => commands.deleteAtomAfter(), () => commands.deleteNextToRun(), 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 a1aeb4638b..2ae4a65a79 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 @@ -170,8 +170,12 @@ export function createStructuredContentLockPlugin() { } } else if (isDelete && to < state.doc.content.size) { affectedTo = to + 1; - // Symmetric: caret immediately before an SDT. + // Symmetric: caret immediately before an inline SDT. Let the + // Delete keymap select its content, mirroring trailing Backspace. const adjacentSDT = sdtNodes.find((s) => s.pos === to); + if (adjacentSDT?.type === 'structuredContent') { + return false; + } if (adjacentSDT) { affectedFrom = adjacentSDT.pos; affectedTo = adjacentSDT.end; 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 ac551164bd..00bcae2674 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,7 +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 { handleBackspace, handleDelete } from '@core/extensions/keymap.js'; import { STRUCTURED_CONTENT_LOCK_KEY } from './structured-content-lock-plugin.js'; /** @@ -473,10 +473,15 @@ describe('StructuredContentLockPlugin', () => { false, 'sdtContentLocked + Backspace at trailing boundary: lets keymap select content', ], - ['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'], + ['unlocked', 'Delete', false, 'unlocked + Delete at leading boundary: lets keymap select content'], + ['contentLocked', 'Delete', false, 'contentLocked + Delete at leading boundary: lets keymap select content'], + ['sdtLocked', 'Delete', false, 'sdtLocked + Delete at leading boundary: lets keymap select content'], + [ + 'sdtContentLocked', + 'Delete', + false, + 'sdtContentLocked + Delete at leading boundary: lets keymap select content', + ], ]; it.each(adjacencyCases)('%s + %s', (lockMode, key, shouldConsume) => { @@ -511,6 +516,24 @@ describe('StructuredContentLockPlugin', () => { }, ); + it.each(['unlocked', 'sdtLocked', 'contentLocked', 'sdtContentLocked'])( + '%s + Delete at the leading boundary selects inline SDT content', + (lockMode) => { + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + placeCaretAt(state, sdtInfo.pos); + + handleDelete(editor); + + const selection = editor.state.selection; + expect(selection).toBeInstanceOf(TextSelection); + expect(selection.from).toBe(sdtInfo.pos + 1); + expect(selection.to).toBe(sdtInfo.end - 1); + }, + ); + it('contentLocked + Backspace then Backspace deletes the SDT (two-stage Word UX)', () => { const doc = createDocWithSDTAndSurroundingText('contentLocked', 'structuredContent'); const initialState = applyDocToEditor(doc); From a5f902dbab55ce8ff3adf5f55d7402913d8768d1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 14:20:22 -0300 Subject: [PATCH 010/135] test(super-editor): clarify inline SDT boundary lock comment --- .../structured-content/structured-content-lock-plugin.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 2ae4a65a79..1e70ddbb25 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 @@ -140,10 +140,11 @@ export function createStructuredContentLockPlugin() { let affectedFrom = from; let affectedTo = to; - // If selection is collapsed, backspace/delete affects adjacent position. - // Note: this is a single-character approximation. joinBackward at paragraph - // boundaries can span wider ranges, but filterTransaction catches the real - // step range as a safety net (with a possible brief cursor jump). + // If selection is collapsed, Backspace/Delete affects adjacent content. + // Inline SDT wrapper boundaries are handed to keymap commands so both + // directions can select the SDT content before a destructive action. + // Other positions use a single-character approximation here; + // filterTransaction catches wider step ranges as a safety net. if (from === to) { const emptyInlineSDT = sdtNodes.find( (s) => s.type === 'structuredContent' && s.pos + 1 === from && s.end - 1 === from, From 6f5abf0b492a4dd0036c3de6ca260367abfbb707 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 14:20:56 -0300 Subject: [PATCH 011/135] test(super-editor): cover inline SDT content Delete flow --- .../structured-content-lock-plugin.test.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 00bcae2674..1d7507c462 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 @@ -456,6 +456,14 @@ describe('StructuredContentLockPlugin', () => { return setSelection(state, TextSelection.create(state.doc, pos)); } + function pressDeleteThroughHandlers() { + const result = invokeLockHandleKeyDown('Delete'); + if (!result.handled) { + handleDelete(editor); + } + return result; + } + describe('Path 2 — caret immediately adjacent to inline SDT', () => { const adjacencyCases = [ // [lockMode, key, shouldConsume, description] @@ -688,6 +696,33 @@ describe('StructuredContentLockPlugin', () => { expect(sdtNodeExists(editor.state.doc, 'structuredContent')).toBe(false); }); + it.each([ + ['unlocked', false, true], + ['sdtLocked', false, true], + ['contentLocked', true, false], + ['sdtContentLocked', true, false], + ])( + '%s: exact content selection + Delete follows lock plugin then keymap', + (lockMode, pluginConsumes, deletesContent) => { + const doc = createDocWithSDTAndSurroundingText(lockMode, 'structuredContent'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContent'); + + setSelection(state, TextSelection.create(state.doc, sdtInfo.pos + 1, sdtInfo.end - 1)); + + const result = pressDeleteThroughHandlers(); + + expect(result.handled).toBe(pluginConsumes); + const sdtAfter = findSDTNode(editor.state.doc, 'structuredContent'); + if (lockMode === 'contentLocked') { + expect(sdtAfter).toBeNull(); + } else { + expect(sdtAfter).not.toBeNull(); + expect(sdtAfter.node.textContent === '').toBe(deletesContent); + } + }, + ); + it.each([['contentLocked']])( '%s: select-all + Cmd+X promotes to NodeSelection in one keystroke (no preventDefault)', (lockMode) => { From d5fdf4b410be12c4cd533fca430d4b75dc004cd7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 14:21:18 -0300 Subject: [PATCH 012/135] test(super-editor): cover inline SDT Cmd+X selection --- .../structured-content-lock-plugin.test.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 1d7507c462..8b7aa3f431 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 @@ -748,6 +748,29 @@ describe('StructuredContentLockPlugin', () => { }, ); + it.each([['unlocked'], ['sdtLocked']])( + '%s: select-all + Cmd+X leaves content selection for PM clipboard handling', + (lockMode) => { + 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 result = invokeLockHandleKeyDown('x', { metaKey: true }); + expect(result.handled).toBe(false); + expect(result.prevented).toBe(false); + + const sel = editor.state.selection; + expect(sel).toBeInstanceOf(TextSelection); + expect(sel).not.toBeInstanceOf(NodeSelection); + expect(sel.from).toBe(contentFrom); + expect(sel.to).toBe(contentTo); + }, + ); + it('sdtLocked: select-all + Backspace still allows content deletion (no promotion)', () => { const doc = createDocWithSDTAndSurroundingText('sdtLocked', 'structuredContent'); const state = applyDocToEditor(doc); From 2f53f52ca32b9e604d47af26df2321a3f6954ab7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 14:21:53 -0300 Subject: [PATCH 013/135] test(super-editor): cover locked inline SDT beforeinput --- .../v1/core/extensions/editable.test.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/extensions/editable.test.js b/packages/super-editor/src/editors/v1/core/extensions/editable.test.js index b3a4ecec70..da38d84e42 100644 --- a/packages/super-editor/src/editors/v1/core/extensions/editable.test.js +++ b/packages/super-editor/src/editors/v1/core/extensions/editable.test.js @@ -47,6 +47,12 @@ const inlineStructuredContentDoc = { ], }; +const inlineStructuredContentDocWithLockMode = (lockMode) => { + const doc = structuredClone(inlineStructuredContentDoc); + doc.content[0].content[1].attrs.lockMode = lockMode; + return doc; +}; + /** * Test the handleKeyDown plugin handler directly via someProp. * Returns true if the handler blocked the key, false if allowed. @@ -201,6 +207,42 @@ describe('Editable extension insertText beforeinput handling', () => { expect(editor.state.selection.empty).toBe(true); }); + it.each([ + ['contentLocked', 'after', 'A Fielda Z', 'A Fielda'], + ['sdtContentLocked', 'after', 'A Fielda Z', 'A Fielda'], + ['contentLocked', 'before', 'A aField Z', 'A a'], + ['sdtContentLocked', 'before', 'A aField Z', 'A a'], + ])( + 'inserts collapsed beforeinput insertText outside a %s inline SDT at the %s boundary', + (lockMode, boundary, expectedText, expectedTextBeforeSelection) => { + ({ editor } = initTestEditor({ + loadFromSchema: true, + content: inlineStructuredContentDocWithLockMode(lockMode), + })); + + const sdt = getInlineStructuredContent(); + const pos = boundary === 'after' ? sdt.pos + sdt.node.nodeSize : sdt.pos; + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos))); + + const beforeInputEvent = new InputEvent('beforeinput', { + data: 'a', + inputType: 'insertText', + bubbles: true, + cancelable: true, + }); + const prevented = !editor.view.dom.dispatchEvent(beforeInputEvent); + + const updatedSdt = findStructuredContent(editor.state.doc); + expect(prevented).toBe(true); + expect(updatedSdt).not.toBeNull(); + expect(updatedSdt.node.attrs.lockMode).toBe(lockMode); + expect(updatedSdt.node.textContent).toBe('Field'); + expect(editor.state.doc.textContent).toBe(expectedText); + expect(editor.state.doc.textBetween(0, editor.state.selection.from)).toBe(expectedTextBeforeSelection); + expect(editor.state.selection.empty).toBe(true); + }, + ); + it('intercepts collapsed beforeinput insertText for active footer editors', () => { ({ editor } = initTestEditor({ mode: 'text', From dc33ec8f37538895731b66af641ee21ec3cc5e75 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:01:51 -0300 Subject: [PATCH 014/135] fix(super-editor): mark block SDT selected when contained image is selected --- .../presentation-editor/PresentationEditor.ts | 29 +++- .../tests/PresentationEditor.test.ts | 138 ++++++++++++++++++ 2 files changed, 161 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index d72de0811f..e667883919 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -4941,7 +4941,6 @@ export class PresentationEditor extends EventEmitter { */ #focusEditorAfterImageSelection(): void { this.#shouldScrollSelectionIntoView = true; - this.#scheduleSelectionUpdate(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } @@ -4950,6 +4949,7 @@ export class PresentationEditor extends EventEmitter { editorDom.focus(); this.#editor.view?.focus(); } + this.#scheduleSelectionUpdate({ immediate: true }); } #resolveFieldAnnotationSelectionFromElement( @@ -6641,14 +6641,23 @@ export class PresentationEditor extends EventEmitter { let node: ProseMirrorNode | null = null; let pos: number | null = null; let id: string | null = null; + let fallbackPos: number | null = null; if (selection instanceof NodeSelection) { - if (selection.node?.type?.name !== 'structuredContentBlock') { - this.#clearSelectedStructuredContentBlockClass(); - return; + if (selection.node?.type?.name === 'structuredContentBlock') { + node = selection.node; + pos = selection.from; + } else { + fallbackPos = selection.from; + const editorDoc = this.#editor?.view?.state?.doc; + const resolved = editorDoc ? findStructuredContentBlockAtPos(editorDoc, selection.from) : null; + if (!resolved) { + this.#clearSelectedStructuredContentBlockClass(); + return; + } + node = resolved.node; + pos = resolved.pos; } - node = selection.node; - pos = selection.from; } else { const editorDoc = this.#editor?.view?.state?.doc; if (!editorDoc) { @@ -6692,6 +6701,14 @@ export class PresentationEditor extends EventEmitter { } } + if (elements.length === 0 && fallbackPos != null && fallbackPos !== pos) { + const elementAtFallbackPos = this.getElementAtPos(fallbackPos, { fallbackToCoords: true }); + const container = elementAtFallbackPos?.closest?.(`.${DOM_CLASS_NAMES.BLOCK_SDT}`) as HTMLElement | null; + if (container) { + elements = [container]; + } + } + if (elements.length === 0) { this.#clearSelectedStructuredContentBlockClass(); return; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index d37adab113..37cdd4eabc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -5,6 +5,7 @@ import type { Editor as EditorInstance } from '../../Editor.js'; import { Editor } from '../../Editor.js'; import { HeaderFooterEditorManager, HeaderFooterLayoutAdapter } from '../../header-footer/HeaderFooterRegistry.js'; import { buildMultiSectionIdentifier } from '@superdoc/layout-bridge'; +import { NodeSelection } from 'prosemirror-state'; type MockedEditor = Mock<(...args: unknown[]) => EditorInstance> & { mock: { @@ -396,6 +397,36 @@ describe('PresentationEditor', () => { let container: HTMLElement; let editor: PresentationEditor; + const makeNodeSelection = (from: number, to: number, node: Record) => { + const selection = Object.create(NodeSelection.prototype); + Object.defineProperty(selection, 'from', { value: from, configurable: true }); + Object.defineProperty(selection, 'to', { value: to, configurable: true }); + Object.defineProperty(selection, 'anchor', { value: from, configurable: true }); + Object.defineProperty(selection, 'head', { value: to, configurable: true }); + Object.defineProperty(selection, 'empty', { value: false, configurable: true }); + Object.defineProperty(selection, 'node', { value: node, configurable: true }); + return selection as NodeSelection; + }; + + const getLastEditorInstance = () => { + const results = (Editor as unknown as MockedEditor).mock.results; + return results[results.length - 1].value; + }; + + const getSelectionUpdateHandler = (editorInstance: EditorInstance) => { + const onMock = editorInstance.on as unknown as Mock; + const call = onMock.mock.calls.find(([event]) => event === 'selectionUpdate'); + expect(call).toBeTruthy(); + return call![1] as () => void; + }; + + const syncViewState = (editorInstance: EditorInstance) => { + ( + editorInstance.view as typeof editorInstance.view & { state?: EditorInstance['state']; hasFocus?: () => boolean } + ).state = editorInstance.state; + (editorInstance.view as typeof editorInstance.view & { hasFocus?: () => boolean }).hasFocus = vi.fn(() => true); + }; + beforeEach(() => { // Create a container element for the presentation editor container = document.createElement('div'); @@ -464,6 +495,113 @@ describe('PresentationEditor', () => { }); }); + describe('structured content selected chrome', () => { + it('marks a block SDT selected when an image NodeSelection is inside it', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'sdt-image-selection-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const editorInstance = getLastEditorInstance(); + syncViewState(editorInstance); + + const sdtNode = { + type: { name: 'structuredContentBlock' }, + attrs: { id: 'sdt-image-block' }, + nodeSize: 24, + }; + const imageNode = { + type: { name: 'image' }, + attrs: {}, + isBlock: false, + isInline: true, + isLeaf: true, + nodeSize: 1, + }; + const doc = { + ...editorInstance.state.doc, + nodeAt: vi.fn(() => imageNode), + resolve: vi.fn((pos: number) => ({ + pos, + depth: 2, + node: (depth: number) => { + if (depth === 1) return sdtNode; + if (depth === 2) return { type: { name: 'paragraph' } }; + return { type: { name: 'doc' } }; + }, + before: (depth: number) => (depth === 1 ? 90 : 93), + start: (depth: number) => (depth === 1 ? 91 : 94), + end: (depth: number) => (depth === 1 ? 113 : 95), + })), + }; + editorInstance.state.doc = doc as never; + (editorInstance.view as typeof editorInstance.view & { state: EditorInstance['state'] }).state = + editorInstance.state; + editorInstance.state.selection = makeNodeSelection(94, 95, imageNode); + + const sdtWrapper = document.createElement('div'); + sdtWrapper.className = 'superdoc-structured-content-block'; + sdtWrapper.dataset.sdtId = 'sdt-image-block'; + sdtWrapper.dataset.pmStart = '94'; + sdtWrapper.dataset.pmEnd = '95'; + container.querySelector('.presentation-editor__pages')?.appendChild(sdtWrapper); + + getSelectionUpdateHandler(editorInstance)(); + + expect(sdtWrapper.classList.contains('ProseMirror-selectednode')).toBe(true); + }); + + it('does not mark a block SDT selected when an image NodeSelection is outside it', async () => { + editor = new PresentationEditor({ + element: container, + documentId: 'outside-sdt-image-selection-doc', + }); + + await vi.waitFor(() => expect(mockIncrementalLayout).toHaveBeenCalled()); + + const editorInstance = getLastEditorInstance(); + syncViewState(editorInstance); + + const imageNode = { + type: { name: 'image' }, + attrs: {}, + isBlock: false, + isInline: true, + isLeaf: true, + nodeSize: 1, + }; + const doc = { + ...editorInstance.state.doc, + nodeAt: vi.fn(() => imageNode), + resolve: vi.fn((pos: number) => ({ + pos, + depth: 1, + node: (depth: number) => (depth === 1 ? { type: { name: 'paragraph' } } : { type: { name: 'doc' } }), + before: () => 10, + start: () => 11, + end: () => 20, + })), + }; + editorInstance.state.doc = doc as never; + (editorInstance.view as typeof editorInstance.view & { state: EditorInstance['state'] }).state = + editorInstance.state; + editorInstance.state.selection = makeNodeSelection(40, 41, imageNode); + + const sdtWrapper = document.createElement('div'); + sdtWrapper.className = 'superdoc-structured-content-block'; + sdtWrapper.dataset.sdtId = 'unrelated-sdt-block'; + sdtWrapper.dataset.pmStart = '90'; + sdtWrapper.dataset.pmEnd = '113'; + container.querySelector('.presentation-editor__pages')?.appendChild(sdtWrapper); + + getSelectionUpdateHandler(editorInstance)(); + + expect(sdtWrapper.classList.contains('ProseMirror-selectednode')).toBe(false); + }); + }); + describe('scrollToPosition', () => { let originalScrollIntoView: unknown; From 37d1ddc95c3559cef15647396dd91465e5918090 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:15:43 -0300 Subject: [PATCH 015/135] fix(layout-bridge): detect inline image run changes in paragraph diff --- .../layout-engine/layout-bridge/src/diff.ts | 32 +++++++++ .../layout-bridge/test/diff.test.ts | 67 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 82ef81fead..9e6b690eef 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -3,6 +3,7 @@ import type { ImageBlock, DrawingBlock, ImageDrawing, + ImageRun, BoxSpacing, ImageAnchor, ImageWrap, @@ -419,6 +420,12 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock for (let i = 0; i < a.runs.length; i += 1) { const runA = a.runs[i]; const runB = b.runs[i]; + if (runA.kind === 'image' || runB.kind === 'image') { + if (runA.kind !== 'image' || runB.kind !== 'image') return false; + if (!imageRunsEqual(runA, runB)) return false; + continue; + } + // MathRun: compare textContent (derived from OMML) to detect equation changes if (runA.kind === 'math' || runB.kind === 'math') { if (runA.kind !== runB.kind) return false; @@ -449,6 +456,31 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock return true; }; +const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => { + return ( + a.src === b.src && + a.width === b.width && + a.height === b.height && + a.alt === b.alt && + a.title === b.title && + a.clipPath === b.clipPath && + a.distTop === b.distTop && + a.distBottom === b.distBottom && + a.distLeft === b.distLeft && + a.distRight === b.distRight && + a.verticalAlign === b.verticalAlign && + a.rotation === b.rotation && + a.flipH === b.flipH && + a.flipV === b.flipV && + a.gain === b.gain && + a.blacklevel === b.blacklevel && + a.grayscale === b.grayscale && + jsonEqual(a.lum, b.lum) && + jsonEqual(a.hyperlink, b.hyperlink) && + shallowRecordEqual(a.dataAttrs, b.dataAttrs) + ); +}; + const imageBlocksEqual = (a: ImageBlock | ImageDrawing, b: ImageBlock | ImageDrawing): boolean => { return ( a.src === b.src && diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 51c195f114..ef278ced5a 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { VectorShapeDrawing } from '@superdoc/contracts'; +import type { ImageRun, ParagraphBlock, VectorShapeDrawing } from '@superdoc/contracts'; import { computeDirtyRegions } from '../src/diff'; const block = (id: string, text: string) => ({ @@ -8,6 +8,19 @@ const block = (id: string, text: string) => ({ runs: [{ text, fontFamily: 'Arial', fontSize: 16 }], }); +const imageRun = (src: string, width: number, height: number): ImageRun => ({ + kind: 'image', + src, + width, + height, +}); + +const paragraphWithRuns = (id: string, runs: ParagraphBlock['runs']) => ({ + kind: 'paragraph' as const, + id, + runs, +}); + const drawing = (overrides?: Partial): VectorShapeDrawing => ({ kind: 'drawing', id: 'drawing-0', @@ -181,6 +194,58 @@ describe('computeDirtyRegions', () => { expect(result.firstDirtyIndex).toBe(0); }); + it('detects inline image height changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 60)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects inline image width changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 120, 50)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('treats identical inline image dimensions as stable', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(next.length); + expect(result.stableBlockIds.has('0-paragraph')).toBe(true); + }); + + it('detects inline image resize in mixed text and image paragraphs', () => { + const prev = [ + paragraphWithRuns('0-paragraph', [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16 }, + imageRun('img.png', 100, 50), + { text: ' after', fontFamily: 'Arial', fontSize: 16 }, + ]), + ]; + const next = [ + paragraphWithRuns('0-paragraph', [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16 }, + imageRun('img.png', 100, 60), + { text: ' after', fontFamily: 'Arial', fontSize: 16 }, + ]), + ]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + + it('detects changes to later inline image runs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 40)])]; + const next = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 60)])]; + const result = computeDirtyRegions(prev, next); + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + it('treats unchanged drawing blocks as stable', () => { const prev = [drawing()]; const next = [drawing()]; From afe273aefcef76f0b01672eac33cca95909c17d9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:23:14 -0300 Subject: [PATCH 016/135] fix(super-editor): disable image resize inside content-locked SDTs --- .../v1/components/ImageResizeOverlay.test.js | 144 ++++++++++++++++++ .../v1/components/ImageResizeOverlay.vue | 33 +++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js index f2f2f14dda..2353a84ebe 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js @@ -21,6 +21,50 @@ function createMockEditor(overrides = {}) { }; } +function createResizableImageElement({ lockMode, ancestorLockMode } = {}) { + const imageEl = document.createElement('div'); + imageEl.setAttribute('data-pm-start', '0'); + imageEl.setAttribute('data-sd-block-id', 'image-block'); + imageEl.setAttribute( + 'data-image-metadata', + JSON.stringify({ + originalWidth: 100, + originalHeight: 50, + maxWidth: 500, + maxHeight: 500, + aspectRatio: 2, + minWidth: 20, + minHeight: 20, + }), + ); + if (lockMode) { + imageEl.setAttribute('data-lock-mode', lockMode); + } + imageEl.getBoundingClientRect = vi.fn(() => ({ + left: 10, + top: 20, + width: 100, + height: 50, + right: 110, + bottom: 70, + x: 10, + y: 20, + toJSON: () => {}, + })); + + if (!ancestorLockMode) { + document.body.appendChild(imageEl); + return { imageEl, remove: () => imageEl.remove() }; + } + + const sdtEl = document.createElement('div'); + sdtEl.className = 'superdoc-structured-content-block'; + sdtEl.setAttribute('data-lock-mode', ancestorLockMode); + sdtEl.appendChild(imageEl); + document.body.appendChild(sdtEl); + return { imageEl, remove: () => sdtEl.remove() }; +} + describe('ImageResizeOverlay', () => { describe('isResizeDisabled guard', () => { it('should report resize disabled when documentMode is viewing', () => { @@ -55,6 +99,106 @@ describe('ImageResizeOverlay', () => { expect(wrapper.vm.isResizeDisabled).toBe(false); }); + + it('should report resize disabled for images inside content-locked SDTs', () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: 'contentLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + props: { editor, visible: true, imageElement: imageEl }, + }); + + expect(wrapper.vm.isResizeDisabled).toBe(true); + + wrapper.unmount(); + remove(); + }); + }); + + it.each(['contentLocked', 'sdtContentLocked'])( + 'should not start image resize drag inside %s SDTs', + async (lockMode) => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: lockMode }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.dragState).toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(false); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + expect(editor.view.state.tr.setNodeMarkup).not.toHaveBeenCalled(); + + wrapper.unmount(); + remove(); + }, + ); + + it('should not start image resize drag when the image element has contentLocked mode directly', async () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ lockMode: 'contentLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.dragState).toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(false); + expect(editor.view.dispatch).not.toHaveBeenCalled(); + + wrapper.unmount(); + remove(); + }); + + it('should still allow image resize inside sdtLocked SDTs', async () => { + const editor = createMockEditor(); + const imageNode = { + type: { name: 'image' }, + attrs: { size: { width: 100, height: 50 } }, + }; + editor.view.state.doc.nodeAt.mockReturnValue(imageNode); + const { imageEl, remove } = createResizableImageElement({ ancestorLockMode: 'sdtLocked' }); + + const wrapper = mount(ImageResizeOverlay, { + attachTo: document.body, + props: { editor, visible: true, imageElement: imageEl }, + }); + await wrapper.vm.$nextTick(); + + const handle = wrapper.find('[data-handle-position="se"]'); + await handle.trigger('mousedown', { clientX: 110, clientY: 70 }); + expect(wrapper.vm.dragState).not.toBe(null); + expect(wrapper.find('.resize-guideline').exists()).toBe(true); + + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 90 })); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 90 })); + + expect(editor.view.state.tr.setNodeMarkup).toHaveBeenCalledWith( + 0, + null, + expect.objectContaining({ size: { width: 140, height: 70 } }), + ); + expect(editor.view.dispatch).toHaveBeenCalledWith(editor.view.state.tr); + + wrapper.unmount(); + remove(); }); it('should dispatch resize transactions through the presentation editor active editor', async () => { diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index a4293db5d8..2d0abce3b2 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -31,6 +31,7 @@ const DIMENSION_CHANGE_THRESHOLD_PX = 1; const Z_INDEX_OVERLAY = 10; const Z_INDEX_HANDLE = 15; const Z_INDEX_GUIDELINE = 20; +const CONTENT_LOCKED_MODES = new Set(['contentLocked', 'sdtContentLocked']); /** * Validates that the editor prop conforms to expected ProseMirror structure @@ -78,8 +79,16 @@ const resizeEditor = computed(() => { return typeof editor?.getActiveEditor === 'function' ? editor.getActiveEditor() : editor; }); +const isImageContentLocked = computed(() => { + const lockMode = resolveImageLockMode(props.imageElement); + return lockMode ? CONTENT_LOCKED_MODES.has(lockMode) : false; +}); + const isResizeDisabled = computed( - () => resizeEditor.value?.options?.documentMode === 'viewing' || !resizeEditor.value?.isEditable, + () => + resizeEditor.value?.options?.documentMode === 'viewing' || + !resizeEditor.value?.isEditable || + isImageContentLocked.value, ); /** @@ -107,6 +116,26 @@ const dragState = ref(null); */ const forcedCleanup = ref(false); +function readLockMode(element) { + return element?.dataset?.lockMode || element?.getAttribute?.('data-lock-mode') || null; +} + +function resolveImageLockMode(imageElement) { + if (!imageElement) return null; + + const directLockMode = readLockMode(imageElement); + if (directLockMode) return directLockMode; + + const innerLockedElement = imageElement.querySelector?.('[data-lock-mode]'); + const innerLockMode = readLockMode(innerLockedElement); + if (innerLockMode) return innerLockMode; + + const sdtElement = imageElement.closest?.( + '.superdoc-structured-content-block[data-lock-mode], .superdoc-structured-content-inline[data-lock-mode]', + ); + return readLockMode(sdtElement); +} + /** * Overlay position and size relative to image element. * The overlay is rendered inside .super-editor wrapper, so we need to @@ -536,7 +565,7 @@ function onDocumentMouseUp(event) { */ function dispatchResizeTransaction(blockId, newWidth, newHeight) { const editor = resizeEditor.value; - if (!isValidEditor(editor) || !props.imageElement) { + if (!isValidEditor(editor) || !props.imageElement || isResizeDisabled.value) { return; } From 2f54eace92a08a7abe9bfd150fc62b6f285d8c88 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:39:30 -0300 Subject: [PATCH 017/135] fix(layout-engine): keep block SDT chrome and inline images out of paragraph geometry Move block SDT border styling onto a ::after pseudo-element with pointer-events: none, and drop the 1px padding so the chrome no longer changes fragment geometry. Default inline image verticalAlign to 'top' so the image box stays within the measured line height. --- .../painters/dom/src/renderer.ts | 7 ++- .../painters/dom/src/styles.test.ts | 15 ++++++ .../layout-engine/painters/dom/src/styles.ts | 48 +++++++++++++++++-- .../src/converters/inline-converters/image.ts | 8 ++-- .../src/converters/paragraph.test.ts | 6 +-- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 1488747529..525b429ecd 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5907,8 +5907,7 @@ export class DomPainter { // When we don't use a wrapper (no clipPath, or clipPath with width/height 0), apply them on the img so layout is correct. const useWrapper = hasClipPath && run.width > 0 && run.height > 0; if (!useWrapper) { - // Apply vertical alignment (bottom-aligned to text baseline) - img.style.verticalAlign = run.verticalAlign ?? 'bottom'; + img.style.verticalAlign = run.verticalAlign ?? 'top'; // Apply spacing as CSS margins if (run.distTop) { @@ -5985,7 +5984,7 @@ export class DomPainter { wrapper.style.height = `${run.height}px`; wrapper.style.boxSizing = 'border-box'; wrapper.style.overflow = 'hidden'; - wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.verticalAlign = run.verticalAlign ?? 'top'; if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; if (run.distBottom) wrapper.style.marginBottom = `${run.distBottom}px`; if (run.distLeft) wrapper.style.marginLeft = `${run.distLeft}px`; @@ -6035,7 +6034,7 @@ export class DomPainter { wrapper.style.display = 'inline-block'; wrapper.style.width = `${run.width}px`; wrapper.style.height = `${run.height}px`; - wrapper.style.verticalAlign = run.verticalAlign ?? 'bottom'; + wrapper.style.verticalAlign = run.verticalAlign ?? 'top'; wrapper.style.position = 'relative'; wrapper.style.zIndex = '1'; if (run.distTop) wrapper.style.marginTop = `${run.distTop}px`; diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index f26a0f8ffd..fb44f49490 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -25,6 +25,21 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain('border-color: var(--sd-content-controls-inline-hover-border, transparent);'); }); + it('keeps block SDT chrome paint-only so it does not change fragment geometry', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const blockRule = cssText.match(/\.superdoc-structured-content-block\s*\{([^}]*)\}/)?.[1] ?? ''; + const chromeRule = cssText.match(/\.superdoc-structured-content-block::after\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(blockRule).not.toContain('padding:'); + expect(blockRule).not.toContain('border:'); + expect(chromeRule).toContain('position: absolute;'); + expect(chromeRule).toContain('border: 1px solid transparent;'); + expect(chromeRule).toContain('pointer-events: none;'); + }); + it('gives empty inline SDTs a default visible affordance', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 28a62683e6..d25c3f22bb 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -497,29 +497,46 @@ const SDT_CONTAINER_STYLES = ` /* Structured Content Block - Blue border container */ .superdoc-structured-content-block { - padding: 1px; box-sizing: border-box; border-radius: 4px; - border: 1px solid transparent; position: relative; } +.superdoc-structured-content-block::after { + content: ''; + position: absolute; + inset: 0; + border: 1px solid transparent; + border-radius: inherit; + box-sizing: border-box; + pointer-events: none; +} + .superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::after { border-color: var(--sd-content-controls-block-hover-border, transparent); } /* Group hover (JavaScript-coordinated via PresentationEditor) */ .superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); +} + +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode)::after { border-color: var(--sd-content-controls-block-hover-border, transparent); } .superdoc-structured-content-block.ProseMirror-selectednode { - border-color: var(--sd-content-controls-block-border, #629be7); outline: none; } +.superdoc-structured-content-block.ProseMirror-selectednode::after { + border-color: var(--sd-content-controls-block-border, #629be7); +} + /* Structured content drag handle/label - positioned above */ .superdoc-structured-content__label { font-size: 11px; @@ -566,29 +583,41 @@ const SDT_CONTAINER_STYLES = ` /* First fragment of a multi-fragment SDT: top corners, no bottom border */ .superdoc-structured-content-block[data-sdt-container-start="true"]:not([data-sdt-container-end="true"]) { border-radius: 4px 4px 0 0; +} + +.superdoc-structured-content-block[data-sdt-container-start="true"]:not([data-sdt-container-end="true"])::after { border-bottom: none; } /* Last fragment of a multi-fragment SDT: bottom corners, no top border */ .superdoc-structured-content-block[data-sdt-container-end="true"]:not([data-sdt-container-start="true"]) { border-radius: 0 0 4px 4px; +} + +.superdoc-structured-content-block[data-sdt-container-end="true"]:not([data-sdt-container-start="true"])::after { border-top: none; } /* Middle fragment (neither start nor end): no corners, no top/bottom borders */ .superdoc-structured-content-block:not([data-sdt-container-start="true"]):not([data-sdt-container-end="true"]) { border-radius: 0; +} + +.superdoc-structured-content-block:not([data-sdt-container-start="true"]):not([data-sdt-container-end="true"])::after { border-top: none; border-bottom: none; } /* Collapse double borders between adjacent SDT blocks */ .superdoc-structured-content-block + .superdoc-structured-content-block { - border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; } +.superdoc-structured-content-block + .superdoc-structured-content-block::after { + border-top: none; +} + /* Structured Content Inline - Inline wrapper with blue border */ .superdoc-structured-content-inline { padding: 1px; @@ -709,6 +738,13 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-structured-content-block::after, +.presentation-editor--viewing .superdoc-structured-content-block:hover::after, +.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::after, +.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::after { + border: none; +} + .presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover, .presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover { background: none; @@ -740,6 +776,10 @@ const SDT_CONTAINER_STYLES = ` padding: 0; } + .superdoc-structured-content-block::after { + border: none; + } + .superdoc-document-section__tooltip, .superdoc-structured-content__label, .superdoc-structured-content-inline__label { diff --git a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts index 2c9a4b5346..0c63581391 100644 --- a/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts +++ b/packages/layout-engine/pm-adapter/src/converters/inline-converters/image.ts @@ -45,7 +45,7 @@ const DEFAULT_IMAGE_DIMENSION_PX = 100; * }, * positionMap * ) - * // Returns: { kind: 'image', src: 'data:...', width: 200, height: 150, alt: 'Company logo', distTop: 10, distBottom: 10, verticalAlign: 'bottom' } + * // Returns: { kind: 'image', src: 'data:...', width: 200, height: 150, alt: 'Company logo', distTop: 10, distBottom: 10, verticalAlign: 'top' } * * // Missing src - returns null * imageNodeToRun({ type: 'image', attrs: {} }, positionMap) @@ -56,7 +56,7 @@ const DEFAULT_IMAGE_DIMENSION_PX = 100; * { type: 'image', attrs: { src: 'image.png', size: { width: NaN, height: -10 } } }, * positionMap * ) - * // Returns: { kind: 'image', src: 'image.png', width: 100, height: 100, verticalAlign: 'bottom' } + * // Returns: { kind: 'image', src: 'image.png', width: 100, height: 100, verticalAlign: 'top' } * ``` */ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverterParams): ImageRun | null { @@ -116,8 +116,8 @@ export function imageNodeToRun({ node, positions, sdtMetadata }: InlineConverter const distRight = pickNumber(wrapAttrs.distRight ?? wrapAttrs.distR); if (distRight != null) run.distRight = distRight; - // Default vertical alignment to bottom (text baseline alignment) - run.verticalAlign = 'bottom'; + // Keep the image box inside the measured line height. + run.verticalAlign = 'top'; // Position tracking const pos = positions.get(node); diff --git a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts index b4ed57350e..a6bc766124 100644 --- a/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/paragraph.test.ts @@ -3962,7 +3962,7 @@ describe('paragraph converters', () => { distBottom: 20, distLeft: 5, distRight: 15, - verticalAlign: 'bottom', + verticalAlign: 'top', pmStart: 10, pmEnd: 11, }); @@ -4219,14 +4219,14 @@ describe('paragraph converters', () => { expect(result?.pmEnd).toBeUndefined(); }); - it('sets verticalAlign to bottom by default', () => { + it('sets verticalAlign to top by default', () => { const node: PMNode = { type: 'image', attrs: { src: 'image.png', inline: true }, }; const result = imageNodeToRun(buildImageParams(node, positions)); - expect(result?.verticalAlign).toBe('bottom'); + expect(result?.verticalAlign).toBe('top'); }); it('omits alt and title when not present', () => { From 8a1bc7ace84eda76306e87888e23cee64d0b4ec6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 15:50:49 -0300 Subject: [PATCH 018/135] fix(layout-engine): bottom-align text on lines with inline images When a paragraph line contains an inline image, set surrounding normal text runs to lineHeight: 'normal' and verticalAlign: 'bottom' so they sit beside the top-aligned image. Runs with explicit vertical positioning (vertAlign, baselineShift) are left untouched. --- .../painters/dom/src/index.test.ts | 91 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 14 +++ .../dom/src/text-style-rendering.test.ts | 1 + 3 files changed, 106 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6d733a18f5..7d7b40cf70 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -7325,6 +7325,9 @@ describe('DomPainter', () => { }); describe('renderImageRun (inline image runs)', () => { + const inlineImageSrc = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + const renderInlineImageRun = ( run: Extract['runs'][number], lineWidth = 100, @@ -7377,6 +7380,94 @@ describe('DomPainter', () => { painter.paint(imageLayout, mount); }; + const renderInlineImageTextLine = (runs: Extract['runs']) => { + const imageBlock: FlowBlock = { + kind: 'paragraph', + id: 'img-text-block', + runs, + }; + + const imageMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: runs.length - 1, + toChar: 'text' in runs[runs.length - 1]! ? runs[runs.length - 1]!.text.length : 0, + width: 140, + ascent: 40, + descent: 0, + lineHeight: 40, + }, + ], + totalHeight: 40, + }; + + const imageLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'img-text-block', + fromLine: 0, + toLine: 1, + x: 0, + y: 0, + width: 140, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [imageBlock], measures: [imageMeasure] }); + painter.paint(imageLayout, mount); + }; + + it('bottom-aligns normal text runs on lines containing inline images', () => { + renderInlineImageTextLine([ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 }, + { kind: 'image', src: inlineImageSrc, width: 40, height: 40, pmStart: 7, pmEnd: 8 }, + { text: ' after', fontFamily: 'Arial', fontSize: 16, pmStart: 8, pmEnd: 14 }, + ]); + + const textSpans = Array.from(mount.querySelectorAll('.superdoc-line > span')) as HTMLElement[]; + expect(textSpans.map((span) => span.textContent)).toEqual(['Before ', ' after']); + expect(textSpans[0]?.style.lineHeight).toBe('normal'); + expect(textSpans[0]?.style.verticalAlign).toBe('bottom'); + expect(textSpans[1]?.style.lineHeight).toBe('normal'); + expect(textSpans[1]?.style.verticalAlign).toBe('bottom'); + + const img = mount.querySelector('img') as HTMLImageElement | null; + expect(img?.style.verticalAlign).toBe('top'); + }); + + it('preserves explicit vertical positioning on text runs beside inline images', () => { + renderInlineImageTextLine([ + { text: 'Base ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }, + { kind: 'image', src: inlineImageSrc, width: 40, height: 40, pmStart: 5, pmEnd: 6 }, + { + text: '2', + fontFamily: 'Arial', + fontSize: 10.4, + vertAlign: 'superscript', + pmStart: 6, + pmEnd: 7, + }, + ]); + + const textSpans = Array.from(mount.querySelectorAll('.superdoc-line > span')) as HTMLElement[]; + expect(textSpans.map((span) => span.textContent)).toEqual(['Base ', '2']); + expect(textSpans[0]?.style.lineHeight).toBe('normal'); + expect(textSpans[0]?.style.verticalAlign).toBe('bottom'); + expect(textSpans[1]?.style.lineHeight).toBe('1'); + expect(textSpans[1]?.style.verticalAlign).toBe('5.28px'); + }); + it('renders img element with valid data URL', () => { const imageBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 525b429ecd..fe332eb077 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5502,6 +5502,17 @@ export class DomPainter { return wrapper; } + private alignNormalTextBesideInlineImage(element: HTMLElement, run: Run, lineContainsInlineImage: boolean): void { + if (!lineContainsInlineImage) return; + if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return; + + const textRun = run as TextRun; + if (normalizeBaselineShift(textRun.baselineShift) != null || textRun.vertAlign != null) return; + + element.style.lineHeight = 'normal'; + element.style.verticalAlign = 'bottom'; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; @@ -6608,6 +6619,7 @@ export class DomPainter { spaceCount, shouldJustify: justifyShouldApply, }); + const lineContainsInlineImage = runsForLine.some((run) => this.isImageRun(run)); const resolveLineIndentOffset = (): number => { if (indentOffsetOverride != null) { return indentOffsetOverride; @@ -6945,6 +6957,7 @@ export class DomPainter { if (styleId) { elem.setAttribute('styleid', styleId); } + this.alignNormalTextBesideInlineImage(elem, segmentRun, lineContainsInlineImage); // Determine X position for this segment // Layout positions are relative to content area start (0). // Add indentOffset to position content at the correct paragraph indent. @@ -7044,6 +7057,7 @@ export class DomPainter { if (styleId) { elem.setAttribute('styleid', styleId); } + this.alignNormalTextBesideInlineImage(elem, run, lineContainsInlineImage); // If this run has inline SDT, add to or create wrapper if (resolved && this.doc) { diff --git a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts index a913bd1333..37ec02a69a 100644 --- a/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts +++ b/packages/layout-engine/painters/dom/src/text-style-rendering.test.ts @@ -431,6 +431,7 @@ describe('DomPainter text style CSS rendering', () => { const span = container.querySelector('span'); expect(span).toBeTruthy(); + expect(span?.style.lineHeight).toBe(''); expect(span?.style.verticalAlign).toBe(''); }); From 8b624ed4118822b5a9420dc37c90eedf63fd698c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:03:42 -0300 Subject: [PATCH 019/135] fix(layout-engine): fit block SDT chrome to actual content width Compute content bounds from rendered lines (honoring paragraph alignment) and expose --sd-sdt-chrome-left/--sd-sdt-chrome-width on the fragment. Hover background moves to a ::before pseudo-element; both ::before and ::after use the chrome vars so the frame hugs the content instead of spanning the full fragment width. --- .../painters/dom/src/index.test.ts | 199 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 50 +++++ .../painters/dom/src/styles.test.ts | 10 + .../layout-engine/painters/dom/src/styles.ts | 35 ++- 4 files changed, 289 insertions(+), 5 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 7d7b40cf70..8057bc63c4 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13030,6 +13030,205 @@ describe('applyRunDataAttributes', () => { expect(fragment.dataset.sdtContainerEnd).toBe('true'); }); + it('limits block SDT chrome to paragraph content width', () => { + const textSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-text', + runs: [{ text: 'Short content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 13 }], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-text', + alias: 'Text Control', + }, + }, + }; + + const textSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 13, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const textSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-text', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 13, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [textSdtBlock], measures: [textSdtMeasure] }); + painter.paint(textSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + + it('limits block SDT chrome to inline image content width', () => { + const imageOnlySdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-image-only', + runs: [ + { + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 210, + height: 118, + pmStart: 0, + pmEnd: 1, + }, + ], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-image-only', + alias: 'Image Control', + }, + }, + }; + + const imageOnlySdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 0, + width: 210, + ascent: 118, + descent: 0, + lineHeight: 118, + }, + ], + totalHeight: 118, + }; + + const imageOnlySdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-image-only', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 1, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [imageOnlySdtBlock], measures: [imageOnlySdtMeasure] }); + painter.paint(imageOnlySdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('0px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('210px'); + }); + + it('positions block SDT chrome around centered paragraph content', () => { + const centeredSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-centered', + runs: [{ text: 'Centered', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + alignment: 'center', + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-centered', + alias: 'Centered Control', + }, + }, + }; + + const centeredSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const centeredSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-centered', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [centeredSdtBlock], measures: [centeredSdtMeasure] }); + painter.paint(centeredSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('110px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); + }); + it('updates block SDT boundaries when appending a new fragment during patch rendering', () => { const sdtMetadata = { type: 'structuredContent' as const, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index fe332eb077..98056ff4a5 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3231,6 +3231,7 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5513,6 +5514,55 @@ export class DomPainter { element.style.verticalAlign = 'bottom'; } + private applyBlockSdtChromeBounds( + element: HTMLElement, + block: ParagraphBlock, + lines: Line[], + fragmentWidth: number, + ): void { + const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; + if (sdt?.type !== 'structuredContent' || sdt.scope !== 'block') return; + + const expandedBlock = { ...block, runs: expandRunsForInlineNewlines(block.runs) }; + let contentLeft = Number.POSITIVE_INFINITY; + let contentRight = Number.NEGATIVE_INFINITY; + + for (const line of lines) { + const runsForLine = sliceRunsForLine(expandedBlock, line); + if (runsForLine.length === 0) continue; + + let hasVisibleContent = false; + for (const run of runsForLine) { + if (run.kind === 'lineBreak' || run.kind === 'break') continue; + if ((run.kind === 'text' || run.kind === undefined) && 'text' in run) { + if ((run.text ?? '').trim().length === 0) continue; + } + hasVisibleContent = true; + break; + } + + if (!hasVisibleContent) continue; + + const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); + if (lineWidth <= 0) continue; + + const alignmentSlack = Math.max(0, fragmentWidth - lineWidth); + const alignment = block.attrs?.alignment; + const lineLeft = alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0; + contentLeft = Math.min(contentLeft, lineLeft); + contentRight = Math.max(contentRight, lineLeft + lineWidth); + } + + if (!Number.isFinite(contentLeft) || !Number.isFinite(contentRight)) return; + + const chromeLeft = Math.max(0, contentLeft); + const chromeWidth = Math.max(0, Math.min(fragmentWidth, contentRight) - chromeLeft); + if (chromeWidth <= 0 || chromeWidth >= fragmentWidth) return; + + element.style.setProperty('--sd-sdt-chrome-left', `${chromeLeft}px`); + element.style.setProperty('--sd-sdt-chrome-width', `${chromeWidth}px`); + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index fb44f49490..44878ede17 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -31,11 +31,21 @@ describe('ensureSdtContainerStyles', () => { const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; const blockRule = cssText.match(/\.superdoc-structured-content-block\s*\{([^}]*)\}/)?.[1] ?? ''; + const hoverRule = + cssText.match( + /\.superdoc-structured-content-block:not\(.ProseMirror-selectednode\):hover::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; + const backgroundRule = cssText.match(/\.superdoc-structured-content-block::before\s*\{([^}]*)\}/)?.[1] ?? ''; const chromeRule = cssText.match(/\.superdoc-structured-content-block::after\s*\{([^}]*)\}/)?.[1] ?? ''; expect(blockRule).not.toContain('padding:'); expect(blockRule).not.toContain('border:'); + expect(blockRule).toContain('--sd-sdt-chrome-left: 0px;'); + expect(blockRule).toContain('--sd-sdt-chrome-width: 100%;'); + expect(backgroundRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); + expect(hoverRule).toContain('background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2);'); expect(chromeRule).toContain('position: absolute;'); + expect(chromeRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); expect(chromeRule).toContain('border: 1px solid transparent;'); expect(chromeRule).toContain('pointer-events: none;'); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index d25c3f22bb..ace4755b5f 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -500,19 +500,36 @@ const SDT_CONTAINER_STYLES = ` box-sizing: border-box; border-radius: 4px; position: relative; + --sd-sdt-chrome-left: 0px; + --sd-sdt-chrome-width: 100%; +} + +.superdoc-structured-content-block::before { + content: ''; + position: absolute; + left: var(--sd-sdt-chrome-left, 0px); + top: 0; + bottom: 0; + width: var(--sd-sdt-chrome-width, 100%); + border-radius: inherit; + box-sizing: border-box; + pointer-events: none; } .superdoc-structured-content-block::after { content: ''; position: absolute; - inset: 0; + left: var(--sd-sdt-chrome-left, 0px); + top: 0; + bottom: 0; + width: var(--sd-sdt-chrome-width, 100%); border: 1px solid transparent; border-radius: inherit; box-sizing: border-box; pointer-events: none; } -.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { +.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); } @@ -521,7 +538,7 @@ const SDT_CONTAINER_STYLES = ` } /* Group hover (JavaScript-coordinated via PresentationEditor) */ -.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) { +.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode)::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); } @@ -543,9 +560,9 @@ const SDT_CONTAINER_STYLES = ` align-items: center; justify-content: center; position: absolute; - left: 2px; + left: calc(var(--sd-sdt-chrome-left, 0px) + 2px); top: -19px; - width: calc(100% - 4px); + width: calc(var(--sd-sdt-chrome-width, 100%) - 4px); max-width: 130px; min-width: 0; height: 18px; @@ -725,6 +742,14 @@ const SDT_CONTAINER_STYLES = ` z-index: 9999999; } +.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) { + background-color: transparent; +} + +.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode)::before { + background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { From 34903ac142923e818132558d85a986caf042a8cb Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:17:08 -0300 Subject: [PATCH 020/135] fix(layout-engine): allow top-aligned inline images --- packages/layout-engine/contracts/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 39d229d2b1..ef180b49a5 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -458,10 +458,10 @@ export type ImageRun = { /** * Vertical alignment of image relative to text baseline. - * Currently only 'bottom' is supported (image sits on baseline). - * Future: 'top', 'middle', 'baseline', 'text-top', 'text-bottom'. + * 'top' keeps the image box inside the measured line height; 'bottom' + * preserves legacy baseline alignment for existing callers. */ - verticalAlign?: 'bottom'; + verticalAlign?: 'top' | 'bottom'; /** Absolute ProseMirror position (inclusive) of this image run. */ pmStart?: number; From 5c93bf2e285fdcc34e0f8dedc3184ca2cf9477da Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:17:53 -0300 Subject: [PATCH 021/135] fix(dom): suppress SDT pseudo hover in viewing mode --- packages/layout-engine/painters/dom/src/styles.test.ts | 5 +++++ packages/layout-engine/painters/dom/src/styles.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 44878ede17..9a7b019a44 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -85,6 +85,10 @@ describe('ensureSdtContainerStyles', () => { const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); const cssText = styleEl?.textContent ?? ''; + const beforeRule = + cssText.match( + /\.presentation-editor--viewing \.superdoc-structured-content-block(?:\:hover|\.sdt-group-hover|\[data-lock-mode\]\.sdt-group-hover)::before\s*\{([^}]*)\}/, + )?.[1] ?? ''; expect(cssText).toContain('.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover'); expect(cssText).toContain( @@ -94,6 +98,7 @@ describe('ensureSdtContainerStyles', () => { '.presentation-editor--viewing .superdoc-structured-content-inline[data-lock-mode]:hover', ); expect(cssText).toContain('background: none;'); + expect(beforeRule).toContain('background: none;'); }); }); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index ace4755b5f..85d9fcf512 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -770,6 +770,12 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-structured-content-block:hover::before, +.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::before, +.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::before { + background: none; +} + .presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover, .presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover { background: none; From 55d5bfd8be3b9df6c64ed7ddc05c96da58438d08 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:19:34 -0300 Subject: [PATCH 022/135] fix(dom): offset block SDT chrome for indents --- .../painters/dom/src/index.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 54 ++++++++++++++-- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 8057bc63c4..2cdbce53af 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13093,6 +13093,70 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); }); + it('offsets block SDT chrome for indented paragraph content', () => { + const indentedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-indented', + runs: [{ text: 'Indented', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + indent: { left: 40 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-indented', + alias: 'Indented Control', + }, + }, + }; + + const indentedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const indentedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-indented', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [indentedSdtBlock], measures: [indentedSdtMeasure] }); + painter.paint(indentedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('40px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + it('limits block SDT chrome to inline image content width', () => { const imageOnlySdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 98056ff4a5..8dfd6ab13b 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -54,6 +54,7 @@ import type { ResolvedImageItem, ResolvedDrawingItem, ResolvedListMarkerItem, + ResolvedParagraphContent, LayoutSourceIdentity, LayoutStoryLocator, } from '@superdoc/contracts'; @@ -3231,7 +3232,7 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width); + this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, content); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5519,6 +5520,7 @@ export class DomPainter { block: ParagraphBlock, lines: Line[], fragmentWidth: number, + content?: ResolvedParagraphContent, ): void { const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; if (sdt?.type !== 'structuredContent' || sdt.scope !== 'block') return; @@ -5527,7 +5529,7 @@ export class DomPainter { let contentLeft = Number.POSITIVE_INFINITY; let contentRight = Number.NEGATIVE_INFINITY; - for (const line of lines) { + for (const [index, line] of lines.entries()) { const runsForLine = sliceRunsForLine(expandedBlock, line); if (runsForLine.length === 0) continue; @@ -5546,9 +5548,11 @@ export class DomPainter { const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); if (lineWidth <= 0) continue; - const alignmentSlack = Math.max(0, fragmentWidth - lineWidth); + const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, content?.lines[index], index); + const alignmentSlack = Math.max(0, fragmentWidth - lineOffset - lineWidth); const alignment = block.attrs?.alignment; - const lineLeft = alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0; + const lineLeft = + lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); contentLeft = Math.min(contentLeft, lineLeft); contentRight = Math.max(contentRight, lineLeft + lineWidth); } @@ -5563,6 +5567,48 @@ export class DomPainter { element.style.setProperty('--sd-sdt-chrome-width', `${chromeWidth}px`); } + private resolveBlockSdtChromeLineOffset( + block: ParagraphBlock, + line: Line, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + lineIndex: number, + ): number { + if (resolvedLine) { + if (resolvedLine.isListFirstLine) { + return resolvedLine.resolvedListTextStartPx ?? resolvedLine.indentOffset; + } + if (resolvedLine.hasExplicitSegmentPositioning) { + return resolvedLine.indentOffset; + } + return Math.max(0, resolvedLine.paddingLeftPx + resolvedLine.textIndentPx); + } + + const paraIndent = block.attrs?.indent; + const indentLeft = paraIndent?.left ?? 0; + const firstLine = paraIndent?.firstLine ?? 0; + const hanging = paraIndent?.hanging ?? 0; + const suppressFirstLineIndent = (block.attrs as Record)?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : firstLine - hanging; + const isFirstLine = lineIndex === 0; + const hasExplicitSegmentPositioning = line.segments?.some((segment) => segment.x !== undefined) === true; + + if (hasExplicitSegmentPositioning) { + const effectiveLeftIndent = indentLeft < 0 ? 0 : indentLeft; + return Math.max(0, effectiveLeftIndent + (isFirstLine ? firstLineOffset : 0)); + } + + if (isFirstLine) { + return Math.max(0, indentLeft + firstLineOffset); + } + if (indentLeft > 0) { + return indentLeft; + } + if (hanging > 0 && indentLeft >= 0) { + return hanging; + } + return 0; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; From 04f6e77fe23b40878102209bb6cd19f0c446b3dd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:23:32 -0300 Subject: [PATCH 023/135] fix(dom): preserve SDT chrome continuation offsets --- .../painters/dom/src/index.test.ts | 104 ++++++++++++++++++ .../painters/dom/src/renderer.ts | 7 +- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 2cdbce53af..3a8214e35f 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13157,6 +13157,110 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); }); + it('uses paragraph-global line index for block SDT chrome on continuation fragments', () => { + const continuedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-continued', + runs: [{ text: 'First line second line', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 22 }], + attrs: { + indent: { left: 40, firstLine: 30 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-continued', + alias: 'Continued Control', + }, + }, + }; + + const continuedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 11, + toRun: 0, + toChar: 22, + width: 96, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const fragment: Fragment = { + kind: 'para', + blockId: 'block-sdt-continued', + fromLine: 1, + toLine: 2, + x: 20, + y: 30, + width: 320, + pmStart: 11, + pmEnd: 22, + continuesFromPrev: true, + }; + + const continuedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [{ number: 1, fragments: [fragment] }], + }; + + const painter = createTestPainter({ blocks: [continuedSdtBlock], measures: [continuedSdtMeasure] }); + painter.setResolvedLayout({ + version: 1, + flowMode: 'paginated', + pageGap: 0, + pages: [ + { + id: 'page-0', + index: 0, + number: 1, + width: 400, + height: 500, + items: [ + { + kind: 'fragment', + id: 'block-sdt-continued:1:2', + pageIndex: 0, + x: fragment.x, + y: fragment.y, + width: fragment.width, + height: 20, + fragmentKind: 'para', + fragment, + blockId: 'block-sdt-continued', + fragmentIndex: 0, + pmStart: fragment.pmStart, + pmEnd: fragment.pmEnd, + continuesFromPrev: true, + block: continuedSdtBlock, + measure: continuedSdtMeasure, + }, + ], + }, + ], + }); + painter.paint(continuedSdtLayout, mount); + + const paintedFragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(paintedFragment.style.width).toBe('320px'); + expect(paintedFragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('40px'); + expect(paintedFragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); + }); + it('limits block SDT chrome to inline image content width', () => { const imageOnlySdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 8dfd6ab13b..ee47f90379 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3232,7 +3232,7 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, content); + this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, fragment.fromLine, content); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5520,6 +5520,7 @@ export class DomPainter { block: ParagraphBlock, lines: Line[], fragmentWidth: number, + fragmentFromLine: number, content?: ResolvedParagraphContent, ): void { const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; @@ -5548,7 +5549,9 @@ export class DomPainter { const lineWidth = Math.max(0, line.naturalWidth ?? line.width ?? 0); if (lineWidth <= 0) continue; - const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, content?.lines[index], index); + const resolvedLine = content?.lines[index]; + const lineIndex = resolvedLine?.lineIndex ?? fragmentFromLine + index; + const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, resolvedLine, lineIndex); const alignmentSlack = Math.max(0, fragmentWidth - lineOffset - lineWidth); const alignment = block.attrs?.alignment; const lineLeft = From 3165abaf95ee4960dd81b1fff1f8154295be8e02 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:24:27 -0300 Subject: [PATCH 024/135] fix(dom): align SDT chrome within paragraph width --- .../painters/dom/src/index.test.ts | 68 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 28 +++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 3a8214e35f..b617ac9928 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13397,6 +13397,74 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); }); + it('positions centered block SDT chrome within paragraph indents', () => { + const centeredIndentedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-centered-indented', + runs: [{ text: 'Centered', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 8 }], + attrs: { + alignment: 'center', + indent: { left: 40, right: 60 }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-centered-indented', + alias: 'Centered Indented Control', + }, + }, + }; + + const centeredIndentedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 8, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const centeredIndentedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-centered-indented', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 8, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [centeredIndentedSdtBlock], + measures: [centeredIndentedSdtMeasure], + }); + painter.paint(centeredIndentedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('100px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); + }); + it('updates block SDT boundaries when appending a new fragment during patch rendering', () => { const sdtMetadata = { type: 'structuredContent' as const, diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index ee47f90379..2dbc3c85d9 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5552,7 +5552,14 @@ export class DomPainter { const resolvedLine = content?.lines[index]; const lineIndex = resolvedLine?.lineIndex ?? fragmentFromLine + index; const lineOffset = this.resolveBlockSdtChromeLineOffset(block, line, resolvedLine, lineIndex); - const alignmentSlack = Math.max(0, fragmentWidth - lineOffset - lineWidth); + const availableWidth = this.resolveBlockSdtChromeAvailableWidth( + block, + line, + fragmentWidth, + lineOffset, + resolvedLine, + ); + const alignmentSlack = Math.max(0, availableWidth - lineWidth); const alignment = block.attrs?.alignment; const lineLeft = lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); @@ -5612,6 +5619,25 @@ export class DomPainter { return 0; } + private resolveBlockSdtChromeAvailableWidth( + block: ParagraphBlock, + line: Line, + fragmentWidth: number, + lineOffset: number, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + ): number { + if (resolvedLine) { + return Math.max(0, resolvedLine.availableWidth); + } + + const rightIndent = Math.max(0, block.attrs?.indent?.right ?? 0); + const fallbackAvailableWidth = Math.max(0, fragmentWidth - lineOffset - rightIndent); + if (line.maxWidth != null) { + return Math.min(line.maxWidth, fallbackAvailableWidth); + } + return fallbackAvailableWidth; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; From 6e487957e1851aaa6b1d30fbdee1b75b17e271ce Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:25:14 -0300 Subject: [PATCH 025/135] fix(layout-bridge): dirty inline image SDT changes --- .../layout-engine/layout-bridge/src/diff.ts | 1 + .../layout-bridge/test/diff.test.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/layout-engine/layout-bridge/src/diff.ts b/packages/layout-engine/layout-bridge/src/diff.ts index 9e6b690eef..6e61b0b80c 100644 --- a/packages/layout-engine/layout-bridge/src/diff.ts +++ b/packages/layout-engine/layout-bridge/src/diff.ts @@ -477,6 +477,7 @@ const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => { a.grayscale === b.grayscale && jsonEqual(a.lum, b.lum) && jsonEqual(a.hyperlink, b.hyperlink) && + jsonEqual(a.sdt, b.sdt) && shallowRecordEqual(a.dataAttrs, b.dataAttrs) ); }; diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index ef278ced5a..1cb1475c14 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -218,6 +218,28 @@ describe('computeDirtyRegions', () => { expect(result.stableBlockIds.has('0-paragraph')).toBe(true); }); + it('detects inline image SDT metadata changes inside paragraphs', () => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [ + paragraphWithRuns('0-paragraph', [ + { + ...imageRun('img.png', 100, 50), + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'image-sdt', + lockMode: 'contentLocked', + }, + }, + ]), + ]; + + const result = computeDirtyRegions(prev, next); + + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }); + it('detects inline image resize in mixed text and image paragraphs', () => { const prev = [ paragraphWithRuns('0-paragraph', [ From 401851230424f4079e0079efc9bf4d3e33b16e13 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:30:21 -0300 Subject: [PATCH 026/135] fix(super-editor): honor ancestor image SDT locks --- .../v1/components/ImageResizeOverlay.test.js | 17 ++++++++++++++ .../v1/components/ImageResizeOverlay.vue | 22 ++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js index 2353a84ebe..fb6faf50a6 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.test.js @@ -113,6 +113,23 @@ describe('ImageResizeOverlay', () => { wrapper.unmount(); remove(); }); + + it('should report resize disabled when an outer SDT is content-locked even if the image has an unlocked lock mode', () => { + const editor = createMockEditor(); + const { imageEl, remove } = createResizableImageElement({ + lockMode: 'unlocked', + ancestorLockMode: 'contentLocked', + }); + + const wrapper = mount(ImageResizeOverlay, { + props: { editor, visible: true, imageElement: imageEl }, + }); + + expect(wrapper.vm.isResizeDisabled).toBe(true); + + wrapper.unmount(); + remove(); + }); }); it.each(['contentLocked', 'sdtContentLocked'])( diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index 2d0abce3b2..b333be6f13 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -123,17 +123,27 @@ function readLockMode(element) { function resolveImageLockMode(imageElement) { if (!imageElement) return null; + const lockModes = []; const directLockMode = readLockMode(imageElement); - if (directLockMode) return directLockMode; + if (directLockMode) lockModes.push(directLockMode); - const innerLockedElement = imageElement.querySelector?.('[data-lock-mode]'); - const innerLockMode = readLockMode(innerLockedElement); - if (innerLockMode) return innerLockMode; + imageElement.querySelectorAll?.('[data-lock-mode]').forEach((element) => { + const lockMode = readLockMode(element); + if (lockMode) lockModes.push(lockMode); + }); - const sdtElement = imageElement.closest?.( + let sdtElement = imageElement.closest?.( '.superdoc-structured-content-block[data-lock-mode], .superdoc-structured-content-inline[data-lock-mode]', ); - return readLockMode(sdtElement); + while (sdtElement) { + const lockMode = readLockMode(sdtElement); + if (lockMode) lockModes.push(lockMode); + sdtElement = sdtElement.parentElement?.closest?.( + '.superdoc-structured-content-block[data-lock-mode], .superdoc-structured-content-inline[data-lock-mode]', + ); + } + + return lockModes.find((lockMode) => CONTENT_LOCKED_MODES.has(lockMode)) ?? lockModes[0] ?? null; } /** From 2f3dfc7a4f7ccb29ccee26308b8cf56ceeea7b01 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:31:59 -0300 Subject: [PATCH 027/135] fix(dom): size SDT chrome for justified lines --- .../painters/dom/src/index.test.ts | 75 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 57 +++++++++++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index b617ac9928..bce4f9e1d0 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13093,6 +13093,81 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('96px'); }); + it('expands block SDT chrome to justified line width', () => { + const justifiedSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-justified', + runs: [{ text: 'Alpha beta gamma', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 16 }], + attrs: { + alignment: 'justify', + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-justified', + alias: 'Justified Control', + }, + }, + }; + + const justifiedSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 10, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 11, + toRun: 0, + toChar: 16, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + + const justifiedSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-justified', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 10, + continuesOnNext: true, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [justifiedSdtBlock], measures: [justifiedSdtMeasure] }); + painter.paint(justifiedSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe(''); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe(''); + }); + it('offsets block SDT chrome for indented paragraph content', () => { const indentedSdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 2dbc3c85d9..a993936cc4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -3232,7 +3232,15 @@ export class DomPainter { // Apply SDT container styling (document sections, structured content blocks) applySdtContainerStyling(this.doc, fragmentEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); - this.applyBlockSdtChromeBounds(fragmentEl, block, lines, fragment.width, fragment.fromLine, content); + this.applyBlockSdtChromeBounds( + fragmentEl, + block, + lines, + fragment.width, + fragment.fromLine, + paraContinuesOnNext, + content, + ); // Render drop cap if present (only on the first fragment, not continuation) if (content?.dropCap) { @@ -5521,6 +5529,7 @@ export class DomPainter { lines: Line[], fragmentWidth: number, fragmentFromLine: number, + fragmentContinuesOnNext: boolean | undefined, content?: ResolvedParagraphContent, ): void { const sdt = block.attrs?.sdt ?? block.attrs?.containerSdt; @@ -5559,12 +5568,23 @@ export class DomPainter { lineOffset, resolvedLine, ); - const alignmentSlack = Math.max(0, availableWidth - lineWidth); + const paintedLineWidth = this.resolveBlockSdtChromePaintedLineWidth( + block, + line, + lineWidth, + availableWidth, + index, + lines.length, + fragmentContinuesOnNext, + resolvedLine, + content, + ); + const alignmentSlack = Math.max(0, availableWidth - paintedLineWidth); const alignment = block.attrs?.alignment; const lineLeft = lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); contentLeft = Math.min(contentLeft, lineLeft); - contentRight = Math.max(contentRight, lineLeft + lineWidth); + contentRight = Math.max(contentRight, lineLeft + paintedLineWidth); } if (!Number.isFinite(contentLeft) || !Number.isFinite(contentRight)) return; @@ -5638,6 +5658,37 @@ export class DomPainter { return fallbackAvailableWidth; } + private resolveBlockSdtChromePaintedLineWidth( + block: ParagraphBlock, + line: Line, + lineWidth: number, + availableWidth: number, + fragmentLineIndex: number, + fragmentLineCount: number, + fragmentContinuesOnNext: boolean | undefined, + resolvedLine: ResolvedParagraphContent['lines'][number] | undefined, + content: ResolvedParagraphContent | undefined, + ): number { + const explicitPositionedSegmentCount = line.segments?.filter((segment) => segment.x !== undefined).length ?? 0; + const hasMultipleExplicitPositionedSegments = explicitPositionedSegmentCount > 1; + const paragraphEndsWithLineBreak = + content?.paragraphEndsWithLineBreak === true || block.runs[block.runs.length - 1]?.kind === 'lineBreak'; + const isLastLineOfParagraph = + resolvedLine != null + ? resolvedLine.skipJustify + : fragmentLineIndex === fragmentLineCount - 1 && !fragmentContinuesOnNext; + const justifyShouldApply = shouldApplyJustify({ + alignment: block.attrs?.alignment, + hasExplicitPositioning: line.segments?.some((segment) => segment.x !== undefined) === true, + hasExplicitTabStops: line.hasExplicitTabStops === true, + isLastLineOfParagraph, + paragraphEndsWithLineBreak, + skipJustifyOverride: (resolvedLine?.skipJustify ?? false) || hasMultipleExplicitPositionedSegments, + }); + + return justifyShouldApply ? Math.max(lineWidth, availableWidth) : lineWidth; + } + private setTextContentWithFormattingSpaceMarks(element: HTMLElement, text: string): void { if (!this.showFormattingMarks || !text.includes(' ') || !this.doc) { element.textContent = text; From de1668161e4d05669fbdf2b46ca21d4895982181 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:37:40 -0300 Subject: [PATCH 028/135] fix(dom): align RTL SDT chrome to text --- .../src/features/inline-direction/index.ts | 2 +- .../painters/dom/src/index.test.ts | 64 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 3 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts index 2492bf20f2..f08cbb3559 100644 --- a/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts +++ b/packages/layout-engine/painters/dom/src/features/inline-direction/index.ts @@ -19,7 +19,7 @@ * @spec ECMA-376 §17.3.1.1 (bidi), §17.3.2.30 (rtl) */ -export { applyRtlStyles, shouldUseSegmentPositioning } from './rtl-styles.js'; +export { applyRtlStyles, resolveTextAlign, shouldUseSegmentPositioning } from './rtl-styles.js'; export { resolveRunDirectionAttribute, normalizeRtlDateTokenForWordParity, diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index bce4f9e1d0..8988662ac7 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -13472,6 +13472,70 @@ describe('applyRunDataAttributes', () => { expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px'); }); + it('positions block SDT chrome around default RTL paragraph content', () => { + const rtlSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-rtl', + runs: [{ text: 'مرحبا', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 5 }], + attrs: { + directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' }, + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-rtl', + alias: 'RTL Control', + }, + }, + }; + + const rtlSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 80, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const rtlSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-rtl', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 5, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [rtlSdtBlock], measures: [rtlSdtMeasure] }); + painter.paint(rtlSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.style.width).toBe('320px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-left')).toBe('240px'); + expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('80px'); + }); + it('positions centered block SDT chrome within paragraph indents', () => { const centeredIndentedSdtBlock: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a993936cc4..611941ba48 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -134,6 +134,7 @@ import { } from './features/paragraph-borders/index.js'; import { applyRtlStyles, + resolveTextAlign, shouldUseSegmentPositioning, resolveRunDirectionAttribute, normalizeRtlDateTokenForWordParity, @@ -5580,7 +5581,7 @@ export class DomPainter { content, ); const alignmentSlack = Math.max(0, availableWidth - paintedLineWidth); - const alignment = block.attrs?.alignment; + const alignment = resolveTextAlign(block.attrs?.alignment, getParagraphInlineDirection(block.attrs) === 'rtl'); const lineLeft = lineOffset + (alignment === 'center' ? alignmentSlack / 2 : alignment === 'right' ? alignmentSlack : 0); contentLeft = Math.min(contentLeft, lineLeft); From a263c87b2d36cd6e4965a15c3521ecefe8a8db28 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:38:48 -0300 Subject: [PATCH 029/135] fix(layout-resolved): version inline image metadata --- .../src/versionSignature.test.ts | 59 ++++++++++++++++++- .../layout-resolved/src/versionSignature.ts | 11 ++++ .../painters/dom/src/renderer.ts | 31 ++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 4385b9453d..7a8eb51dd8 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageRun, SourceAnchor, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -66,3 +66,60 @@ describe('deriveBlockVersion - bidi', () => { expect(a).toBe(b); }); }); + +describe('deriveBlockVersion - inline image runs', () => { + const makeParagraphWithImage = (overrides: Partial = {}): FlowBlock => ({ + kind: 'paragraph', + id: 'p-image', + runs: [ + { + kind: 'image', + src: 'img.png', + width: 100, + height: 50, + ...overrides, + }, + ], + }); + + it('produces a different version when inline image SDT metadata changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const locked = deriveBlockVersion( + makeParagraphWithImage({ + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'image-sdt', + lockMode: 'contentLocked', + }, + }), + ); + + expect(locked).not.toBe(plain); + }); + + it('produces a different version when inline image data attributes change', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const withDataAttrs = deriveBlockVersion(makeParagraphWithImage({ dataAttrs: { 'data-example': '1' } })); + + expect(withDataAttrs).not.toBe(plain); + }); + + it('produces a different version when inline image paint metadata changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImage()); + const withPaintMetadata = deriveBlockVersion( + makeParagraphWithImage({ + verticalAlign: 'top', + rotation: 90, + flipH: true, + gain: '50000', + blacklevel: '20000', + grayscale: true, + lum: { bright: 10000, contrast: -10000 }, + hyperlink: { url: 'https://example.com', tooltip: 'Example' }, + }), + ); + + expect(withPaintMetadata).not.toBe(plain); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 7d1f223147..724931317b 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -230,6 +230,17 @@ export const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distLeft ?? '', imgRun.distRight ?? '', readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + imgRun.verticalAlign ?? '', + imgRun.rotation ?? '', + imgRun.flipH ? 1 : 0, + imgRun.flipV ? 1 : 0, + imgRun.gain ?? '', + imgRun.blacklevel ?? '', + imgRun.grayscale ? 1 : 0, + stableSerializeEvidenceValue(imgRun.lum), + stableSerializeEvidenceValue(imgRun.hyperlink), + stableSerializeEvidenceValue(imgRun.sdt), + stableSerializeEvidenceValue(imgRun.dataAttrs), ].join(','); } diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 611941ba48..35463af5eb 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7945,6 +7945,26 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); }; +const stableSerializeEvidenceValue = (value: unknown): string => { + if (value === undefined) return ''; + if (value === null) return 'null'; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerializeEvidenceValue(item)).join(',')}]`; + } + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableSerializeEvidenceValue(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(String(value)); +}; + /** * Type guard to validate list marker attributes structure. * @@ -8047,6 +8067,17 @@ const deriveBlockVersion = (block: FlowBlock): string => { imgRun.distLeft ?? '', imgRun.distRight ?? '', readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + imgRun.verticalAlign ?? '', + imgRun.rotation ?? '', + imgRun.flipH ? 1 : 0, + imgRun.flipV ? 1 : 0, + imgRun.gain ?? '', + imgRun.blacklevel ?? '', + imgRun.grayscale ? 1 : 0, + stableSerializeEvidenceValue(imgRun.lum), + stableSerializeEvidenceValue(imgRun.hyperlink), + stableSerializeEvidenceValue(imgRun.sdt), + stableSerializeEvidenceValue(imgRun.dataAttrs), // Note: pmStart/pmEnd intentionally excluded to prevent O(n) change detection ].join(','); } From 0b4e6b177a6930b004f0daa1baf61661598d7525 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:45:38 -0300 Subject: [PATCH 030/135] test(layout-bridge): cover inline image diff fields --- .../layout-bridge/test/diff.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/layout-engine/layout-bridge/test/diff.test.ts b/packages/layout-engine/layout-bridge/test/diff.test.ts index 1cb1475c14..b06b3dde46 100644 --- a/packages/layout-engine/layout-bridge/test/diff.test.ts +++ b/packages/layout-engine/layout-bridge/test/diff.test.ts @@ -218,6 +218,38 @@ describe('computeDirtyRegions', () => { expect(result.stableBlockIds.has('0-paragraph')).toBe(true); }); + it.each([ + ['src', { src: 'other.png' }], + ['alt', { alt: 'Diagram' }], + ['title', { title: 'Title' }], + ['clipPath', { clipPath: 'inset(1px)' }], + ['distTop', { distTop: 1 }], + ['distBottom', { distBottom: 2 }], + ['distLeft', { distLeft: 3 }], + ['distRight', { distRight: 4 }], + ['verticalAlign', { verticalAlign: 'top' as const }], + ['rotation', { rotation: 90 }], + ['flipH', { flipH: true }], + ['flipV', { flipV: true }], + ['gain', { gain: '50000' }], + ['blacklevel', { blacklevel: '20000' }], + ['grayscale', { grayscale: true }], + ['lum', { lum: { bright: 10000, contrast: -10000 } }], + ['hyperlink', { hyperlink: { url: 'https://example.com', tooltip: 'Example' } }], + ['dataAttrs', { dataAttrs: { 'data-example': '1' } }], + ] satisfies Array<[string, Partial]>)( + 'detects inline image %s changes inside paragraphs', + (_field, overrides) => { + const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; + const next = [paragraphWithRuns('0-paragraph', [{ ...imageRun('img.png', 100, 50), ...overrides }])]; + + const result = computeDirtyRegions(prev, next); + + expect(result.firstDirtyIndex).toBe(0); + expect(result.stableBlockIds.has('0-paragraph')).toBe(false); + }, + ); + it('detects inline image SDT metadata changes inside paragraphs', () => { const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])]; const next = [ From 76d060fe641838144342a19f7f6583ce9f38edb9 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 25 May 2026 16:46:20 -0300 Subject: [PATCH 031/135] fix(super-editor): share SDT lock predicates --- .../src/editors/v1/components/ImageResizeOverlay.vue | 6 +++--- .../structured-content/StructuredContentViewBase.js | 7 +++---- .../editors/v1/extensions/structured-content/lockModes.js | 7 +++++++ 3 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/extensions/structured-content/lockModes.js diff --git a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue index b333be6f13..c77d97d44f 100644 --- a/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue +++ b/packages/super-editor/src/editors/v1/components/ImageResizeOverlay.vue @@ -22,6 +22,7 @@