Skip to content

Commit 98af5b6

Browse files
committed
feat(tables): allow resizing table rows
1 parent 18040d6 commit 98af5b6

8 files changed

Lines changed: 907 additions & 39 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,8 @@ export type TableRowBoundary = {
16181618
index: number;
16191619
y: number;
16201620
height: number;
1621+
minHeight: number;
1622+
resizable: boolean;
16211623
};
16221624

16231625
export type TableFragmentMetadata = {

packages/layout-engine/layout-engine/src/layout-table.test.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ describe('layoutTableBlock', () => {
314314
});
315315
});
316316

317-
it('should not include rowBoundaries metadata (Phase 1 scope)', () => {
317+
it('should include rowBoundaries metadata', () => {
318318
const block = createMockTableBlock(3);
319319
const measure = createMockTableMeasure([100, 150], [20, 25, 30]);
320320

@@ -336,7 +336,123 @@ describe('layoutTableBlock', () => {
336336
});
337337

338338
const fragment = fragments[0];
339-
expect(fragment.metadata?.rowBoundaries).toBeUndefined();
339+
const rowBoundaries = fragment.metadata?.rowBoundaries;
340+
expect(rowBoundaries).toBeDefined();
341+
expect(rowBoundaries).toHaveLength(3);
342+
343+
// Each boundary should have required fields
344+
expect(rowBoundaries![0]).toMatchObject({
345+
index: 0,
346+
y: 0,
347+
height: 20,
348+
resizable: true,
349+
});
350+
expect(rowBoundaries![1]).toMatchObject({
351+
index: 1,
352+
y: 20,
353+
height: 25,
354+
resizable: true,
355+
});
356+
expect(rowBoundaries![2]).toMatchObject({
357+
index: 2,
358+
y: 45,
359+
height: 30,
360+
resizable: true,
361+
});
362+
363+
// minHeight should be at least ROW_MIN_HEIGHT_PX (10)
364+
rowBoundaries!.forEach((rb) => {
365+
expect(rb.minHeight).toBeGreaterThanOrEqual(10);
366+
});
367+
});
368+
369+
it('uses partial row height in rowBoundaries and marks it non-resizable', () => {
370+
const block = createMockTableBlock(1, [{ cantSplit: false }]);
371+
const measure = createMockTableMeasure([100], [200], [[10, 10, 10, 10, 10, 10]]);
372+
373+
const fragments: TableFragment[] = [];
374+
let cursorY = 0;
375+
let contentBottom = 40; // Force a partial-row first fragment
376+
377+
layoutTableBlock({
378+
block,
379+
measure,
380+
columnWidth: 100,
381+
ensurePage: () => ({
382+
page: { fragments },
383+
columnIndex: 0,
384+
cursorY,
385+
contentBottom,
386+
}),
387+
advanceColumn: () => {
388+
cursorY = 0;
389+
contentBottom = 300;
390+
return {
391+
page: { fragments },
392+
columnIndex: 0,
393+
cursorY,
394+
contentBottom,
395+
};
396+
},
397+
columnX: () => 0,
398+
});
399+
400+
const partialFragment = fragments.find((fragment) => fragment.partialRow != null);
401+
expect(partialFragment).toBeDefined();
402+
expect(partialFragment!.partialRow).toBeTruthy();
403+
404+
const rowBoundaries = partialFragment!.metadata?.rowBoundaries;
405+
expect(rowBoundaries).toHaveLength(1);
406+
expect(rowBoundaries![0].height).toBe(partialFragment!.partialRow!.partialHeight);
407+
expect(rowBoundaries![0].resizable).toBe(false);
408+
expect(rowBoundaries![0].minHeight).toBe(partialFragment!.partialRow!.partialHeight);
409+
});
410+
411+
it('marks repeated header row boundaries as non-resizable on continuation fragments', () => {
412+
const block = createMockTableBlock(4, [
413+
{ repeatHeader: true },
414+
{ repeatHeader: false },
415+
{ repeatHeader: false },
416+
{ repeatHeader: false },
417+
]);
418+
const measure = createMockTableMeasure([100], [20, 20, 20, 20]);
419+
420+
const fragments: TableFragment[] = [];
421+
let cursorY = 0;
422+
let contentBottom = 60; // First page fits 3 rows; continuation should repeat header
423+
424+
layoutTableBlock({
425+
block,
426+
measure,
427+
columnWidth: 100,
428+
ensurePage: () => ({
429+
page: { fragments },
430+
columnIndex: 0,
431+
cursorY,
432+
contentBottom,
433+
}),
434+
advanceColumn: () => {
435+
cursorY = 0;
436+
contentBottom = 60;
437+
return {
438+
page: { fragments },
439+
columnIndex: 0,
440+
cursorY,
441+
contentBottom,
442+
};
443+
},
444+
columnX: () => 0,
445+
});
446+
447+
const continuation = fragments.find((fragment) => (fragment.repeatHeaderCount ?? 0) > 0);
448+
expect(continuation).toBeDefined();
449+
450+
const rowBoundaries = continuation!.metadata?.rowBoundaries;
451+
expect(rowBoundaries).toBeDefined();
452+
expect(rowBoundaries!.length).toBeGreaterThanOrEqual(2);
453+
expect(rowBoundaries![0].index).toBe(0);
454+
expect(rowBoundaries![0].resizable).toBe(false);
455+
expect(rowBoundaries![1].resizable).toBe(true);
340456
});
341457
});
342458

packages/layout-engine/layout-engine/src/layout-table.ts

Lines changed: 144 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
TableMeasure,
44
TableFragment,
55
TableColumnBoundary,
6+
TableRowBoundary,
67
TableFragmentMetadata,
78
TableRowMeasure,
89
TableRow,
@@ -195,6 +196,7 @@ export function rescaleColumnWidths(
195196

196197
const COLUMN_MIN_WIDTH_PX = 25;
197198
const COLUMN_MAX_WIDTH_PX = 200;
199+
const ROW_MIN_HEIGHT_PX = 10;
198200

199201
/**
200202
* Calculate minimum width for a table column from its measured width.
@@ -261,6 +263,98 @@ function generateColumnBoundaries(measure: TableMeasure, effectiveWidths?: numbe
261263
return boundaries;
262264
}
263265

266+
/**
267+
* Generate row boundary metadata for interactive table row resizing.
268+
*
269+
* Creates metadata that enables the overlay component to position horizontal
270+
* resize handles and enforce minimum height constraints during drag operations.
271+
*
272+
* Boundaries are marked non-resizable when:
273+
* - A cell in the row above has a rowSpan that crosses the boundary
274+
* - The row is a repeated header on a continuation fragment (resize originals only)
275+
*
276+
* @param measure - Table measurement containing row heights
277+
* @param block - Table block (used for rowSpan inspection)
278+
* @param fromRow - Starting body row index (inclusive)
279+
* @param toRow - Ending body row index (exclusive)
280+
* @param repeatHeaderCount - Number of repeated header rows on this fragment
281+
* @param cellSpacingPx - Cell spacing in pixels (border-spacing)
282+
* @returns Array of row boundary metadata
283+
*/
284+
function generateRowBoundaries(
285+
measure: TableMeasure,
286+
block: TableBlock,
287+
fromRow: number,
288+
toRow: number,
289+
repeatHeaderCount: number,
290+
cellSpacingPx: number,
291+
partialRow?: PartialRowInfo | null,
292+
): TableRowBoundary[] {
293+
const boundaries: TableRowBoundary[] = [];
294+
295+
// Build ordered list of rendered rows: headers first, then body rows
296+
const renderedRows: Array<{ rowIndex: number; isRepeatedHeader: boolean }> = [];
297+
if (repeatHeaderCount > 0) {
298+
for (let r = 0; r < repeatHeaderCount && r < measure.rows.length; r++) {
299+
renderedRows.push({ rowIndex: r, isRepeatedHeader: fromRow > 0 });
300+
}
301+
}
302+
for (let r = fromRow; r < toRow && r < measure.rows.length; r++) {
303+
renderedRows.push({ rowIndex: r, isRepeatedHeader: false });
304+
}
305+
306+
// Build a set of ABSOLUTE row boundaries blocked by rowspan cells.
307+
// A boundary after absolute row N is blocked if any cell starting at row N
308+
// has a rowSpan that extends beyond row N.
309+
const blockedBoundaries = new Set<number>();
310+
for (let ri = 0; ri < renderedRows.length; ri++) {
311+
const { rowIndex } = renderedRows[ri];
312+
const rowMeasure = measure.rows[rowIndex];
313+
if (!rowMeasure) continue;
314+
315+
for (const cellMeasure of rowMeasure.cells) {
316+
const rowSpan = cellMeasure.rowSpan ?? 1;
317+
if (rowSpan <= 1) continue;
318+
319+
// This cell spans from rowIndex to rowIndex + rowSpan - 1.
320+
// Block absolute boundaries between the start row and end row.
321+
// Example: rowIndex=2, rowSpan=3 blocks boundaries after rows 2 and 3.
322+
for (let boundaryRow = rowIndex; boundaryRow < rowIndex + rowSpan - 1; boundaryRow++) {
323+
blockedBoundaries.add(boundaryRow);
324+
}
325+
}
326+
}
327+
328+
let yPosition = cellSpacingPx;
329+
for (let ri = 0; ri < renderedRows.length; ri++) {
330+
const { rowIndex, isRepeatedHeader } = renderedRows[ri];
331+
const rowMeasure = measure.rows[rowIndex];
332+
if (!rowMeasure) continue;
333+
334+
const isPartial = partialRow?.rowIndex === rowIndex;
335+
const height = isPartial ? partialRow.partialHeight : rowMeasure.height;
336+
const contentHeight = getRowContentHeight(block.rows[rowIndex], rowMeasure);
337+
const minHeight = isPartial ? Math.max(1, height) : Math.max(ROW_MIN_HEIGHT_PX, contentHeight);
338+
339+
// A boundary is resizable unless:
340+
// 1. It's a repeated header on a continuation fragment
341+
// 2. A rowspan crosses this boundary (blockedBoundaries)
342+
const resizable = !isRepeatedHeader && !isPartial && !blockedBoundaries.has(rowIndex);
343+
344+
boundaries.push({
345+
index: rowIndex,
346+
y: yPosition,
347+
height,
348+
minHeight,
349+
resizable,
350+
});
351+
352+
yPosition += height + cellSpacingPx;
353+
}
354+
355+
return boundaries;
356+
}
357+
264358
/**
265359
* Count contiguous header rows from the beginning of the table.
266360
*
@@ -1097,23 +1191,29 @@ function findSplitPoint(
10971191
/**
10981192
* Generate fragment metadata for a table fragment.
10991193
*
1100-
* Currently only includes column boundaries; row boundaries omitted to reduce DOM overhead.
1194+
* Includes column boundaries and row boundaries for interactive resizing.
11011195
*
11021196
* @param measure - Table measurements
1103-
* @param fromRow - Starting row (unused but kept for future row boundaries)
1104-
* @param toRow - Ending row (unused but kept for future row boundaries)
1105-
* @param repeatHeaderCount - Header count (unused but kept for future metadata)
1197+
* @param block - Table block (used for rowSpan and content height inspection)
1198+
* @param fromRow - Starting body row index (inclusive)
1199+
* @param toRow - Ending body row index (exclusive)
1200+
* @param repeatHeaderCount - Number of repeated header rows on this fragment
1201+
* @param effectiveWidths - Optional rescaled column widths
11061202
* @returns Table fragment metadata
11071203
*/
11081204
function generateFragmentMetadata(
11091205
measure: TableMeasure,
1110-
_fromRow: number,
1111-
_toRow: number,
1112-
_repeatHeaderCount: number,
1206+
block: TableBlock,
1207+
fromRow: number,
1208+
toRow: number,
1209+
repeatHeaderCount: number,
11131210
effectiveWidths?: number[],
1211+
partialRow?: PartialRowInfo | null,
11141212
): TableFragmentMetadata {
1213+
const cellSpacingPx = measure.cellSpacingPx ?? 0;
11151214
return {
11161215
columnBoundaries: generateColumnBoundaries(measure, effectiveWidths),
1216+
rowBoundaries: generateRowBoundaries(measure, block, fromRow, toRow, repeatHeaderCount, cellSpacingPx, partialRow),
11171217
coordinateSystem: 'fragment',
11181218
};
11191219
}
@@ -1138,10 +1238,14 @@ function layoutMonolithicTable(context: TableLayoutContext): void {
11381238
const { x, width } = resolveTableFrame(baseX, context.columnWidth, baseWidth, context.block.attrs);
11391239
const columnWidths = rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width);
11401240

1141-
const metadata: TableFragmentMetadata = {
1142-
columnBoundaries: generateColumnBoundaries(context.measure, columnWidths),
1143-
coordinateSystem: 'fragment',
1144-
};
1241+
const metadata = generateFragmentMetadata(
1242+
context.measure,
1243+
context.block,
1244+
0,
1245+
context.block.rows.length,
1246+
0,
1247+
columnWidths,
1248+
);
11451249

11461250
const fragment: TableFragment = {
11471251
kind: 'table',
@@ -1288,10 +1392,7 @@ export function layoutTableBlock({
12881392
const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs);
12891393
const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width);
12901394

1291-
const metadata: TableFragmentMetadata = {
1292-
columnBoundaries: generateColumnBoundaries(measure, columnWidths),
1293-
coordinateSystem: 'fragment',
1294-
};
1395+
const metadata = generateFragmentMetadata(measure, block, 0, 0, 0, columnWidths);
12951396

12961397
const fragment: TableFragment = {
12971398
kind: 'table',
@@ -1408,7 +1509,15 @@ export function layoutTableBlock({
14081509
continuesOnNext: hasRemainingLinesAfterContinuation || rowIndex + 1 < block.rows.length,
14091510
repeatHeaderCount,
14101511
partialRow: continuationPartialRow,
1411-
metadata: generateFragmentMetadata(measure, rowIndex, rowIndex + 1, repeatHeaderCount, scaledWidths),
1512+
metadata: generateFragmentMetadata(
1513+
measure,
1514+
block,
1515+
rowIndex,
1516+
rowIndex + 1,
1517+
repeatHeaderCount,
1518+
scaledWidths,
1519+
continuationPartialRow,
1520+
),
14121521
columnWidths: scaledWidths,
14131522
};
14141523

@@ -1486,7 +1595,15 @@ export function layoutTableBlock({
14861595
continuesOnNext: !forcedPartialRow.isLastPart || forcedEndRow < block.rows.length,
14871596
repeatHeaderCount,
14881597
partialRow: forcedPartialRow,
1489-
metadata: generateFragmentMetadata(measure, bodyStartRow, forcedEndRow, repeatHeaderCount, scaledWidths),
1598+
metadata: generateFragmentMetadata(
1599+
measure,
1600+
block,
1601+
bodyStartRow,
1602+
forcedEndRow,
1603+
repeatHeaderCount,
1604+
scaledWidths,
1605+
forcedPartialRow,
1606+
),
14901607
columnWidths: scaledWidths,
14911608
};
14921609

@@ -1530,7 +1647,15 @@ export function layoutTableBlock({
15301647
continuesOnNext: endRow < block.rows.length || (partialRow ? !partialRow.isLastPart : false),
15311648
repeatHeaderCount,
15321649
partialRow: partialRow || undefined,
1533-
metadata: generateFragmentMetadata(measure, bodyStartRow, endRow, repeatHeaderCount, scaledWidths),
1650+
metadata: generateFragmentMetadata(
1651+
measure,
1652+
block,
1653+
bodyStartRow,
1654+
endRow,
1655+
repeatHeaderCount,
1656+
scaledWidths,
1657+
partialRow,
1658+
),
15341659
columnWidths: scaledWidths,
15351660
};
15361661

@@ -1568,10 +1693,7 @@ export function createAnchoredTableFragment(
15681693
x: number,
15691694
y: number,
15701695
): TableFragment {
1571-
const metadata: TableFragmentMetadata = {
1572-
columnBoundaries: generateColumnBoundaries(measure),
1573-
coordinateSystem: 'fragment',
1574-
};
1696+
const metadata = generateFragmentMetadata(measure, block, 0, block.rows.length, 0);
15751697

15761698
const fragment: TableFragment = {
15771699
kind: 'table',

0 commit comments

Comments
 (0)