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 a07005d614..a2e0666783 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -40,16 +40,21 @@ 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(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;'); }); @@ -83,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); @@ -219,6 +240,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 8586ef9b22..c3752a4741 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -525,8 +525,9 @@ 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; + z-index: 0; --sd-sdt-chrome-left: 0px; --sd-sdt-chrome-width: 100%; --sd-sdt-chrome-bottom-extension: 0px; @@ -540,7 +541,9 @@ 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; + z-index: -1; pointer-events: none; } @@ -554,6 +557,7 @@ const SDT_CONTAINER_STYLES = ` border: 1px solid transparent; border-radius: inherit; box-sizing: border-box; + z-index: 1; pointer-events: none; } @@ -689,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); @@ -820,6 +829,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 { @@ -861,6 +871,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 { 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] 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})`); 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..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 @@ -90,7 +90,8 @@ test('inline and block structured content field chrome use Template Builder fiel }); 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)'); @@ -107,7 +108,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 +141,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'));