Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,8 @@ export type TableAttrs = {
tableDirectionContext?: TableDirectionContext;
sdt?: SdtMetadata;
containerSdt?: SdtMetadata;
/** Ordered chain of enclosing block-container SDTs (outer to inner). See #3752. */
sdtContainers?: SdtMetadata[];
[key: string]: unknown;
};

Expand Down Expand Up @@ -952,6 +954,8 @@ export type DocumentBackground = {
export type ImageBlockAttrs = {
sdt?: SdtMetadata;
containerSdt?: SdtMetadata;
/** Ordered chain of enclosing block-container SDTs (outer to inner). See #3752. */
sdtContainers?: SdtMetadata[];
[key: string]: unknown;
};

Expand Down Expand Up @@ -1838,6 +1842,13 @@ export type ParagraphAttrs = {
sdt?: SdtMetadata;
/** Container SDT for blocks with both primary and container metadata. */
containerSdt?: SdtMetadata;
/**
* Ordered chain of enclosing block-container SDTs (outermost first, innermost
* last) for nested content controls. `sdt` stays this block's nearest primary
* metadata; this records the full ancestry so resolved layout and the painter
* can group and draw nested control chrome. See #3752.
*/
sdtContainers?: SdtMetadata[];
};

export type ParagraphFrame = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -999,8 +999,7 @@ function computeNoteBodyHeights(
if (measure.kind === 'paragraph') {
const measureH = (measure as { totalHeight?: number }).totalHeight;
if (typeof measureH === 'number' && Number.isFinite(measureH)) total += measureH;
const spacing = (block as { attrs?: { spacing?: { after?: number; lineSpaceAfter?: number } } }).attrs
?.spacing;
const spacing = (block as { attrs?: { spacing?: { after?: number; lineSpaceAfter?: number } } }).attrs?.spacing;
const after = spacing?.after ?? spacing?.lineSpaceAfter;
if (typeof after === 'number' && Number.isFinite(after) && after > 0) total += after;
// SD-2656: first paragraph's first line is the first valid run.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ const makeMeasure = (lineHeight: number, lineCount: number): Measure => ({
totalHeight: lineCount * lineHeight,
});

const countFootnoteFragments = (layout: { pages: Array<{ fragments: Array<{ blockId?: string }> }> }, idPrefix: string) => {
const countFootnoteFragments = (
layout: { pages: Array<{ fragments: Array<{ blockId?: string }> }> },
idPrefix: string,
) => {
let count = 0;
for (const page of layout.pages) {
for (const f of page.fragments) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,10 @@ describe('footnote convergence warm-start (SD-3432)', () => {
let settled = false;
for (let i = 0; i < 3; i += 1) {
const next = await run(bodyBlocks, options, measureBlock, prev.footnoteReserveSeed ?? null);
if (JSON.stringify(layoutJson(next)) === JSON.stringify(layoutJson(prev)) &&
JSON.stringify(next.footnoteReserveSeed) === JSON.stringify(prev.footnoteReserveSeed)) {
if (
JSON.stringify(layoutJson(next)) === JSON.stringify(layoutJson(prev)) &&
JSON.stringify(next.footnoteReserveSeed) === JSON.stringify(prev.footnoteReserveSeed)
) {
settled = true;
prev = next;
break;
Expand Down
5 changes: 4 additions & 1 deletion packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,7 +2592,10 @@ export class DomPainter {
}
if (freshStart == null || !Number.isFinite(freshStart)) return;

const elements = [fragmentEl, ...Array.from(fragmentEl.querySelectorAll<HTMLElement>('[data-pm-start], [data-pm-end]'))];
const elements = [
fragmentEl,
...Array.from(fragmentEl.querySelectorAll<HTMLElement>('[data-pm-start], [data-pm-end]')),
];
let paintedStart = Infinity;
for (const el of elements) {
const start = Number(el.dataset.pmStart);
Expand Down
85 changes: 85 additions & 0 deletions packages/layout-engine/tests/src/sdt-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,91 @@ describe('SDT metadata integration', () => {
});
});

it('preserves nested block structuredContent content and metadata', () => {
const nestedBlockDoc = {
type: 'doc',
content: [
{
type: 'structuredContentBlock',
attrs: {
id: 'outer-block-sdt',
tag: 'outer_block',
alias: 'Outer Block',
},
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Outer paragraph' }],
},
{
type: 'structuredContentBlock',
attrs: {
id: 'inner-block-sdt',
tag: 'inner_block',
alias: 'Inner Block',
},
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Inner paragraph' }],
},
],
},
],
},
],
};
const { blocks: nestedBlocks } = toFlowBlocks(nestedBlockDoc);
const innerParagraph = nestedBlocks.find(
(block) => block.kind === 'paragraph' && block.runs?.some((run) => run.text === 'Inner paragraph'),
);

expect(innerParagraph).toBeDefined();
expect(innerParagraph?.attrs?.sdt).toMatchObject({
type: 'structuredContent',
scope: 'block',
id: 'inner-block-sdt',
tag: 'inner_block',
alias: 'Inner Block',
});
});

it('records the full outer-to-inner container chain on nested block SDTs', () => {
const nestedBlockDoc = {
type: 'doc',
content: [
{
type: 'structuredContentBlock',
attrs: { id: 'outer-block-sdt', tag: 'outer_block', alias: 'Outer Block' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Outer paragraph' }] },
{
type: 'structuredContentBlock',
attrs: { id: 'inner-block-sdt', tag: 'inner_block', alias: 'Inner Block' },
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inner paragraph' }] }],
},
],
},
],
};
const { blocks } = toFlowBlocks(nestedBlockDoc);
const outerParagraph = blocks.find(
(block) => block.kind === 'paragraph' && block.runs?.some((run) => run.text === 'Outer paragraph'),
);
const innerParagraph = blocks.find(
(block) => block.kind === 'paragraph' && block.runs?.some((run) => run.text === 'Inner paragraph'),
);

// Outer block: outer identity is its nearest sdt and its sole container.
expect(outerParagraph?.attrs?.sdt).toMatchObject({ id: 'outer-block-sdt', scope: 'block' });
expect(outerParagraph?.attrs?.sdtContainers?.map((s) => s.id)).toEqual(['outer-block-sdt']);

// Inner block: inner identity stays the nearest sdt, but the container chain
// records the full outer-to-inner ancestry so the outer control is not lost.
expect(innerParagraph?.attrs?.sdt).toMatchObject({ id: 'inner-block-sdt', scope: 'block' });
expect(innerParagraph?.attrs?.sdtContainers?.map((s) => s.id)).toEqual(['outer-block-sdt', 'inner-block-sdt']);
});

it('handles nested structuredContent (inline within inline)', () => {
const nestedBlock = summary.find((b) => b.blockId === '2-paragraph');
const outerRun = nestedBlock?.runMetadata.find((r) => r.metadata?.id === 'nested-outer');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { TextSelection } from 'prosemirror-state';

export const SELECT_FOOTNOTE_MARKER_META = 'selectFootnoteMarker';

const isNoteReference = (node) =>
node?.type.name === 'footnoteReference' || node?.type.name === 'endnoteReference';
const isNoteReference = (node) => node?.type.name === 'footnoteReference' || node?.type.name === 'endnoteReference';

/**
* Resolves the note marker ending at `boundaryPos` (the position right after it).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ export function applySdtMetadataToTableBlock(tableBlock: FlowBlock | undefined,
});
}

/**
* Record the ordered block-container chain (outermost first) on every emitted
* block, and fill in `attrs.sdt` for block kinds the metadata helpers skip
* (drawings, images) so every block in a control carries that control's
* identity and chain. (Resolved layout ignores image/drawing keys today; once
* it consumes them in a follow-up, this is what keeps a drawing or image from
* splitting the control's grouping run.) Recurses table cells.
*
* @param blocks - Flow blocks to stamp
* @param nearest - This control's metadata (each block's nearest container)
* @param chain - Full container ancestry, outermost first
*/
export function applySdtContainerChain(
blocks: FlowBlock[],
nearest: SdtMetadata | undefined,
chain: readonly SdtMetadata[],
): void {
if (!nearest && chain.length === 0) return;
for (const block of blocks) applyChainToBlock(block, nearest, chain);
}

function applyChainToBlock(block: FlowBlock, nearest: SdtMetadata | undefined, chain: readonly SdtMetadata[]): void {
const target = block as { kind: FlowBlock['kind']; attrs?: Record<string, unknown> };
if (!target.attrs) target.attrs = {};
if (nearest && target.attrs.sdt == null) target.attrs.sdt = nearest;
if (chain.length > 0) target.attrs.sdtContainers = chain as SdtMetadata[];
Comment on lines +173 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire nested SDT ancestry into paint grouping

When this stamps the outer-to-inner chain only into attrs.sdtContainers, the production render path still ignores it: resolveFragmentSdtContainerKey derives the painter's sdtContainerKey only from attrs.sdt / attrs.containerSdt, and rg sdtContainers shows no runtime consumer outside these new tests. For a nested block SDT, the inner paragraph now has attrs.sdt set to the inner control, so the outer control is still lost for resolved-layout boundaries and DomPainter chrome even though the chain was preserved on the FlowBlock.

Useful? React with 👍 / 👎.

if (block.kind === 'table') {
const table = block as TableBlock;
table.rows?.forEach((row) => {
row.cells?.forEach((cell) => {
const cellBlocks = cell.blocks && cell.blocks.length > 0 ? cell.blocks : cell.paragraph ? [cell.paragraph] : [];
cellBlocks.forEach((cellBlock) => applyChainToBlock(cellBlock, nearest, chain));
});
});
}
}

/**
* Applies SDT metadata to all list items within a ListBlock.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@

import type { FlowBlock, ParagraphBlock, TableBlock, TextRun } from '@superdoc/contracts';
import type { PMNode, NodeHandlerContext } from '../types.js';
import { resolveNodeSdtMetadata, applySdtMetadataToParagraphBlocks, applySdtMetadataToTableBlock } from './metadata.js';
import {
resolveNodeSdtMetadata,
applySdtMetadataToParagraphBlocks,
applySdtMetadataToTableBlock,
applySdtContainerChain,
} from './metadata.js';

const NON_RENDERED_STRUCTURAL_INLINE_TYPES = new Set([
'bookmarkEnd',
Expand Down Expand Up @@ -103,6 +108,15 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand
const structuredContentMetadata = resolveNodeSdtMetadata(node, 'structuredContentBlock');
const paragraphToFlowBlocks = converters?.paragraphToFlowBlocks;

// Ordered block-container ancestry for this node's content: the parent chain
// threaded via context plus this control (when it has resolvable metadata).
// `structuredContentMetadata` stays each block's nearest `sdt`; `chain`
// records the full outer-to-inner ancestry for nested content controls so the
// outer control's identity survives on inner blocks.
const parentChain = context.sdtContainerChain ?? [];
const chain = structuredContentMetadata ? [...parentChain, structuredContentMetadata] : parentChain;
const childContext: NodeHandlerContext = chain.length ? { ...context, sdtContainerChain: chain } : context;

const emitPlaceholderBlock = (contentPos?: number): void => {
if (!structuredContentMetadata) return;
const placeholderRun: TextRun = {
Expand All @@ -118,7 +132,9 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand
kind: 'paragraph',
id: nextBlockId('paragraph'),
runs: [placeholderRun],
attrs: { sdt: structuredContentMetadata },
attrs: chain.length
? { sdt: structuredContentMetadata, sdtContainers: chain }
: { sdt: structuredContentMetadata },
};
blocks.push(placeholderBlock);
recordBlockKind?.(placeholderBlock.kind);
Expand Down Expand Up @@ -154,6 +170,7 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand
paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[],
structuredContentMetadata,
);
applySdtContainerChain(paragraphBlocks, structuredContentMetadata, chain);
if (applyPlaceholderToEmptyParagraphBlocks(paragraphBlocks, structuredContentMetadata, contentPos)) {
paragraphBlocks.forEach((block) => {
blocks.push(block);
Expand Down Expand Up @@ -199,6 +216,7 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand
paragraphBlocks.filter((b) => b.kind === 'paragraph') as ParagraphBlock[],
structuredContentMetadata,
);
applySdtContainerChain(paragraphBlocks, structuredContentMetadata, chain);
paragraphBlocks.forEach((block) => {
blocks.push(block);
recordBlockKind?.(block.kind);
Expand All @@ -221,17 +239,26 @@ export function handleStructuredContentBlockNode(node: PMNode, context: NodeHand
});
if (tableBlock) {
applySdtMetadataToTableBlock(tableBlock as TableBlock, structuredContentMetadata);
applySdtContainerChain([tableBlock], structuredContentMetadata, chain);
blocks.push(tableBlock);
recordBlockKind?.(tableBlock.kind);
}
}
return;
}
// A nested block content control. Recurse with the extended container chain
// so its inner blocks carry the full outer-to-inner ancestry while this
// control's metadata stays their nearest `sdt`. findParagraphsWithSectPr
// does not recurse structuredContentBlock, so no currentParagraphIndex
// bookkeeping is needed here.
if (child.type === 'structuredContentBlock') {
handleStructuredContentBlockNode(child, childContext);
return;
}
// SD-1333: documentPartObject is a transparent wrapper - recurse its content.
// SD-3005: a block field (bibliography / index / table of authorities) generated
// inside this content control is likewise transparent here; render its entry
// paragraphs without advancing currentParagraphIndex, since
// findParagraphsWithSectPr does not recurse structuredContentBlock.
// paragraphs without advancing currentParagraphIndex.
if (
Array.isArray(child.content) &&
(child.type === 'documentPartObject' ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
* Type definitions for ProseMirror to FlowBlock adapter
*/

import type { TrackedChangesMode, SectionMetadata, FlowBlock, TrackedChangeMeta } from '@superdoc/contracts';
import type {
TrackedChangesMode,
SectionMetadata,
FlowBlock,
TrackedChangeMeta,
SdtMetadata,
} from '@superdoc/contracts';
import type { StyleContext as StyleEngineContext, ComputedParagraphStyle } from '@superdoc/style-engine';
import type { SectionRange } from './sections/index.js';
import type { ConverterContext } from './converter-context.js';
Expand Down Expand Up @@ -336,6 +342,11 @@ export interface NodeHandlerContext {

// Converters for nested content
converters: NestedConverters;
// Ordered chain of enclosing block-container SDT metadata (outermost first)
// for the node currently being processed. Threaded by the SDT handlers so
// nested content controls record their full container ancestry on emitted
// blocks. Absent/empty at the top level.
sdtContainerChain?: SdtMetadata[];
themeColors?: ThemeColorPalette;
// FlowBlock cache for incremental conversion (optional)
flowBlockCache?: import('./cache.js').FlowBlockCache;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,5 +124,4 @@ export class NoteSessionCoordinator {
if (fullyVisible) return;
fragment.scrollIntoView({ block: 'center', behavior: 'smooth' });
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ const schema = new Schema({
* p1: "See" + bookmarkStart(_Ref1)[ run[ <noteRef id=8> ] ] + "below"
* p2: "as noted in" + crossReference(target=_Ref1, "footnote 8")
*/
function makeDoc(noteRefType: 'footnoteReference' | 'endnoteReference' = 'footnoteReference', bookmarkContent?: ProseMirrorNode[]) {
function makeDoc(
noteRefType: 'footnoteReference' | 'endnoteReference' = 'footnoteReference',
bookmarkContent?: ProseMirrorNode[],
) {
const noteRef = schema.nodes[noteRefType].create({ id: '8' });
const bookmark = schema.nodes.bookmarkStart.create(
{ name: '_Ref1', id: '1' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,10 @@ function resolvePmPoint(
if (blockContainers.length) {
const blockLines = collectRenderedLineElements(blockContainers)
.map((line) => ({ line, pmStart: getPmStart(line), pmEnd: getPmEnd(line) }))
.filter((entry): entry is { line: HTMLElement; pmStart: number; pmEnd: number } =>
entry.pmStart != null && entry.pmEnd != null)
.filter(
(entry): entry is { line: HTMLElement; pmStart: number; pmEnd: number } =>
entry.pmStart != null && entry.pmEnd != null,
)
.sort((a, b) => a.pmStart - b.pmStart || a.pmEnd - b.pmEnd);
if (blockLines.length) {
const delta = anchor.currentStart - blockLines[0].pmStart;
Expand All @@ -357,8 +359,10 @@ function resolvePmPoint(
// forward-affinity scan and the gap snap pick the right line (SD-3400).
const lines = collectRenderedLineElements(containers)
.map((line) => ({ line, pmStart: getPmStart(line), pmEnd: getPmEnd(line) }))
.filter((entry): entry is { line: HTMLElement; pmStart: number; pmEnd: number } =>
entry.pmStart != null && entry.pmEnd != null)
.filter(
(entry): entry is { line: HTMLElement; pmStart: number; pmEnd: number } =>
entry.pmStart != null && entry.pmEnd != null,
)
.sort((a, b) => a.pmStart - b.pmStart || a.pmEnd - b.pmEnd);
let lineElement: HTMLElement | null = null;
let resolvedPos = pos;
Expand Down
Loading
Loading