Skip to content

Commit 49d4728

Browse files
Update presentation when typing inside table in sdt (#2729)
* fix: update presentation when typing inside table in sdt * chore: add tests * fix: add comments and test * fix: align table cell block cache invalidation with renderer versioning --------- Co-authored-by: Nick Bernal <nick@superdoc.dev>
1 parent 4a01c92 commit 49d4728

5 files changed

Lines changed: 583 additions & 5 deletions

File tree

packages/layout-engine/layout-bridge/src/cache.ts

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type {
2+
DrawingBlock,
23
FlowBlock,
4+
ImageBlock,
35
ImageRun,
6+
ListBlock,
47
TableBlock,
58
ParagraphBlock,
69
ParagraphAttrs,
@@ -78,7 +81,176 @@ const hashParagraphFrame = (frame: ParagraphFrame): string => {
7881
};
7982

8083
/**
81-
* Generates a cache key hash from a block's runs, incorporating content and formatting.
84+
* Returns the content blocks stored in a table cell.
85+
*
86+
* Table cells support both the legacy `paragraph` field and the newer `blocks`
87+
* collection. The cache must normalize those shapes the same way the renderer
88+
* does so both sides respond to the same content changes.
89+
*
90+
* @param cell - The table cell to read blocks from
91+
* @returns The cell's content blocks in render order
92+
*/
93+
const getTableCellBlocks = (cell: TableBlock['rows'][number]['cells'][number]): FlowBlock[] => {
94+
return cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []);
95+
};
96+
97+
/**
98+
* Reads a clip path from either the block itself or its attrs record.
99+
*
100+
* @param block - The image-like block
101+
* @returns The clip path string, or an empty string when not present
102+
*/
103+
const readBlockClipPath = (block: { clipPath?: string; attrs?: Record<string, unknown> }): string => {
104+
if (typeof block.clipPath === 'string') {
105+
return block.clipPath;
106+
}
107+
if (typeof block.attrs?.clipPath === 'string') {
108+
return block.attrs.clipPath;
109+
}
110+
return '';
111+
};
112+
113+
/**
114+
* Creates a compact geometry key for drawing blocks.
115+
*
116+
* @param geometry - The drawing geometry
117+
* @returns A deterministic geometry signature
118+
*/
119+
const hashDrawingGeometry = (geometry: {
120+
width: number;
121+
height: number;
122+
rotation?: number;
123+
flipH?: boolean;
124+
flipV?: boolean;
125+
}): string => {
126+
return [geometry.width, geometry.height, geometry.rotation ?? 0, geometry.flipH ? 1 : 0, geometry.flipV ? 1 : 0].join(
127+
':',
128+
);
129+
};
130+
131+
/**
132+
* Hashes an image-like block using the visual properties that can affect
133+
* measurement and rendering.
134+
*
135+
* @param block - The image or image drawing block
136+
* @returns A deterministic hash fragment for the block
137+
*/
138+
const hashImageLikeBlock = (
139+
block: Pick<
140+
ImageBlock,
141+
'src' | 'width' | 'height' | 'alt' | 'title' | 'objectFit' | 'rotation' | 'flipH' | 'flipV'
142+
> & {
143+
clipPath?: string;
144+
attrs?: Record<string, unknown>;
145+
},
146+
): string => {
147+
return [
148+
block.src.slice(0, 50),
149+
block.width ?? '',
150+
block.height ?? '',
151+
block.alt ?? '',
152+
block.title ?? '',
153+
block.objectFit ?? '',
154+
readBlockClipPath(block),
155+
block.rotation ?? '',
156+
block.flipH ? 1 : 0,
157+
block.flipV ? 1 : 0,
158+
].join(':');
159+
};
160+
161+
/**
162+
* Hashes a list block by folding in each marker and paragraph item.
163+
*
164+
* @param block - The list block to hash
165+
* @returns A deterministic list hash fragment
166+
*/
167+
const hashListBlock = (block: ListBlock): string => {
168+
return block.items.map((item) => `${item.id}:${item.marker.text}:${hashRuns(item.paragraph)}`).join('|');
169+
};
170+
171+
/**
172+
* Hashes a drawing block using the fields that affect its rendered footprint.
173+
*
174+
* @param block - The drawing block to hash
175+
* @returns A deterministic drawing hash fragment
176+
*/
177+
const hashDrawingBlock = (block: DrawingBlock): string => {
178+
if (block.drawingKind === 'image') {
179+
return `drawing:image:${hashImageLikeBlock(block)}`;
180+
}
181+
182+
if (block.drawingKind === 'vectorShape') {
183+
return [
184+
'drawing:vector',
185+
hashDrawingGeometry(block.geometry),
186+
block.shapeKind ?? '',
187+
JSON.stringify(block.fillColor ?? null),
188+
JSON.stringify(block.strokeColor ?? null),
189+
block.strokeWidth ?? '',
190+
JSON.stringify(block.customGeometry ?? null),
191+
JSON.stringify(block.lineEnds ?? null),
192+
JSON.stringify(block.effectExtent ?? null),
193+
JSON.stringify(block.textContent ?? null),
194+
block.textAlign ?? '',
195+
block.textVerticalAlign ?? '',
196+
JSON.stringify(block.textInsets ?? null),
197+
].join(':');
198+
}
199+
200+
if (block.drawingKind === 'shapeGroup') {
201+
return [
202+
'drawing:shapeGroup',
203+
hashDrawingGeometry(block.geometry),
204+
JSON.stringify(block.groupTransform ?? null),
205+
JSON.stringify(block.shapes),
206+
block.size?.width ?? '',
207+
block.size?.height ?? '',
208+
].join(':');
209+
}
210+
211+
return [
212+
'drawing:chart',
213+
hashDrawingGeometry(block.geometry),
214+
block.chartData?.chartType ?? '',
215+
block.chartData?.subType ?? '',
216+
JSON.stringify(block.chartData?.series ?? []),
217+
block.chartRelId ?? '',
218+
].join(':');
219+
};
220+
221+
/**
222+
* Hashes a non-paragraph block embedded inside a table cell.
223+
*
224+
* The renderer versions these blocks through `deriveBlockVersion()`. The
225+
* measure cache must follow the same policy so parent-table repainting and
226+
* remeasurement stay aligned when nested tables, images, or drawings change.
227+
*
228+
* @param block - The non-paragraph cell block
229+
* @returns A deterministic hash fragment for the block
230+
*/
231+
const hashNonParagraphCellBlock = (block: Exclude<FlowBlock, ParagraphBlock>): string => {
232+
if (block.kind === 'table') {
233+
return `table:${hashRuns(block)}`;
234+
}
235+
236+
if (block.kind === 'image') {
237+
return `image:${hashImageLikeBlock(block)}`;
238+
}
239+
240+
if (block.kind === 'drawing') {
241+
return hashDrawingBlock(block);
242+
}
243+
244+
if (block.kind === 'list') {
245+
return `list:${hashListBlock(block)}`;
246+
}
247+
248+
return `${block.kind}:${block.id}`;
249+
};
250+
251+
/**
252+
* Generates a cache key hash from a block's content, incorporating text,
253+
* formatting, and embedded block data.
82254
*
83255
* Text content is preserved verbatim without whitespace normalization. Different
84256
* whitespace (multiple spaces, tabs, leading/trailing spaces) produces different
@@ -138,10 +310,15 @@ const hashRuns = (block: FlowBlock): string => {
138310
}
139311

140312
// Support both new multi-block cells and legacy single paragraph cells
141-
const cellBlocks = cell.blocks ?? (cell.paragraph ? [cell.paragraph] : []);
313+
const cellBlocks = getTableCellBlocks(cell);
142314

143315
for (const cellBlock of cellBlocks) {
144-
const paragraphBlock = cellBlock as ParagraphBlock;
316+
if (cellBlock.kind !== 'paragraph') {
317+
cellHashes.push(`nb:${hashNonParagraphCellBlock(cellBlock)}`);
318+
continue;
319+
}
320+
321+
const paragraphBlock = cellBlock;
145322

146323
// Safety: Check that runs array exists before iterating
147324
if (!paragraphBlock.runs) {

packages/layout-engine/layout-bridge/test/cache.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, it, expect, beforeEach } from 'vitest';
22
import { MeasureCache } from '../src/cache';
3-
import type { FlowBlock, ImageRun, TableBlock, TableCell } from '@superdoc/contracts';
3+
import type {
4+
FlowBlock,
5+
ImageBlock,
6+
ImageRun,
7+
ParagraphBlock,
8+
TableBlock,
9+
TableCell,
10+
VectorShapeDrawing,
11+
} from '@superdoc/contracts';
412

513
const block = (id: string, text: string): FlowBlock => ({
614
kind: 'paragraph',
@@ -21,6 +29,12 @@ const blockWithImage = (id: string, imgRun: ImageRun): FlowBlock => ({
2129
runs: [imgRun],
2230
});
2331

32+
const paragraphBlock = (id: string, text: string): ParagraphBlock => ({
33+
kind: 'paragraph',
34+
id,
35+
runs: [{ text, fontFamily: 'Arial', fontSize: 12 }],
36+
});
37+
2438
/**
2539
* Creates a table block with specified cell content for testing.
2640
* Supports both new multi-block cells and legacy single paragraph cells.
@@ -53,6 +67,43 @@ const tableBlock = (id: string, cellContents: string[][], useMultiBlock = false)
5367
})),
5468
});
5569

70+
/**
71+
* Creates a single-cell table whose content is defined by explicit cell blocks.
72+
*/
73+
const tableWithCellBlocks = (id: string, blocks: FlowBlock[]): TableBlock => ({
74+
kind: 'table',
75+
id,
76+
rows: [
77+
{
78+
id: `${id}-row-0`,
79+
cells: [
80+
{
81+
id: `${id}-cell-0-0`,
82+
blocks,
83+
},
84+
],
85+
},
86+
],
87+
});
88+
89+
const imageBlock = (id: string, src: string, width: number, height: number): ImageBlock => ({
90+
kind: 'image',
91+
id,
92+
src,
93+
width,
94+
height,
95+
});
96+
97+
const vectorShapeBlock = (id: string, width: number, height: number, fillColor: string): VectorShapeDrawing => ({
98+
kind: 'drawing',
99+
id,
100+
drawingKind: 'vectorShape',
101+
geometry: { width, height },
102+
fillColor,
103+
strokeColor: '#000000',
104+
strokeWidth: 1,
105+
});
106+
56107
describe('MeasureCache', () => {
57108
let cache: MeasureCache<{ totalHeight: number }>;
58109

@@ -334,6 +385,63 @@ describe('MeasureCache', () => {
334385
expect(cache.get(table2, 800, 600)).toEqual({ totalHeight: 120 });
335386
});
336387

388+
it('invalidates when nested table content changes inside a cell block', () => {
389+
const nestedTable = (id: string, text: string): TableBlock => ({
390+
kind: 'table',
391+
id,
392+
rows: [
393+
{
394+
id: `${id}-row-0`,
395+
cells: [
396+
{
397+
id: `${id}-cell-0-0`,
398+
blocks: [
399+
{
400+
kind: 'paragraph',
401+
id: `${id}-para-0`,
402+
runs: [{ text, fontFamily: 'Arial', fontSize: 12 }],
403+
},
404+
],
405+
},
406+
],
407+
},
408+
],
409+
});
410+
411+
const hostParagraph = paragraphBlock('parent-cell-0-0-para', 'Host');
412+
const parentTable1 = tableWithCellBlocks('parent-table', [
413+
hostParagraph,
414+
nestedTable('nested-table', 'Nested A'),
415+
]);
416+
const parentTable2 = tableWithCellBlocks('parent-table', [
417+
hostParagraph,
418+
nestedTable('nested-table', 'Nested B'),
419+
]);
420+
421+
cache.set(parentTable1, 800, 600, { totalHeight: 130 });
422+
expect(cache.get(parentTable2, 800, 600)).toBeUndefined();
423+
});
424+
425+
it.each([
426+
{
427+
name: 'image blocks',
428+
initialBlock: imageBlock('cell-image', 'data:image/png;base64,AAA', 48, 32),
429+
updatedBlock: imageBlock('cell-image', 'data:image/png;base64,BBB', 96, 64),
430+
},
431+
{
432+
name: 'drawing blocks',
433+
initialBlock: vectorShapeBlock('cell-drawing', 48, 32, '#ff0000'),
434+
updatedBlock: vectorShapeBlock('cell-drawing', 96, 64, '#00ff00'),
435+
},
436+
])('invalidates when $name change inside a cell block', ({ initialBlock, updatedBlock }) => {
437+
const hostParagraph = paragraphBlock('parent-cell-0-0-para', 'Host');
438+
const parentTable1 = tableWithCellBlocks('parent-table', [hostParagraph, initialBlock]);
439+
const parentTable2 = tableWithCellBlocks('parent-table', [hostParagraph, updatedBlock]);
440+
441+
cache.set(parentTable1, 800, 600, { totalHeight: 130 });
442+
expect(cache.get(parentTable2, 800, 600)).toBeUndefined();
443+
});
444+
337445
it('handles legacy single paragraph cells', () => {
338446
const table1 = tableBlock(
339447
'table-1',

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7635,6 +7635,12 @@ const deriveBlockVersion = (block: FlowBlock): string => {
76357635
hash = hashString(hash, getRunStringProp(run, 'vertAlign'));
76367636
hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift'));
76377637
}
7638+
} else if (cellBlock?.kind) {
7639+
// Non-paragraph cell blocks participate in the parent table version
7640+
// through their own block-level signatures. layout-bridge/cache.ts
7641+
// mirrors this policy so repaint and remeasure stay aligned for
7642+
// nested tables, images, drawings, and other embedded cell content.
7643+
hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock));
76387644
}
76397645
}
76407646
}

0 commit comments

Comments
 (0)