diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 88b7b7e8ad..df9232099a 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -75,6 +75,13 @@ export { export { computeFragmentPmRange, computeLinePmRange, type LinePmRange } from './pm-range.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; +export { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, + isSdtContainerMetadata, +} from './sdt-container.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; diff --git a/packages/layout-engine/contracts/src/sdt-container.test.ts b/packages/layout-engine/contracts/src/sdt-container.test.ts new file mode 100644 index 0000000000..d0f8986479 --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { SdtMetadata } from './index.js'; +import { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, +} from './sdt-container.js'; + +describe('SDT container key helpers', () => { + it('uses the first renderable container metadata', () => { + const containerSdt: SdtMetadata = { type: 'documentSection', id: 'section-1' }; + + expect(getSdtContainerMetadata({ type: 'structuredContent', scope: 'inline', id: 'inline-1' }, containerSdt)).toBe( + containerSdt, + ); + }); + + it('derives explicit keys for block content controls and document sections', () => { + expect(getSdtContainerKey({ type: 'structuredContent', scope: 'block', id: 'sdt-1' })).toBe( + 'structuredContent:sdt-1', + ); + expect(getSdtContainerKey({ type: 'documentSection', sdBlockId: 'section-block-1' })).toBe( + 'documentSection:section-block-1', + ); + }); + + it('derives stable object keys for id-less containers', () => { + const sharedSdt: SdtMetadata = { type: 'structuredContent', scope: 'block', alias: 'Shared' }; + const firstKey = getSdtContainerKey(sharedSdt); + + expect(firstKey).toMatch(/^idlessSdt:/); + expect(getSdtContainerKey(sharedSdt)).toBe(firstKey); + expect(hasExplicitSdtContainerKey(sharedSdt)).toBe(false); + }); + + it('derives keys from any block-like object with SDT attrs', () => { + const sdt: SdtMetadata = { type: 'structuredContent', scope: 'block', id: 'media-sdt' }; + + expect(getSdtContainerKeyForBlock({ attrs: { sdt } })).toBe('structuredContent:media-sdt'); + }); +}); diff --git a/packages/layout-engine/contracts/src/sdt-container.ts b/packages/layout-engine/contracts/src/sdt-container.ts new file mode 100644 index 0000000000..8f650c197a --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.ts @@ -0,0 +1,78 @@ +import type { SdtMetadata } from './index.js'; + +type SdtBlockCandidate = { + attrs?: { + sdt?: SdtMetadata | null; + containerSdt?: SdtMetadata | null; + } | null; +}; + +const idlessSdtContainerKeys = new WeakMap(); +let nextIdlessSdtContainerKey = 0; + +function getIdlessSdtContainerKey(metadata: SdtMetadata): string { + const existingKey = idlessSdtContainerKeys.get(metadata); + if (existingKey) return existingKey; + + // AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same + // SdtMetadata object across sibling blocks in one container. Do not replace + // this with alias/title matching; separate controls can share display text. + const key = `idlessSdt:${++nextIdlessSdtContainerKey}`; + idlessSdtContainerKeys.set(metadata, key); + return key; +} + +export function isSdtContainerMetadata(sdt: SdtMetadata | null | undefined): boolean { + if (!sdt) return false; + if (sdt.type === 'documentSection') return true; + if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; + return false; +} + +export function getSdtContainerMetadata( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, +): SdtMetadata | null { + if (isSdtContainerMetadata(sdt)) return sdt ?? null; + if (isSdtContainerMetadata(containerSdt)) return containerSdt ?? null; + return null; +} + +export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (metadata.id) return `structuredContent:${metadata.id}`; + return getIdlessSdtContainerKey(metadata); + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (sectionId) return `documentSection:${sectionId}`; + return getIdlessSdtContainerKey(metadata); + } + + return null; +} + +export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; + + if (metadata.type === 'structuredContent') { + return metadata.scope === 'block' && Boolean(metadata.id); + } + + if (metadata.type === 'documentSection') { + return Boolean(metadata.id ?? metadata.sdBlockId); + } + + return false; +} + +export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { + if (!block) return null; + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); +} diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index 525329512c..84bec6ed47 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2763,7 +2763,7 @@ describe('resolveLayout', () => { expect(drItem.sdtContainerKey).toBeUndefined(); }); - it('returns null (omits key) for structuredContent block scope with no id', () => { + it('sets an object-stable key for structuredContent block scope with no id', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2785,10 +2785,10 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); - it('returns null (omits key) for documentSection with no id or sdBlockId', () => { + it('sets an object-stable key for documentSection with no id or sdBlockId', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2810,7 +2810,7 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 28f63bb75b..bec8b56f74 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -21,12 +21,12 @@ import type { ParagraphBlock, ParagraphMeasure, } from '@superdoc/contracts'; +import { getSdtContainerKey } from '@superdoc/contracts'; import { resolveParagraphContent } from './resolveParagraph.js'; import { resolveTableItem } from './resolveTable.js'; import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; -import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; import { deriveBlockVersion, fragmentSignature, sourceAnchorSignature } from './versionSignature.js'; @@ -156,17 +156,17 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map listItem.id === fragment.itemId); - return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + return getSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); } if (fragment.kind === 'table' && block.kind === 'table') { - return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); } // image, drawing — no SDT container keys diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts deleted file mode 100644 index 4cee08673f..0000000000 --- a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SdtMetadata } from '@superdoc/contracts'; - -/** - * Returns a stable key for grouping consecutive fragments in the same SDT container. - * - * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts` - * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package. - * Only the key derivation is needed; DOM styling helpers are not. - */ -export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (!metadata.id) return null; - return `structuredContent:${metadata.id}`; - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) return null; - return `documentSection:${sectionId}`; - } - - return null; -} - -function isSdtContainer(sdt?: SdtMetadata | null): boolean { - if (!sdt) return false; - if (sdt.type === 'documentSection') return true; - if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; - return false; -} - -function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null { - if (isSdtContainer(sdt)) return sdt ?? null; - if (isSdtContainer(containerSdt)) return containerSdt ?? null; - return null; -} diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index fe032d0001..c1a5e91387 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -14,7 +14,12 @@ import { getParagraphInlineDirection, } from '@superdoc/contracts'; import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; -import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { + applySdtContainerChrome, + shouldRenderSdtContainerChrome, + type SdtAncestorOptions, + type SdtBoundaryOptions, +} from '../sdt/container.js'; import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; import { applyParagraphLineIndentation, @@ -78,7 +83,11 @@ export type RenderParagraphContentParams = { betweenInfo?: BetweenBorderInfo; sdtBoundary?: SdtBoundaryOptions; spacingPolicy?: ParagraphSpacingPolicy; - shouldApplySdtContainerStyling?: (sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null) => boolean; + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + onSdtContainerChrome?: () => void; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; renderLine: ParagraphRenderLine; @@ -115,7 +124,11 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re betweenInfo, sdtBoundary, spacingPolicy, - shouldApplySdtContainerStyling, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, applySdtDataset, applyContainerSdtDataset, renderDropCap, @@ -135,9 +148,16 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re applySdtDataset(frameEl, block.attrs?.sdt); applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); - const applySdtChrome = shouldApplySdtContainerStyling?.(block.attrs?.sdt, block.attrs?.containerSdt) ?? true; + const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + }); if (applySdtChrome) { - applySdtContainerStyling(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) { + onSdtContainerChrome?.(); + } } renderParagraphDropCap({ diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 671bbdb2e3..cf07c6bff4 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -9,7 +9,7 @@ import type { import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; -import type { SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; import type { BetweenBorderInfo } from './borders/index.js'; import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; @@ -72,11 +72,7 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): const isTocEntry = block.attrs?.isTocEntry; const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; - const hasSdtContainer = - block.attrs?.sdt?.type === 'documentSection' || - block.attrs?.sdt?.type === 'structuredContent' || - block.attrs?.containerSdt?.type === 'documentSection' || - block.attrs?.containerSdt?.type === 'structuredContent'; + const hasSdtContainer = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt); const paraIndentForOverflow = block.attrs?.indent; const hasNegativeIndent = (paraIndentForOverflow?.left ?? 0) < 0 || (paraIndentForOverflow?.right ?? 0) < 0; const styles = isTocEntry diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 890679e6da..8a97c2a890 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -20,7 +20,6 @@ import type { ParagraphBlock, PositionedDrawingGeometry, Run, - SdtMetadata, ShapeGroupChild, ShapeGroupDrawing, ShapeTextContent, @@ -50,7 +49,6 @@ import { createChartElement as renderChartToElement } from './chart-renderer.js' import { hashCellBorders, hashTableBorders } from './paragraph-hash-utils.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { - BROWSER_DEFAULT_FONT_SIZE, CLASS_NAMES, containerStyles, containerStylesHorizontal, @@ -70,7 +68,26 @@ import { import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; import { applyImageClipPath } from './utils/image-clip-path.js'; -import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; +import { computeSdtBoundaries } from './sdt/boundaries.js'; +import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; +import { + applyContainerSdtDataset, + applySdtDataset, + getSdtMetadataId, + getSdtMetadataLockMode, + getSdtMetadataVersion, +} from './sdt/dataset.js'; +import { + createInlineSdtWrapper, + expandSdtWrapperPmRange, + resolveRunSdtId, + syncInlineSdtWrapperTypography, +} from './sdt/inline.js'; +import { + collectSdtSnapshotEntitiesFromDomRoot, + type PaintSnapshotStructuredContentBlockEntity, + type PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; import { deriveParagraphBlockVersion, hashParagraphBlockForTableVersion } from './paragraph/block-version.js'; import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; @@ -80,6 +97,11 @@ import type { RunRenderContext } from './runs/types.js'; import { buildImageFilters } from './runs/image-run.js'; import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; +export type { + PaintSnapshotStructuredContentBlockEntity, + PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; + type LineEnd = { type?: string; width?: string; @@ -291,22 +313,6 @@ export type PaintSnapshotAnnotationEntity = { type?: string; }; -export type PaintSnapshotStructuredContentBlockEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; -}; - -export type PaintSnapshotStructuredContentInlineEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; -}; - export type PaintSnapshotImageEntity = { element: HTMLElement; pageIndex: number; @@ -510,43 +516,13 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap ); } - const blockSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`), - ); - for (const element of blockSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentBlocks.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - }) as PaintSnapshotStructuredContentBlockEntity, - ); - } - - const inlineSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}[data-sdt-id]`), - ); - for (const element of inlineSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentInlines.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - }) as PaintSnapshotStructuredContentInlineEntity, - ); - } + const sdtEntities = collectSdtSnapshotEntitiesFromDomRoot(rootEl, { + resolvePageIndex: resolveSnapshotPageIndex, + readDatasetNumber: readSnapshotDatasetNumber, + compactObject: compactSnapshotObject, + }); + entities.structuredContentBlocks.push(...sdtEntities.structuredContentBlocks); + entities.structuredContentInlines.push(...sdtEntities.structuredContentInlines); const inlineImageElements = Array.from( rootEl.querySelectorAll( @@ -2492,8 +2468,8 @@ export class DomPainter { applyResolvedFragmentFrame: (el, item, paraFragment) => this.applyResolvedFragmentFrame(el, item, paraFragment, context.section), applyFragmentFrame: (el, paraFragment) => this.applyFragmentFrame(el, paraFragment, context.section), - applySdtDataset: this.applySdtDataset.bind(this), - applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + applySdtDataset, + applyContainerSdtDataset, renderLine: ({ block, line, @@ -2582,8 +2558,8 @@ export class DomPainter { fragmentEl.style.height = `${fragment.height}px`; this.applyFragmentWrapperZIndex(fragmentEl, fragment); } - this.applySdtDataset(fragmentEl, block.attrs?.sdt); - this.applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); + applySdtDataset(fragmentEl, block.attrs?.sdt); + applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); // Add block ID for PM transaction targeting if (block.id) { @@ -3886,8 +3862,8 @@ export class DomPainter { }, renderDrawingContent: renderDrawingContentForTableCell, applyFragmentFrame: applyFragmentFrameWithSection, - applySdtDataset: this.applySdtDataset.bind(this), - applyContainerSdtDataset: this.applyContainerSdtDataset.bind(this), + applySdtDataset, + applyContainerSdtDataset, applyStyles, }); @@ -3945,23 +3921,24 @@ export class DomPainter { throw new Error('DomPainter: document is not available'); } - return { + const runContext: RunRenderContext = { doc: this.doc, layoutEpoch: this.layoutEpoch, showFormattingMarks: this.showFormattingMarks, pendingTooltips: this.pendingTooltips, getNextLinkId: () => `superdoc-link-${++this.linkIdCounter}`, - applySdtDataset: this.applySdtDataset.bind(this), + applySdtDataset, buildImageHyperlinkAnchor: this.buildImageHyperlinkAnchor.bind( this, ) as RunRenderContext['buildImageHyperlinkAnchor'], resolveTrackedChangesConfig, applyTrackedChangeDecorations, - resolveRunSdtId: this.resolveRunSdtId.bind(this), - createInlineSdtWrapper: this.createInlineSdtWrapper.bind(this), - syncInlineSdtWrapperTypography: this.syncInlineSdtWrapperTypography.bind(this), - expandSdtWrapperPmRange: this.expandSdtWrapperPmRange.bind(this), + resolveRunSdtId, + createInlineSdtWrapper: (sdt) => createInlineSdtWrapper(sdt, runContext), + syncInlineSdtWrapperTypography, + expandSdtWrapperPmRange, }; + return runContext; } /** @@ -4143,263 +4120,8 @@ export class DomPainter { } return 0; } - - /** - * All dataset keys used for SDT metadata. - * Shared between applySdtDataset and clearSdtDataset to ensure consistency. - */ - private static readonly SDT_DATASET_KEYS = [ - 'sdtType', - 'sdtId', - 'sdtFieldId', - 'sdtFieldType', - 'sdtFieldVariant', - 'sdtFieldVisibility', - 'sdtFieldHidden', - 'sdtFieldLocked', - 'sdtScope', - 'sdtTag', - 'sdtAlias', - 'lockMode', - 'sdtSectionTitle', - 'sdtSectionType', - 'sdtSectionLocked', - 'sdtDocpartGallery', - 'sdtDocpartId', - 'sdtDocpartInstruction', - ] as const; - - /** - * Helper to set a string dataset attribute if the value is truthy. - */ - private setDatasetString(el: HTMLElement, key: string, value: string | null | undefined): void { - if (value) { - el.dataset[key] = value; - } - } - - /** - * Helper to set a boolean dataset attribute if the value is not null/undefined. - */ - private setDatasetBoolean(el: HTMLElement, key: string, value: boolean | null | undefined): void { - if (value != null) { - el.dataset[key] = String(value); - } - } - - /** - * Resolve the inline SDT id from a run, or null if the run is not inside an inline SDT. - */ - private resolveRunSdtId(run: Run): { sdtId: string; sdt: SdtMetadata } | null { - const sdt = (run as TextRun).sdt; - if (sdt?.type === 'structuredContent' && sdt?.scope === 'inline' && sdt?.id) { - return { sdtId: String(sdt.id), sdt }; - } - return null; - } - - /** - * Create an inline SDT wrapper `` with className, layoutEpoch, dataset, and label. - * Shared by both the geometry and run-based rendering paths. - */ - private createInlineSdtWrapper(sdt: SdtMetadata): HTMLElement { - const wrapper = this.doc!.createElement('span'); - wrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; - wrapper.dataset.layoutEpoch = String(this.layoutEpoch); - this.applySdtDataset(wrapper, sdt); - const alias = (sdt as { alias?: string })?.alias || 'Inline content'; - const labelEl = this.doc!.createElement('span'); - labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; - labelEl.textContent = alias; - wrapper.appendChild(labelEl); - return wrapper; - } - - private syncInlineSdtWrapperTypography(wrapper: HTMLElement, runForSizing?: Run): void { - // The line container sets fontSize:0 (strut fix). Keep wrapper typography - // synced with the current run so border height tracks text-size edits. - const runFontSize = - runForSizing && 'fontSize' in runForSizing && typeof runForSizing.fontSize === 'number' - ? `${runForSizing.fontSize}px` - : BROWSER_DEFAULT_FONT_SIZE; - wrapper.style.fontSize = runFontSize; - wrapper.style.lineHeight = 'normal'; - } - - /** - * Expand the PM position range tracked on an SDT wrapper to include a new run's range. - */ - private expandSdtWrapperPmRange(wrapper: HTMLElement, pmStart?: number | null, pmEnd?: number | null): void { - if (pmStart != null) { - const cur = wrapper.dataset.pmStart; - if (!cur || pmStart < parseInt(cur, 10)) { - wrapper.dataset.pmStart = String(pmStart); - } - } - if (pmEnd != null) { - const cur = wrapper.dataset.pmEnd; - if (!cur || pmEnd > parseInt(cur, 10)) { - wrapper.dataset.pmEnd = String(pmEnd); - } - } - } - - /** - * Applies SDT (Structured Document Tag) metadata to an element's dataset as data-sdt-* attributes. - * Supports field annotations, structured content, document sections, and doc parts. - * Clears existing SDT metadata before applying new values. - * - * @param el - The HTML element to annotate - * @param metadata - The SDT metadata to render as data attributes - */ - private applySdtDataset(el: HTMLElement | null, metadata?: SdtMetadata | null): void { - if (!el?.dataset) return; - this.clearSdtDataset(el); - if (!metadata) return; - - el.dataset.sdtType = metadata.type; - - if ('id' in metadata && metadata.id != null) { - el.dataset.sdtId = String(metadata.id); - } - - if (metadata.type === 'fieldAnnotation') { - this.setDatasetString(el, 'sdtFieldId', metadata.fieldId); - this.setDatasetString(el, 'sdtFieldType', metadata.fieldType); - this.setDatasetString(el, 'sdtFieldVariant', metadata.variant); - this.setDatasetString(el, 'sdtFieldVisibility', metadata.visibility); - this.setDatasetBoolean(el, 'sdtFieldHidden', metadata.hidden); - this.setDatasetBoolean(el, 'sdtFieldLocked', metadata.isLocked); - } else if (metadata.type === 'structuredContent') { - this.setDatasetString(el, 'sdtScope', metadata.scope); - this.setDatasetString(el, 'sdtTag', metadata.tag); - this.setDatasetString(el, 'sdtAlias', metadata.alias); - // Always set lockMode (defaulting to 'unlocked') so CSS can target all SDTs uniformly. - this.setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); - } else if (metadata.type === 'documentSection') { - this.setDatasetString(el, 'sdtSectionTitle', metadata.title); - this.setDatasetString(el, 'sdtSectionType', metadata.sectionType); - this.setDatasetBoolean(el, 'sdtSectionLocked', metadata.isLocked); - } else if (metadata.type === 'docPartObject') { - this.setDatasetString(el, 'sdtDocpartGallery', metadata.gallery); - this.setDatasetString(el, 'sdtDocpartId', metadata.uniqueId); - this.setDatasetString(el, 'sdtDocpartInstruction', metadata.instruction); - } - } - - private clearSdtDataset(el: HTMLElement): void { - DomPainter.SDT_DATASET_KEYS.forEach((key) => { - delete el.dataset[key]; - }); - } - - /** - * Applies container SDT metadata to an element's dataset (data-sdt-container-* attributes). - * Used when a block has both primary SDT metadata (e.g., docPartObject) and container - * metadata (e.g., documentSection). The container metadata is rendered with a "Container" - * prefix to distinguish it from the primary SDT metadata. - * - * @param el - The HTML element to annotate - * @param metadata - The container SDT metadata (typically documentSection) - */ - private applyContainerSdtDataset(el: HTMLElement | null, metadata?: SdtMetadata | null): void { - if (!el?.dataset) return; - if (!metadata) return; - - el.dataset.sdtContainerType = metadata.type; - - if ('id' in metadata && metadata.id != null) { - el.dataset.sdtContainerId = String(metadata.id); - } - - if (metadata.type === 'documentSection') { - this.setDatasetString(el, 'sdtContainerSectionTitle', metadata.title); - this.setDatasetString(el, 'sdtContainerSectionType', metadata.sectionType); - this.setDatasetBoolean(el, 'sdtContainerSectionLocked', metadata.isLocked); - } - // Other container types can be added here if needed - } } -const computeSdtBoundaries = ( - resolvedItems: readonly ResolvedPaintItem[], - sdtLabelsRendered: Set, -): Map => { - const boundaries = new Map(); - const containerKeys: (string | null)[] = resolvedItems.map((item) => { - if (item && 'sdtContainerKey' in item) { - const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; - return key ?? null; - } - return null; - }); - - const fragmentOf = (idx: number): Fragment | null => { - const item = resolvedItems[idx]; - return item && item.kind === 'fragment' ? item.fragment : null; - }; - - let i = 0; - while (i < resolvedItems.length) { - const currentKey = containerKeys[i]; - const startFrag = fragmentOf(i); - if (!currentKey || !startFrag) { - i += 1; - continue; - } - - let groupRight = startFrag.x + startFrag.width; - let j = i; - - while (j + 1 < resolvedItems.length && containerKeys[j + 1] === 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); - } - - boundaries.set(k, { - isStart, - isEnd, - widthOverride: groupRight - fragment.x, - paddingBottomOverride, - showLabel, - }); - } - - i = j + 1; - } - - return boundaries; -}; - const fragmentKey = (fragment: Fragment): string => { switch (fragment.kind) { case 'para': @@ -4442,24 +4164,6 @@ const isNonBodyStoryBlockId = (blockId: string | undefined): boolean => blockId.startsWith('__sd_semantic_footnote-') || blockId.startsWith('__sd_semantic_endnote-')); -const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - if ('id' in metadata && metadata.id != null) { - return String(metadata.id); - } - return ''; -}; - -const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; -}; - -const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { - if (!metadata) return ''; - return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); -}; - /** * Derives a version string for a flow block based on its content and styling properties. * diff --git a/packages/layout-engine/painters/dom/src/runs/index.ts b/packages/layout-engine/painters/dom/src/runs/index.ts index d5117686d4..6d3b5cb9a1 100644 --- a/packages/layout-engine/painters/dom/src/runs/index.ts +++ b/packages/layout-engine/painters/dom/src/runs/index.ts @@ -14,5 +14,5 @@ export { createInlineSdtWrapper, syncInlineSdtWrapperTypography, expandSdtWrapperPmRange, -} from './sdt.js'; +} from '../sdt/inline.js'; export type { RenderedLineInfo, RenderLineParams, RunRenderContext, TrackedChangesRenderConfig } from './types.js'; diff --git a/packages/layout-engine/painters/dom/src/sdt/boundaries.ts b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts new file mode 100644 index 0000000000..213967303b --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/boundaries.ts @@ -0,0 +1,81 @@ +import type { Fragment, ResolvedPaintItem } from '@superdoc/contracts'; +import type { SdtBoundaryOptions } from './container.js'; + +export const computeSdtBoundaries = ( + resolvedItems: readonly ResolvedPaintItem[], + sdtLabelsRendered: Set, +): Map => { + const boundaries = new Map(); + const containerKeys: (string | null)[] = resolvedItems.map((item) => { + if (item && 'sdtContainerKey' in item) { + const key = (item as { sdtContainerKey?: string | null }).sdtContainerKey; + return key ?? null; + } + return null; + }); + + const fragmentOf = (idx: number): Fragment | null => { + const item = resolvedItems[idx]; + return item && item.kind === 'fragment' ? item.fragment : null; + }; + + let i = 0; + while (i < resolvedItems.length) { + const currentKey = containerKeys[i]; + const startFrag = fragmentOf(i); + if (!currentKey || !startFrag) { + i += 1; + continue; + } + + let groupRight = startFrag.x + startFrag.width; + let j = i; + + while (j + 1 < resolvedItems.length && containerKeys[j + 1] === 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); + } + + boundaries.set(k, { + isStart, + isEnd, + widthOverride: groupRight - fragment.x, + paddingBottomOverride, + showLabel, + }); + } + + i = j + 1; + } + + return boundaries; +}; diff --git a/packages/layout-engine/painters/dom/src/sdt/container.test.ts b/packages/layout-engine/painters/dom/src/sdt/container.test.ts new file mode 100644 index 0000000000..08d57980d8 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/container.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import type { SdtMetadata } from '@superdoc/contracts'; +import { + applySdtContainerChrome, + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtSiblingBoundaries, + shouldRenderSdtContainerChrome, +} from './container.js'; + +describe('SDT container chrome', () => { + it('renders block structuredContent chrome', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'block-sdt', + alias: 'Signer', + }; + + applySdtContainerChrome(doc, el, sdt); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(el.dataset.sdtContainerStart).toBe('true'); + expect(el.dataset.sdtContainerEnd).toBe('true'); + expect(el.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Signer'); + }); + + it('does not render block chrome for inline structuredContent', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome(doc, el, { + type: 'structuredContent', + scope: 'inline', + id: 'inline-sdt', + alias: 'Inline', + }); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(el.dataset.sdtContainerStart).toBeUndefined(); + }); + + it('renders documentSection chrome', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome(doc, el, { + type: 'documentSection', + id: 'section-1', + title: 'Locked Section', + }); + + expect(el.classList.contains('superdoc-document-section')).toBe(true); + expect(el.querySelector('.superdoc-document-section__tooltip')?.textContent).toBe('Locked Section'); + }); + + it('uses containerSdt as a fallback', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome(doc, el, null, { + type: 'structuredContent', + scope: 'block', + id: 'container-sdt', + alias: 'Container', + lockMode: 'contentLocked', + }); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(el.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Container'); + expect(el.dataset.lockMode).toBe('contentLocked'); + }); + + it('uses the rendered container metadata for lock mode', () => { + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + + applySdtContainerChrome( + doc, + el, + { + type: 'structuredContent', + scope: 'inline', + id: 'inline-sdt', + alias: 'Inline', + lockMode: 'contentLocked', + }, + { + type: 'structuredContent', + scope: 'block', + id: 'container-sdt', + alias: 'Container', + lockMode: 'sdtLocked', + }, + ); + + expect(el.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(el.dataset.lockMode).toBe('sdtLocked'); + }); + + it('suppresses same-key ancestor chrome', () => { + const childSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'shared-sdt', + alias: 'Child', + }; + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'shared-sdt', + alias: 'Ancestor', + }; + + expect( + shouldRenderSdtContainerChrome(childSdt, null, { + ancestorContainerKey: getSdtContainerKey(ancestorSdt), + }), + ).toBe(false); + + const doc = document.implementation.createHTMLDocument('sdt-container'); + const el = doc.createElement('div'); + applySdtContainerChrome(doc, el, childSdt, null, undefined, { + ancestorContainerKey: getSdtContainerKey(ancestorSdt), + }); + expect(el.classList.contains('superdoc-structured-content-block')).toBe(false); + }); + + it('does not suppress distinct primary chrome when fallback container metadata matches ancestor', () => { + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Ancestor', + }; + const childSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Child', + }; + + expect( + shouldRenderSdtContainerChrome(childSdt, ancestorSdt, { + ancestorContainerSdt: ancestorSdt, + }), + ).toBe(true); + }); + + it('suppresses pure id-less container metadata by reference', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Shared', + }; + + expect( + shouldRenderSdtContainerChrome(null, sharedSdt, { + ancestorContainerSdt: sharedSdt, + }), + ).toBe(false); + }); + + it('computes stable sibling start and end boundaries', () => { + expect(getSdtSiblingBoundaries(['a', 'a', 'b', null, 'b'])).toEqual([ + { isStart: true, isEnd: false }, + { isStart: false, isEnd: true }, + { isStart: true, isEnd: true }, + undefined, + { isStart: true, isEnd: true }, + ]); + }); + + it('computes merged boundaries for shared id-less sibling metadata', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Shared', + }; + + expect(getSdtSiblingBoundaries([getSdtContainerKey(sharedSdt), getSdtContainerKey(sharedSdt)])).toEqual([ + { isStart: true, isEnd: false }, + { isStart: false, isEnd: true }, + ]); + }); + + it('gets container keys for image and drawing blocks', () => { + const sdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'media-sdt', + alias: 'Media', + }; + + expect(getSdtContainerKeyForBlock({ kind: 'image', attrs: { sdt } })).toBe('structuredContent:media-sdt'); + expect(getSdtContainerKeyForBlock({ kind: 'drawing', attrs: { containerSdt: sdt } })).toBe( + 'structuredContent:media-sdt', + ); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts new file mode 100644 index 0000000000..e601b909e1 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -0,0 +1,169 @@ +import type { SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; +export { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, +} from '@superdoc/contracts'; +import { getSdtContainerKey, getSdtContainerMetadata } from '@superdoc/contracts'; + +export type SdtContainerConfig = { + className: string; + labelText: string; + labelClassName: string; + isStart: boolean; + isEnd: boolean; +} | null; + +export type SdtBoundaryOptions = { + isStart?: boolean; + isEnd?: boolean; + widthOverride?: number; + paddingBottomOverride?: number; + showLabel?: boolean; +}; + +export type SdtAncestorOptions = { + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: readonly (string | null | undefined)[]; + ancestorContainerSdts?: readonly (SdtMetadata | null | undefined)[]; +}; + +export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { + type: 'structuredContent'; + scope: 'inline' | 'block'; + alias?: string | null; + lockMode?: StructuredContentLockMode; +} { + return ( + sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'structuredContent' + ); +} + +export function isDocumentSectionMetadata( + sdt: SdtMetadata | null | undefined, +): sdt is { type: 'documentSection'; title?: string | null } { + return ( + sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'documentSection' + ); +} + +export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtContainerConfig { + if (isDocumentSectionMetadata(sdt)) { + return { + className: 'superdoc-document-section', + labelText: sdt.title ?? 'Document section', + labelClassName: 'superdoc-document-section__tooltip', + isStart: true, + isEnd: true, + }; + } + + if (isStructuredContentMetadata(sdt) && sdt.scope === 'block') { + return { + className: 'superdoc-structured-content-block', + labelText: sdt.alias ?? 'Structured content', + labelClassName: 'superdoc-structured-content__label superdoc-structured-content-block__label', + isStart: true, + isEnd: true, + }; + } + + return null; +} + +export function shouldRenderSdtContainerChrome( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, + options?: SdtAncestorOptions, +): boolean { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; + + const containerKey = getSdtContainerKey(sdt, containerSdt); + const ancestorKeys = [options?.ancestorContainerKey, ...(options?.ancestorContainerKeys ?? [])]; + if (containerKey && ancestorKeys.includes(containerKey)) { + return false; + } + + const ancestorSdts = [options?.ancestorContainerSdt, ...(options?.ancestorContainerSdts ?? [])]; + if (ancestorSdts.includes(metadata)) { + return false; + } + + return true; +} + +export function getSdtSiblingBoundaries( + containerKeys: readonly (string | null)[], +): Array { + return containerKeys.map((key, index): SdtBoundaryOptions | undefined => { + if (!key) return undefined; + const prev = index > 0 ? containerKeys[index - 1] : null; + const next = index < containerKeys.length - 1 ? containerKeys[index + 1] : null; + return { isStart: key !== prev, isEnd: key !== next }; + }); +} + +export function applySdtContainerChrome( + doc: Document, + container: HTMLElement, + sdt: SdtMetadata | null | undefined, + containerSdt?: SdtMetadata | null | undefined, + boundaryOptions?: SdtBoundaryOptions, + options?: SdtAncestorOptions, +): boolean { + if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return false; + + const metadata = getSdtContainerMetadata(sdt, containerSdt); + const config = getSdtContainerConfig(metadata); + if (!config) return false; + + const isStart = boundaryOptions?.isStart ?? config.isStart; + const isEnd = boundaryOptions?.isEnd ?? config.isEnd; + + container.classList.add(config.className); + container.dataset.sdtContainerStart = String(isStart); + container.dataset.sdtContainerEnd = String(isEnd); + container.style.overflow = 'visible'; + + if (isStructuredContentMetadata(metadata)) { + container.dataset.lockMode = metadata.lockMode || 'unlocked'; + } + + if (boundaryOptions?.widthOverride != null) { + container.style.width = `${boundaryOptions.widthOverride}px`; + } + + if (boundaryOptions?.paddingBottomOverride != null && boundaryOptions.paddingBottomOverride > 0) { + container.style.paddingBottom = `${boundaryOptions.paddingBottomOverride}px`; + } + + const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; + + if (shouldShowLabel) { + const labelEl = doc.createElement('div'); + labelEl.className = config.labelClassName; + const labelText = doc.createElement('span'); + labelText.textContent = config.labelText; + labelEl.appendChild(labelText); + container.appendChild(labelEl); + } + + return true; +} + +export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { + if (!boundary) { + return element.dataset.sdtContainerStart !== undefined; + } + const startAttr = element.dataset.sdtContainerStart; + const endAttr = element.dataset.sdtContainerEnd; + const expectedStart = String(boundary.isStart ?? true); + const expectedEnd = String(boundary.isEnd ?? true); + if (startAttr === undefined || endAttr === undefined) { + return true; + } + return startAttr !== expectedStart || endAttr !== expectedEnd; +} diff --git a/packages/layout-engine/painters/dom/src/sdt/dataset.ts b/packages/layout-engine/painters/dom/src/sdt/dataset.ts new file mode 100644 index 0000000000..ca4477d372 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/dataset.ts @@ -0,0 +1,110 @@ +import type { SdtMetadata } from '@superdoc/contracts'; + +const SDT_DATASET_KEYS = [ + 'sdtType', + 'sdtId', + 'sdtFieldId', + 'sdtFieldType', + 'sdtFieldVariant', + 'sdtFieldVisibility', + 'sdtFieldHidden', + 'sdtFieldLocked', + 'sdtScope', + 'sdtTag', + 'sdtAlias', + 'lockMode', + 'sdtSectionTitle', + 'sdtSectionType', + 'sdtSectionLocked', + 'sdtDocpartGallery', + 'sdtDocpartId', + 'sdtDocpartInstruction', +] as const; + +const setDatasetString = (el: HTMLElement, key: string, value: string | null | undefined): void => { + if (value) { + el.dataset[key] = value; + } +}; + +const setDatasetBoolean = (el: HTMLElement, key: string, value: boolean | null | undefined): void => { + if (value != null) { + el.dataset[key] = String(value); + } +}; + +export const clearSdtDataset = (el: HTMLElement): void => { + SDT_DATASET_KEYS.forEach((key) => { + delete el.dataset[key]; + }); +}; + +export const applySdtDataset = (el: HTMLElement | null, metadata?: SdtMetadata | null): void => { + if (!el?.dataset) return; + clearSdtDataset(el); + if (!metadata) return; + + el.dataset.sdtType = metadata.type; + + if ('id' in metadata && metadata.id != null) { + el.dataset.sdtId = String(metadata.id); + } + + if (metadata.type === 'fieldAnnotation') { + setDatasetString(el, 'sdtFieldId', metadata.fieldId); + setDatasetString(el, 'sdtFieldType', metadata.fieldType); + setDatasetString(el, 'sdtFieldVariant', metadata.variant); + setDatasetString(el, 'sdtFieldVisibility', metadata.visibility); + setDatasetBoolean(el, 'sdtFieldHidden', metadata.hidden); + setDatasetBoolean(el, 'sdtFieldLocked', metadata.isLocked); + } else if (metadata.type === 'structuredContent') { + setDatasetString(el, 'sdtScope', metadata.scope); + setDatasetString(el, 'sdtTag', metadata.tag); + setDatasetString(el, 'sdtAlias', metadata.alias); + // Always set lockMode so CSS can target all structured-content SDTs uniformly. + setDatasetString(el, 'lockMode', metadata.lockMode || 'unlocked'); + } else if (metadata.type === 'documentSection') { + setDatasetString(el, 'sdtSectionTitle', metadata.title); + setDatasetString(el, 'sdtSectionType', metadata.sectionType); + setDatasetBoolean(el, 'sdtSectionLocked', metadata.isLocked); + } else if (metadata.type === 'docPartObject') { + setDatasetString(el, 'sdtDocpartGallery', metadata.gallery); + setDatasetString(el, 'sdtDocpartId', metadata.uniqueId); + setDatasetString(el, 'sdtDocpartInstruction', metadata.instruction); + } +}; + +export const applyContainerSdtDataset = (el: HTMLElement | null, metadata?: SdtMetadata | null): void => { + if (!el?.dataset) return; + if (!metadata) return; + + el.dataset.sdtContainerType = metadata.type; + + if ('id' in metadata && metadata.id != null) { + el.dataset.sdtContainerId = String(metadata.id); + } + + if (metadata.type === 'documentSection') { + setDatasetString(el, 'sdtContainerSectionTitle', metadata.title); + setDatasetString(el, 'sdtContainerSectionType', metadata.sectionType); + setDatasetBoolean(el, 'sdtContainerSectionLocked', metadata.isLocked); + } +}; + +export const getSdtMetadataId = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + if ('id' in metadata && metadata.id != null) { + return String(metadata.id); + } + return ''; +}; + +export const getSdtMetadataLockMode = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return metadata.type === 'structuredContent' ? (metadata.lockMode ?? '') : ''; +}; + +export const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string => { + if (!metadata) return ''; + return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); +}; diff --git a/packages/layout-engine/painters/dom/src/runs/sdt.ts b/packages/layout-engine/painters/dom/src/sdt/inline.ts similarity index 87% rename from packages/layout-engine/painters/dom/src/runs/sdt.ts rename to packages/layout-engine/painters/dom/src/sdt/inline.ts index 4db629b8fb..1da85c170f 100644 --- a/packages/layout-engine/painters/dom/src/runs/sdt.ts +++ b/packages/layout-engine/painters/dom/src/sdt/inline.ts @@ -1,7 +1,7 @@ import type { Run, SdtMetadata, TextRun } from '@superdoc/contracts'; import { DOM_CLASS_NAMES } from '../constants.js'; -import { BROWSER_DEFAULT_FONT_SIZE } from './text-run.js'; -import type { RunRenderContext } from './types.js'; +import { BROWSER_DEFAULT_FONT_SIZE } from '../styles.js'; +import type { RunRenderContext } from '../runs/types.js'; export const resolveRunSdtId = (run: Run): { sdtId: string; sdt: SdtMetadata } | null => { const sdt = (run as TextRun).sdt; @@ -25,8 +25,7 @@ export const createInlineSdtWrapper = (sdt: SdtMetadata, context: RunRenderConte }; export const syncInlineSdtWrapperTypography = (wrapper: HTMLElement, runForSizing?: Run): void => { - // The line container sets fontSize:0 (strut fix). Keep wrapper typography - // synced with the current run so border height tracks text-size edits. + // The line container sets fontSize:0; keep wrapper chrome aligned with the run text size. const runFontSize = runForSizing && 'fontSize' in runForSizing && typeof runForSizing.fontSize === 'number' ? `${runForSizing.fontSize}px` diff --git a/packages/layout-engine/painters/dom/src/sdt/snapshot.ts b/packages/layout-engine/painters/dom/src/sdt/snapshot.ts new file mode 100644 index 0000000000..2eb000f2dd --- /dev/null +++ b/packages/layout-engine/painters/dom/src/sdt/snapshot.ts @@ -0,0 +1,78 @@ +import { DOM_CLASS_NAMES } from '../constants.js'; + +export type PaintSnapshotStructuredContentBlockEntity = { + element: HTMLElement; + pageIndex: number; + sdtId: string; + pmStart?: number; + pmEnd?: number; +}; + +export type PaintSnapshotStructuredContentInlineEntity = { + element: HTMLElement; + pageIndex: number; + sdtId: string; + pmStart?: number; + pmEnd?: number; +}; + +export type SdtSnapshotEntities = { + structuredContentBlocks: PaintSnapshotStructuredContentBlockEntity[]; + structuredContentInlines: PaintSnapshotStructuredContentInlineEntity[]; +}; + +type CollectSdtSnapshotEntitiesOptions = { + resolvePageIndex: (element: HTMLElement) => number | null; + readDatasetNumber: (value: string | null | undefined) => number | null; + compactObject: >(input: T) => T; +}; + +export const collectSdtSnapshotEntitiesFromDomRoot = ( + rootEl: HTMLElement, + options: CollectSdtSnapshotEntitiesOptions, +): SdtSnapshotEntities => { + const entities: SdtSnapshotEntities = { + structuredContentBlocks: [], + structuredContentInlines: [], + }; + + const blockSdtElements = Array.from( + rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`), + ); + for (const element of blockSdtElements) { + const pageIndex = options.resolvePageIndex(element); + const sdtId = element.dataset.sdtId; + if (pageIndex == null || !sdtId) continue; + + entities.structuredContentBlocks.push( + options.compactObject({ + element, + pageIndex, + sdtId, + pmStart: options.readDatasetNumber(element.dataset.pmStart), + pmEnd: options.readDatasetNumber(element.dataset.pmEnd), + }) as PaintSnapshotStructuredContentBlockEntity, + ); + } + + const inlineSdtElements = Array.from( + rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}[data-sdt-id]`), + ); + for (const element of inlineSdtElements) { + const pageIndex = options.resolvePageIndex(element); + const sdtId = element.dataset.sdtId; + if (pageIndex == null || !sdtId) continue; + + entities.structuredContentInlines.push( + options.compactObject({ + element, + pageIndex, + sdtId, + pmStart: options.readDatasetNumber(element.dataset.pmStart), + pmEnd: options.readDatasetNumber(element.dataset.pmEnd), + }) as PaintSnapshotStructuredContentInlineEntity, + ); + } + + return entities; +}; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 15340458bf..b07553b029 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -417,7 +417,7 @@ const FORMATTING_MARKS_STYLES = ` * * **Implementation Note:** * These styles are injected once per document via ensureSdtContainerStyles() to avoid - * duplication. The DOM painter applies corresponding classes via applySdtContainerStyling(). + * duplication. The DOM painter applies corresponding classes via applySdtContainerChrome(). */ const SDT_CONTAINER_STYLES = ` /* Document Section - Block-level container with gray border and hover tooltip */ diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index 9430547ba5..1c46042b39 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -6,7 +6,9 @@ import type { ParagraphMeasure, TableCell, TableCellMeasure, + TableBlock, TableMeasure, + SdtMetadata, ImageBlock, DrawingBlock, DrawingMeasure, @@ -3692,20 +3694,26 @@ describe('renderTableCell', () => { expect(cellElement.style.overflow).toBe('hidden'); }); - it('should not apply SDT container styling when block SDT matches tableSdt', () => { - const tableSdt = { + it('should not apply SDT container styling when block SDT key matches ancestor table SDT key', () => { + const tableSdt: SdtMetadata = { type: 'structuredContent' as const, scope: 'block' as const, id: 'table-sdt', alias: 'Table Container', }; + const blockSdt: SdtMetadata = { + type: 'structuredContent' as const, + scope: 'block' as const, + id: 'table-sdt', + alias: 'Cell Container', + }; const para: ParagraphBlock = { kind: 'paragraph', id: 'para-same-sdt', runs: [{ text: 'Content in table SDT', fontFamily: 'Arial', fontSize: 16 }], attrs: { - sdt: tableSdt, // Same reference as tableSdt + sdt: blockSdt, }, }; @@ -3745,12 +3753,64 @@ describe('renderTableCell', () => { ...createBaseDeps(), cellMeasure, cell, - tableSdt, // Pass the same SDT as the table level + ancestorContainerKey: 'structuredContent:table-sdt', + }); + + expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); + }); + + it('should not apply SDT container styling when id-less block SDT matches ancestor table SDT metadata', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent' as const, + scope: 'block' as const, + alias: 'Table Container', + }; + const para: ParagraphBlock = { + kind: 'paragraph', + id: 'para-same-idless-sdt', + runs: [{ text: 'Content in id-less table SDT', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + sdt: sharedSdt, + }, + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 28, + width: 100, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [measure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-table-idless-sdt', + blocks: [para], + attrs: {}, + }, + ancestorContainerSdt: sharedSdt, }); - // Cell should keep overflow:hidden because block SDT matches tableSdt - // (no duplicate container styling needed) expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); }); it('should keep overflow:hidden for inline scope structuredContent (not a block container)', () => { @@ -3808,6 +3868,1138 @@ describe('renderTableCell', () => { // Inline SDTs don't get container styling, so overflow stays hidden expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should set overflow:visible and render chrome when cell contains nested table SDT', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-sdt-table', + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'nested-table-sdt', + alias: 'Nested Table', + }, + }, + rows: [ + { + id: 'nested-row', + cells: [ + { + id: 'nested-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + const cellMeasure: TableCellMeasure = { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + const cell: TableCell = { + id: 'cell-nested-table-sdt', + blocks: [nestedTable], + attrs: {}, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + }); + + expect(cellElement.style.overflow).toBe('visible'); + const tableChrome = cellElement.querySelector('[data-block-id="nested-sdt-table"]') as HTMLElement; + expect(tableChrome?.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); + }); + + it('should set overflow:visible when only rendered nested descendants have SDT chrome', () => { + const descendantSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'nested-descendant-sdt', + alias: 'Nested Descendant', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-descendant-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: descendantSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-table-with-descendant-sdt', + rows: [ + { + id: 'nested-descendant-row', + cells: [ + { + id: 'nested-descendant-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-descendant-sdt', + blocks: [nestedTable], + attrs: {}, + }, + }); + + expect(cellElement.style.overflow).toBe('visible'); + expect(cellElement.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Descendant'); + }); + + it('should not apply nested table chrome when its SDT key matches the ancestor table SDT key', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'ancestor-table-sdt', + alias: 'Ancestor Table', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-ancestor-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-ancestor-sdt-table', + attrs: { sdt: sharedSdt }, + rows: [ + { + id: 'nested-ancestor-row', + cells: [ + { + id: 'nested-ancestor-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-ancestor-sdt-table', + blocks: [nestedTable], + attrs: {}, + }, + ancestorContainerKey: 'structuredContent:ancestor-table-sdt', + ancestorContainerSdt: sharedSdt, + }); + + const tableElement = cellElement.querySelector('[data-block-id="nested-ancestor-sdt-table"]') as HTMLElement; + expect(cellElement.style.overflow).toBe('hidden'); + expect(tableElement?.classList.contains('superdoc-structured-content-block')).toBe(false); + expect(tableElement?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should preserve ancestor SDT suppression for paragraphs inside nested tables without table SDT', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-table-sdt', + alias: 'Outer Table', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-inherited-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: sharedSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-table-with-inherited-sdt-child', + rows: [ + { + id: 'nested-inherited-row', + cells: [ + { + id: 'nested-inherited-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-table-inherited-sdt-child', + blocks: [nestedTable], + attrs: {}, + }, + ancestorContainerKey: 'structuredContent:outer-table-sdt', + ancestorContainerSdt: sharedSdt, + }); + + expect(cellElement.querySelector('.superdoc-structured-content-block')).toBeFalsy(); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should allow overflow when a suppressed nested table contains distinct descendant SDT chrome', () => { + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-table-sdt', + alias: 'Outer Table', + }; + const descendantSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'descendant-sdt', + alias: 'Descendant', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-distinct-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: descendantSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-suppressed-table-with-distinct-child', + attrs: { sdt: ancestorSdt }, + rows: [ + { + id: 'nested-distinct-row', + cells: [ + { + id: 'nested-distinct-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-distinct-sdt-child', + blocks: [nestedTable], + attrs: {}, + }, + ancestorContainerKey: 'structuredContent:outer-table-sdt', + ancestorContainerSdt: ancestorSdt, + }); + + expect(cellElement.style.overflow).toBe('visible'); + expect(cellElement.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Descendant'); + }); + + it('should preserve outer ancestor suppression when a nested table has distinct SDT chrome', () => { + const ancestorSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-table-sdt', + alias: 'Outer Table', + }; + const nestedTableSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'nested-table-sdt', + alias: 'Nested Table', + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-inherited-outer-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { containerSdt: ancestorSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-distinct-table-with-outer-child', + attrs: { sdt: nestedTableSdt }, + rows: [ + { + id: 'nested-outer-child-row', + cells: [{ id: 'nested-outer-child-cell', blocks: [nestedParagraph], attrs: {} }], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-nested-distinct-table-with-outer-child', + blocks: [nestedTable], + attrs: {}, + }, + ancestorContainerKey: 'structuredContent:outer-table-sdt', + ancestorContainerSdt: ancestorSdt, + }); + + const labels = cellElement.querySelectorAll('.superdoc-structured-content__label'); + expect(labels).toHaveLength(1); + expect(labels[0]?.textContent).toBe('Nested Table'); + }); + + it('should keep overflow hidden when top-level SDT chrome is outside the rendered cell range', () => { + const hiddenSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'hidden-top-level-sdt', + alias: 'Hidden', + }; + const firstParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'visible-top-level-para', + runs: [{ text: 'Visible', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const secondParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'hidden-top-level-sdt-para', + runs: [{ text: 'Hidden', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: hiddenSdt }, + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 7, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + let chromeNotifications = 0; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, paragraphMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-partial-top-level-hidden-sdt', + blocks: [firstParagraph, secondParagraph], + attrs: {}, + }, + fromLine: 0, + toLine: 1, + onSdtContainerChrome: () => { + chromeNotifications += 1; + }, + }); + + expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + expect(chromeNotifications).toBe(0); + }); + + it('should keep overflow hidden when descendant SDT chrome is outside the rendered nested table range', () => { + const descendantSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'unrendered-descendant-sdt', + alias: 'Unrendered', + }; + const firstParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'visible-nested-para', + runs: [{ text: 'Visible', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const secondParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'unrendered-nested-sdt-para', + runs: [{ text: 'Hidden', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: descendantSdt }, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'partial-nested-table-with-unrendered-sdt', + rows: [ + { + id: 'visible-nested-row', + cells: [{ id: 'visible-nested-cell', blocks: [firstParagraph], attrs: {} }], + }, + { + id: 'unrendered-nested-row', + cells: [{ id: 'unrendered-nested-cell', blocks: [secondParagraph], attrs: {} }], + }, + ], + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 7, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [paragraphMeasure], + }, + ], + }, + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [paragraphMeasure], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 40, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-partial-nested-unrendered-sdt', + blocks: [nestedTable], + attrs: {}, + }, + fromLine: 0, + toLine: 1, + }); + + expect(cellElement.style.overflow).toBe('hidden'); + expect(cellElement.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should continue SDT boundaries across adjacent paragraph and nested table blocks', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'shared-block-sdt', + alias: 'Shared Block', + }; + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'sdt-paragraph-before-table', + runs: [{ text: 'Before', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: sharedSdt }, + }; + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-shared-sdt-para', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-shared-sdt-table', + attrs: { sdt: sharedSdt }, + rows: [ + { + id: 'nested-shared-row', + cells: [ + { + id: 'nested-shared-cell', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [paragraphMeasure, nestedMeasure], + width: 120, + height: 44, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-shared-sdt-paragraph-table', + blocks: [paragraph, nestedTable], + attrs: {}, + }, + }); + + const chromeElements = cellElement.querySelectorAll('.superdoc-structured-content-block'); + const paragraphChrome = chromeElements[0]; + const tableChrome = cellElement.querySelector('[data-block-id="nested-shared-sdt-table"]') as HTMLElement; + expect(chromeElements).toHaveLength(2); + expect(paragraphChrome?.dataset.sdtContainerStart).toBe('true'); + expect(paragraphChrome?.dataset.sdtContainerEnd).toBe('false'); + expect(tableChrome?.dataset.sdtContainerStart).toBe('false'); + expect(tableChrome?.dataset.sdtContainerEnd).toBe('true'); + expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should not let media-only blocks consume SDT container start boundaries', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'media-before-paragraph-sdt', + alias: 'Media Container', + }; + const imageBlock: ImageBlock = { + kind: 'image', + id: 'sdt-image-before-paragraph', + src: 'data:image/png;base64,AAA', + attrs: { sdt: sharedSdt }, + }; + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'sdt-paragraph-after-image', + runs: [{ text: 'After image', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: sharedSdt }, + }; + const imageMeasure = { + kind: 'image' as const, + width: 40, + height: 20, + }; + const paragraphMeasure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 11, + width: 70, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [imageMeasure, paragraphMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-media-before-sdt-paragraph', + blocks: [imageBlock, paragraph], + attrs: {}, + }, + }); + + const paragraphChrome = cellElement.querySelector('.superdoc-structured-content-block'); + expect(paragraphChrome?.dataset.sdtContainerStart).toBe('true'); + expect(paragraphChrome?.dataset.sdtContainerEnd).toBe('true'); + expect(paragraphChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe( + 'Media Container', + ); + }); + + it('should continue SDT paragraph boundaries across split table-cell fragments', () => { + const paragraphSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'split-paragraph-sdt', + alias: 'Split Paragraph', + }; + const paragraph: ParagraphBlock = { + kind: 'paragraph', + id: 'split-sdt-paragraph', + runs: [{ text: 'Split paragraph', fontFamily: 'Arial', fontSize: 16 }], + attrs: { sdt: paragraphSdt }, + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 5, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + { + fromRun: 0, + fromChar: 6, + toRun: 0, + toChar: 15, + width: 80, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 40, + }; + const cellMeasure: TableCellMeasure = { + blocks: [measure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + const cell: TableCell = { + id: 'cell-split-sdt-paragraph', + blocks: [paragraph], + attrs: {}, + }; + + const firstFragment = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + fromLine: 0, + toLine: 1, + }).cellElement.querySelector('.superdoc-structured-content-block'); + const continuationFragment = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + fromLine: 1, + toLine: 2, + }).cellElement.querySelector('.superdoc-structured-content-block'); + + expect(firstFragment?.dataset.sdtContainerStart).toBe('true'); + expect(firstFragment?.dataset.sdtContainerEnd).toBe('false'); + expect(firstFragment?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Split Paragraph'); + expect(continuationFragment?.dataset.sdtContainerStart).toBe('false'); + expect(continuationFragment?.dataset.sdtContainerEnd).toBe('true'); + expect(continuationFragment?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + + it('should continue SDT boundaries across partial nested table renders', () => { + const nestedTableSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'partial-nested-table-sdt', + alias: 'Partial Nested Table', + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'partial-nested-table', + attrs: { sdt: nestedTableSdt }, + rows: [ + { + id: 'partial-nested-row-1', + cells: [ + { + id: 'partial-nested-cell-1', + blocks: [ + { + kind: 'paragraph', + id: 'partial-nested-para-1', + runs: [{ text: 'One', fontFamily: 'Arial', fontSize: 16 }], + }, + ], + }, + ], + }, + { + id: 'partial-nested-row-2', + cells: [ + { + id: 'partial-nested-cell-2', + blocks: [ + { + kind: 'paragraph', + id: 'partial-nested-para-2', + runs: [{ text: 'Two', fontFamily: 'Arial', fontSize: 16 }], + }, + ], + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 3, + width: 30, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + { + height: 20, + cells: [ + { + width: 80, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 3, + width: 30, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 40, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: { + blocks: [nestedMeasure], + width: 120, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + cell: { + id: 'cell-partial-nested-table-sdt', + blocks: [nestedTable], + attrs: {}, + }, + fromLine: 1, + toLine: 2, + }); + + const tableChrome = cellElement.querySelector('[data-block-id="partial-nested-table"]') as HTMLElement; + expect(tableChrome?.dataset.sdtContainerStart).toBe('false'); + expect(tableChrome?.dataset.sdtContainerEnd).toBe('true'); + expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); }); }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c8453ea764..8be7e9b621 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -21,7 +21,12 @@ import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; import { applySquareWrapExclusionsToLines } from '../utils/anchor-helpers'; import { applyImageClipPath } from '../utils/image-clip-path.js'; -import { getSdtContainerConfig, getSdtContainerKey, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { + getSdtContainerKeyForBlock, + getSdtSiblingBoundaries, + type SdtAncestorOptions, + type SdtBoundaryOptions, +} from '../sdt/container.js'; import { applyCellBorders } from './border-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './renderTableFragment.js'; import { renderParagraphContent } from '../paragraph/renderParagraphContent.js'; @@ -222,6 +227,18 @@ type EmbeddedTableRenderParams = { toRow?: number; /** Partial row info for mid-row splits within the embedded table */ partialRow?: PartialRowInfo; + /** Optional SDT boundary overrides for container styling */ + sdtBoundary?: SdtBoundaryOptions; + /** Ancestor SDT key used to suppress duplicate container chrome in nested tables */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys used to suppress duplicate container chrome in nested tables */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + /** Receives notification when this embedded table or its descendants render SDT chrome */ + onSdtContainerChrome?: () => void; }; /** @@ -252,7 +269,9 @@ type EmbeddedTableRenderParams = { * cellContent.appendChild(tableEl); * ``` */ -const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => { +const renderEmbeddedTable = ( + params: EmbeddedTableRenderParams, +): { element: HTMLElement; hasSdtContainerChrome: boolean } => { const { doc, table, @@ -266,6 +285,12 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, + sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, } = params; const effectiveFromRow = paramFromRow ?? 0; @@ -301,7 +326,8 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => el.dataset.blockId = frag.blockId; }; - return renderTableFragmentElement({ + let hasSdtContainerChrome = false; + const tableEl = renderTableFragmentElement({ doc, fragment, context, @@ -315,7 +341,18 @@ const renderEmbeddedTable = (params: EmbeddedTableRenderParams): HTMLElement => applyFragmentFrame, applySdtDataset, applyStyles: applyInlineStyles, + sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome: () => { + hasSdtContainerChrome = true; + onSdtContainerChrome?.(); + }, }); + + return { element: tableEl, hasSdtContainerChrome }; }; /** @@ -338,7 +375,13 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot?: EmbeddedTableRenderParams['captureLineSnapshot']; renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; -}): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number } { + sdtBoundary?: SdtBoundaryOptions; + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + onSdtContainerChrome?: () => void; +}): { element: HTMLElement | null; height: number; nextCumulativeLineCount: number; hasSdtContainerChrome: boolean } { const { doc, block, @@ -352,6 +395,12 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot, renderDrawingContent, applySdtDataset, + sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, } = params; // Compute per-row segment counts (recursive, matching getCellLines/getEmbeddedRowLines). @@ -364,7 +413,7 @@ function renderPartialEmbeddedTable(params: { // Skip entirely if no segments are in the visible range if (tableEndSegment <= globalFromLine || tableStartSegment >= globalToLine) { - return { element: null, height: 0, nextCumulativeLineCount }; + return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } // Map global line range to local segment range within this embedded table @@ -423,10 +472,18 @@ function renderPartialEmbeddedTable(params: { } if (embeddedFromRow === -1) { - return { element: null, height: 0, nextCumulativeLineCount }; + return { element: null, height: 0, nextCumulativeLineCount, hasSdtContainerChrome: false }; } const visibleHeight = computeVisibleHeight(tableMeasure.rows, embeddedFromRow, embeddedToRow, partialRowInfo); + const effectiveSdtBoundary = sdtBoundary + ? { + ...sdtBoundary, + isStart: (sdtBoundary.isStart ?? true) && localFrom === 0, + isEnd: (sdtBoundary.isEnd ?? true) && localTo >= totalTableSegments, + showLabel: sdtBoundary.showLabel === undefined ? undefined : sdtBoundary.showLabel && localFrom === 0, + } + : undefined; const tableWrapper = doc.createElement('div'); tableWrapper.style.position = 'relative'; @@ -435,7 +492,7 @@ function renderPartialEmbeddedTable(params: { tableWrapper.style.flexShrink = '0'; tableWrapper.style.boxSizing = 'border-box'; - const tableEl = renderEmbeddedTable({ + const tableResult = renderEmbeddedTable({ doc, table: block, measure: tableMeasure, @@ -448,10 +505,21 @@ function renderPartialEmbeddedTable(params: { fromRow: embeddedFromRow, toRow: embeddedToRow, partialRow: partialRowInfo, + sdtBoundary: effectiveSdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, }); - tableWrapper.appendChild(tableEl); + tableWrapper.appendChild(tableResult.element); - return { element: tableWrapper, height: visibleHeight, nextCumulativeLineCount }; + return { + element: tableWrapper, + height: visibleHeight, + nextCumulativeLineCount, + hasSdtContainerChrome: tableResult.hasSdtContainerChrome, + }; } /** @@ -504,8 +572,16 @@ type TableCellRenderDependencies = { context: FragmentRenderContext; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Table-level SDT metadata for suppressing duplicate container styling in cells */ - tableSdt?: SdtMetadata | null; + /** Ancestor SDT container key for suppressing duplicate container styling in cells */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys for suppressing duplicate container styling in cells */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + /** Receives notification when this cell or descendants render SDT container chrome */ + onSdtContainerChrome?: () => void; /** Table indent in pixels (applied to table fragment positioning) */ tableIndent?: number; /** Whether the table is visually right-to-left (w:bidiVisual, ECMA-376 §17.4.1) */ @@ -596,7 +672,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, context, applySdtDataset, - tableSdt, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, tableIndent, isRtl, cellWidth, @@ -640,60 +720,11 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // Support multi-block cells with backward compatibility const cellBlocks = cell?.blocks ?? (cell?.paragraph ? [cell.paragraph] : []); const blockMeasures = cellMeasure?.blocks ?? (cellMeasure?.paragraph ? [cellMeasure.paragraph] : []); - const sdtContainerKeys = cellBlocks.map((block) => { - if (block.kind !== 'paragraph') { - return null; - } - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - return getSdtContainerKey(attrs?.sdt, attrs?.containerSdt); - }); + const sdtContainerKeys = cellBlocks.map((block) => + block.kind === 'paragraph' || block.kind === 'table' ? getSdtContainerKeyForBlock(block) : null, + ); + const sdtBoundaries = getSdtSiblingBoundaries(sdtContainerKeys); - const sdtBoundaries = sdtContainerKeys.map((key, index): SdtBoundaryOptions | undefined => { - if (!key) return undefined; - const prev = index > 0 ? sdtContainerKeys[index - 1] : null; - const next = index < sdtContainerKeys.length - 1 ? sdtContainerKeys[index + 1] : null; - return { isStart: key !== prev, isEnd: key !== next }; - }); - /** - * Determines if SDT container styling should be applied to a block. - * - * We skip styling when the block's SDT matches the table's SDT to prevent - * duplicate visual containers - the table already has the SDT container styling, - * so individual paragraphs inside it shouldn't also show container borders. - * - * @param sdt - The block's direct SDT metadata - * @param containerSdt - The block's inherited container SDT metadata - * @returns True if container styling should be applied - */ - const tableSdtKey = tableSdt ? getSdtContainerKey(tableSdt, null) : null; - const shouldApplySdtContainerStyling = ( - sdt?: SdtMetadata | null, - containerSdt?: SdtMetadata | null, - blockKey?: string | null, - ): boolean => { - const resolvedKey = blockKey ?? getSdtContainerKey(sdt, containerSdt); - // Skip if this SDT is the same as the table's SDT (already styled at table level) - if (tableSdtKey && resolvedKey && tableSdtKey === resolvedKey) { - return false; - } - if (tableSdt && (sdt === tableSdt || containerSdt === tableSdt)) { - return false; - } - return Boolean(getSdtContainerConfig(sdt) || getSdtContainerConfig(containerSdt)); - }; - - // Check if any block in the cell has SDT container styling - const hasSdtContainer = cellBlocks.some((block, index) => { - const attrs = (block as { attrs?: { sdt?: SdtMetadata; containerSdt?: SdtMetadata } }).attrs; - const blockKey = sdtContainerKeys[index] ?? null; - return shouldApplySdtContainerStyling(attrs?.sdt, attrs?.containerSdt, blockKey); - }); - - // SDT containers display labels that extend above the content boundary. - // Change overflow to 'visible' so these labels aren't clipped by the cell. - if (hasSdtContainer) { - cellEl.style.overflow = 'visible'; - } if (cellBlocks.length > 0 && blockMeasures.length > 0) { // Content is a child of the cell, positioned relative to it // Cell's overflow:hidden handles clipping, no explicit width needed @@ -727,7 +758,6 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen const blockLineCounts: number[] = []; for (let i = 0; i < Math.min(blockMeasures.length, cellBlocks.length); i++) { const bm = blockMeasures[i]; - const blk = cellBlocks[i]; if (bm.kind === 'paragraph') { blockLineCounts.push((bm as ParagraphMeasure).lines?.length || 0); } else if (bm.kind === 'table') { @@ -773,12 +803,21 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot, renderDrawingContent, applySdtDataset, + sdtBoundary: sdtBoundaries[i], + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, }); cumulativeLineCount = result.nextCumulativeLineCount; if (result.element) { content.appendChild(result.element); flowCursorY += result.height; } + if (result.hasSdtContainerChrome) { + cellEl.style.overflow = 'visible'; + } continue; } @@ -941,8 +980,16 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paraWrapper.style.position = 'relative'; paraWrapper.style.left = '0'; paraWrapper.style.width = '100%'; - const sdtBoundary = sdtBoundaries[i]; - const blockKey = sdtContainerKeys[i] ?? null; + const baseSdtBoundary = sdtBoundaries[i]; + const sdtBoundary = baseSdtBoundary + ? { + ...baseSdtBoundary, + isStart: (baseSdtBoundary.isStart ?? true) && localStartLine === 0, + isEnd: (baseSdtBoundary.isEnd ?? true) && localEndLine >= blockLineCount, + showLabel: + baseSdtBoundary.showLabel === undefined ? undefined : baseSdtBoundary.showLabel && localStartLine === 0, + } + : undefined; content.appendChild(paraWrapper); const result = renderParagraphContent({ @@ -961,8 +1008,14 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen paddingTop, }, sdtBoundary, - shouldApplySdtContainerStyling: (sdt, containerSdt) => - shouldApplySdtContainerStyling(sdt, containerSdt, blockKey), + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome: () => { + cellEl.style.overflow = 'visible'; + onSdtContainerChrome?.(); + }, applySdtDataset, renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => renderLine(block, line, { ...context, section: 'body' }, lineIndex, isLastLine, resolvedListTextStartPx), diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index 26da8b6ebe..5c1bcdfbeb 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -12,6 +12,7 @@ import type { TableColumnBoundary, TableRowBoundary, ParagraphBlock, + SdtMetadata, } from '@superdoc/contracts'; import type { FragmentRenderContext } from '../renderer.js'; @@ -135,6 +136,200 @@ describe('renderTableFragment', () => { expect(element.dataset.pmEnd).toBe('34'); }); + it('suppresses child chrome when table containerSdt shares id-less metadata', () => { + const sharedSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Shared Container', + }; + const block: TableBlock = { + kind: 'table', + id: 'container-sdt-table' as BlockId, + attrs: { + containerSdt: sharedSdt, + }, + rows: [ + { + id: 'container-sdt-row' as BlockId, + cells: [ + { + id: 'container-sdt-cell' as BlockId, + blocks: [ + { + kind: 'paragraph', + id: 'container-sdt-para' as BlockId, + runs: [{ text: 'Shared', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + containerSdt: sharedSdt, + }, + }, + ], + }, + ], + }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + width: 100, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + ], + height: 20, + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 20, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: (el, styles) => Object.assign(el.style, styles), + }); + + const chromeElements = [ + ...(element.classList.contains('superdoc-structured-content-block') ? [element] : []), + ...Array.from(element.querySelectorAll('.superdoc-structured-content-block')), + ]; + expect(chromeElements).toHaveLength(1); + }); + + it('preserves keyed ancestor suppression through an id-less table SDT', () => { + const idlessTableSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + alias: 'Idless Table', + }; + const outerSdt: SdtMetadata = { + type: 'structuredContent', + scope: 'block', + id: 'outer-sdt', + alias: 'Outer', + }; + const block: TableBlock = { + kind: 'table', + id: 'idless-nested-table' as BlockId, + attrs: { + sdt: idlessTableSdt, + }, + rows: [ + { + id: 'idless-nested-row' as BlockId, + cells: [ + { + id: 'idless-nested-cell' as BlockId, + blocks: [ + { + kind: 'paragraph', + id: 'idless-nested-para' as BlockId, + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: { + containerSdt: outerSdt, + }, + }, + ], + }, + ], + }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { + cells: [ + { + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + width: 100, + height: 20, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }, + ], + height: 20, + }, + ], + columnWidths: [100], + totalWidth: 100, + totalHeight: 20, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + ancestorContainerKey: 'structuredContent:outer-sdt', + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: (el, styles) => Object.assign(el.style, styles), + }); + + const chromeElements = [ + ...(element.classList.contains('superdoc-structured-content-block') ? [element] : []), + ...Array.from(element.querySelectorAll('.superdoc-structured-content-block')), + ]; + expect(chromeElements).toHaveLength(1); + expect(chromeElements[0].querySelector('.superdoc-structured-content__label')?.textContent).toBe('Idless Table'); + }); + describe('merged-cell border ownership', () => { it('renders the outer right border for a merged header cell in collapsed mode', () => { const block: TableBlock = { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 5057b2677b..59c4029e91 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -12,7 +12,14 @@ import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; import type { FragmentRenderContext } from '../renderer.js'; import { renderTableRow } from './renderTableRow.js'; -import { applySdtContainerStyling, type SdtBoundaryOptions } from '../utils/sdt-helpers.js'; +import { + applySdtContainerChrome, + getSdtContainerKey, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, + type SdtAncestorOptions, + type SdtBoundaryOptions, +} from '../sdt/container.js'; import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; import { getTableCellGridBounds } from './grid-geometry.js'; @@ -40,6 +47,16 @@ export type TableRenderDependencies = { effectiveColumnWidths: number[]; /** Optional SDT boundary overrides for container styling */ sdtBoundary?: SdtBoundaryOptions; + /** Ancestor SDT key used to suppress duplicate container chrome in nested tables */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys used to suppress duplicate container chrome in nested tables */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain used to suppress duplicate id-less container chrome in nested tables */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + /** Receives notification when this table fragment or descendants render SDT container chrome */ + onSdtContainerChrome?: () => void; /** Function to render a line of paragraph content */ renderLine: ( block: ParagraphBlock, @@ -83,7 +100,7 @@ export type TableRenderDependencies = { * * **SDT Container Styling:** * If the table block has SDT metadata (`block.attrs?.sdt`), applies appropriate - * container styling via `applySdtContainerStyling()`: + * container styling via `applySdtContainerChrome()`: * - Document sections: Gray border with hover tooltip * - Structured content blocks: Blue border with label * Uses type-safe helper functions to avoid unsafe type assertions. @@ -142,6 +159,11 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement effectiveColumnWidths, context, sdtBoundary, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, renderLine, captureLineSnapshot, renderDrawingContent, @@ -202,7 +224,28 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement const contentTop = tableBorderWidths?.top ?? 0; // Apply SDT container styling (document sections, structured content blocks) - applySdtContainerStyling(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary); + if ( + applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + }) + ) { + onSdtContainerChrome?.(); + } + const tableContainerSdt = getSdtContainerMetadata(block.attrs?.sdt, block.attrs?.containerSdt); + const tableContainerKey = getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + const nextAncestorContainerKeys = [ + ...(ancestorContainerKeys ?? []), + ancestorContainerKey, + hasExplicitSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt) ? tableContainerKey : null, + ].filter((key): key is string => Boolean(key)); + const nextAncestorContainerSdts = [...(ancestorContainerSdts ?? []), ancestorContainerSdt, tableContainerSdt].filter( + (sdt): sdt is SdtMetadata => Boolean(sdt), + ); + const nextAncestorContainerKey = nextAncestorContainerKeys[nextAncestorContainerKeys.length - 1] ?? null; + const nextAncestorContainerSdt = nextAncestorContainerSdts[nextAncestorContainerSdts.length - 1] ?? null; // Add table-specific class for resize overlay targeting and click mapping container.classList.add(DOM_CLASS_NAMES.TABLE_FRAGMENT); @@ -384,7 +427,11 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - tableSdt: block.attrs?.sdt ?? null, + ancestorContainerKey: nextAncestorContainerKey, + ancestorContainerSdt: nextAncestorContainerSdt, + ancestorContainerKeys: nextAncestorContainerKeys, + ancestorContainerSdts: nextAncestorContainerSdts, + onSdtContainerChrome, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -546,7 +593,11 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement captureLineSnapshot, renderDrawingContent, applySdtDataset, - tableSdt: block.attrs?.sdt ?? null, + ancestorContainerKey: nextAncestorContainerKey, + ancestorContainerSdt: nextAncestorContainerSdt, + ancestorContainerKeys: nextAncestorContainerKeys, + ancestorContainerSdts: nextAncestorContainerSdts, + onSdtContainerChrome, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 764f5873d2..15701ffb05 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -19,6 +19,7 @@ import { } from './border-utils.js'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; import type { FragmentRenderContext } from '../renderer.js'; +import type { SdtAncestorOptions } from '../sdt/container.js'; type TableRowMeasure = TableMeasure['rows'][number]; type TableRow = TableBlock['rows'][number]; @@ -173,8 +174,16 @@ type TableRowRenderDependencies = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; - /** Table-level SDT metadata for suppressing duplicate container styling in cells */ - tableSdt?: SdtMetadata | null; + /** Ancestor SDT container key for suppressing duplicate container styling in cells */ + ancestorContainerKey?: string | null; + /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdt?: SdtMetadata | null; + /** Ancestor SDT keys for suppressing duplicate container styling in cells */ + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + /** Ancestor SDT metadata chain for suppressing duplicate id-less container styling in cells */ + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + /** Receives notification when cells render SDT container chrome */ + onSdtContainerChrome?: () => void; /** * If true, this row is the first body row of a continuation fragment. * MS Word draws borders at split points to visually close the table on each page, @@ -254,7 +263,11 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { captureLineSnapshot, renderDrawingContent, applySdtDataset, - tableSdt, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, continuesFromPrev, continuesOnNext, partialRow, @@ -426,7 +439,11 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { renderDrawingContent, context, applySdtDataset, - tableSdt, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, fromLine, toLine, tableIndent, diff --git a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts b/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts deleted file mode 100644 index 7ded23f441..0000000000 --- a/packages/layout-engine/painters/dom/src/utils/sdt-helpers.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * SDT Helper Utilities - * - * Provides type guards and helper functions for working with SDT (Structured Document Tag) metadata - * in the DOM painter. These utilities ensure type-safe access to SDT properties and reduce code - * duplication across rendering logic. - */ - -import type { SdtMetadata, StructuredContentLockMode } from '@superdoc/contracts'; - -/** - * Type guard for StructuredContentMetadata. - */ -export function isStructuredContentMetadata(sdt: SdtMetadata | null | undefined): sdt is { - type: 'structuredContent'; - scope: 'inline' | 'block'; - alias?: string | null; - lockMode?: StructuredContentLockMode; -} { - return ( - sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'structuredContent' - ); -} - -/** - * Type guard for DocumentSectionMetadata. - */ -export function isDocumentSectionMetadata( - sdt: SdtMetadata | null | undefined, -): sdt is { type: 'documentSection'; title?: string | null } { - return ( - sdt !== null && sdt !== undefined && typeof sdt === 'object' && 'type' in sdt && sdt.type === 'documentSection' - ); -} - -/** - * SDT container styling configuration returned by applySdtContainerStyling. - */ -export type SdtContainerConfig = { - /** CSS class name to add to the container element */ - className: string; - /** Label/tooltip text to display */ - labelText: string; - /** Label element class name */ - labelClassName: string; - /** Whether this is the start of the SDT container (for multi-fragment SDTs) */ - isStart: boolean; - /** Whether this is the end of the SDT container (for multi-fragment SDTs) */ - isEnd: boolean; -} | null; - -/** - * Determines SDT container styling configuration based on metadata. - * - * Analyzes the SDT metadata and returns configuration for applying visual styling - * to block-level SDT containers (document sections and structured content blocks). - * This function centralizes the logic for determining container appearance, - * eliminating duplication between paragraph and table rendering. - * - * **Supported SDT Types:** - * - `documentSection`: Gray bordered container with hover tooltip showing title - * - `structuredContent` (block scope): Blue bordered container with label showing alias - * - `structuredContent` (inline scope): Returns null (not a block container) - * - Other types: Returns null (no container styling) - * - * **Container Continuation:** - * For SDTs that span multiple fragments (pages), the `isStart` and `isEnd` flags - * control border radius and border visibility: - * - Start fragment: Top borders and top border radius - * - Middle fragments: No top/bottom borders or radius - * - End fragment: Bottom borders and bottom border radius - * - * @param sdt - The SDT metadata from block.attrs?.sdt - * @returns Configuration object with styling details, or null if no container styling needed - * - * @example - * ```typescript - * const config = getSdtContainerConfig(block.attrs?.sdt); - * if (config) { - * container.classList.add(config.className); - * container.dataset.sdtContainerStart = String(config.isStart); - * container.dataset.sdtContainerEnd = String(config.isEnd); - * // Create label element... - * } - * ``` - */ -export function getSdtContainerConfig(sdt: SdtMetadata | null | undefined): SdtContainerConfig { - if (isDocumentSectionMetadata(sdt)) { - return { - className: 'superdoc-document-section', - labelText: sdt.title ?? 'Document section', - labelClassName: 'superdoc-document-section__tooltip', - isStart: true, - isEnd: true, - }; - } - - if (isStructuredContentMetadata(sdt) && sdt.scope === 'block') { - return { - className: 'superdoc-structured-content-block', - labelText: sdt.alias ?? 'Structured content', - labelClassName: 'superdoc-structured-content__label superdoc-structured-content-block__label', - isStart: true, - isEnd: true, - }; - } - - return null; -} - -/** - * Returns the SDT metadata for container styling, preferring `sdt` over `containerSdt`. - */ -export function getSdtContainerMetadata( - sdt?: SdtMetadata | null, - containerSdt?: SdtMetadata | null, -): SdtMetadata | null { - if (getSdtContainerConfig(sdt)) return sdt ?? null; - if (getSdtContainerConfig(containerSdt)) return containerSdt ?? null; - return null; -} - -/** - * Returns a stable key for grouping consecutive fragments in the same SDT container. - */ -export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (!metadata.id) { - return null; - } - return `structuredContent:${metadata.id}`; - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) { - return null; - } - return `documentSection:${sectionId}`; - } - - return null; -} - -/** - * Options for SDT container boundary overrides. - * - * When multiple consecutive fragments share the same SDT container metadata, - * use these options to control which fragments show start/end styling. - */ -export type SdtBoundaryOptions = { - /** Override isStart - true for first fragment in SDT group */ - isStart?: boolean; - /** Override isEnd - true for last fragment in SDT group */ - isEnd?: boolean; - /** Optional width override for the SDT container element */ - widthOverride?: number; - /** Optional padding bottom override for filling gaps between fragments */ - paddingBottomOverride?: number; - /** Whether to show the label (overrides isStart check if provided) */ - showLabel?: boolean; -}; - -/** - * Applies SDT container styling to a DOM element. - * - * This helper function encapsulates all logic for applying block-level SDT container - * styling, including CSS classes, data attributes, overflow settings, and label/tooltip - * elements. It eliminates code duplication between paragraph fragment rendering and - * table fragment rendering. - * - * **Container SDT Fallback:** - * If the primary `sdt` parameter is null/undefined or doesn't match a container type, - * the function will check the `containerSdt` parameter as a fallback. This supports - * paragraphs inside document sections where the paragraph itself doesn't have `sdt` - * but inherits container styling from its parent section. - * - * **Visual Effects Applied:** - * - Container CSS class for border and background styling - * - Data attributes for continuation detection (`data-sdt-container-start/end`) - * - Overflow visible to allow labels to appear above content - * - Label/tooltip element created and appended to container when isStart=true - * - Padding bottom applied if paddingBottomOverride is provided (for filling gaps) - * - * **Label Element Structure:** - * ```html - *
- * Section Title - *
- * ``` - * - * **Non-Destructive:** - * This function only adds classes and elements; it does not remove existing styling. - * It's safe to call multiple times or alongside other styling logic. - * - * @param doc - Document object for creating DOM elements - * @param container - The container element to style (typically a fragment div) - * @param sdt - The primary SDT metadata from block.attrs?.sdt - * @param containerSdt - Optional fallback SDT metadata from block.attrs?.containerSdt - * @param boundaryOptions - Optional overrides for start/end styling in multi-fragment containers - * - * @example - * ```typescript - * const container = doc.createElement('div'); - * container.classList.add(CLASS_NAMES.fragment); - * applySdtContainerStyling(doc, container, block.attrs?.sdt, block.attrs?.containerSdt); - * // Container now has SDT styling if applicable - * ``` - */ -export function applySdtContainerStyling( - doc: Document, - container: HTMLElement, - sdt: SdtMetadata | null | undefined, - containerSdt?: SdtMetadata | null | undefined, - boundaryOptions?: SdtBoundaryOptions, -): void { - let config = getSdtContainerConfig(sdt); - if (!config && containerSdt) { - config = getSdtContainerConfig(containerSdt); - } - if (!config) return; - - const isStart = boundaryOptions?.isStart ?? config.isStart; - const isEnd = boundaryOptions?.isEnd ?? config.isEnd; - - container.classList.add(config.className); - container.dataset.sdtContainerStart = String(isStart); - container.dataset.sdtContainerEnd = String(isEnd); - container.style.overflow = 'visible'; // Allow label to show above - - if (isStructuredContentMetadata(sdt)) { - container.dataset.lockMode = sdt.lockMode || 'unlocked'; - } else if (isStructuredContentMetadata(containerSdt)) { - container.dataset.lockMode = containerSdt.lockMode || 'unlocked'; - } - - if (boundaryOptions?.widthOverride != null) { - container.style.width = `${boundaryOptions.widthOverride}px`; - } - - if (boundaryOptions?.paddingBottomOverride != null && boundaryOptions.paddingBottomOverride > 0) { - container.style.paddingBottom = `${boundaryOptions.paddingBottomOverride}px`; - } - - const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; - - if (shouldShowLabel) { - const labelEl = doc.createElement('div'); - labelEl.className = config.labelClassName; - const labelText = doc.createElement('span'); - labelText.textContent = config.labelText; - labelEl.appendChild(labelText); - container.appendChild(labelEl); - } -} - -/** - * Checks whether a fragment element needs rebuilding due to SDT boundary changes. - * - * Handles two cases: - * 1. Element was in an SDT but no longer is (stale attributes need removal) - * 2. Element's start/end boundary flags don't match expected values - */ -export function shouldRebuildForSdtBoundary(element: HTMLElement, boundary: SdtBoundaryOptions | undefined): boolean { - if (!boundary) { - // Rebuild if element has stale SDT container attributes that should be removed - return element.dataset.sdtContainerStart !== undefined; - } - const startAttr = element.dataset.sdtContainerStart; - const endAttr = element.dataset.sdtContainerEnd; - const expectedStart = String(boundary.isStart ?? true); - const expectedEnd = String(boundary.isEnd ?? true); - if (startAttr === undefined || endAttr === undefined) { - return true; - } - return startAttr !== expectedStart || endAttr !== expectedEnd; -}