From ce45799152b72194a4d6387f8a61b083a02267db Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:09:18 -0300 Subject: [PATCH 1/5] feat(painter-dom): depth-aware SDT boundary layers for nested controls Add computeSdtBoundaryLayers, which reads each resolved item's sdtContainerKeys chain and groups contiguous items per nesting depth: an outer control spanning [outer], [outer, inner], [outer] is one run at depth 0 and the inner control is a run at depth 1. Image/drawing items that carry the chain stay in the run; labels dedupe by key; falls back to the single sdtContainerKey for non-nested content. Additive - the existing flat computeSdtBoundaries is unchanged. Renderer wiring, image/drawing chrome, and layered CSS follow in this PR. Refs #3745 --- .../painters/dom/src/sdt/boundaries.test.ts | 101 ++++++++++++++++ .../painters/dom/src/sdt/boundaries.ts | 114 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts diff --git a/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts b/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts new file mode 100644 index 0000000000..1903c4361c --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import type { ResolvedPaintItem } from '@superdoc/contracts'; +import { computeSdtBoundaryLayers } from './boundaries.js'; + +const makeItem = (y: number, sdtContainerKeys: (string | null)[]): ResolvedPaintItem => + ({ + kind: 'fragment', + id: `f-${y}`, + pageIndex: 0, + fragmentKind: 'para', + blockId: `b-${y}`, + fragmentIndex: y, + height: 20, + fragment: { kind: 'para', blockId: `b-${y}`, x: 0, y, width: 100 }, + sdtContainerKey: sdtContainerKeys.length ? sdtContainerKeys[sdtContainerKeys.length - 1] : null, + sdtContainerKeys, + }) as unknown as ResolvedPaintItem; + +const layerAtDepth = (layers: ReturnType, idx: number, depth: number) => + layers.get(idx)?.find((layer) => layer.depth === depth); + +describe('computeSdtBoundaryLayers', () => { + it('groups outer-inner-outer into one outer run and one inner run', () => { + const items = [ + makeItem(0, ['structuredContent:outer']), + makeItem(20, ['structuredContent:outer', 'structuredContent:inner']), + makeItem(40, ['structuredContent:outer']), + ]; + const layers = computeSdtBoundaryLayers(items, new Set()); + + // depth 0: the outer control spans all three items as one run. + expect(layerAtDepth(layers, 0, 0)).toMatchObject({ key: 'structuredContent:outer', isStart: true, isEnd: false }); + expect(layerAtDepth(layers, 1, 0)).toMatchObject({ key: 'structuredContent:outer', isStart: false, isEnd: false }); + expect(layerAtDepth(layers, 2, 0)).toMatchObject({ key: 'structuredContent:outer', isStart: false, isEnd: true }); + + // depth 1: only the middle item belongs to the inner control. + expect(layerAtDepth(layers, 1, 1)).toMatchObject({ key: 'structuredContent:inner', isStart: true, isEnd: true }); + expect(layerAtDepth(layers, 0, 1)).toBeUndefined(); + expect(layerAtDepth(layers, 2, 1)).toBeUndefined(); + }); + + it('keeps an image/drawing item inside the run instead of splitting it', () => { + // The middle item (e.g. a drawing fragment) now carries the same outer chain + // via FU2, so it stays in the outer run rather than breaking it. + const items = [ + makeItem(0, ['structuredContent:outer']), + makeItem(20, ['structuredContent:outer']), + makeItem(40, ['structuredContent:outer']), + ]; + const layers = computeSdtBoundaryLayers(items, new Set()); + + expect(layerAtDepth(layers, 0, 0)).toMatchObject({ isStart: true, isEnd: false }); + expect(layerAtDepth(layers, 1, 0)).toMatchObject({ isStart: false, isEnd: false }); + expect(layerAtDepth(layers, 2, 0)).toMatchObject({ isStart: false, isEnd: true }); + }); + + it('dedupes labels by key and renders each container label once', () => { + const labels = new Set(); + const items = [ + makeItem(0, ['structuredContent:outer']), + makeItem(20, ['structuredContent:outer', 'structuredContent:inner']), + ]; + const layers = computeSdtBoundaryLayers(items, labels); + + expect(layerAtDepth(layers, 0, 0)?.showLabel).toBe(true); + expect(layerAtDepth(layers, 1, 1)?.showLabel).toBe(true); + expect(labels.has('structuredContent:outer')).toBe(true); + expect(labels.has('structuredContent:inner')).toBe(true); + + // A second pass with the populated set must not re-show the outer label. + const layers2 = computeSdtBoundaryLayers([makeItem(60, ['structuredContent:outer'])], labels); + expect(layerAtDepth(layers2, 0, 0)?.showLabel).toBe(false); + }); + + it('falls back to the single sdtContainerKey when there is no chain', () => { + const item = { + kind: 'fragment', + id: 'f', + pageIndex: 0, + fragmentKind: 'para', + blockId: 'b', + fragmentIndex: 0, + height: 20, + fragment: { kind: 'para', blockId: 'b', x: 0, y: 0, width: 100 }, + sdtContainerKey: 'documentSection:sec-1', + } as unknown as ResolvedPaintItem; + + const layers = computeSdtBoundaryLayers([item], new Set()); + expect(layerAtDepth(layers, 0, 0)).toMatchObject({ + key: 'documentSection:sec-1', + depth: 0, + isStart: true, + isEnd: true, + }); + }); + + it('produces no layers for items with no container key', () => { + const layers = computeSdtBoundaryLayers([makeItem(0, [])], new Set()); + expect(layers.size).toBe(0); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/sdt/boundaries.ts b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts index 213967303b..870068ee61 100644 --- a/packages/layout-engine/painters/dom/src/sdt/boundaries.ts +++ b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts @@ -79,3 +79,117 @@ export const computeSdtBoundaries = ( return boundaries; }; + +export type SdtBoundaryLayer = SdtBoundaryOptions & { + /** Container key for this layer (`structuredContent:` or `documentSection:`). */ + key: string; + /** Nesting depth; 0 = outermost container. */ + depth: number; +}; + +/** + * Depth-aware variant of computeSdtBoundaries for nested block content controls. + * Reads each item's `sdtContainerKeys` chain (outermost first) and, at every + * depth independently, groups contiguous items sharing the same container key at + * that depth. An item inside N nested controls gets N boundary layers + * (depth 0 = outermost). Falls back to the single `sdtContainerKey` when an item + * carries no chain, so non-nested content behaves exactly as before. + * + * Example: chains [outer], [outer, inner], [outer] produce one continuous outer + * run at depth 0 and a single inner run (the middle item) at depth 1. + * Image/drawing items that carry the same chain stay inside the run rather than + * splitting it. Labels dedupe by key, matching computeSdtBoundaries. + */ +export const computeSdtBoundaryLayers = ( + resolvedItems: readonly ResolvedPaintItem[], + sdtLabelsRendered: Set, +): Map => { + const layers = new Map(); + + const chains: (string | null)[][] = resolvedItems.map((item) => { + if (item && 'sdtContainerKeys' in item) { + const chain = (item as { sdtContainerKeys?: (string | null)[] }).sdtContainerKeys; + if (chain && chain.length > 0) return chain; + } + if (item && 'sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + if (key) return [key]; + } + return []; + }); + + const fragmentOf = (idx: number): Fragment | null => { + const item = resolvedItems[idx]; + return item && item.kind === 'fragment' ? item.fragment : null; + }; + + const keyAtDepth = (idx: number, depth: number): string | null => { + const chain = chains[idx]; + return depth < chain.length ? chain[depth] : null; + }; + + const addLayer = (idx: number, layer: SdtBoundaryLayer): void => { + const existing = layers.get(idx); + if (existing) existing.push(layer); + else layers.set(idx, [layer]); + }; + + const maxDepth = chains.reduce((max, chain) => Math.max(max, chain.length), 0); + + for (let depth = 0; depth < maxDepth; depth += 1) { + let i = 0; + while (i < resolvedItems.length) { + const currentKey = keyAtDepth(i, depth); + const startFrag = fragmentOf(i); + if (!currentKey || !startFrag) { + i += 1; + continue; + } + + let groupRight = startFrag.x + startFrag.width; + let j = i; + while (j + 1 < resolvedItems.length && keyAtDepth(j + 1, depth) === currentKey) { + j += 1; + const nextFrag = fragmentOf(j); + if (!nextFrag) break; + const fragmentRight = nextFrag.x + nextFrag.width; + if (fragmentRight > groupRight) groupRight = fragmentRight; + } + + for (let k = i; k <= j; k += 1) { + const fragment = fragmentOf(k); + if (!fragment) continue; + const isStart = k === i; + const isEnd = k === j; + + let paddingBottomOverride: number | undefined; + if (!isEnd) { + const nextFragment = fragmentOf(k + 1); + const currentHeight = (resolvedItems[k] as { height?: number } | undefined)?.height ?? 0; + const currentBottom = fragment.y + currentHeight; + if (nextFragment) { + const gapToNext = nextFragment.y - currentBottom; + if (gapToNext > 0) paddingBottomOverride = gapToNext; + } + } + + const showLabel = isStart && !sdtLabelsRendered.has(currentKey); + if (showLabel) sdtLabelsRendered.add(currentKey); + + addLayer(k, { + isStart, + isEnd, + widthOverride: groupRight - fragment.x, + paddingBottomOverride, + showLabel, + key: currentKey, + depth, + }); + } + + i = j + 1; + } + } + + return layers; +}; From dbcd7d13af1bf66f3c27923ab729e55475982d85 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:25:46 -0300 Subject: [PATCH 2/5] test(painter-dom): exercise real image/drawing items in boundary-layer run --- .../painters/dom/src/sdt/boundaries.test.ts | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts b/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts index 1903c4361c..605d92c706 100644 --- a/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/boundaries.test.ts @@ -2,16 +2,23 @@ import { describe, expect, it } from 'vitest'; import type { ResolvedPaintItem } from '@superdoc/contracts'; import { computeSdtBoundaryLayers } from './boundaries.js'; -const makeItem = (y: number, sdtContainerKeys: (string | null)[]): ResolvedPaintItem => +const makeItem = ( + y: number, + sdtContainerKeys: (string | null)[], + fragmentKind: 'para' | 'image' | 'drawing' = 'para', +): ResolvedPaintItem => ({ kind: 'fragment', id: `f-${y}`, pageIndex: 0, - fragmentKind: 'para', + fragmentKind, blockId: `b-${y}`, fragmentIndex: y, height: 20, - fragment: { kind: 'para', blockId: `b-${y}`, x: 0, y, width: 100 }, + // Image/drawing resolved items are kind: 'fragment' with a fragment + // back-pointer of the matching kind (see ResolvedImageItem/ResolvedDrawingItem), + // so the boundary pass reads their geometry the same way as paragraphs. + fragment: { kind: fragmentKind, blockId: `b-${y}`, x: 0, y, width: 100 }, sdtContainerKey: sdtContainerKeys.length ? sdtContainerKeys[sdtContainerKeys.length - 1] : null, sdtContainerKeys, }) as unknown as ResolvedPaintItem; @@ -39,19 +46,26 @@ describe('computeSdtBoundaryLayers', () => { expect(layerAtDepth(layers, 2, 1)).toBeUndefined(); }); - it('keeps an image/drawing item inside the run instead of splitting it', () => { - // The middle item (e.g. a drawing fragment) now carries the same outer chain - // via FU2, so it stays in the outer run rather than breaking it. + it('keeps real image and drawing items inside the run instead of splitting it', () => { + // Image and drawing resolved items are kind: 'fragment' (fragmentKind + // 'image'/'drawing') and, via FU2, carry the same container chain. They must + // stay inside the outer run rather than break it into separate boxes. const items = [ - makeItem(0, ['structuredContent:outer']), - makeItem(20, ['structuredContent:outer']), - makeItem(40, ['structuredContent:outer']), + makeItem(0, ['structuredContent:outer'], 'para'), + makeItem(20, ['structuredContent:outer'], 'image'), + makeItem(40, ['structuredContent:outer'], 'drawing'), + makeItem(60, ['structuredContent:outer'], 'para'), ]; const layers = computeSdtBoundaryLayers(items, new Set()); + // One continuous outer run across para, image, drawing, para. expect(layerAtDepth(layers, 0, 0)).toMatchObject({ isStart: true, isEnd: false }); expect(layerAtDepth(layers, 1, 0)).toMatchObject({ isStart: false, isEnd: false }); - expect(layerAtDepth(layers, 2, 0)).toMatchObject({ isStart: false, isEnd: true }); + expect(layerAtDepth(layers, 2, 0)).toMatchObject({ isStart: false, isEnd: false }); + expect(layerAtDepth(layers, 3, 0)).toMatchObject({ isStart: false, isEnd: true }); + // The image and drawing items are part of the run, not skipped. + expect(layerAtDepth(layers, 1, 0)?.key).toBe('structuredContent:outer'); + expect(layerAtDepth(layers, 2, 0)?.key).toBe('structuredContent:outer'); }); it('dedupes labels by key and renders each container label once', () => { From 5aab320e4cc2286c35e2bc00158174fa4b5ea004 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:45:27 -0300 Subject: [PATCH 3/5] feat(painter-dom): render nested content-control chrome via ancestor overlays Wire computeSdtBoundaryLayers into the initial page render: each fragment's nearest control is still drawn by the existing border path, and renderFragment now adds one non-interactive overlay per ANCESTOR control (renderSdtAncestorLayers) so an outer control keeps a continuous outline and label around inner controls. Edges are shared by default (Word-like), tunable via --sd-sdt-layer-offset. Also stamps the previously-missing SDT dataset on the drawing render path. Scope: initial render path only. The patch/diff render path and the shouldRebuildForSdtBoundary layer signature are the remaining FU3 wiring. Needs visual QA on the nested fixture before #3745 closes. Refs #3745 --- .../painters/dom/src/renderer.ts | 51 +++++++++++-- .../dom/src/sdt/ancestor-layers.test.ts | 71 +++++++++++++++++++ .../painters/dom/src/sdt/container.ts | 64 +++++++++++++++++ .../layout-engine/painters/dom/src/styles.ts | 45 ++++++++++++ 4 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 182251db31..0e985602db 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -41,7 +41,7 @@ import type { ResolvedDrawingItem, LayoutSourceIdentity, LayoutStoryLocator, - ListBlock, + ListBlock, SdtMetadata } from '@superdoc/contracts'; import { computeLinePmRange, @@ -82,8 +82,8 @@ import { } from './styles.js'; import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; -import { computeSdtBoundaries } from './sdt/boundaries.js'; -import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; +import { computeSdtBoundaries, computeSdtBoundaryLayers, type SdtBoundaryLayer } from './sdt/boundaries.js'; +import { shouldRebuildForSdtBoundary, renderSdtAncestorLayers, type SdtBoundaryOptions } from './sdt/container.js'; import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js'; import { createInlineSdtWrapper, @@ -1870,6 +1870,10 @@ export class DomPainter { const resolvedItems = page.items; const sdtBoundaries = computeSdtBoundaries(resolvedItems, this.sdtLabelsRendered); + // Ancestor overlay layers for nested block content controls. Computed after + // computeSdtBoundaries so it shares this.sdtLabelsRendered: the nearest + // control's label is already claimed, so only ancestor labels are emitted here. + const sdtBoundaryLayers = computeSdtBoundaryLayers(resolvedItems, this.sdtLabelsRendered); const betweenBorderFlags = computeBetweenBorderFlags(resolvedItems); resolvedItems.forEach((resolvedItem, index) => { @@ -1877,7 +1881,14 @@ export class DomPainter { const fragment = resolvedItem.fragment; const sdtBoundary = sdtBoundaries.get(index); el.appendChild( - this.renderFragment(fragment, contextBase, sdtBoundary, betweenBorderFlags.get(index), resolvedItem), + this.renderFragment( + fragment, + contextBase, + sdtBoundary, + betweenBorderFlags.get(index), + resolvedItem, + sdtBoundaryLayers.get(index), + ), ); }); this.renderDecorationsForPage(el, page, pageIndex); @@ -2592,7 +2603,10 @@ export class DomPainter { } if (freshStart == null || !Number.isFinite(freshStart)) return; - const elements = [fragmentEl, ...Array.from(fragmentEl.querySelectorAll('[data-pm-start], [data-pm-end]'))]; + const elements = [ + fragmentEl, + ...Array.from(fragmentEl.querySelectorAll('[data-pm-start], [data-pm-end]')), + ]; let paintedStart = Infinity; for (const el of elements) { const start = Number(el.dataset.pmStart); @@ -2762,6 +2776,26 @@ export class DomPainter { sdtBoundary?: SdtBoundaryOptions, betweenInfo?: BetweenBorderInfo, resolvedItem?: ResolvedPaintItem, + sdtLayers?: SdtBoundaryLayer[], + ): HTMLElement { + const element = this.renderFragmentByKind(fragment, context, sdtBoundary, betweenInfo, resolvedItem); + // Draw ancestor content-control chrome (nested block SDTs) as overlays. The + // fragment's own nearest control is already drawn by the kind renderer; this + // adds the enclosing controls above it. No-op for non-nested fragments. + if (this.doc && sdtLayers && sdtLayers.length > 1) { + const containerChain = (resolvedItem as { block?: { attrs?: { sdtContainers?: SdtMetadata[] } } } | undefined) + ?.block?.attrs?.sdtContainers; + renderSdtAncestorLayers(this.doc, element, sdtLayers, containerChain, this.contentControlsChrome); + } + return element; + } + + private renderFragmentByKind( + fragment: Fragment, + context: FragmentRenderContext, + sdtBoundary?: SdtBoundaryOptions, + betweenInfo?: BetweenBorderInfo, + resolvedItem?: ResolvedPaintItem, ): HTMLElement { if (fragment.kind === 'para') { return this.renderParagraphFragment( @@ -2960,6 +2994,13 @@ export class DomPainter { fragmentEl.style.position = 'absolute'; fragmentEl.style.overflow = 'hidden'; + // Stamp SDT dataset so a drawing inside a content control carries the + // control identity (the drawing path previously set neither dataset nor + // chrome). Ancestor chrome is layered on by renderFragment. + const drawingAttrs = block.attrs as { sdt?: SdtMetadata | null; containerSdt?: SdtMetadata | null } | undefined; + applySdtDataset(fragmentEl, drawingAttrs?.sdt); + applyContainerSdtDataset(fragmentEl, drawingAttrs?.containerSdt); + const innerWrapper = this.doc.createElement('div'); innerWrapper.classList.add('superdoc-drawing-inner'); innerWrapper.style.position = 'absolute'; diff --git a/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts b/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts new file mode 100644 index 0000000000..5371d218e6 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import type { SdtMetadata } from '@superdoc/contracts'; +import { renderSdtAncestorLayers } from './container.js'; +import type { SdtBoundaryLayer } from './boundaries.js'; + +const outer = { + type: 'structuredContent', + scope: 'block', + id: 'outer', + alias: 'Outer Control', +} as unknown as SdtMetadata; +const inner = { + type: 'structuredContent', + scope: 'block', + id: 'inner', + alias: 'Inner Control', +} as unknown as SdtMetadata; + +// A fragment inside [outer, inner]: depth 0 = outer (ancestor), depth 1 = inner (nearest). +const layers: SdtBoundaryLayer[] = [ + { key: 'structuredContent:outer', depth: 0, isStart: true, isEnd: false, showLabel: true }, + { key: 'structuredContent:inner', depth: 1, isStart: true, isEnd: true, showLabel: true }, +]; + +describe('renderSdtAncestorLayers', () => { + let host: HTMLElement; + beforeEach(() => { + host = document.createElement('div'); + }); + + it('renders one overlay for the ancestor and skips the nearest (deepest) layer', () => { + renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default'); + const overlays = host.querySelectorAll('.superdoc-sdt-ancestor-layer'); + expect(overlays.length).toBe(1); + expect((overlays[0] as HTMLElement).dataset.sdtDepth).toBe('0'); + }); + + it('carries start/end boundary attributes from the layer', () => { + renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default'); + const overlay = host.querySelector('.superdoc-sdt-ancestor-layer') as HTMLElement; + expect(overlay.dataset.sdtContainerStart).toBe('true'); + expect(overlay.dataset.sdtContainerEnd).toBe('false'); + }); + + it('renders the ancestor label from its metadata when showLabel is set', () => { + renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default'); + const label = host.querySelector('.superdoc-sdt-ancestor-layer span'); + expect(label?.textContent).toBe('Outer Control'); + }); + + it('omits the label when chrome is none', () => { + renderSdtAncestorLayers(document, host, layers, [outer, inner], 'none'); + expect(host.querySelector('.superdoc-sdt-ancestor-layer span')).toBeNull(); + // The overlay box itself is still rendered. + expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(1); + }); + + it('renders nothing for a non-nested fragment (single layer)', () => { + const single: SdtBoundaryLayer[] = [ + { key: 'structuredContent:only', depth: 0, isStart: true, isEnd: true, showLabel: true }, + ]; + renderSdtAncestorLayers(document, host, single, [outer], 'default'); + expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(0); + }); + + it('skips an ancestor whose metadata is hidden', () => { + const hiddenOuter = { ...(outer as object), appearance: 'hidden' } as unknown as SdtMetadata; + renderSdtAncestorLayers(document, host, layers, [hiddenOuter, inner], 'default'); + expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(0); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index 9a29784d45..bed1c9b881 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -1,4 +1,5 @@ import type { SdtMetadata, StructuredContentMetadata } from '@superdoc/contracts'; +import type { SdtBoundaryLayer } from './boundaries.js'; export { getSdtContainerKey, getSdtContainerKeyForBlock, @@ -158,6 +159,69 @@ export function applySdtContainerChrome( return true; } +/** + * Render ancestor content-control chrome as overlay layers on a fragment host + * element, for nested block SDTs. The fragment's own (nearest) control is still + * drawn by the existing `applySdtContainerChrome` border path; this adds one + * absolutely-positioned, non-interactive overlay per ANCESTOR control above it, + * so an outer control keeps a continuous outline around inner controls. + * + * Edges are shared with the inner box by default (offset 0), matching Word, + * which distinguishes nested controls by label and boundary rather than by + * deeply inset rectangles. Each overlay carries `data-sdt-depth` and + * start/end attributes so CSS can draw the correct edges and a per-depth + * offset can be tuned without touching this code. + * + * @param doc - Owning document + * @param hostEl - The fragment's positioned element (overlays fill it) + * @param layers - This fragment's boundary layers from computeSdtBoundaryLayers + * @param containerChain - Ordered ancestor metadata (block.attrs.sdtContainers) + * @param chrome - 'none' suppresses labels (matches the nearest-box behavior) + */ +export function renderSdtAncestorLayers( + doc: Document, + hostEl: HTMLElement, + layers: readonly SdtBoundaryLayer[] | undefined, + containerChain: readonly SdtMetadata[] | undefined, + chrome?: 'default' | 'none', +): void { + if (!layers || layers.length === 0) return; + // The deepest layer is the fragment's nearest control, already drawn by the + // border path; overlays cover only the ancestors above it. + const maxDepth = layers.reduce((max, layer) => Math.max(max, layer.depth), 0); + for (const layer of layers) { + if (layer.depth >= maxDepth) continue; + const metadata = containerChain?.[layer.depth]; + if (isStructuredContentMetadata(metadata) && metadata.appearance === 'hidden') continue; + const config = getSdtContainerConfig(metadata); + + const overlay = doc.createElement('div'); + overlay.className = 'superdoc-sdt-ancestor-layer'; + if (config) overlay.classList.add(config.className); + overlay.dataset.sdtDepth = String(layer.depth); + overlay.dataset.sdtContainerStart = String(layer.isStart ?? true); + overlay.dataset.sdtContainerEnd = String(layer.isEnd ?? true); + overlay.style.setProperty('--sd-sdt-layer-depth', String(layer.depth)); + if (isStructuredContentMetadata(metadata)) { + overlay.dataset.lockMode = metadata.lockMode || 'unlocked'; + } + if (layer.paddingBottomOverride != null && layer.paddingBottomOverride > 0) { + overlay.style.setProperty('--sd-sdt-chrome-bottom-extension', `${layer.paddingBottomOverride}px`); + } + + if (layer.showLabel && chrome !== 'none' && config) { + const labelEl = doc.createElement('div'); + labelEl.className = `superdoc-sdt-ancestor-layer__label ${config.labelClassName}`; + const labelText = doc.createElement('span'); + labelText.textContent = config.labelText; + labelEl.appendChild(labelText); + overlay.appendChild(labelEl); + } + + hostEl.appendChild(overlay); + } +} + export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { if (!boundary) { return element.dataset.sdtContainerStart !== undefined; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 28189fa24e..8bd2524c17 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -662,6 +662,51 @@ const SDT_CONTAINER_STYLES = ` pointer-events: none; } +/* + * Ancestor overlay layers for nested block content controls (#3752). + * The fragment's own (nearest) control is drawn by .superdoc-structured-content-block + * above; each enclosing control is drawn as a non-interactive overlay covering the + * same region. Edges are shared with the inner box by default (offset 0), matching + * Word, which separates nested controls by label and boundary rather than deep insets. + * --sd-sdt-layer-offset can nudge edges outward per-depth if QA shows overlap. + */ +.superdoc-sdt-ancestor-layer { + position: absolute; + left: calc(0px - var(--sd-sdt-layer-offset, 0px)); + right: calc(0px - var(--sd-sdt-layer-offset, 0px)); + top: 0; + bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px)); + border: 0 solid var(--sd-content-controls-block-border, #629be7); + border-left-width: 1px; + border-right-width: 1px; + border-radius: 4px; + box-sizing: border-box; + pointer-events: none; + z-index: 1; +} + +.superdoc-sdt-ancestor-layer[data-sdt-container-start='true'] { + border-top-width: 1px; +} + +.superdoc-sdt-ancestor-layer[data-sdt-container-end='true'] { + border-bottom-width: 1px; +} + +.superdoc-sdt-ancestor-layer:not([data-sdt-container-start='true']) { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.superdoc-sdt-ancestor-layer:not([data-sdt-container-end='true']) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.superdoc-cc-chrome-none .superdoc-sdt-ancestor-layer { + border-color: transparent; +} + .superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); } From 252d6569ede8e151ee5792f7950529e6f5b4d8ea Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:47:11 -0300 Subject: [PATCH 4/5] style(painter-dom): format SdtMetadata import --- packages/layout-engine/painters/dom/src/renderer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0e985602db..884569c1e9 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -41,7 +41,8 @@ import type { ResolvedDrawingItem, LayoutSourceIdentity, LayoutStoryLocator, - ListBlock, SdtMetadata + ListBlock, + SdtMetadata, } from '@superdoc/contracts'; import { computeLinePmRange, From 62ba84e4ee21d03202557c754edef2bc496d7547 Mon Sep 17 00:00:00 2001 From: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:59:08 -0300 Subject: [PATCH 5/5] fix(painter-dom): complete nested SDT chrome for media, labels, width, clipping Address four gaps in the ancestor-overlay chrome: - Image/drawing draw their nearest control too (renderFragment passes nearestDrawnByHost=false; they have no border path, so every layer is an overlay). - Ancestor label is self-contained and visible (the nearest label class gates visibility on editor selection/hover state the overlay never has). - Ancestor overlays honor widthOverride for multi-fragment run width. - Drawing chrome host stays overflow:visible; content clipping moves to an inner wrapper, so an ancestor label above a drawing is not clipped. Refs #3745 --- .../painters/dom/src/renderer.ts | 40 ++++++++++++++----- .../dom/src/sdt/ancestor-layers.test.ts | 25 ++++++++++++ .../painters/dom/src/sdt/container.ts | 21 ++++++++-- .../layout-engine/painters/dom/src/styles.ts | 40 ++++++++++++++++++- 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 884569c1e9..c3f3e180e4 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -2780,13 +2780,25 @@ export class DomPainter { sdtLayers?: SdtBoundaryLayer[], ): HTMLElement { const element = this.renderFragmentByKind(fragment, context, sdtBoundary, betweenInfo, resolvedItem); - // Draw ancestor content-control chrome (nested block SDTs) as overlays. The - // fragment's own nearest control is already drawn by the kind renderer; this - // adds the enclosing controls above it. No-op for non-nested fragments. - if (this.doc && sdtLayers && sdtLayers.length > 1) { - const containerChain = (resolvedItem as { block?: { attrs?: { sdtContainers?: SdtMetadata[] } } } | undefined) - ?.block?.attrs?.sdtContainers; - renderSdtAncestorLayers(this.doc, element, sdtLayers, containerChain, this.contentControlsChrome); + // Draw content-control chrome for nested block SDTs as overlays. Paragraph and + // table draw their own nearest control via the border path, so overlays add + // only the enclosing (ancestor) controls. Image and drawing have no border + // path, so every control in their chain is drawn as an overlay here. + if (this.doc && sdtLayers && sdtLayers.length > 0) { + const nearestDrawnByHost = fragment.kind === 'para' || fragment.kind === 'table'; + const hasOverlayWork = nearestDrawnByHost ? sdtLayers.length > 1 : sdtLayers.length >= 1; + if (hasOverlayWork) { + const containerChain = (resolvedItem as { block?: { attrs?: { sdtContainers?: SdtMetadata[] } } } | undefined) + ?.block?.attrs?.sdtContainers; + renderSdtAncestorLayers( + this.doc, + element, + sdtLayers, + containerChain, + this.contentControlsChrome, + nearestDrawnByHost, + ); + } } return element; } @@ -2993,7 +3005,10 @@ export class DomPainter { this.applyFragmentWrapperZIndex(fragmentEl, fragment); } fragmentEl.style.position = 'absolute'; - fragmentEl.style.overflow = 'hidden'; + // Chrome host stays overflow:visible so an ancestor SDT label drawn above + // the box is not clipped; content clipping moves to an inner wrapper sized + // to the fragment box (clips identically to the old overflow:hidden here). + fragmentEl.style.overflow = 'visible'; // Stamp SDT dataset so a drawing inside a content control carries the // control identity (the drawing path previously set neither dataset nor @@ -3002,6 +3017,12 @@ export class DomPainter { applySdtDataset(fragmentEl, drawingAttrs?.sdt); applyContainerSdtDataset(fragmentEl, drawingAttrs?.containerSdt); + const clipWrapper = this.doc.createElement('div'); + clipWrapper.classList.add('superdoc-drawing-clip'); + clipWrapper.style.position = 'absolute'; + clipWrapper.style.inset = '0'; + clipWrapper.style.overflow = 'hidden'; + const innerWrapper = this.doc.createElement('div'); innerWrapper.classList.add('superdoc-drawing-inner'); innerWrapper.style.position = 'absolute'; @@ -3020,7 +3041,8 @@ export class DomPainter { innerWrapper.style.transform = transforms.join(' '); innerWrapper.appendChild(this.renderDrawingContent(block, fragment, context)); - fragmentEl.appendChild(innerWrapper); + clipWrapper.appendChild(innerWrapper); + fragmentEl.appendChild(clipWrapper); return fragmentEl; } catch (error) { diff --git a/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts b/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts index 5371d218e6..b46bcc3e17 100644 --- a/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts +++ b/packages/layout-engine/painters/dom/src/sdt/ancestor-layers.test.ts @@ -68,4 +68,29 @@ describe('renderSdtAncestorLayers', () => { renderSdtAncestorLayers(document, host, layers, [hiddenOuter, inner], 'default'); expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(0); }); + + it('draws the nearest control as an overlay for media (nearestDrawnByHost=false)', () => { + // Image/drawing have no border path, so even a single (nearest) control must + // be drawn as an overlay or the media gets no chrome at all. + const single: SdtBoundaryLayer[] = [ + { key: 'structuredContent:only', depth: 0, isStart: true, isEnd: true, showLabel: true }, + ]; + renderSdtAncestorLayers(document, host, single, [outer], 'default', false); + expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(1); + }); + + it('draws both nearest and ancestor for media inside nested controls', () => { + renderSdtAncestorLayers(document, host, layers, [outer, inner], 'default', false); + expect(host.querySelectorAll('.superdoc-sdt-ancestor-layer').length).toBe(2); + }); + + it('applies widthOverride as the ancestor run width', () => { + const withWidth: SdtBoundaryLayer[] = [ + { key: 'structuredContent:outer', depth: 0, isStart: true, isEnd: false, showLabel: true, widthOverride: 480 }, + { key: 'structuredContent:inner', depth: 1, isStart: true, isEnd: true, showLabel: true }, + ]; + renderSdtAncestorLayers(document, host, withWidth, [outer, inner], 'default'); + const overlay = host.querySelector('.superdoc-sdt-ancestor-layer') as HTMLElement; + expect(overlay.style.getPropertyValue('--sd-sdt-ancestor-width')).toBe('480px'); + }); }); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index bed1c9b881..9a2522ce95 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -177,6 +177,10 @@ export function applySdtContainerChrome( * @param layers - This fragment's boundary layers from computeSdtBoundaryLayers * @param containerChain - Ordered ancestor metadata (block.attrs.sdtContainers) * @param chrome - 'none' suppresses labels (matches the nearest-box behavior) + * @param nearestDrawnByHost - When true (paragraph/table, which draw their own + * nearest border), the deepest layer is skipped because the host already drew + * it. When false (image/drawing, which have no border path), every layer is + * drawn as an overlay so the nearest control still gets a box. */ export function renderSdtAncestorLayers( doc: Document, @@ -184,13 +188,15 @@ export function renderSdtAncestorLayers( layers: readonly SdtBoundaryLayer[] | undefined, containerChain: readonly SdtMetadata[] | undefined, chrome?: 'default' | 'none', + nearestDrawnByHost = true, ): void { if (!layers || layers.length === 0) return; - // The deepest layer is the fragment's nearest control, already drawn by the - // border path; overlays cover only the ancestors above it. + // The deepest layer is the fragment's nearest control. Paragraph/table draw it + // via the border path, so overlays cover only the ancestors above it; media + // has no border path, so it is drawn as an overlay too. const maxDepth = layers.reduce((max, layer) => Math.max(max, layer.depth), 0); for (const layer of layers) { - if (layer.depth >= maxDepth) continue; + if (nearestDrawnByHost && layer.depth >= maxDepth) continue; const metadata = containerChain?.[layer.depth]; if (isStructuredContentMetadata(metadata) && metadata.appearance === 'hidden') continue; const config = getSdtContainerConfig(metadata); @@ -205,13 +211,20 @@ export function renderSdtAncestorLayers( if (isStructuredContentMetadata(metadata)) { overlay.dataset.lockMode = metadata.lockMode || 'unlocked'; } + // Match the existing chrome's run width for multi-fragment continuation. + if (layer.widthOverride != null) { + overlay.style.setProperty('--sd-sdt-ancestor-width', `${layer.widthOverride}px`); + } if (layer.paddingBottomOverride != null && layer.paddingBottomOverride > 0) { overlay.style.setProperty('--sd-sdt-chrome-bottom-extension', `${layer.paddingBottomOverride}px`); } if (layer.showLabel && chrome !== 'none' && config) { + // Use only the self-contained ancestor-label class. The nearest control's + // label class gates visibility on editor selection/hover state the overlay + // never has, which would keep the ancestor label hidden. const labelEl = doc.createElement('div'); - labelEl.className = `superdoc-sdt-ancestor-layer__label ${config.labelClassName}`; + labelEl.className = 'superdoc-sdt-ancestor-layer__label'; const labelText = doc.createElement('span'); labelText.textContent = config.labelText; labelEl.appendChild(labelText); diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 8bd2524c17..256247b13e 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -673,9 +673,9 @@ const SDT_CONTAINER_STYLES = ` .superdoc-sdt-ancestor-layer { position: absolute; left: calc(0px - var(--sd-sdt-layer-offset, 0px)); - right: calc(0px - var(--sd-sdt-layer-offset, 0px)); top: 0; bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px)); + width: var(--sd-sdt-ancestor-width, 100%); border: 0 solid var(--sd-content-controls-block-border, #629be7); border-left-width: 1px; border-right-width: 1px; @@ -707,6 +707,44 @@ const SDT_CONTAINER_STYLES = ` border-color: transparent; } +/* + * Ancestor label. Self-contained and always displayable: unlike the nearest + * control's label (shown only on .ProseMirror-selectednode / .sdt-group-hover), + * an ancestor's label must show whenever the ancestor box is drawn, since the + * overlay never carries editor selection/hover state. + */ +.superdoc-sdt-ancestor-layer__label { + display: inline-flex; + align-items: center; + position: absolute; + left: var(--sd-sdt-chrome-left, 0px); + top: -18px; + max-width: 130px; + height: 16px; + padding: 0 6px; + border: 1px solid var(--sd-content-controls-label-border, #629be7); + border-bottom: none; + border-radius: 6px 6px 0 0; + background-color: var(--sd-content-controls-label-bg, #629be7); + color: var(--sd-content-controls-label-text, #ffffff); + font-size: 11px; + line-height: 16px; + white-space: nowrap; + box-sizing: border-box; + pointer-events: none; + z-index: 10; +} + +.superdoc-sdt-ancestor-layer__label span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.superdoc-cc-chrome-none .superdoc-sdt-ancestor-layer__label { + display: none; +} + .superdoc-structured-content-block:not(.ProseMirror-selectednode):hover::before { background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); }