From 6797585478b22259d5c18c8f5b218dc880de19bd Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 16:24:14 -0300 Subject: [PATCH 1/7] fix(layout-engine): paint block SDT background on chrome layer Move the block structured-content background-color from the container element to the ::before chrome layer, keeping the container itself transparent. This prevents the field background from sitting above content and interfering with hover/click cursor placement, while still rendering the Template Builder field background via the chrome pseudo-element. Update unit and behavior tests to assert the container is transparent and that the background is applied to the ::before layer. --- .../layout-engine/painters/dom/src/styles.test.ts | 2 ++ packages/layout-engine/painters/dom/src/styles.ts | 3 ++- .../template-builder-block-field-styling.spec.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index a07005d614..d34981af65 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -40,11 +40,13 @@ describe('ensureSdtContainerStyles', () => { expect(blockRule).not.toContain('padding:'); expect(blockRule).not.toContain('border:'); + expect(blockRule).toContain('background-color: transparent;'); expect(blockRule).toContain('--sd-sdt-chrome-left: 0px;'); expect(blockRule).toContain('--sd-sdt-chrome-width: 100%;'); expect(blockRule).toContain('--sd-sdt-chrome-bottom-extension: 0px;'); expect(backgroundRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); expect(backgroundRule).toContain('bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px));'); + expect(backgroundRule).toContain('background-color: var(--sd-content-controls-block-bg, transparent);'); 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%);'); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 8586ef9b22..c18185992e 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -525,7 +525,7 @@ const SDT_CONTAINER_STYLES = ` .superdoc-structured-content-block { box-sizing: border-box; border-radius: 4px; - background-color: var(--sd-content-controls-block-bg, transparent); + background-color: transparent; position: relative; --sd-sdt-chrome-left: 0px; --sd-sdt-chrome-width: 100%; @@ -540,6 +540,7 @@ const SDT_CONTAINER_STYLES = ` bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px)); width: var(--sd-sdt-chrome-width, 100%); border-radius: inherit; + background-color: var(--sd-content-controls-block-bg, transparent); box-sizing: border-box; pointer-events: none; } diff --git a/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts b/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts index ca415f391d..1f13dad0cc 100644 --- a/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts +++ b/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts @@ -89,11 +89,10 @@ test('inline and block structured content field chrome use Template Builder fiel el.classList.remove('sdt-group-hover'); }); - const inlineBackground = await inline.evaluate((el) => getComputedStyle(el).backgroundColor); - const blockBackground = await block.evaluate((el) => getComputedStyle(el).backgroundColor); + await expect(block).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + const blockBackground = await block.evaluate((el) => getComputedStyle(el, '::before').backgroundColor); - expect(inlineBackground).toBe(blockBackground); - expect(inlineBackground).not.toBe('rgba(0, 0, 0, 0)'); + expect(blockBackground).not.toBe('rgba(0, 0, 0, 0)'); }; const expectSelectedStateBackgrounds = async (fieldType: 'owner' | 'signer') => { @@ -107,7 +106,8 @@ test('inline and block structured content field chrome use Template Builder fiel }); await expect(inline).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); - const blockSelectedBackground = await block.evaluate((el) => getComputedStyle(el).backgroundColor); + await expect(block).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + const blockSelectedBackground = await block.evaluate((el) => getComputedStyle(el, '::before').backgroundColor); expect(blockSelectedBackground).not.toBe('rgba(0, 0, 0, 0)'); }; @@ -139,7 +139,7 @@ test('inline and block structured content field chrome use Template Builder fiel el.classList.add('sdt-group-hover'); }); await expect - .poll(async () => block.evaluate((el) => getComputedStyle(el).backgroundColor)) + .poll(async () => block.evaluate((el) => getComputedStyle(el, '::before').backgroundColor)) .toBe(inlineHoverBackground); await inline.evaluate((el) => el.classList.add('ProseMirror-selectednode')); From 4a7cdf4914d156f50955dd701b226bb1e8035dc8 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 16:27:23 -0300 Subject: [PATCH 2/7] fix(layout-engine): hide block sdt fills in output modes --- .../painters/dom/src/styles.test.ts | 17 +++++++++++++++++ .../layout-engine/painters/dom/src/styles.ts | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index d34981af65..b0b4711287 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -221,6 +221,23 @@ describe('ensureSdtContainerStyles', () => { expect(beforeRule).toContain('background: none;'); }); + it('suppresses block SDT resting background paint in viewing and print modes', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const viewingBeforeRule = + cssText.match( + /\.presentation-editor--viewing \.superdoc-structured-content-block::before,[\s\S]*?\{([^}]*)\}/, + )?.[1] ?? ''; + const printBeforeRule = + cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-structured-content-block::before\s*\{([^}]*)\}/)?.[1] ?? ''; + + expect(cssText).toContain('.presentation-editor--viewing .superdoc-structured-content-block::before,'); + expect(viewingBeforeRule).toContain('background: none;'); + expect(printBeforeRule).toContain('background: none;'); + }); + it('keeps hidden-appearance inline SDTs transparent at rest', () => { ensureSdtContainerStyles(document); const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index c18185992e..2453c08335 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -821,6 +821,7 @@ const SDT_CONTAINER_STYLES = ` border: none; } +.presentation-editor--viewing .superdoc-structured-content-block::before, .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 { @@ -862,6 +863,10 @@ const SDT_CONTAINER_STYLES = ` border: none; } + .superdoc-structured-content-block::before { + background: none; + } + .superdoc-document-section__tooltip, .superdoc-structured-content__label, .superdoc-structured-content-inline__label { From eb3d7370edc8ebb40cb32317b6691887d8d0f278 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 16:31:09 -0300 Subject: [PATCH 3/7] fix(layout-engine): keep block sdt fill behind content --- packages/layout-engine/painters/dom/src/styles.test.ts | 3 +++ packages/layout-engine/painters/dom/src/styles.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index b0b4711287..b9cddab0df 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -44,14 +44,17 @@ describe('ensureSdtContainerStyles', () => { expect(blockRule).toContain('--sd-sdt-chrome-left: 0px;'); expect(blockRule).toContain('--sd-sdt-chrome-width: 100%;'); expect(blockRule).toContain('--sd-sdt-chrome-bottom-extension: 0px;'); + expect(blockRule).toContain('z-index: 0;'); expect(backgroundRule).toContain('width: var(--sd-sdt-chrome-width, 100%);'); expect(backgroundRule).toContain('bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px));'); expect(backgroundRule).toContain('background-color: var(--sd-content-controls-block-bg, transparent);'); + expect(backgroundRule).toContain('z-index: -1;'); 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('bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px));'); expect(chromeRule).toContain('border: 1px solid transparent;'); + expect(chromeRule).toContain('z-index: 1;'); 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 2453c08335..224c12a04d 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -527,6 +527,7 @@ const SDT_CONTAINER_STYLES = ` border-radius: 4px; background-color: transparent; position: relative; + z-index: 0; --sd-sdt-chrome-left: 0px; --sd-sdt-chrome-width: 100%; --sd-sdt-chrome-bottom-extension: 0px; @@ -542,6 +543,7 @@ const SDT_CONTAINER_STYLES = ` border-radius: inherit; background-color: var(--sd-content-controls-block-bg, transparent); box-sizing: border-box; + z-index: -1; pointer-events: none; } @@ -555,6 +557,7 @@ const SDT_CONTAINER_STYLES = ` border: 1px solid transparent; border-radius: inherit; box-sizing: border-box; + z-index: 1; pointer-events: none; } From 87fe22650ab496511a5dd265fa7fd34bdca34b54 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 16:37:45 -0300 Subject: [PATCH 4/7] fix(layout-engine): promote image-bearing inline SDT wrappers to inline-block Inline structured-content wrappers default to `display: inline`, which collapses geometry around inline images. Tag wrappers that contain an inline image run with `data-contains-inline-image` and add a scoped rule promoting only those wrappers to `inline-block` with `vertical-align: top`, so the image and its SDT chrome size correctly. --- .../painters/dom/src/index.test.ts | 103 ++++++++++++++++++ .../painters/dom/src/runs/render-line.ts | 12 +- .../painters/dom/src/styles.test.ts | 16 +++ .../layout-engine/painters/dom/src/styles.ts | 5 + 4 files changed, 131 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 d7eed553f8..942586e187 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2738,6 +2738,7 @@ describe('DomPainter', () => { expect(wrapper.dataset.sdtScope).toBe('inline'); expect(wrapper.dataset.sdtId).toBe('sc-inline-1'); expect(wrapper.dataset.sdtTag).toBe('dropdown'); + expect(wrapper.dataset.containsInlineImage).toBeUndefined(); // The wrapper should span all contained runs (pmStart=7 to pmEnd=22) expect(wrapper.dataset.pmStart).toBe('7'); @@ -2755,6 +2756,108 @@ describe('DomPainter', () => { expect(wrapper.textContent).toContain('controlled text'); }); + it('marks inline structuredContent wrappers that contain inline images', () => { + const block: FlowBlock = { + kind: 'paragraph', + id: 'inline-sc-image', + runs: [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 }, + { + text: 'Caption ', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 7, + pmEnd: 15, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-inline-image', + alias: 'Image control', + }, + }, + { + kind: 'image', + src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + width: 40, + height: 40, + pmStart: 15, + pmEnd: 16, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-inline-image', + alias: 'Image control', + }, + }, + { + text: ' after', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 16, + pmEnd: 22, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-inline-image', + alias: 'Image control', + }, + }, + ], + attrs: {}, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 3, + toChar: 6, + width: 160, + ascent: 40, + descent: 0, + lineHeight: 40, + }, + ], + totalHeight: 40, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-sc-image', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 22, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="sc-inline-image"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + expect(wrapper?.dataset.containsInlineImage).toBe('true'); + expect(wrapper?.querySelector('.superdoc-inline-image')).toBeTruthy(); + expect(wrapper?.dataset.pmStart).toBe('7'); + expect(wrapper?.dataset.pmEnd).toBe('22'); + }); + it('omits chrome and alias label when inline SDT appearance is hidden (SD-3110)', () => { // ECMA-376 `` should render the // SDT transparently: no padding/border/label, and the alias text diff --git a/packages/layout-engine/painters/dom/src/runs/render-line.ts b/packages/layout-engine/painters/dom/src/runs/render-line.ts index e229026edb..b72d613bc8 100644 --- a/packages/layout-engine/painters/dom/src/runs/render-line.ts +++ b/packages/layout-engine/painters/dom/src/runs/render-line.ts @@ -55,11 +55,7 @@ const isWhitespaceOnly = (text: string): boolean => { return true; }; -const alignNormalTextBesideInlineImage = ( - element: HTMLElement, - run: Run, - lineContainsInlineImage: boolean, -): void => { +const alignNormalTextBesideInlineImage = (element: HTMLElement, run: Run, lineContainsInlineImage: boolean): void => { if (!lineContainsInlineImage) return; if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return; @@ -522,6 +518,9 @@ const renderExplicitlyPositionedRuns = ({ geoSdtWrapper.style.top = '0px'; geoSdtWrapper.style.height = `${line.lineHeight}px`; } + if (isImageRun(runForSdt)) { + geoSdtWrapper.dataset.containsInlineImage = 'true'; + } runContext.syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt); elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`; geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx); @@ -771,6 +770,9 @@ const renderInlineRuns = ({ runContext.syncInlineSdtWrapperTypography(currentInlineSdtWrapper, run); currentInlineSdtId = runSdtId; } + if (isImageRun(run)) { + currentInlineSdtWrapper.dataset.containsInlineImage = 'true'; + } // Typography is set when wrapper is created from the first run. // Follow-up (SD-2744): define a deterministic mixed-typography rule. runContext.expandSdtWrapperPmRange(currentInlineSdtWrapper, run.pmStart, run.pmEnd); diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index b9cddab0df..a2e0666783 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -88,6 +88,22 @@ describe('ensureSdtContainerStyles', () => { expect(emptyRule).not.toContain('vertical-align'); }); + it('promotes only image-bearing inline SDT wrappers to inline-block geometry', () => { + ensureSdtContainerStyles(document); + + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + const baseInlineRule = cssText.match(/\.superdoc-structured-content-inline\s*\{([^}]*)\}/)?.[1] ?? ''; + const imageInlineRule = + cssText.match( + /\.superdoc-structured-content-inline\[data-contains-inline-image='true'\]:not\(\[data-appearance='hidden'\]\)\s*\{([^}]*)\}/, + )?.[1] ?? ''; + + expect(baseInlineRule).toContain('display: inline;'); + expect(imageInlineRule).toContain('display: inline-block;'); + expect(imageInlineRule).toContain('vertical-align: top;'); + }); + it('uses the same label box model for block and inline SDTs', () => { ensureSdtContainerStyles(document); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 224c12a04d..c3752a4741 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -693,6 +693,11 @@ const SDT_CONTAINER_STYLES = ` z-index: 10; } +.superdoc-structured-content-inline[data-contains-inline-image='true']:not([data-appearance='hidden']) { + display: inline-block; + vertical-align: top; +} + /* Hover effect for inline structured content */ .superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover { background-color: var(--sd-content-controls-inline-hover-bg, #f2f2f2); From dd11b7e131a7c964a36c148c6e4155fe438ecd5f Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 16:41:49 -0300 Subject: [PATCH 5/7] test(visual): assert inline template field fill --- .../behavior/template-builder-block-field-styling.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts b/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts index 1f13dad0cc..534bb749b0 100644 --- a/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts +++ b/tests/visual/tests/behavior/template-builder-block-field-styling.spec.ts @@ -89,10 +89,12 @@ test('inline and block structured content field chrome use Template Builder fiel el.classList.remove('sdt-group-hover'); }); + const inlineBackground = await inline.evaluate((el) => getComputedStyle(el).backgroundColor); await expect(block).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); const blockBackground = await block.evaluate((el) => getComputedStyle(el, '::before').backgroundColor); - expect(blockBackground).not.toBe('rgba(0, 0, 0, 0)'); + expect(inlineBackground).toBe(blockBackground); + expect(inlineBackground).not.toBe('rgba(0, 0, 0, 0)'); }; const expectSelectedStateBackgrounds = async (fieldType: 'owner' | 'signer') => { From bd163a03edba2c48253bfd1d16445eaa203fc413 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 17:04:49 -0300 Subject: [PATCH 6/7] fix(super-editor): allow block SDT wrapper deletion to follow lock rules Backspace/Delete at the start of a block SDT's first paragraph was caught by the generic lock handling. Detect this wrapper-delete position explicitly and let the keypress through, so deletion is governed by the SDT's own lock mode (blocked when content-locked, allowed otherwise) rather than being unconditionally swallowed. --- .../structured-content-lock-plugin.js | 29 ++++++++++++++ .../structured-content-lock-plugin.test.js | 38 +++++++++++++++++++ 2 files changed, 67 insertions(+) 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 65c2951b3d..b999f0ddc4 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 @@ -69,6 +69,28 @@ function checkLockViolation(sdtNodes, from, to) { return { blocked: false }; } +function isAtBlockSdtWrapperDeletePosition(state, sdt, pos) { + if (sdt.type !== 'structuredContentBlock') return false; + + const $pos = state.doc.resolve(pos); + let sdtDepth = null; + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if ($pos.node(depth).type.name === 'structuredContentBlock' && $pos.before(depth) === sdt.pos) { + sdtDepth = depth; + break; + } + } + if (sdtDepth == null) return false; + + const textblockDepth = sdtDepth + 1; + if ($pos.depth < textblockDepth) return false; + if (!$pos.node(textblockDepth).isTextblock) return false; + if ($pos.node(textblockDepth).type.name !== 'paragraph') return false; + if ($pos.pos !== $pos.start(textblockDepth)) return false; + + return $pos.before(textblockDepth) === $pos.start(sdtDepth); +} + export function createStructuredContentLockPlugin() { return new Plugin({ key: STRUCTURED_CONTENT_LOCK_KEY, @@ -166,6 +188,13 @@ export function createStructuredContentLockPlugin() { return true; } + const blockSdtAtWrapperDeletePosition = sdtNodes.find((s) => + isAtBlockSdtWrapperDeletePosition(state, s, from), + ); + if ((isBackspace || isDelete) && blockSdtAtWrapperDeletePosition) { + return false; + } + const inlineSdtAncestor = sdtNodes.find( (s) => s.type === 'structuredContent' && from > s.pos && from < s.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 432de5e231..f2c9b31974 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 @@ -464,6 +464,44 @@ describe('StructuredContentLockPlugin', () => { return result; } + it.each([ + ['contentLocked', 'Backspace', true], + ['contentLocked', 'Delete', true], + ['sdtContentLocked', 'Backspace', false], + ['sdtContentLocked', 'Delete', false], + ])( + '%s + %s at the start of the first block SDT paragraph follows wrapper lock rules', + (lockMode, key, shouldDeleteWrapper) => { + const doc = createDocWithSDT(lockMode, 'structuredContentBlock'); + const state = applyDocToEditor(doc); + const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock'); + let firstParagraphStart = null; + + state.doc.descendants((node, pos) => { + if (node.type.name === 'paragraph' && pos > sdtInfo.pos && pos < sdtInfo.end) { + firstParagraphStart = pos + 1; + return false; + } + return true; + }); + + expect(firstParagraphStart).not.toBeNull(); + placeCaretAt(state, firstParagraphStart); + + const result = invokeLockHandleKeyDown(key); + expect(result.handled).toBe(false); + expect(result.prevented).toBe(false); + + if (key === 'Backspace') { + handleBackspace(editor); + } else { + handleDelete(editor); + } + + expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(!shouldDeleteWrapper); + }, + ); + describe('Path 2 — caret immediately adjacent to inline SDT', () => { const adjacencyCases = [ // [lockMode, key, shouldConsume, description] From dd951d2790656ff46fe6d4128e6c4a06207b5ea7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 28 May 2026 17:55:24 -0300 Subject: [PATCH 7/7] test(behavior): search all matching roots in SDT getTextPoint helper The getTextPoint helper queried a single root via querySelector, so text occurrences living under later matches of the selector were never found. Iterate over all matching roots with querySelectorAll, carrying the occurrence counter across them, so the helper can locate text wherever it renders. --- .../sdt/sd-3237-sdt-interactions.spec.ts | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts index f2224500df..64904e85c8 100644 --- a/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts +++ b/tests/behavior/tests/sdt/sd-3237-sdt-interactions.spec.ts @@ -28,31 +28,33 @@ async function getTextPoint( ): Promise<{ x: number; y: number }> { return page.evaluate( ({ selector, text, offset, occurrence }) => { - const root = document.querySelector(selector); - if (!root) throw new Error(`Element not found: ${selector}`); - - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let seen = 0; - let node = walker.nextNode() as Text | null; - while (node) { - const index = node.data.indexOf(text); - if (index !== -1) { - if (seen !== occurrence) { - seen += 1; - node = walker.nextNode() as Text | null; - continue; - } - const start = Math.min(index + offset, node.data.length - 1); - const range = document.createRange(); - range.setStart(node, start); - range.setEnd(node, Math.min(start + 1, node.data.length)); - const rect = range.getBoundingClientRect(); - range.detach(); - if (rect.width || rect.height) { - return { x: rect.left + 1, y: rect.top + rect.height / 2 }; + const roots = Array.from(document.querySelectorAll(selector)); + if (!roots.length) throw new Error(`Element not found: ${selector}`); + + for (const root of roots) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode() as Text | null; + while (node) { + const index = node.data.indexOf(text); + if (index !== -1) { + if (seen !== occurrence) { + seen += 1; + node = walker.nextNode() as Text | null; + continue; + } + const start = Math.min(index + offset, node.data.length - 1); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, Math.min(start + 1, node.data.length)); + const rect = range.getBoundingClientRect(); + range.detach(); + if (rect.width || rect.height) { + return { x: rect.left + 1, y: rect.top + rect.height / 2 }; + } } + node = walker.nextNode() as Text | null; } - node = walker.nextNode() as Text | null; } throw new Error(`Text occurrence not found: ${text} (${occurrence})`);