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
Binary file not shown.
65 changes: 65 additions & 0 deletions e2e-tests/tests/visuals/layout-engine.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,70 @@ if (!shouldRun) {
});
});
});

const loadStructuredContentDocument = async (page) => {
const superEditor = await goToPageAndWaitForEditor(page, { layout: 1 });
const fileInput = page.locator('input[type="file"]');

await fileInput.setInputFiles('./test-data/structured-content/sdt-basic.docx');

await page.waitForFunction(() => window.superdoc !== undefined && window.editor !== undefined, null, {
polling: 100,
timeout: 10_000,
});

await page.waitForFunction(() => {
const toolbar = document.querySelector('#toolbar');
return toolbar && toolbar.children.length > 0;
});

return superEditor;
};

test('structured content: inline selection (sdt-basic.docx)', async ({ page }) => {
const superEditor = await loadStructuredContentDocument(page);
const inlineStructuredContent = page.locator('.superdoc-structured-content-inline').first();

await expect(inlineStructuredContent).toBeVisible();
await inlineStructuredContent.scrollIntoViewIfNeeded();
await inlineStructuredContent.hover();
await inlineStructuredContent.click({ force: true });
await expect(inlineStructuredContent).toHaveClass(/ProseMirror-selectednode/);
await page.waitForFunction(() => {
return document.querySelectorAll('.superdoc-structured-content-block.sdt-group-hover').length === 0;
});
const inlineEditorBox = await superEditor.boundingBox();
if (inlineEditorBox) {
await page.mouse.move(inlineEditorBox.x - 10, inlineEditorBox.y - 10);
}

await expect(superEditor).toHaveScreenshot();
});

test('structured content: block selection (sdt-basic.docx)', async ({ page }) => {
const superEditor = await loadStructuredContentDocument(page);
const blockStructuredContent = page.locator('.superdoc-structured-content-block').first();

await expect(blockStructuredContent).toBeVisible();
await blockStructuredContent.scrollIntoViewIfNeeded();
await blockStructuredContent.hover();
await blockStructuredContent.click({ force: true });
await expect(blockStructuredContent).toHaveClass(/ProseMirror-selectednode/);
const blockEditorBox = await superEditor.boundingBox();
if (blockEditorBox) {
await page.mouse.move(blockEditorBox.x - 10, blockEditorBox.y - 10);
}
await page.waitForFunction(
() => {
const block = document.querySelector('.superdoc-structured-content-block');
if (block?.matches(':hover')) return false;
return document.querySelectorAll('.superdoc-structured-content-block.sdt-group-hover').length === 0;
},
null,
{ timeout: 2_000 },
);

await expect(superEditor).toHaveScreenshot();
});
});
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 64 additions & 5 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ export class DomPainter {
private footerProvider?: PageDecorationProvider;
private totalPages = 0;
private linkIdCounter = 0; // Counter for generating unique link IDs
private sdtLabelsRendered = new Set<string>(); // Tracks SDT labels rendered across pages

/**
* WeakMap storing tooltip data for hyperlink elements before DOM insertion.
Expand Down Expand Up @@ -1007,6 +1008,7 @@ export class DomPainter {
throw new Error('DomPainter.paint requires a DOM-like document');
}
this.doc = doc;
this.sdtLabelsRendered.clear(); // Reset SDT label tracking for new render cycle

// Simple transaction gate: only use position mapping optimization for single-step transactions.
// Complex transactions (paste, multi-step replace, etc.) fall back to full rebuild.
Expand Down Expand Up @@ -1440,7 +1442,7 @@ export class DomPainter {
pageNumberText: page.numberText,
};

const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup);
const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);

page.fragments.forEach((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
Expand Down Expand Up @@ -1747,7 +1749,7 @@ export class DomPainter {

const existing = new Map(state.fragments.map((frag) => [frag.key, frag]));
const nextFragments: FragmentDomState[] = [];
const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup);
const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);

const contextBase: FragmentRenderContext = {
pageNumber: page.number,
Expand Down Expand Up @@ -1883,7 +1885,7 @@ export class DomPainter {
section: 'body',
};

const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup);
const sdtBoundaries = computeSdtBoundaries(page.fragments, this.blockLookup, this.sdtLabelsRendered);

const fragments: FragmentDomState[] = page.fragments.map((fragment, index) => {
const sdtBoundary = sdtBoundaries.get(index);
Expand Down Expand Up @@ -5175,9 +5177,45 @@ const getFragmentSdtContainerKey = (fragment: Fragment, blockLookup: BlockLookup
return null;
};

const getFragmentHeight = (fragment: Fragment, blockLookup: BlockLookup): number => {
if (fragment.kind === 'table' || fragment.kind === 'image' || fragment.kind === 'drawing') {
return fragment.height;
}

const lookup = blockLookup.get(fragment.blockId);
if (!lookup) return 0;

if (fragment.kind === 'para' && lookup.measure.kind === 'paragraph') {
const measure = lookup.measure;
const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine);
if (lines.length === 0) return 0;
let totalHeight = 0;
for (const line of lines) {
totalHeight += line.lineHeight ?? 0;
}
return totalHeight;
}

if (fragment.kind === 'list-item' && lookup.measure.kind === 'list') {
const listMeasure = lookup.measure as ListMeasure;
const item = listMeasure.items.find((it) => it.itemId === fragment.itemId);
if (!item) return 0;
const lines = item.paragraph.lines.slice(fragment.fromLine, fragment.toLine);
if (lines.length === 0) return 0;
let totalHeight = 0;
for (const line of lines) {
totalHeight += line.lineHeight ?? 0;
}
return totalHeight;
}

return 0;
};

const computeSdtBoundaries = (
fragments: readonly Fragment[],
blockLookup: BlockLookup,
sdtLabelsRendered: Set<string>,
): Map<number, SdtBoundaryOptions> => {
const boundaries = new Map<number, SdtBoundaryOptions>();
const containerKeys = fragments.map((fragment) => getFragmentSdtContainerKey(fragment, blockLookup));
Expand All @@ -5203,10 +5241,31 @@ const computeSdtBoundaries = (

for (let k = i; k <= j; k += 1) {
const fragment = fragments[k];
const isStart = k === i;
const isEnd = k === j;

let paddingBottomOverride: number | undefined;
if (!isEnd) {
const nextFragment = fragments[k + 1];
const currentHeight = getFragmentHeight(fragment, blockLookup);
const currentBottom = fragment.y + currentHeight;
const gapToNext = nextFragment.y - currentBottom;
if (gapToNext > 0) {
paddingBottomOverride = gapToNext;
}
}

const showLabel = isStart && !sdtLabelsRendered.has(currentKey);
if (showLabel) {
sdtLabelsRendered.add(currentKey);
}
Comment on lines +5258 to +5261

Choose a reason for hiding this comment

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

P2 Badge Reset SDT label tracking during virtual scroll

showLabel is suppressed once a key is in sdtLabelsRendered, but that set is only cleared in paint(). In virtualized mode, updateVirtualWindow mounts/unmounts pages without calling paint(), so after scrolling away and back, the start fragment’s label won’t render because the key is still in the set. This makes SDT labels disappear on virtual scroll; reset the set per virtual window rebuild or scope it to mounted pages.

Useful? React with 👍 / 👎.


boundaries.set(k, {
isStart: k === i,
isEnd: k === j,
isStart,
isEnd,
widthOverride: groupRight - fragment.x,
paddingBottomOverride,
showLabel,
});
}

Expand Down
57 changes: 48 additions & 9 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,26 @@ const SDT_CONTAINER_STYLES = `
padding: 1px;
box-sizing: border-box;
border-radius: 4px;
border: 1px solid #629be7;
border: 1px solid transparent;
position: relative;
}

.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover {
background-color: #f2f2f2;
border-color: transparent;
}

/* Group hover (JavaScript-coordinated) */
.superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode) {
background-color: #f2f2f2;
border-color: transparent;
}

.superdoc-structured-content-block.ProseMirror-selectednode {
border-color: #629be7;
outline: none;
}

/* Structured content drag handle/label - positioned above */
.superdoc-structured-content__label {
font-size: 10px;
Expand All @@ -376,7 +392,9 @@ const SDT_CONTAINER_STYLES = `
box-sizing: border-box;
z-index: 10;
display: none;
pointer-events: none;
pointer-events: auto;
cursor: pointer;
user-select: none;
}

.superdoc-structured-content__label span {
Expand All @@ -386,10 +404,14 @@ const SDT_CONTAINER_STYLES = `
text-overflow: ellipsis;
}

.superdoc-structured-content-block:hover .superdoc-structured-content__label {
.superdoc-structured-content-block.ProseMirror-selectednode .superdoc-structured-content__label {
display: inline-flex;
}

.superdoc-structured-content-block:not(.ProseMirror-selectednode):hover .superdoc-structured-content__label {
display: none;
}

/* Continuation styling for structured content blocks */
/* Single fragment (both start and end): full border radius */
.superdoc-structured-content-block[data-sdt-container-start="true"][data-sdt-container-end="true"] {
Expand Down Expand Up @@ -420,16 +442,22 @@ const SDT_CONTAINER_STYLES = `
padding: 1px;
box-sizing: border-box;
border-radius: 4px;
border: 1px solid #629be7;
border: 1px solid transparent;
position: relative;
display: inline;
z-index: 10;
}

/* Hover effect for inline structured content */
.superdoc-structured-content-inline:hover {
background-color: rgba(98, 155, 231, 0.15);
border-color: #4a8ad9;
.superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover {
background-color: #f2f2f2;
border-color: transparent;
}

.superdoc-structured-content-inline.ProseMirror-selectednode {
border-color: #629be7;
outline: none;
background-color: transparent;
}

/* Inline structured content label - shown on hover */
Expand All @@ -446,13 +474,19 @@ const SDT_CONTAINER_STYLES = `
white-space: nowrap;
z-index: 100;
display: none;
pointer-events: none;
pointer-events: auto;
cursor: pointer;
user-select: none;
}

.superdoc-structured-content-inline:hover .superdoc-structured-content-inline__label {
.superdoc-structured-content-inline.ProseMirror-selectednode .superdoc-structured-content-inline__label {
display: block;
}

.superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover .superdoc-structured-content-inline__label {
display: none;
}

/* Viewing mode: remove structured content affordances */
.presentation-editor--viewing .superdoc-structured-content-block,
.presentation-editor--viewing .superdoc-structured-content-inline {
Expand All @@ -461,6 +495,11 @@ const SDT_CONTAINER_STYLES = `
padding: 0;
}

.presentation-editor--viewing .superdoc-structured-content-block:hover {
background: none;
border: none;
}

.presentation-editor--viewing .superdoc-structured-content-inline:hover {
background: none;
border: none;
Expand Down
Loading
Loading