Skip to content
Merged
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
103 changes: 103 additions & 0 deletions packages/layout-engine/painters/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2738,6 +2738,7 @@ describe('DomPainter', () => {
expect(wrapper.dataset.sdtScope).toBe('inline');
expect(wrapper.dataset.sdtId).toBe('sc-inline-1');
expect(wrapper.dataset.sdtTag).toBe('dropdown');
expect(wrapper.dataset.containsInlineImage).toBeUndefined();

// The wrapper should span all contained runs (pmStart=7 to pmEnd=22)
expect(wrapper.dataset.pmStart).toBe('7');
Expand All @@ -2755,6 +2756,108 @@ describe('DomPainter', () => {
expect(wrapper.textContent).toContain('controlled text');
});

it('marks inline structuredContent wrappers that contain inline images', () => {
const block: FlowBlock = {
kind: 'paragraph',
id: 'inline-sc-image',
runs: [
{ text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 },
{
text: 'Caption ',
fontFamily: 'Arial',
fontSize: 16,
pmStart: 7,
pmEnd: 15,
sdt: {
type: 'structuredContent',
scope: 'inline',
id: 'sc-inline-image',
alias: 'Image control',
},
},
{
kind: 'image',
src: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
width: 40,
height: 40,
pmStart: 15,
pmEnd: 16,
sdt: {
type: 'structuredContent',
scope: 'inline',
id: 'sc-inline-image',
alias: 'Image control',
},
},
{
text: ' after',
fontFamily: 'Arial',
fontSize: 16,
pmStart: 16,
pmEnd: 22,
sdt: {
type: 'structuredContent',
scope: 'inline',
id: 'sc-inline-image',
alias: 'Image control',
},
},
],
attrs: {},
};

const measure: Measure = {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 3,
toChar: 6,
width: 160,
ascent: 40,
descent: 0,
lineHeight: 40,
},
],
totalHeight: 40,
};

const layout: Layout = {
pageSize: { w: 612, h: 792 },
pages: [
{
number: 1,
fragments: [
{
kind: 'para',
blockId: 'inline-sc-image',
fromLine: 0,
toLine: 1,
x: 30,
y: 40,
width: 552,
pmStart: 0,
pmEnd: 22,
},
],
},
],
};

const painter = createTestPainter({ blocks: [block], measures: [measure] });
painter.paint(layout, mount);

const wrapper = mount.querySelector(
'.superdoc-structured-content-inline[data-sdt-id="sc-inline-image"]',
) as HTMLElement | null;
expect(wrapper).toBeTruthy();
expect(wrapper?.dataset.containsInlineImage).toBe('true');
expect(wrapper?.querySelector('.superdoc-inline-image')).toBeTruthy();
expect(wrapper?.dataset.pmStart).toBe('7');
expect(wrapper?.dataset.pmEnd).toBe('22');
});

it('omits chrome and alias label when inline SDT appearance is hidden (SD-3110)', () => {
// ECMA-376 `<w15:appearance w15:val="hidden"/>` should render the
// SDT transparently: no padding/border/label, and the alias text
Expand Down
12 changes: 7 additions & 5 deletions packages/layout-engine/painters/dom/src/runs/render-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,7 @@ const isWhitespaceOnly = (text: string): boolean => {
return true;
};

const alignNormalTextBesideInlineImage = (
element: HTMLElement,
run: Run,
lineContainsInlineImage: boolean,
): void => {
const alignNormalTextBesideInlineImage = (element: HTMLElement, run: Run, lineContainsInlineImage: boolean): void => {
if (!lineContainsInlineImage) return;
if ((run.kind !== 'text' && run.kind !== undefined) || !('text' in run)) return;

Expand Down Expand Up @@ -522,6 +518,9 @@ const renderExplicitlyPositionedRuns = ({
geoSdtWrapper.style.top = '0px';
geoSdtWrapper.style.height = `${line.lineHeight}px`;
}
if (isImageRun(runForSdt)) {
geoSdtWrapper.dataset.containsInlineImage = 'true';
}
runContext.syncInlineSdtWrapperTypography(geoSdtWrapper, runForSdt);
elem.style.left = `${elemLeftPx - geoSdtWrapperLeft}px`;
geoSdtMaxRight = Math.max(geoSdtMaxRight, elemLeftPx + elemWidthPx);
Expand Down Expand Up @@ -771,6 +770,9 @@ const renderInlineRuns = ({
runContext.syncInlineSdtWrapperTypography(currentInlineSdtWrapper, run);
currentInlineSdtId = runSdtId;
}
if (isImageRun(run)) {
currentInlineSdtWrapper.dataset.containsInlineImage = 'true';
}
// Typography is set when wrapper is created from the first run.
// Follow-up (SD-2744): define a deterministic mixed-typography rule.
runContext.expandSdtWrapperPmRange(currentInlineSdtWrapper, run.pmStart, run.pmEnd);
Expand Down
38 changes: 38 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,21 @@ describe('ensureSdtContainerStyles', () => {

expect(blockRule).not.toContain('padding:');
expect(blockRule).not.toContain('border:');
expect(blockRule).toContain('background-color: transparent;');
expect(blockRule).toContain('--sd-sdt-chrome-left: 0px;');
expect(blockRule).toContain('--sd-sdt-chrome-width: 100%;');
expect(blockRule).toContain('--sd-sdt-chrome-bottom-extension: 0px;');
expect(blockRule).toContain('z-index: 0;');
expect(backgroundRule).toContain('width: var(--sd-sdt-chrome-width, 100%);');
expect(backgroundRule).toContain('bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px));');
expect(backgroundRule).toContain('background-color: var(--sd-content-controls-block-bg, transparent);');
expect(backgroundRule).toContain('z-index: -1;');
expect(hoverRule).toContain('background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2);');
expect(chromeRule).toContain('position: absolute;');
expect(chromeRule).toContain('width: var(--sd-sdt-chrome-width, 100%);');
expect(chromeRule).toContain('bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px));');
expect(chromeRule).toContain('border: 1px solid transparent;');
expect(chromeRule).toContain('z-index: 1;');
expect(chromeRule).toContain('pointer-events: none;');
});

Expand Down Expand Up @@ -83,6 +88,22 @@ describe('ensureSdtContainerStyles', () => {
expect(emptyRule).not.toContain('vertical-align');
});

it('promotes only image-bearing inline SDT wrappers to inline-block geometry', () => {
ensureSdtContainerStyles(document);

const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]');
const cssText = styleEl?.textContent ?? '';
const baseInlineRule = cssText.match(/\.superdoc-structured-content-inline\s*\{([^}]*)\}/)?.[1] ?? '';
const imageInlineRule =
cssText.match(
/\.superdoc-structured-content-inline\[data-contains-inline-image='true'\]:not\(\[data-appearance='hidden'\]\)\s*\{([^}]*)\}/,
)?.[1] ?? '';

expect(baseInlineRule).toContain('display: inline;');
expect(imageInlineRule).toContain('display: inline-block;');
expect(imageInlineRule).toContain('vertical-align: top;');
});

it('uses the same label box model for block and inline SDTs', () => {
ensureSdtContainerStyles(document);

Expand Down Expand Up @@ -219,6 +240,23 @@ describe('ensureSdtContainerStyles', () => {
expect(beforeRule).toContain('background: none;');
});

it('suppresses block SDT resting background paint in viewing and print modes', () => {
ensureSdtContainerStyles(document);

const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]');
const cssText = styleEl?.textContent ?? '';
const viewingBeforeRule =
cssText.match(
/\.presentation-editor--viewing \.superdoc-structured-content-block::before,[\s\S]*?\{([^}]*)\}/,
)?.[1] ?? '';
const printBeforeRule =
cssText.match(/@media print\s*\{[\s\S]*?\.superdoc-structured-content-block::before\s*\{([^}]*)\}/)?.[1] ?? '';

expect(cssText).toContain('.presentation-editor--viewing .superdoc-structured-content-block::before,');
expect(viewingBeforeRule).toContain('background: none;');
expect(printBeforeRule).toContain('background: none;');
});

it('keeps hidden-appearance inline SDTs transparent at rest', () => {
ensureSdtContainerStyles(document);
const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]');
Expand Down
16 changes: 15 additions & 1 deletion packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,8 +525,9 @@ const SDT_CONTAINER_STYLES = `
.superdoc-structured-content-block {
box-sizing: border-box;
border-radius: 4px;
background-color: var(--sd-content-controls-block-bg, transparent);
background-color: transparent;
position: relative;
z-index: 0;
--sd-sdt-chrome-left: 0px;
--sd-sdt-chrome-width: 100%;
--sd-sdt-chrome-bottom-extension: 0px;
Expand All @@ -540,7 +541,9 @@ const SDT_CONTAINER_STYLES = `
bottom: calc(0px - var(--sd-sdt-chrome-bottom-extension, 0px));
width: var(--sd-sdt-chrome-width, 100%);
border-radius: inherit;
background-color: var(--sd-content-controls-block-bg, transparent);
box-sizing: border-box;
z-index: -1;
pointer-events: none;
}

Expand All @@ -554,6 +557,7 @@ const SDT_CONTAINER_STYLES = `
border: 1px solid transparent;
border-radius: inherit;
box-sizing: border-box;
z-index: 1;
pointer-events: none;
}

Expand Down Expand Up @@ -689,6 +693,11 @@ const SDT_CONTAINER_STYLES = `
z-index: 10;
}

.superdoc-structured-content-inline[data-contains-inline-image='true']:not([data-appearance='hidden']) {
display: inline-block;
vertical-align: top;
}

/* Hover effect for inline structured content */
.superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover {
background-color: var(--sd-content-controls-inline-hover-bg, #f2f2f2);
Expand Down Expand Up @@ -820,6 +829,7 @@ const SDT_CONTAINER_STYLES = `
border: none;
}

.presentation-editor--viewing .superdoc-structured-content-block::before,
.presentation-editor--viewing .superdoc-structured-content-block:hover::before,
.presentation-editor--viewing .superdoc-structured-content-block.sdt-group-hover::before,
.presentation-editor--viewing .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::before {
Expand Down Expand Up @@ -861,6 +871,10 @@ const SDT_CONTAINER_STYLES = `
border: none;
}

.superdoc-structured-content-block::before {
background: none;
}

.superdoc-document-section__tooltip,
.superdoc-structured-content__label,
.superdoc-structured-content-inline__label {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ function checkLockViolation(sdtNodes, from, to) {
return { blocked: false };
}

function isAtBlockSdtWrapperDeletePosition(state, sdt, pos) {
if (sdt.type !== 'structuredContentBlock') return false;

const $pos = state.doc.resolve(pos);
let sdtDepth = null;
for (let depth = $pos.depth; depth > 0; depth -= 1) {
if ($pos.node(depth).type.name === 'structuredContentBlock' && $pos.before(depth) === sdt.pos) {
sdtDepth = depth;
break;
}
}
if (sdtDepth == null) return false;

const textblockDepth = sdtDepth + 1;
if ($pos.depth < textblockDepth) return false;
if (!$pos.node(textblockDepth).isTextblock) return false;
if ($pos.node(textblockDepth).type.name !== 'paragraph') return false;
if ($pos.pos !== $pos.start(textblockDepth)) return false;

return $pos.before(textblockDepth) === $pos.start(sdtDepth);
}

export function createStructuredContentLockPlugin() {
return new Plugin({
key: STRUCTURED_CONTENT_LOCK_KEY,
Expand Down Expand Up @@ -166,6 +188,13 @@ export function createStructuredContentLockPlugin() {
return true;
}

const blockSdtAtWrapperDeletePosition = sdtNodes.find((s) =>
isAtBlockSdtWrapperDeletePosition(state, s, from),
);
if ((isBackspace || isDelete) && blockSdtAtWrapperDeletePosition) {
return false;
}

const inlineSdtAncestor = sdtNodes.find(
(s) => s.type === 'structuredContent' && from > s.pos && from < s.end,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,44 @@ describe('StructuredContentLockPlugin', () => {
return result;
}

it.each([
['contentLocked', 'Backspace', true],
['contentLocked', 'Delete', true],
['sdtContentLocked', 'Backspace', false],
['sdtContentLocked', 'Delete', false],
])(
'%s + %s at the start of the first block SDT paragraph follows wrapper lock rules',
(lockMode, key, shouldDeleteWrapper) => {
const doc = createDocWithSDT(lockMode, 'structuredContentBlock');
const state = applyDocToEditor(doc);
const sdtInfo = findSDTNode(state.doc, 'structuredContentBlock');
let firstParagraphStart = null;

state.doc.descendants((node, pos) => {
if (node.type.name === 'paragraph' && pos > sdtInfo.pos && pos < sdtInfo.end) {
firstParagraphStart = pos + 1;
return false;
}
return true;
});

expect(firstParagraphStart).not.toBeNull();
placeCaretAt(state, firstParagraphStart);

const result = invokeLockHandleKeyDown(key);
expect(result.handled).toBe(false);
expect(result.prevented).toBe(false);

if (key === 'Backspace') {
handleBackspace(editor);
} else {
handleDelete(editor);
}

expect(sdtNodeExists(editor.state.doc, 'structuredContentBlock')).toBe(!shouldDeleteWrapper);
},
);

describe('Path 2 — caret immediately adjacent to inline SDT', () => {
const adjacencyCases = [
// [lockMode, key, shouldConsume, description]
Expand Down
Loading
Loading