From 98af5b653b568b3b944dc14eade37a3f85de8505 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 28 Feb 2026 12:02:08 -0800 Subject: [PATCH 1/2] feat(tables): allow resizing table rows --- packages/layout-engine/contracts/src/index.ts | 2 + .../layout-engine/src/layout-table.test.ts | 120 ++++++- .../layout-engine/src/layout-table.ts | 166 +++++++-- .../dom/src/table/renderTableFragment.test.ts | 99 +++++- .../dom/src/table/renderTableFragment.ts | 21 +- .../src/components/SuperEditor.vue | 52 ++- .../src/components/TableResizeOverlay.vue | 326 +++++++++++++++++- .../behavior/tests/tables/row-resize.spec.ts | 160 +++++++++ 8 files changed, 907 insertions(+), 39 deletions(-) create mode 100644 tests/behavior/tests/tables/row-resize.spec.ts diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f3e8379c0a..eb677d367f 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -1618,6 +1618,8 @@ export type TableRowBoundary = { index: number; y: number; height: number; + minHeight: number; + resizable: boolean; }; export type TableFragmentMetadata = { diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 195bb4567d..99a2cc791a 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -314,7 +314,7 @@ describe('layoutTableBlock', () => { }); }); - it('should not include rowBoundaries metadata (Phase 1 scope)', () => { + it('should include rowBoundaries metadata', () => { const block = createMockTableBlock(3); const measure = createMockTableMeasure([100, 150], [20, 25, 30]); @@ -336,7 +336,123 @@ describe('layoutTableBlock', () => { }); const fragment = fragments[0]; - expect(fragment.metadata?.rowBoundaries).toBeUndefined(); + const rowBoundaries = fragment.metadata?.rowBoundaries; + expect(rowBoundaries).toBeDefined(); + expect(rowBoundaries).toHaveLength(3); + + // Each boundary should have required fields + expect(rowBoundaries![0]).toMatchObject({ + index: 0, + y: 0, + height: 20, + resizable: true, + }); + expect(rowBoundaries![1]).toMatchObject({ + index: 1, + y: 20, + height: 25, + resizable: true, + }); + expect(rowBoundaries![2]).toMatchObject({ + index: 2, + y: 45, + height: 30, + resizable: true, + }); + + // minHeight should be at least ROW_MIN_HEIGHT_PX (10) + rowBoundaries!.forEach((rb) => { + expect(rb.minHeight).toBeGreaterThanOrEqual(10); + }); + }); + + it('uses partial row height in rowBoundaries and marks it non-resizable', () => { + const block = createMockTableBlock(1, [{ cantSplit: false }]); + const measure = createMockTableMeasure([100], [200], [[10, 10, 10, 10, 10, 10]]); + + const fragments: TableFragment[] = []; + let cursorY = 0; + let contentBottom = 40; // Force a partial-row first fragment + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: { fragments }, + columnIndex: 0, + cursorY, + contentBottom, + }), + advanceColumn: () => { + cursorY = 0; + contentBottom = 300; + return { + page: { fragments }, + columnIndex: 0, + cursorY, + contentBottom, + }; + }, + columnX: () => 0, + }); + + const partialFragment = fragments.find((fragment) => fragment.partialRow != null); + expect(partialFragment).toBeDefined(); + expect(partialFragment!.partialRow).toBeTruthy(); + + const rowBoundaries = partialFragment!.metadata?.rowBoundaries; + expect(rowBoundaries).toHaveLength(1); + expect(rowBoundaries![0].height).toBe(partialFragment!.partialRow!.partialHeight); + expect(rowBoundaries![0].resizable).toBe(false); + expect(rowBoundaries![0].minHeight).toBe(partialFragment!.partialRow!.partialHeight); + }); + + it('marks repeated header row boundaries as non-resizable on continuation fragments', () => { + const block = createMockTableBlock(4, [ + { repeatHeader: true }, + { repeatHeader: false }, + { repeatHeader: false }, + { repeatHeader: false }, + ]); + const measure = createMockTableMeasure([100], [20, 20, 20, 20]); + + const fragments: TableFragment[] = []; + let cursorY = 0; + let contentBottom = 60; // First page fits 3 rows; continuation should repeat header + + layoutTableBlock({ + block, + measure, + columnWidth: 100, + ensurePage: () => ({ + page: { fragments }, + columnIndex: 0, + cursorY, + contentBottom, + }), + advanceColumn: () => { + cursorY = 0; + contentBottom = 60; + return { + page: { fragments }, + columnIndex: 0, + cursorY, + contentBottom, + }; + }, + columnX: () => 0, + }); + + const continuation = fragments.find((fragment) => (fragment.repeatHeaderCount ?? 0) > 0); + expect(continuation).toBeDefined(); + + const rowBoundaries = continuation!.metadata?.rowBoundaries; + expect(rowBoundaries).toBeDefined(); + expect(rowBoundaries!.length).toBeGreaterThanOrEqual(2); + expect(rowBoundaries![0].index).toBe(0); + expect(rowBoundaries![0].resizable).toBe(false); + expect(rowBoundaries![1].resizable).toBe(true); }); }); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 09f7dea525..a1194b5438 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -3,6 +3,7 @@ import type { TableMeasure, TableFragment, TableColumnBoundary, + TableRowBoundary, TableFragmentMetadata, TableRowMeasure, TableRow, @@ -195,6 +196,7 @@ export function rescaleColumnWidths( const COLUMN_MIN_WIDTH_PX = 25; const COLUMN_MAX_WIDTH_PX = 200; +const ROW_MIN_HEIGHT_PX = 10; /** * Calculate minimum width for a table column from its measured width. @@ -261,6 +263,98 @@ function generateColumnBoundaries(measure: TableMeasure, effectiveWidths?: numbe return boundaries; } +/** + * Generate row boundary metadata for interactive table row resizing. + * + * Creates metadata that enables the overlay component to position horizontal + * resize handles and enforce minimum height constraints during drag operations. + * + * Boundaries are marked non-resizable when: + * - A cell in the row above has a rowSpan that crosses the boundary + * - The row is a repeated header on a continuation fragment (resize originals only) + * + * @param measure - Table measurement containing row heights + * @param block - Table block (used for rowSpan inspection) + * @param fromRow - Starting body row index (inclusive) + * @param toRow - Ending body row index (exclusive) + * @param repeatHeaderCount - Number of repeated header rows on this fragment + * @param cellSpacingPx - Cell spacing in pixels (border-spacing) + * @returns Array of row boundary metadata + */ +function generateRowBoundaries( + measure: TableMeasure, + block: TableBlock, + fromRow: number, + toRow: number, + repeatHeaderCount: number, + cellSpacingPx: number, + partialRow?: PartialRowInfo | null, +): TableRowBoundary[] { + const boundaries: TableRowBoundary[] = []; + + // Build ordered list of rendered rows: headers first, then body rows + const renderedRows: Array<{ rowIndex: number; isRepeatedHeader: boolean }> = []; + if (repeatHeaderCount > 0) { + for (let r = 0; r < repeatHeaderCount && r < measure.rows.length; r++) { + renderedRows.push({ rowIndex: r, isRepeatedHeader: fromRow > 0 }); + } + } + for (let r = fromRow; r < toRow && r < measure.rows.length; r++) { + renderedRows.push({ rowIndex: r, isRepeatedHeader: false }); + } + + // Build a set of ABSOLUTE row boundaries blocked by rowspan cells. + // A boundary after absolute row N is blocked if any cell starting at row N + // has a rowSpan that extends beyond row N. + const blockedBoundaries = new Set(); + for (let ri = 0; ri < renderedRows.length; ri++) { + const { rowIndex } = renderedRows[ri]; + const rowMeasure = measure.rows[rowIndex]; + if (!rowMeasure) continue; + + for (const cellMeasure of rowMeasure.cells) { + const rowSpan = cellMeasure.rowSpan ?? 1; + if (rowSpan <= 1) continue; + + // This cell spans from rowIndex to rowIndex + rowSpan - 1. + // Block absolute boundaries between the start row and end row. + // Example: rowIndex=2, rowSpan=3 blocks boundaries after rows 2 and 3. + for (let boundaryRow = rowIndex; boundaryRow < rowIndex + rowSpan - 1; boundaryRow++) { + blockedBoundaries.add(boundaryRow); + } + } + } + + let yPosition = cellSpacingPx; + for (let ri = 0; ri < renderedRows.length; ri++) { + const { rowIndex, isRepeatedHeader } = renderedRows[ri]; + const rowMeasure = measure.rows[rowIndex]; + if (!rowMeasure) continue; + + const isPartial = partialRow?.rowIndex === rowIndex; + const height = isPartial ? partialRow.partialHeight : rowMeasure.height; + const contentHeight = getRowContentHeight(block.rows[rowIndex], rowMeasure); + const minHeight = isPartial ? Math.max(1, height) : Math.max(ROW_MIN_HEIGHT_PX, contentHeight); + + // A boundary is resizable unless: + // 1. It's a repeated header on a continuation fragment + // 2. A rowspan crosses this boundary (blockedBoundaries) + const resizable = !isRepeatedHeader && !isPartial && !blockedBoundaries.has(rowIndex); + + boundaries.push({ + index: rowIndex, + y: yPosition, + height, + minHeight, + resizable, + }); + + yPosition += height + cellSpacingPx; + } + + return boundaries; +} + /** * Count contiguous header rows from the beginning of the table. * @@ -1097,23 +1191,29 @@ function findSplitPoint( /** * Generate fragment metadata for a table fragment. * - * Currently only includes column boundaries; row boundaries omitted to reduce DOM overhead. + * Includes column boundaries and row boundaries for interactive resizing. * * @param measure - Table measurements - * @param fromRow - Starting row (unused but kept for future row boundaries) - * @param toRow - Ending row (unused but kept for future row boundaries) - * @param repeatHeaderCount - Header count (unused but kept for future metadata) + * @param block - Table block (used for rowSpan and content height inspection) + * @param fromRow - Starting body row index (inclusive) + * @param toRow - Ending body row index (exclusive) + * @param repeatHeaderCount - Number of repeated header rows on this fragment + * @param effectiveWidths - Optional rescaled column widths * @returns Table fragment metadata */ function generateFragmentMetadata( measure: TableMeasure, - _fromRow: number, - _toRow: number, - _repeatHeaderCount: number, + block: TableBlock, + fromRow: number, + toRow: number, + repeatHeaderCount: number, effectiveWidths?: number[], + partialRow?: PartialRowInfo | null, ): TableFragmentMetadata { + const cellSpacingPx = measure.cellSpacingPx ?? 0; return { columnBoundaries: generateColumnBoundaries(measure, effectiveWidths), + rowBoundaries: generateRowBoundaries(measure, block, fromRow, toRow, repeatHeaderCount, cellSpacingPx, partialRow), coordinateSystem: 'fragment', }; } @@ -1138,10 +1238,14 @@ function layoutMonolithicTable(context: TableLayoutContext): void { const { x, width } = resolveTableFrame(baseX, context.columnWidth, baseWidth, context.block.attrs); const columnWidths = rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width); - const metadata: TableFragmentMetadata = { - columnBoundaries: generateColumnBoundaries(context.measure, columnWidths), - coordinateSystem: 'fragment', - }; + const metadata = generateFragmentMetadata( + context.measure, + context.block, + 0, + context.block.rows.length, + 0, + columnWidths, + ); const fragment: TableFragment = { kind: 'table', @@ -1288,10 +1392,7 @@ export function layoutTableBlock({ const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); - const metadata: TableFragmentMetadata = { - columnBoundaries: generateColumnBoundaries(measure, columnWidths), - coordinateSystem: 'fragment', - }; + const metadata = generateFragmentMetadata(measure, block, 0, 0, 0, columnWidths); const fragment: TableFragment = { kind: 'table', @@ -1408,7 +1509,15 @@ export function layoutTableBlock({ continuesOnNext: hasRemainingLinesAfterContinuation || rowIndex + 1 < block.rows.length, repeatHeaderCount, partialRow: continuationPartialRow, - metadata: generateFragmentMetadata(measure, rowIndex, rowIndex + 1, repeatHeaderCount, scaledWidths), + metadata: generateFragmentMetadata( + measure, + block, + rowIndex, + rowIndex + 1, + repeatHeaderCount, + scaledWidths, + continuationPartialRow, + ), columnWidths: scaledWidths, }; @@ -1486,7 +1595,15 @@ export function layoutTableBlock({ continuesOnNext: !forcedPartialRow.isLastPart || forcedEndRow < block.rows.length, repeatHeaderCount, partialRow: forcedPartialRow, - metadata: generateFragmentMetadata(measure, bodyStartRow, forcedEndRow, repeatHeaderCount, scaledWidths), + metadata: generateFragmentMetadata( + measure, + block, + bodyStartRow, + forcedEndRow, + repeatHeaderCount, + scaledWidths, + forcedPartialRow, + ), columnWidths: scaledWidths, }; @@ -1530,7 +1647,15 @@ export function layoutTableBlock({ continuesOnNext: endRow < block.rows.length || (partialRow ? !partialRow.isLastPart : false), repeatHeaderCount, partialRow: partialRow || undefined, - metadata: generateFragmentMetadata(measure, bodyStartRow, endRow, repeatHeaderCount, scaledWidths), + metadata: generateFragmentMetadata( + measure, + block, + bodyStartRow, + endRow, + repeatHeaderCount, + scaledWidths, + partialRow, + ), columnWidths: scaledWidths, }; @@ -1568,10 +1693,7 @@ export function createAnchoredTableFragment( x: number, y: number, ): TableFragment { - const metadata: TableFragmentMetadata = { - columnBoundaries: generateColumnBoundaries(measure), - coordinateSystem: 'fragment', - }; + const metadata = generateFragmentMetadata(measure, block, 0, block.rows.length, 0); const fragment: TableFragment = { kind: 'table', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index c5d9a27426..99391a442d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -10,6 +10,7 @@ import type { TableMeasure, BlockId, TableColumnBoundary, + TableRowBoundary, ParagraphBlock, } from '@superdoc/contracts'; import type { BlockLookup, FragmentRenderContext } from '../renderer.js'; @@ -66,7 +67,10 @@ function createTestTableMeasure(): TableMeasure { /** * Create a test table fragment with metadata */ -function createTestTableFragment(columnBoundaries?: TableColumnBoundary[]): TableFragment { +function createTestTableFragment( + columnBoundaries?: TableColumnBoundary[], + rowBoundaries?: TableRowBoundary[], +): TableFragment { return { kind: 'table', blockId: 'test-table-1' as BlockId, @@ -79,6 +83,7 @@ function createTestTableFragment(columnBoundaries?: TableColumnBoundary[]): Tabl metadata: columnBoundaries ? { columnBoundaries, + ...(rowBoundaries ? { rowBoundaries } : {}), coordinateSystem: 'fragment', } : undefined, @@ -141,6 +146,98 @@ describe('renderTableFragment', () => { }); }); + it('should embed row boundary metadata when rowBoundaries are present', () => { + const block = createTestTableBlock(); + const measure = createTestTableMeasure(); + const columnBoundaries: TableColumnBoundary[] = [{ index: 0, x: 0, width: 100, minWidth: 25, resizable: true }]; + const rowBoundaries: TableRowBoundary[] = [{ index: 0, y: 0, height: 20, minHeight: 10, resizable: true }]; + const fragment = createTestTableFragment(columnBoundaries, rowBoundaries); + + blockLookup.set(fragment.blockId, { block, measure }); + + const element = renderTableFragment({ + doc, + fragment, + context, + blockLookup, + renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), + applyFragmentFrame: () => { + // Intentionally empty for test mock + }, + applySdtDataset: () => { + // Intentionally empty for test mock + }, + applyStyles: () => { + // Intentionally empty for test mock + }, + }); + + const metadataAttr = element.getAttribute('data-table-boundaries'); + expect(metadataAttr).toBeTruthy(); + + const parsed = JSON.parse(metadataAttr!); + expect(parsed.rows).toHaveLength(1); + expect(parsed.rows[0]).toMatchObject({ + i: 0, + y: 0, + h: 20, + min: 10, + r: 1, + }); + }); + + it('should apply contentTop offset to row boundary y positions', () => { + const block = createTestTableBlock(); + block.attrs = { + borderCollapse: 'separate', + borders: { + top: { size: 2, color: '#000000', val: 'single' }, + right: { size: 2, color: '#000000', val: 'single' }, + bottom: { size: 2, color: '#000000', val: 'single' }, + left: { size: 2, color: '#000000', val: 'single' }, + }, + }; + + const measure = createTestTableMeasure(); + measure.tableBorderWidths = { top: 2, right: 2, bottom: 2, left: 2 }; + + const columnBoundaries: TableColumnBoundary[] = [{ index: 0, x: 0, width: 100, minWidth: 25, resizable: true }]; + const rowBoundaries: TableRowBoundary[] = [{ index: 0, y: 3, height: 20, minHeight: 10, resizable: false }]; + const fragment = createTestTableFragment(columnBoundaries, rowBoundaries); + + blockLookup.set(fragment.blockId, { block, measure }); + + const element = renderTableFragment({ + doc, + fragment, + context, + blockLookup, + renderLine: (_block, _line, _ctx, _lineIndex, _isLastLine) => doc.createElement('div'), + applyFragmentFrame: () => { + // Intentionally empty for test mock + }, + applySdtDataset: () => { + // Intentionally empty for test mock + }, + applyStyles: () => { + // Intentionally empty for test mock + }, + }); + + const metadataAttr = element.getAttribute('data-table-boundaries'); + expect(metadataAttr).toBeTruthy(); + + const parsed = JSON.parse(metadataAttr!); + expect(parsed.rows).toHaveLength(1); + expect(parsed.rows[0]).toMatchObject({ + i: 0, + y: 5, // row y (3) + contentTop (2) + h: 20, + min: 10, + r: 0, + }); + }); + it('should produce valid JSON serialization', () => { const block = createTestTableBlock(); const measure = createTestTableMeasure(); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index cedb22e76e..16417095ed 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -94,10 +94,15 @@ export type TableRenderDependencies = { * "columns": [ * {"i": 0, "x": 0, "w": 100, "min": 25, "r": 1}, * {"i": 1, "x": 100, "w": 150, "min": 30, "r": 1} + * ], + * "rows": [ + * {"i": 0, "y": 0, "h": 30, "min": 10, "r": 1}, + * {"i": 1, "y": 34, "h": 25, "min": 10, "r": 1} * ] * } * ``` - * Where: i=index, x=position, w=width, min=minWidth, r=resizable(0/1) + * Where for columns: i=index, x=position, w=width, min=minWidth, r=resizable(0/1) + * Where for rows: i=index, y=position, h=height, min=minHeight, r=resizable(0/1) * * **Edge Cases:** * - Missing metadata: Element created without data-table-boundaries attribute @@ -297,7 +302,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement rowY += height + cellSpacingPx; } - const metadata = { + const metadata: Record = { columns: fragment.metadata.columnBoundaries.map((boundary) => ({ i: boundary.index, x: boundary.x + contentLeft, @@ -315,6 +320,18 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement ), }; + // Add row boundary metadata for interactive row resizing + // Where: i=index, y=position, h=height, min=minHeight, r=resizable(0/1) + if (fragment.metadata.rowBoundaries && fragment.metadata.rowBoundaries.length > 0) { + metadata.rows = fragment.metadata.rowBoundaries.map((rb) => ({ + i: rb.index, + y: rb.y + contentTop, + h: rb.height, + min: rb.minHeight, + r: rb.resizable ? 1 : 0, + })); + } + container.setAttribute('data-table-boundaries', JSON.stringify(metadata)); } diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index d577756e42..d1a5a8483b 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -498,9 +498,55 @@ const isNearColumnBoundary = (event, tableElement) => { } }; +/** + * Check if mouse position is near any row boundary in the table. + * Returns true if within threshold of a resizable row boundary bottom edge. + * + * @param {MouseEvent} event - The mouse event containing clientX and clientY coordinates + * @param {HTMLElement} tableElement - The table DOM element with data-table-boundaries attribute + * @returns {boolean} True if the mouse is near a row boundary, false otherwise + */ +const isNearRowBoundary = (event, tableElement) => { + if (!event || typeof event.clientX !== 'number' || typeof event.clientY !== 'number') { + return false; + } + if (!tableElement || !(tableElement instanceof HTMLElement)) { + return false; + } + + const boundariesAttr = tableElement.getAttribute('data-table-boundaries'); + if (!boundariesAttr) return false; + + try { + const metadata = JSON.parse(boundariesAttr); + if (!metadata.rows || !Array.isArray(metadata.rows)) return false; + + const zoom = getEditorZoom(); + const tableRect = tableElement.getBoundingClientRect(); + const mouseYScreen = event.clientY - tableRect.top; + + for (const row of metadata.rows) { + if (!row || typeof row.y !== 'number' || typeof row.h !== 'number') continue; + // Only check resizable boundaries + if (row.r !== 1) continue; + + // The bottom edge of this row boundary in screen space + const boundaryYScreen = (row.y + row.h) * zoom; + + if (Math.abs(mouseYScreen - boundaryYScreen) <= TABLE_RESIZE_HOVER_THRESHOLD) { + return true; + } + } + + return false; + } catch { + return false; + } +}; + /** * Update table resize overlay visibility based on mouse position. - * Shows overlay only when hovering near column boundaries, not anywhere in the table. + * Shows overlay only when hovering near column or row boundaries, not anywhere in the table. * Throttled to run at most once per TABLE_RESIZE_THROTTLE_MS milliseconds. * * @param {MouseEvent} event - The mouse event containing target and coordinates @@ -526,8 +572,8 @@ const updateTableResizeOverlay = (event) => { } if (target.classList?.contains('superdoc-table-fragment') && target.hasAttribute('data-table-boundaries')) { - // Only show overlay if mouse is near a column boundary - if (isNearColumnBoundary(event, target)) { + // Show overlay if mouse is near a column or row boundary + if (isNearColumnBoundary(event, target) || isNearRowBoundary(event, target)) { tableResizeState.visible = true; tableResizeState.tableElement = target; } else { diff --git a/packages/super-editor/src/components/TableResizeOverlay.vue b/packages/super-editor/src/components/TableResizeOverlay.vue index 0ecbffbb97..4fdc5a6a9f 100644 --- a/packages/super-editor/src/components/TableResizeOverlay.vue +++ b/packages/super-editor/src/components/TableResizeOverlay.vue @@ -27,8 +27,24 @@ > - +
+ + +
+ + +
@@ -132,6 +148,19 @@ const getZoom = () => { */ const dragState = ref(null); +/** + * Row drag state tracking (mutually exclusive with column drag) + * @type {import('vue').Ref<{ + * rowIndex: number, + * rowBoundaryIndex: number, + * initialY: number, + * initialHeight: number, + * minHeight: number, + * constrainedDelta: number + * } | null>} + */ +const rowDragState = ref(null); + /** * Flag to track forced cleanup (overlay hidden during drag) */ @@ -155,6 +184,9 @@ const forcedCleanup = ref(false); */ const RESIZE_HANDLE_WIDTH_PX = 9; +/** Height of the row resize handle hit area in pixels (screen space) */ +const RESIZE_HANDLE_HEIGHT_PX = 9; + /** * Horizontal offset to center the resize handle on the boundary line. * @@ -238,18 +270,22 @@ const overlayStyle = computed(() => { // During any drag operation, use a very large overlay to ensure smooth mouse tracking // This prevents issues when the mouse moves beyond the original table bounds let overlayWidth = rect.width; + let overlayHeight = rect.height; + const isDragging = dragState.value || rowDragState.value; if (dragState.value) { - // Set a fixed large width during drag to avoid reactive resize triggering re-renders overlayWidth = Math.max(rect.width + DRAG_OVERLAY_EXTENSION_PX, MIN_DRAG_OVERLAY_WIDTH_PX); } + if (rowDragState.value) { + overlayHeight = Math.max(rect.height + DRAG_OVERLAY_EXTENSION_PX, MIN_DRAG_OVERLAY_WIDTH_PX); + } return { position: 'absolute', left: `${rect.left}px`, top: `${rect.top}px`, width: `${overlayWidth}px`, - height: `${rect.height}px`, - pointerEvents: dragState.value ? 'auto' : 'none', + height: `${overlayHeight}px`, + pointerEvents: isDragging ? 'auto' : 'none', zIndex: 10, }; }); @@ -338,6 +374,23 @@ const resizableBoundaries = computed(() => { return boundaries; }); +/** + * Filter to only resizable row boundaries. + * Adds a computed handleY (bottom edge of the row) for positioning. + */ +const resizableRowBoundaries = computed(() => { + if (!tableMetadata.value?.rows || !Array.isArray(tableMetadata.value.rows)) { + return []; + } + + return tableMetadata.value.rows + .filter((row) => row.r === 1) + .map((row) => ({ + ...row, + handleY: row.y + row.h, // bottom edge of the row + })); +}); + /** * Retrieves vertical segments for a column boundary where resize handles should appear. * @@ -490,6 +543,51 @@ const guidelineStyle = computed(() => { }; }); +/** + * Generates CSS styles for positioning a row resize handle at the bottom edge of a row. + * @param {{handleY: number}} rowBoundary - Row boundary with handleY in layout coords + * @returns {Record} CSS style object + */ +function getRowHandleStyle(rowBoundary) { + const zoom = getZoom(); + const scaledY = rowBoundary.handleY * zoom; + + return { + position: 'absolute', + left: '0', + top: `${scaledY}px`, + width: '100%', + height: `${RESIZE_HANDLE_HEIGHT_PX}px`, + transform: `translateY(-${RESIZE_HANDLE_OFFSET_PX}px)`, + cursor: 'row-resize', + pointerEvents: 'auto', + }; +} + +/** + * Style for the row drag guideline (horizontal line) + */ +const rowGuidelineStyle = computed(() => { + if (!rowDragState.value || !tableMetadata.value) return { display: 'none' }; + + const rowBoundary = resizableRowBoundaries.value[rowDragState.value.rowBoundaryIndex]; + if (!rowBoundary) return { display: 'none' }; + + const zoom = getZoom(); + const newY = (rowBoundary.handleY + rowDragState.value.constrainedDelta) * zoom; + + return { + position: 'absolute', + left: '0', + top: `${newY}px`, + width: '100%', + height: '2px', + backgroundColor: '#4A90E2', + pointerEvents: 'none', + zIndex: 20, + }; +}); + /** * Parses table metadata from the `data-table-boundaries` attribute on the table element. * @@ -572,7 +670,24 @@ function parseTableMetadata() { // Each segment has {c: columnIndex, y: yPosition, h: height} const segments = Array.isArray(parsed.segments) ? parsed.segments : undefined; - tableMetadata.value = { columns: validatedColumns, segments }; + // Extract row boundaries if present (for row resizing) + const rows = Array.isArray(parsed.rows) + ? parsed.rows.filter( + (row) => + typeof row === 'object' && + Number.isFinite(row.i) && + row.i >= 0 && + Number.isFinite(row.y) && + row.y >= 0 && + Number.isFinite(row.h) && + row.h > 0 && + Number.isFinite(row.min) && + row.min > 0 && + (row.r === 0 || row.r === 1), + ) + : undefined; + + tableMetadata.value = { columns: validatedColumns, segments, rows }; } catch (error) { tableMetadata.value = null; emit('resize-error', { @@ -966,6 +1081,164 @@ function updateCellColwidths(tr, tableNode, tablePos, affectedColumns, newWidths }); } +// ============================================================================ +// Row Resize Drag Handlers +// ============================================================================ + +/** + * Handle mouse down on a row resize handle + * @param {MouseEvent} event - Mouse event + * @param {number} rowBoundaryIndex - Index in the resizableRowBoundaries array + */ +function onRowHandleMouseDown(event, rowBoundaryIndex) { + event.preventDefault(); + event.stopPropagation(); + + const rowBoundary = resizableRowBoundaries.value[rowBoundaryIndex]; + if (!rowBoundary) return; + + rowDragState.value = { + rowIndex: rowBoundary.i, + rowBoundaryIndex, + initialY: event.clientY, + initialHeight: rowBoundary.h, + minHeight: rowBoundary.min, + constrainedDelta: 0, + }; + + if (!props.editor?.view?.dom) { + emit('resize-error', { error: 'Editor view not available' }); + rowDragState.value = null; + return; + } + props.editor.view.dom.style.pointerEvents = 'none'; + + document.addEventListener('mousemove', onRowDocumentMouseMove); + document.addEventListener('mouseup', onRowDocumentMouseUp); + + emit('resize-start', { rowIndex: rowBoundary.i }); +} + +// Throttled row mouse move handler +const rowMouseMoveThrottle = throttle((event) => { + if (isUnmounted || !rowDragState.value) return; + + const zoom = getZoom(); + const screenDelta = event.clientY - rowDragState.value.initialY; + const delta = screenDelta / zoom; + + // Constrain: can't shrink below minHeight, no upper limit + const minDelta = -(rowDragState.value.initialHeight - rowDragState.value.minHeight); + const constrainedDelta = Math.max(minDelta, delta); + + rowDragState.value.constrainedDelta = constrainedDelta; + + emit('resize-move', { rowIndex: rowDragState.value.rowIndex, delta: constrainedDelta }); +}, THROTTLE_INTERVAL_MS); + +const onRowDocumentMouseMove = rowMouseMoveThrottle.throttled; + +/** + * Handle mouse up to end row drag + */ +function onRowDocumentMouseUp() { + if (!rowDragState.value) return; + + const finalDelta = rowDragState.value.constrainedDelta; + const rowIndex = rowDragState.value.rowIndex; + const newHeight = rowDragState.value.initialHeight + finalDelta; + + document.removeEventListener('mousemove', onRowDocumentMouseMove); + document.removeEventListener('mouseup', onRowDocumentMouseUp); + + if (props.editor?.view?.dom) { + props.editor.view.dom.style.pointerEvents = 'auto'; + } + + if (!forcedCleanup.value && Math.abs(finalDelta) > MIN_RESIZE_DELTA_PX) { + dispatchRowResizeTransaction(rowIndex, newHeight); + emit('resize-end', { rowIndex, newHeight, delta: finalDelta }); + } + + rowDragState.value = null; +} + +/** + * Dispatch ProseMirror transaction to update row height. + * Updates both rowHeight (pixels) and tableRowProperties.rowHeight (twips) attributes. + * + * @param {number} rowIndex - Index of the resized row + * @param {number} newHeightPx - New row height in pixels + */ +function dispatchRowResizeTransaction(rowIndex, newHeightPx) { + if (!props.editor?.view || !props.tableElement) return; + + try { + const { state, dispatch } = props.editor.view; + const tr = state.tr; + + const tablePos = findTablePosition(state, props.tableElement); + if (tablePos === null) { + emit('resize-error', { rowIndex, error: 'Table position not found' }); + return; + } + + const tableNode = state.doc.nodeAt(tablePos); + if (!tableNode || tableNode.type.name !== 'table') { + emit('resize-error', { rowIndex, error: 'Invalid table node' }); + return; + } + + // Walk table children to find the tableRow at rowIndex + let currentRowIdx = 0; + let rowPos = null; + let rowNode = null; + + tableNode.forEach((child, offset) => { + if (child.type.name === 'tableRow' && currentRowIdx === rowIndex) { + rowPos = tablePos + 1 + offset; + rowNode = child; + } + if (child.type.name === 'tableRow') { + currentRowIdx++; + } + }); + + if (rowPos === null || !rowNode) { + emit('resize-error', { rowIndex, error: 'Row not found at index' }); + return; + } + + const heightTwips = pixelsToTwips(newHeightPx); + const existingRowProps = rowNode.attrs.tableRowProperties || {}; + + const newAttrs = { + ...rowNode.attrs, + rowHeight: newHeightPx, + tableRowProperties: { + ...existingRowProps, + rowHeight: { value: heightTwips, rule: 'atLeast' }, + }, + }; + + tr.setNodeMarkup(rowPos, null, newAttrs); + dispatch(tr); + + // Invalidate measure cache + const blockId = props.tableElement?.getAttribute('data-sd-block-id'); + if (blockId && blockId.trim()) { + measureCache.invalidate([blockId]); + } + + emit('resize-success', { rowIndex, newHeight: newHeightPx }); + } catch (error) { + emit('resize-error', { + rowIndex, + error: error instanceof Error ? error.message : String(error), + }); + } +} + /** * Watch for changes to table element and reparse metadata */ @@ -1001,6 +1274,11 @@ watch( onDocumentMouseUp(new MouseEvent('mouseup')); forcedCleanup.value = false; } + if (rowDragState.value) { + forcedCleanup.value = true; + onRowDocumentMouseUp(); + forcedCleanup.value = false; + } } }, ); @@ -1020,16 +1298,22 @@ onBeforeUnmount(() => { // Cancel any pending throttled calls to prevent memory leaks mouseMoveThrottle.cancel(); + rowMouseMoveThrottle.cancel(); stopOverlayTracking(); if (dragState.value) { document.removeEventListener('mousemove', onDocumentMouseMove); document.removeEventListener('mouseup', onDocumentMouseUp); + } - // Re-enable PM pointer events - if (props.editor?.view?.dom) { - props.editor.view.dom.style.pointerEvents = 'auto'; - } + if (rowDragState.value) { + document.removeEventListener('mousemove', onRowDocumentMouseMove); + document.removeEventListener('mouseup', onRowDocumentMouseUp); + } + + // Re-enable PM pointer events + if ((dragState.value || rowDragState.value) && props.editor?.view?.dom) { + props.editor.view.dom.style.pointerEvents = 'auto'; } window.removeEventListener('scroll', updateOverlayRect, true); @@ -1077,6 +1361,30 @@ onBeforeUnmount(() => { transform: translateX(-1px); } +.resize-handle--row { + cursor: row-resize; +} + +.resize-handle--row::before { + left: 0; + top: 50%; + width: 100%; + height: 2px; + transform: translateY(-1px); +} + +.resize-handle--row:hover::before { + height: 3px; + width: 100%; + transform: translateY(-1.5px); +} + +.resize-handle--row.resize-handle--active::before { + height: 2px; + width: 100%; + transform: translateY(-1px); +} + .resize-guideline { position: absolute; background-color: #4a90e2; diff --git a/tests/behavior/tests/tables/row-resize.spec.ts b/tests/behavior/tests/tables/row-resize.spec.ts new file mode 100644 index 0000000000..f3bdf83b0a --- /dev/null +++ b/tests/behavior/tests/tables/row-resize.spec.ts @@ -0,0 +1,160 @@ +import { test, expect } from '../../fixtures/superdoc.js'; +import type { Page, Locator } from '@playwright/test'; + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +/** + * Hover near a row boundary's bottom edge to trigger the resize overlay. + * Reads the `rows` array from `data-table-boundaries` and positions the + * mouse at the bottom edge (y + h) of the specified row index. + */ +async function hoverRowBoundary(page: Page, rowIndex: number) { + const pos = await page.evaluate((ri) => { + const frag = document.querySelector('.superdoc-table-fragment[data-table-boundaries]'); + if (!frag) throw new Error('No table fragment with boundaries found'); + const meta = JSON.parse(frag.getAttribute('data-table-boundaries')!); + if (!meta.rows) throw new Error('No row boundaries in metadata'); + const row = meta.rows.find((r: any) => r.i === ri); + if (!row) throw new Error(`Row boundary ${ri} not found`); + const rect = frag.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, // center horizontally + y: rect.top + row.y + row.h, // bottom edge of the row + }; + }, rowIndex); + + await page.mouse.move(pos.x, pos.y); +} + +/** + * Drag a resize handle vertically by deltaY pixels. + * Uses incremental moves with 20ms gaps so the overlay's throttled handler (16ms) fires. + */ +async function dragRowHandle(page: Page, handle: Locator, deltaY: number) { + const box = await handle.boundingBox(); + if (!box) throw new Error('Row resize handle not visible'); + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + + await page.mouse.move(x, y); + await page.mouse.down(); + for (let i = 1; i <= 10; i++) { + await page.mouse.move(x, y + (deltaY * i) / 10); + await page.waitForTimeout(20); + } + await page.mouse.up(); +} + +/** + * Read the attrs of the Nth tableRow node in the first table. + */ +async function getRowAttrs(page: Page, rowIndex: number) { + return page.evaluate((ri) => { + const doc = (window as any).editor.state.doc; + let idx = 0; + let result: any = null; + doc.descendants((node: any) => { + if (result) return false; + if (node.type.name === 'tableRow') { + if (idx === ri) { + result = node.attrs; + return false; + } + idx++; + } + }); + return result; + }, rowIndex); +} + +test('resize a row by dragging its bottom boundary', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 3, cols: 3, withHeaderRow: false }); + await superdoc.waitForStable(); + + await superdoc.type('Row 0'); + await superdoc.press('Tab'); + await superdoc.type('Cell'); + await superdoc.press('Tab'); + await superdoc.type('Cell'); + await superdoc.waitForStable(); + await superdoc.snapshot('table before row resize'); + + // No explicit rowHeight before resize + const attrsBefore = await getRowAttrs(superdoc.page, 0); + expect(attrsBefore?.rowHeight).toBeFalsy(); + + // Hover the first row boundary to show the resize overlay + await hoverRowBoundary(superdoc.page, 0); + await superdoc.waitForStable(); + + const handle = superdoc.page.locator('.resize-handle--row').first(); + await expect(handle).toBeAttached({ timeout: 5000 }); + await superdoc.snapshot('row resize handle visible'); + + // Drag the row boundary down by 40px + await dragRowHandle(superdoc.page, handle, 40); + await superdoc.waitForStable(); + await superdoc.snapshot('after row resize'); + + // After resize, rowHeight should be set on the tableRow node + const attrsAfter = await getRowAttrs(superdoc.page, 0); + expect(attrsAfter?.rowHeight).toBeGreaterThan(0); + + // tableRowProperties.rowHeight should have twips value with 'atLeast' rule + const rowHeightProp = attrsAfter?.tableRowProperties?.rowHeight; + expect(rowHeightProp).toBeDefined(); + expect(rowHeightProp.value).toBeGreaterThan(0); + expect(rowHeightProp.rule).toBe('atLeast'); +}); + +test('row boundary is not resizable at rowspan-merged rows', async ({ superdoc }) => { + await superdoc.executeCommand('insertTable', { rows: 3, cols: 2, withHeaderRow: false }); + await superdoc.waitForStable(); + + // Fill cells with labels: A1, B1, A2, B2, A3, B3 + const labels = ['A1', 'B1', 'A2', 'B2', 'A3', 'B3']; + for (let i = 0; i < labels.length; i++) { + await superdoc.type(labels[i]); + if (i < labels.length - 1) await superdoc.press('Tab'); + } + await superdoc.waitForStable(); + + // Select A1 and A2, then merge to create a rowspan=2 cell + const fromLine = superdoc.page.locator('.superdoc-line').filter({ hasText: 'A1' }).first(); + const toLine = superdoc.page.locator('.superdoc-line').filter({ hasText: 'A2' }).first(); + const fromBox = await fromLine.boundingBox(); + const toBox = await toLine.boundingBox(); + if (!fromBox || !toBox) throw new Error('Could not resolve cell bounds'); + + await superdoc.page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2); + await superdoc.page.mouse.down(); + await superdoc.page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2); + await superdoc.page.mouse.up(); + await superdoc.waitForStable(); + + await superdoc.executeCommand('mergeCells'); + await superdoc.waitForStable(); + await superdoc.snapshot('table with rowspan merge'); + + // The boundary between row 0 and row 1 should be marked non-resizable (r: 0) + // because the merged cell spans across it. + const boundaryInfo = await superdoc.page.evaluate(() => { + const frag = document.querySelector('.superdoc-table-fragment[data-table-boundaries]'); + if (!frag) return null; + const meta = JSON.parse(frag.getAttribute('data-table-boundaries')!); + if (!meta.rows) return null; + return meta.rows.map((r: any) => ({ index: r.i, resizable: r.r })); + }); + + expect(boundaryInfo).toBeDefined(); + + // Row 0 boundary should be blocked by the rowspan (r: 0) + const row0 = boundaryInfo!.find((r: any) => r.index === 0); + expect(row0).toBeDefined(); + expect(row0!.resizable).toBe(0); + + // Row 1 (last row, below the merged cell) should be resizable (r: 1) + const row1 = boundaryInfo!.find((r: any) => r.index === 1); + expect(row1).toBeDefined(); + expect(row1!.resizable).toBe(1); +}); From fdea3d0579c9dbeff90af5fde5b1fe58ee29a705 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Sat, 28 Feb 2026 12:14:22 -0800 Subject: [PATCH 2/2] chore: additional fixes --- .../layout-engine/src/layout-table.test.ts | 61 +++++++++++++++++++ .../layout-engine/src/layout-table.ts | 22 ++++--- .../src/components/TableResizeOverlay.vue | 2 +- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/layout-engine/src/layout-table.test.ts b/packages/layout-engine/layout-engine/src/layout-table.test.ts index 99a2cc791a..85c03c4fdf 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.test.ts @@ -454,6 +454,67 @@ describe('layoutTableBlock', () => { expect(rowBoundaries![0].resizable).toBe(false); expect(rowBoundaries![1].resizable).toBe(true); }); + + it('marks row boundaries as non-resizable when a rowspan from a prior fragment crosses them', () => { + // 5 rows, 2 columns. First cell in row 0 has rowSpan=4, covering rows 0-3. + // When the table splits so a continuation fragment renders rows 2-4, + // the boundary after row 2 must be blocked because the span from row 0 + // still extends through it (the span covers rows 0,1,2,3). + // The boundary after row 3 (end of span) and row 4 should be resizable. + const block = createMockTableBlock(5); + const measure = createMockTableMeasure([100, 100], [30, 30, 30, 30, 30]); + + // Inject rowSpan=4 on the first cell of row 0 + (measure.rows[0].cells[0] as any).rowSpan = 4; + + const fragments: TableFragment[] = []; + let cursorY = 0; + let contentBottom = 65; // Fits rows 0-1 (30+30=60 < 65), forces split before row 2 + + layoutTableBlock({ + block, + measure, + columnWidth: 200, + ensurePage: () => ({ + page: { fragments }, + columnIndex: 0, + cursorY, + contentBottom, + }), + advanceColumn: () => { + cursorY = 0; + contentBottom = 200; // continuation page has room for remaining rows + return { + page: { fragments }, + columnIndex: 0, + cursorY, + contentBottom, + }; + }, + columnX: () => 0, + }); + + // Collect all row boundaries across continuation fragments (fromRow >= 2) + const continuationFragments = fragments.filter((f) => f.fromRow >= 2); + expect(continuationFragments.length).toBeGreaterThan(0); + + const allRowBoundaries = continuationFragments.flatMap((f) => f.metadata?.rowBoundaries ?? []); + + // Row 2 boundary should be blocked (rowSpan from row 0 extends through row 3) + const row2 = allRowBoundaries.find((rb) => rb.index === 2); + expect(row2).toBeDefined(); + expect(row2!.resizable).toBe(false); + + // Row 3 is the last row of the span — its bottom boundary is NOT blocked + const row3 = allRowBoundaries.find((rb) => rb.index === 3); + expect(row3).toBeDefined(); + expect(row3!.resizable).toBe(true); + + // Row 4 is entirely outside the span (may be in a later fragment) + const row4 = allRowBoundaries.find((rb) => rb.index === 4); + expect(row4).toBeDefined(); + expect(row4!.resizable).toBe(true); + }); }); describe('cellSpacing', () => { diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index a1194b5438..251b2b4512 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -303,23 +303,25 @@ function generateRowBoundaries( renderedRows.push({ rowIndex: r, isRepeatedHeader: false }); } - // Build a set of ABSOLUTE row boundaries blocked by rowspan cells. - // A boundary after absolute row N is blocked if any cell starting at row N - // has a rowSpan that extends beyond row N. + // Build a set of ABSOLUTE row indices whose bottom boundary is blocked by rowspan cells. + // A boundary after absolute row N is blocked if any cell's rowSpan crosses it. + // + // We must scan ALL table rows, not just renderedRows, because a rowspan that + // starts before fromRow can extend into this fragment's rendered range. + // Example: row 1 has rowSpan=4, fragment renders rows 3-5. The boundary after + // row 3 is blocked because the span from row 1 crosses it. const blockedBoundaries = new Set(); - for (let ri = 0; ri < renderedRows.length; ri++) { - const { rowIndex } = renderedRows[ri]; - const rowMeasure = measure.rows[rowIndex]; + for (let r = 0; r < measure.rows.length; r++) { + const rowMeasure = measure.rows[r]; if (!rowMeasure) continue; for (const cellMeasure of rowMeasure.cells) { const rowSpan = cellMeasure.rowSpan ?? 1; if (rowSpan <= 1) continue; - // This cell spans from rowIndex to rowIndex + rowSpan - 1. - // Block absolute boundaries between the start row and end row. - // Example: rowIndex=2, rowSpan=3 blocks boundaries after rows 2 and 3. - for (let boundaryRow = rowIndex; boundaryRow < rowIndex + rowSpan - 1; boundaryRow++) { + // This cell spans from row r to r + rowSpan - 1. + // Block boundaries after rows r through r + rowSpan - 2. + for (let boundaryRow = r; boundaryRow < r + rowSpan - 1; boundaryRow++) { blockedBoundaries.add(boundaryRow); } } diff --git a/packages/super-editor/src/components/TableResizeOverlay.vue b/packages/super-editor/src/components/TableResizeOverlay.vue index 4fdc5a6a9f..911a9880dd 100644 --- a/packages/super-editor/src/components/TableResizeOverlay.vue +++ b/packages/super-editor/src/components/TableResizeOverlay.vue @@ -33,7 +33,7 @@