Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
126 commits
Select commit Hold shift + click to select a range
dc33ec8
fix(super-editor): mark block SDT selected when contained image is se…
luccas-harbour May 25, 2026
37d1ddc
fix(layout-bridge): detect inline image run changes in paragraph diff
luccas-harbour May 25, 2026
afe273a
fix(super-editor): disable image resize inside content-locked SDTs
luccas-harbour May 25, 2026
2f54eac
fix(layout-engine): keep block SDT chrome and inline images out of pa…
luccas-harbour May 25, 2026
8a1bc7a
fix(layout-engine): bottom-align text on lines with inline images
luccas-harbour May 25, 2026
8b624ed
fix(layout-engine): fit block SDT chrome to actual content width
luccas-harbour May 25, 2026
34903ac
fix(layout-engine): allow top-aligned inline images
luccas-harbour May 25, 2026
5c93bf2
fix(dom): suppress SDT pseudo hover in viewing mode
luccas-harbour May 25, 2026
55d5bfd
fix(dom): offset block SDT chrome for indents
luccas-harbour May 25, 2026
04f6e77
fix(dom): preserve SDT chrome continuation offsets
luccas-harbour May 25, 2026
3165aba
fix(dom): align SDT chrome within paragraph width
luccas-harbour May 25, 2026
6e48795
fix(layout-bridge): dirty inline image SDT changes
luccas-harbour May 25, 2026
4018512
fix(super-editor): honor ancestor image SDT locks
luccas-harbour May 25, 2026
2f3dfc7
fix(dom): size SDT chrome for justified lines
luccas-harbour May 25, 2026
de16681
fix(dom): align RTL SDT chrome to text
luccas-harbour May 25, 2026
a263c87
fix(layout-resolved): version inline image metadata
luccas-harbour May 25, 2026
0b4e6b1
test(layout-bridge): cover inline image diff fields
luccas-harbour May 25, 2026
76d060f
fix(super-editor): share SDT lock predicates
luccas-harbour May 25, 2026
dd5d334
fix(template-builder): color SDT block label backdrop with field color
luccas-harbour May 25, 2026
cd60771
fix(super-editor): persist data URI images set via setPresetContent
luccas-harbour May 26, 2026
94ce66d
fix(super-editor): register sized SVG data URI images without canvas …
luccas-harbour May 26, 2026
c4d3b8e
fix(pm-adapter): scope inline SDT placeholder to structuredContent me…
luccas-harbour May 26, 2026
c6e5483
fix(super-editor): support non-base64 data URI images in registration
luccas-harbour May 26, 2026
a9d63cd
fix(super-editor): reuse data URI image exports
luccas-harbour May 26, 2026
c17124b
fix(super-editor): extract shared hash helpers
luccas-harbour May 26, 2026
f962681
fix(super-editor): decode non-base64 data URI exports
luccas-harbour May 26, 2026
a49193b
fix(super-editor): mirror in-place image media to parent
luccas-harbour May 26, 2026
cab65c2
fix(layout-engine): allow non-base64 SVG data URLs in image rendering
luccas-harbour May 26, 2026
d0adb9d
fix(super-editor): export field annotation svgs as svg
luccas-harbour May 26, 2026
7e00837
fix(super-editor): guard non-base64 data uri exports
luccas-harbour May 26, 2026
3e8ff74
fix(super-editor): validate in-place svg image data
luccas-harbour May 26, 2026
63f0b39
fix(super-editor): normalize svg data uri filenames
luccas-harbour May 26, 2026
4fc5dcb
fix(super-editor): skip invalid data uri image targets
luccas-harbour May 26, 2026
15f060d
fix(super-editor): share data uri media parsing
luccas-harbour May 26, 2026
2863400
test(super-editor): cover structured content image edges
luccas-harbour May 26, 2026
e209424
fix(super-editor): read svg data uri dimensions
luccas-harbour May 26, 2026
75fcd9a
fix(super-editor): block non-image data uri exports
luccas-harbour May 26, 2026
be7053f
fix(super-editor): reject separatorless data uri files
luccas-harbour May 26, 2026
2a3856e
fix(super-editor): warn on skipped image exports
luccas-harbour May 26, 2026
3a804f7
fix(super-editor): reject malformed svg data uri payloads
luccas-harbour May 26, 2026
024080b
fix(super-editor): block raw raster data uri exports
luccas-harbour May 26, 2026
4d43272
fix(super-editor): avoid duplicate image rids
luccas-harbour May 26, 2026
b40c97b
docs(super-editor): clarify image registration comments
luccas-harbour May 26, 2026
0221054
test(super-editor): repaint saved sdt images through painter
luccas-harbour May 26, 2026
95140d4
fix(super-editor): reject malformed data uri files
luccas-harbour May 26, 2026
ba43f9c
fix(super-editor): validate field annotation data uri exports
luccas-harbour May 26, 2026
3b2d73e
fix(super-editor): validate in-place svg payloads
luccas-harbour May 26, 2026
89509a3
fix(super-editor): reuse target image relationships
luccas-harbour May 26, 2026
08ae366
fix(shared): centralize image data url policy
luccas-harbour May 26, 2026
419a778
perf(super-editor): avoid scanning data uri media
luccas-harbour May 26, 2026
79ec4f7
fix(super-editor): normalize image data uri extensions
luccas-harbour May 26, 2026
37060e9
refactor(super-editor): trim data uri metadata fields
luccas-harbour May 26, 2026
5cb63da
refactor(super-editor): share data uri text decoding
luccas-harbour May 26, 2026
c60f750
fix(pm-adapter): narrow sdt metadata overrides
luccas-harbour May 26, 2026
e0ecf26
fix(super-editor): register preset raster data uris in place
luccas-harbour May 26, 2026
71fb7d8
fix(super-editor): validate oversized async svg images
luccas-harbour May 26, 2026
2d9c2d6
fix(super-editor): reject raw raster data uri dimensions
luccas-harbour May 26, 2026
31c3c32
fix(super-editor): avoid non-image data uri extensions
luccas-harbour May 26, 2026
4e6670a
refactor(shared): centralize image data uri parsing
luccas-harbour May 26, 2026
3368c73
refactor(super-editor): share image relationship export lookup
luccas-harbour May 26, 2026
3f0360f
fix(super-editor): enforce upload byte cap for data uris
luccas-harbour May 26, 2026
0b62c1d
refactor(super-editor): reuse shared data uri export policy
luccas-harbour May 26, 2026
5d8339e
test(shared): cover image data uri length boundary
luccas-harbour May 26, 2026
1e403b3
fix(super-editor): reuse colliding data uri media targets
luccas-harbour May 26, 2026
5221736
test(super-editor): roundtrip mixed image block sdts
luccas-harbour May 26, 2026
c977f30
docs(shared): document image data uri helpers
luccas-harbour May 26, 2026
e184217
docs(super-editor): clarify data uri buffer conversion
luccas-harbour May 26, 2026
881cda9
refactor(super-editor): wrap shared tryDecodeDataUriText re-export
luccas-harbour May 26, 2026
f92cf9b
fix(shared): reject malformed base64 image data URIs
luccas-harbour May 26, 2026
01da84e
feat(super-editor): disable mutation toolbar controls inside content-…
luccas-harbour May 26, 2026
5f62324
fix(super-editor): block locked sdt toolbar execution
luccas-harbour May 26, 2026
97632fe
fix(super-editor): guard unlisted locked toolbar commands
luccas-harbour May 26, 2026
50b6982
fix(super-editor): block disabled toolbar execution
luccas-harbour May 27, 2026
d2ebafc
refactor(super-editor): share structured content predicates
luccas-harbour May 27, 2026
1550433
fix(super-editor): keep text-align enabled in locked SDT paragraphs
luccas-harbour May 27, 2026
f288be6
fix(super-editor): exclude sdt chrome labels from caret position lookup
luccas-harbour May 27, 2026
e0b7e55
feat(super-editor): move caret into preceding block sdt on backspace
luccas-harbour May 27, 2026
2da50a0
feat(super-editor): move caret into following block sdt on delete
luccas-harbour May 27, 2026
5c1cbfe
fix(super-editor): avoid restoring dragged block to its source position
luccas-harbour May 27, 2026
6482205
refactor(layout-engine): share box model between block and inline sdt…
luccas-harbour May 27, 2026
2ec58fb
fix(super-editor): handle empty block sdt navigation
luccas-harbour May 27, 2026
5e6aa89
fix(super-editor): respect inline atoms in sdt navigation
luccas-harbour May 27, 2026
eaffbf7
fix(super-editor): target nearest sdt cursor position
luccas-harbour May 27, 2026
9c228eb
fix(super-editor): skip hidden sdt navigation markers
luccas-harbour May 27, 2026
840a42b
fix(super-editor): keep visible atoms in sdt navigation
luccas-harbour May 27, 2026
a88c66b
fix(super-editor): handle marker-only sdt paragraphs
luccas-harbour May 27, 2026
dd82661
fix(super-editor): skip hidden block sdt markers
luccas-harbour May 27, 2026
25616b3
fix(super-editor): skip hidden metadata sdt markers
luccas-harbour May 27, 2026
209c2c3
fix(super-editor): skip hidden field annotations in sdt navigation
luccas-harbour May 27, 2026
bbaa179
fix(super-editor): handle sdt marker gaps and block atoms
luccas-harbour May 27, 2026
6482915
fix(layout-engine): cap block sdt label width
luccas-harbour May 27, 2026
6a0ebfe
fix(super-editor): ignore empty block sdt key targets
luccas-harbour May 27, 2026
9c3c173
fix(super-editor): target marker-only textblock end
luccas-harbour May 27, 2026
168ab05
fix(super-editor): drop unreachable move fallback
luccas-harbour May 27, 2026
aed0d45
refactor(super-editor): share block sdt navigation helpers
luccas-harbour May 27, 2026
f3fe907
test(super-editor): cover nested block sdt navigation
luccas-harbour May 27, 2026
83b1ba8
fix(super-editor): allow history transactions through sdt lock
luccas-harbour May 27, 2026
25e3d51
fix(super-editor): collapse selection on sdtContentLocked delete
luccas-harbour May 27, 2026
70679ac
feat(super-editor): render placeholder text for empty SDTs
luccas-harbour May 27, 2026
cb7b23d
feat(super-editor): inherit run styles in empty block SDT placeholders
luccas-harbour May 27, 2026
200d3e8
fix(layout-engine): size SDT block labels to content width
luccas-harbour May 27, 2026
ae5a890
fix(layout-engine): use measured width for empty SDT placeholders
luccas-harbour May 27, 2026
dc5f1ad
fix(super-editor): align empty block sdt caret
luccas-harbour May 27, 2026
6f2c40e
fix(layout-engine): hide empty block sdt placeholder
luccas-harbour May 27, 2026
917cb8e
fix(layout-engine): expose block sdt appearance
luccas-harbour May 27, 2026
31e9fc9
fix(super-editor): ignore collapsed inline sdt cut
luccas-harbour May 27, 2026
e18d0ed
fix(layout-engine): hide sdt placeholders in viewing
luccas-harbour May 27, 2026
6cad1e9
fix(layout-engine): hide sdt placeholders in print
luccas-harbour May 27, 2026
813fd30
fix(layout-engine): remeasure sdt placeholders
luccas-harbour May 27, 2026
b7fd891
fix(layout-engine): transform sdt placeholder measure
luccas-harbour May 27, 2026
17d9807
fix(layout-engine): keep remeasured sdt placeholder atomic
luccas-harbour May 27, 2026
d31d247
fix(layout-engine): suppress hidden block sdt chrome
luccas-harbour May 27, 2026
798741c
fix(pm-adapter): preserve vanished block sdt paragraphs
luccas-harbour May 27, 2026
06cce7b
fix(pm-adapter): keep vanished sdt paragraph side effects
luccas-harbour May 27, 2026
c7b2fa1
fix(pm-adapter): trust empty sdt paragraph conversion
luccas-harbour May 27, 2026
bf6b4de
fix(layout-engine): keep sdt placeholder pm range atomic
luccas-harbour May 27, 2026
826a5a1
fix(layout-engine): collapse hidden sdt placeholder text
luccas-harbour May 27, 2026
b2f56c2
fix(pm-adapter): preserve empty sdt bookmark placeholders
luccas-harbour May 27, 2026
27b548c
fix(pm-adapter): preserve comment-only sdt placeholders
luccas-harbour May 27, 2026
78f3719
fix(pm-adapter): preserve permission-only sdt placeholders
luccas-harbour May 27, 2026
50279e2
fix(super-editor): skip empty sdt scan on arrow right
luccas-harbour May 27, 2026
6392bda
fix(layout-engine): show empty SDT placeholder text in viewing and pr…
luccas-harbour May 27, 2026
c361468
Merge pull request #3542 from superdoc-dev/luccas/sdt-deletion-adjust…
caio-pizzol May 27, 2026
b5db967
Merge pull request #3536 from superdoc-dev/luccas/sdt-table-navigation
caio-pizzol May 27, 2026
20fdcea
Merge pull request #3534 from superdoc-dev/luccas/sd-3274-disable-sty…
caio-pizzol May 27, 2026
4518925
Merge pull request #3516 from superdoc-dev/luccas/sd-3116-bug-image-s…
caio-pizzol May 27, 2026
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
19 changes: 14 additions & 5 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,10 @@ export type FlowRunLink = {
history?: boolean;
};

export const EMPTY_SDT_PLACEHOLDER_TEXT = 'Click or tap here to enter text';

export type SdtVisualPlaceholder = 'emptyInlineSdt' | 'emptyBlockSdt';

/**
* Common formatting marks that can be applied to any run type.
* Used by TextRun, TabRun, and other run types that support inline formatting.
Expand Down Expand Up @@ -343,7 +347,7 @@ export type TextRun = RunMarks & {
dataAttrs?: Record<string, string>;
sdt?: SdtMetadata;
/** Layout-only placeholder for visual affordances that do not represent document text. */
visualPlaceholder?: 'emptyInlineSdt';
visualPlaceholder?: SdtVisualPlaceholder;
link?: FlowRunLink;
/** Token annotations for dynamic content (page numbers, etc.). */
token?: 'pageNumber' | 'totalPageCount' | 'pageReference';
Expand Down Expand Up @@ -458,10 +462,10 @@ export type ImageRun = {

/**
* Vertical alignment of image relative to text baseline.
* Currently only 'bottom' is supported (image sits on baseline).
* Future: 'top', 'middle', 'baseline', 'text-top', 'text-bottom'.
* 'top' keeps the image box inside the measured line height; 'bottom'
* preserves legacy baseline alignment for existing callers.
*/
verticalAlign?: 'bottom';
verticalAlign?: 'top' | 'bottom';

/** Absolute ProseMirror position (inclusive) of this image run. */
pmStart?: number;
Expand Down Expand Up @@ -2199,6 +2203,11 @@ export { isResolvedTableItem, isResolvedImageItem, isResolvedDrawingItem } from

// Pure transformations on inline-run shapes (used by pm-adapter, layout-bridge,
// and painter-dom). Located in contracts to avoid reverse stage dependencies.
export { expandRunsForInlineNewlines, isEmptyInlineSdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js';
export {
expandRunsForInlineNewlines,
isEmptyInlineSdtPlaceholderRun,
isEmptySdtPlaceholderRun,
sliceRunsForLine,
} from './run-helpers.js';

export * as Engines from './engines/index.js';
10 changes: 10 additions & 0 deletions packages/layout-engine/contracts/src/pm-range.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FlowBlock, Line, ParagraphBlock, ParagraphMeasure } from './index.js';
import { isEmptySdtPlaceholderRun } from './run-helpers.js';

/**
* Represents a ProseMirror position range for a line or fragment.
Expand Down Expand Up @@ -93,6 +94,15 @@ export function computeLinePmRange(block: FlowBlock, line: Line): LinePmRange {
const runPmStart = coercePmStart(run);
if (runPmStart == null) continue;

if (isEmptySdtPlaceholderRun(run)) {
const runPmEnd = coercePmEnd(run) ?? runPmStart;
if (pmStart == null) {
pmStart = runPmStart;
}
pmEnd = runPmEnd;
continue;
}

if (isAtomicRunKind((run as { kind?: unknown }).kind) || isImageLikeRun(run)) {
const runPmEnd = coercePmEnd(run) ?? runPmStart + 1;
if (pmStart == null) {
Expand Down
15 changes: 14 additions & 1 deletion packages/layout-engine/contracts/src/run-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import type { FlowBlock, Line, ParagraphBlock, Run, TabRun, TextRun, TrackedChangeMeta } from './index.js';
import { expandRunsForInlineNewlines, sliceRunsForLine } from './run-helpers.js';
import { expandRunsForInlineNewlines, isEmptySdtPlaceholderRun, sliceRunsForLine } from './run-helpers.js';

describe('expandRunsForInlineNewlines', () => {
const makeRun = (text: string, pmStart = 0): TextRun => ({
Expand Down Expand Up @@ -153,4 +153,17 @@ describe('sliceRunsForLine', () => {

expect(sliceRunsForLine(block, line)).toEqual([run]);
});

it('recognizes block SDT visual placeholders', () => {
const run: TextRun = {
kind: 'text',
text: '',
fontFamily: 'Arial',
fontSize: 12,
visualPlaceholder: 'emptyBlockSdt',
sdt: { type: 'structuredContent', scope: 'block', id: 'sdt-block-1' },
};

expect(isEmptySdtPlaceholderRun(run)).toBe(true);
});
});
12 changes: 9 additions & 3 deletions packages/layout-engine/contracts/src/run-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@

import type { FlowBlock, Line, Run, TextRun } from './index.js';

export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } {
export function isEmptySdtPlaceholderRun(
run: Run,
): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' | 'emptyBlockSdt' } {
return (
(run.kind === 'text' || run.kind === undefined) &&
'text' in run &&
(run as TextRun).visualPlaceholder === 'emptyInlineSdt'
((run as TextRun).visualPlaceholder === 'emptyInlineSdt' || (run as TextRun).visualPlaceholder === 'emptyBlockSdt')
);
}

export function isEmptyInlineSdtPlaceholderRun(run: Run): run is TextRun & { visualPlaceholder: 'emptyInlineSdt' } {
return isEmptySdtPlaceholderRun(run) && run.visualPlaceholder === 'emptyInlineSdt';
}

/**
* Expands text runs that contain inline newlines into multiple runs.
*
Expand Down Expand Up @@ -90,7 +96,7 @@ export function sliceRunsForLine(block: FlowBlock, line: Line): Run[] {
}

const text = run.text ?? '';
if (isEmptyInlineSdtPlaceholderRun(run)) {
if (isEmptySdtPlaceholderRun(run)) {
result.push(run);
continue;
}
Expand Down
33 changes: 33 additions & 0 deletions packages/layout-engine/layout-bridge/src/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ImageBlock,
DrawingBlock,
ImageDrawing,
ImageRun,
BoxSpacing,
ImageAnchor,
ImageWrap,
Expand Down Expand Up @@ -419,6 +420,12 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock
for (let i = 0; i < a.runs.length; i += 1) {
const runA = a.runs[i];
const runB = b.runs[i];
if (runA.kind === 'image' || runB.kind === 'image') {
if (runA.kind !== 'image' || runB.kind !== 'image') return false;
if (!imageRunsEqual(runA, runB)) return false;
continue;
}

// MathRun: compare textContent (derived from OMML) to detect equation changes
if (runA.kind === 'math' || runB.kind === 'math') {
if (runA.kind !== runB.kind) return false;
Expand Down Expand Up @@ -449,6 +456,32 @@ const paragraphBlocksEqual = (a: FlowBlock & { kind: 'paragraph' }, b: FlowBlock
return true;
};

const imageRunsEqual = (a: ImageRun, b: ImageRun): boolean => {
return (
a.src === b.src &&
a.width === b.width &&
a.height === b.height &&
a.alt === b.alt &&
a.title === b.title &&
a.clipPath === b.clipPath &&
a.distTop === b.distTop &&
a.distBottom === b.distBottom &&
a.distLeft === b.distLeft &&
a.distRight === b.distRight &&
a.verticalAlign === b.verticalAlign &&
a.rotation === b.rotation &&
a.flipH === b.flipH &&
a.flipV === b.flipV &&
a.gain === b.gain &&
a.blacklevel === b.blacklevel &&
a.grayscale === b.grayscale &&
jsonEqual(a.lum, b.lum) &&
jsonEqual(a.hyperlink, b.hyperlink) &&
jsonEqual(a.sdt, b.sdt) &&
shallowRecordEqual(a.dataAttrs, b.dataAttrs)
);
};

const imageBlocksEqual = (a: ImageBlock | ImageDrawing, b: ImageBlock | ImageDrawing): boolean => {
return (
a.src === b.src &&
Expand Down
17 changes: 16 additions & 1 deletion packages/layout-engine/layout-bridge/src/remeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
ParagraphIndent,
LeaderDecoration,
} from '@superdoc/contracts';
import { Engines } from '@superdoc/contracts';
import { EMPTY_SDT_PLACEHOLDER_TEXT, Engines, isEmptySdtPlaceholderRun } from '@superdoc/contracts';
import type { WordParagraphLayoutOutput } from '@superdoc/word-layout';
import {
LIST_MARKER_GAP as _LIST_MARKER_GAP,
Expand Down Expand Up @@ -126,6 +126,10 @@ function fontString(run: Run): string {
* @returns Text content of the run, or empty string for non-text runs
*/
function runText(run: Run): string {
if (isEmptySdtPlaceholderRun(run)) {
return run.sdt?.type === 'structuredContent' && run.sdt.appearance === 'hidden' ? '' : EMPTY_SDT_PLACEHOLDER_TEXT;
}

return 'src' in run ||
run.kind === 'lineBreak' ||
run.kind === 'break' ||
Expand Down Expand Up @@ -1380,6 +1384,17 @@ export function remeasureParagraph(
if (text.length > 0 && isTextRun(run)) {
lineMaxTextFontSize = Math.max(lineMaxTextFontSize, run.fontSize ?? 16);
}
if (isEmptySdtPlaceholderRun(run)) {
const placeholderWidth = text.length > 0 ? measureRunSliceWidth(run, 0, text.length) : 0;
if (width > 0 && width + placeholderWidth > effectiveMaxWidth - WIDTH_FUDGE_PX) {
didBreakInThisLine = true;
break;
}
width += placeholderWidth;
endRun = r;
endChar = text.length > 0 ? text.length : start + 1;
continue;
}
for (let c = start; c < text.length; c += 1) {
const ch = text[c];
if (ch === '\t') {
Expand Down
121 changes: 120 additions & 1 deletion packages/layout-engine/layout-bridge/test/diff.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import type { VectorShapeDrawing } from '@superdoc/contracts';
import type { ImageRun, ParagraphBlock, VectorShapeDrawing } from '@superdoc/contracts';
import { computeDirtyRegions } from '../src/diff';

const block = (id: string, text: string) => ({
Expand All @@ -8,6 +8,19 @@ const block = (id: string, text: string) => ({
runs: [{ text, fontFamily: 'Arial', fontSize: 16 }],
});

const imageRun = (src: string, width: number, height: number): ImageRun => ({
kind: 'image',
src,
width,
height,
});

const paragraphWithRuns = (id: string, runs: ParagraphBlock['runs']) => ({
kind: 'paragraph' as const,
id,
runs,
});

const drawing = (overrides?: Partial<VectorShapeDrawing>): VectorShapeDrawing => ({
kind: 'drawing',
id: 'drawing-0',
Expand Down Expand Up @@ -181,6 +194,112 @@ describe('computeDirtyRegions', () => {
expect(result.firstDirtyIndex).toBe(0);
});

it('detects inline image height changes inside paragraphs', () => {
const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])];
const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 60)])];
const result = computeDirtyRegions(prev, next);
expect(result.firstDirtyIndex).toBe(0);
expect(result.stableBlockIds.has('0-paragraph')).toBe(false);
});

it('detects inline image width changes inside paragraphs', () => {
const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])];
const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 120, 50)])];
const result = computeDirtyRegions(prev, next);
expect(result.firstDirtyIndex).toBe(0);
expect(result.stableBlockIds.has('0-paragraph')).toBe(false);
});

it('treats identical inline image dimensions as stable', () => {
const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])];
const next = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])];
const result = computeDirtyRegions(prev, next);
expect(result.firstDirtyIndex).toBe(next.length);
expect(result.stableBlockIds.has('0-paragraph')).toBe(true);
});

it.each([
['src', { src: 'other.png' }],
['alt', { alt: 'Diagram' }],
['title', { title: 'Title' }],
['clipPath', { clipPath: 'inset(1px)' }],
['distTop', { distTop: 1 }],
['distBottom', { distBottom: 2 }],
['distLeft', { distLeft: 3 }],
['distRight', { distRight: 4 }],
['verticalAlign', { verticalAlign: 'top' as const }],
['rotation', { rotation: 90 }],
['flipH', { flipH: true }],
['flipV', { flipV: true }],
['gain', { gain: '50000' }],
['blacklevel', { blacklevel: '20000' }],
['grayscale', { grayscale: true }],
['lum', { lum: { bright: 10000, contrast: -10000 } }],
['hyperlink', { hyperlink: { url: 'https://example.com', tooltip: 'Example' } }],
['dataAttrs', { dataAttrs: { 'data-example': '1' } }],
] satisfies Array<[string, Partial<ImageRun>]>)(
'detects inline image %s changes inside paragraphs',
(_field, overrides) => {
const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])];
const next = [paragraphWithRuns('0-paragraph', [{ ...imageRun('img.png', 100, 50), ...overrides }])];

const result = computeDirtyRegions(prev, next);

expect(result.firstDirtyIndex).toBe(0);
expect(result.stableBlockIds.has('0-paragraph')).toBe(false);
},
);

it('detects inline image SDT metadata changes inside paragraphs', () => {
const prev = [paragraphWithRuns('0-paragraph', [imageRun('img.png', 100, 50)])];
const next = [
paragraphWithRuns('0-paragraph', [
{
...imageRun('img.png', 100, 50),
sdt: {
type: 'structuredContent',
scope: 'inline',
id: 'image-sdt',
lockMode: 'contentLocked',
},
},
]),
];

const result = computeDirtyRegions(prev, next);

expect(result.firstDirtyIndex).toBe(0);
expect(result.stableBlockIds.has('0-paragraph')).toBe(false);
});

it('detects inline image resize in mixed text and image paragraphs', () => {
const prev = [
paragraphWithRuns('0-paragraph', [
{ text: 'Before ', fontFamily: 'Arial', fontSize: 16 },
imageRun('img.png', 100, 50),
{ text: ' after', fontFamily: 'Arial', fontSize: 16 },
]),
];
const next = [
paragraphWithRuns('0-paragraph', [
{ text: 'Before ', fontFamily: 'Arial', fontSize: 16 },
imageRun('img.png', 100, 60),
{ text: ' after', fontFamily: 'Arial', fontSize: 16 },
]),
];
const result = computeDirtyRegions(prev, next);
expect(result.firstDirtyIndex).toBe(0);
expect(result.stableBlockIds.has('0-paragraph')).toBe(false);
});

it('detects changes to later inline image runs', () => {
const prev = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 40)])];
const next = [paragraphWithRuns('0-paragraph', [imageRun('img1.png', 100, 50), imageRun('img2.png', 80, 60)])];
const result = computeDirtyRegions(prev, next);
expect(result.firstDirtyIndex).toBe(0);
expect(result.stableBlockIds.has('0-paragraph')).toBe(false);
});

it('treats unchanged drawing blocks as stable', () => {
const prev = [drawing()];
const next = [drawing()];
Expand Down
Loading
Loading