Operand

do no harem.

gram: docs

> ./packages/core/src/layout-painter/renderTable.ts

/**
* Table Renderer
*
* Renders table fragments to DOM. Handles:
* - Multi-row tables split across pages
* - Cell content (paragraphs within cells)
* - Column widths and cell spans
* - Basic cell styling (borders, backgrounds)
*/
import type {
TableFragment,
TableBlock,
TableMeasure,
TableCell,
TableCellMeasure,
ParagraphBlock,
ParagraphMeasure,
ParagraphFragment,
} from '../layout-engine/types';
import type { RenderContext } from './renderPage';
import { renderFloatingImagesLayer } from './floatingImageLayer';
import { floatingImageIsBehindDoc, floatingImageWrapsText } from './floatingImageFlow';
import { renderParagraphFragment } from './renderParagraph';
import { measureParagraph, type FloatingImageZone } from '../layout-bridge/measuring';
import { resolveCellGrid } from '../layout-bridge/tableWidthUtils';
import { extractCellFloatingImages } from './renderTableCellFloating';
import {
applyBorder,
buildRowYPositions,
isVisibleBorder,
makeCutBorder,
} from './renderTableBorders';
import type { RevisionInfo } from '../types/content/trackedChange';
/**
* Apply tracked-change classes + data attrs to a painted row/cell. The
* sidebar reads `data-revision-id` / `data-revision-author` to anchor
* cards; the CSS in `prosemirror/editor.css` keys on the two classes.
*/
function applyRevisionAttrs(
el: HTMLElement,
scope: 'row' | 'cell',
kind: 'ins' | 'del' | 'merge',
info: RevisionInfo
): void {
el.classList.add(`ep-revision-${scope}`, `ep-revision-${kind}`);
el.dataset.revisionId = String(info.revisionId);
el.dataset.revisionAuthor = info.author;
if (info.date) el.dataset.revisionDate = info.date;
}
/**
* CSS class names for table elements
*/
export const TABLE_CLASS_NAMES = {
table: 'layout-table',
row: 'layout-table-row',
cell: 'layout-table-cell',
cellContent: 'layout-table-cell-content',
resizeHandle: 'layout-table-resize-handle',
rowResizeHandle: 'layout-table-row-resize-handle',
tableEdgeHandleBottom: 'layout-table-edge-handle-bottom',
tableEdgeHandleRight: 'layout-table-edge-handle-right',
};
/**
* Options for rendering a table fragment
*/
export interface RenderTableFragmentOptions {
document?: Document;
}
/**
* Render cell content (paragraphs and nested tables)
*/
function renderCellContent(
cell: TableCell,
cellMeasure: TableCellMeasure,
context: RenderContext,
doc: Document
): HTMLElement {
const contentEl = doc.createElement('div');
contentEl.className = TABLE_CLASS_NAMES.cellContent;
contentEl.style.position = 'relative';
// Cell uses border-box sizing, so content width must subtract padding.
const padLeft = cell.padding?.left ?? 7;
const padRight = cell.padding?.right ?? 7;
const contentWidth = Math.max(0, cellMeasure.width - padLeft - padRight);
contentEl.style.width = `${contentWidth}px`;
// Extract floating images from cell paragraphs
const cellFloatingImages = extractCellFloatingImages(cell, cellMeasure, contentWidth);
// Build floating zones for measurement and render floating layer
let floatingZones: FloatingImageZone[] | undefined;
if (cellFloatingImages.length > 0) {
floatingZones = cellFloatingImages.filter(floatingImageWrapsText).map((img) => {
const rectRight = img.x + img.width + img.distRight;
const rectTop = img.y - img.distTop;
const rectBottom = img.y + img.height + img.distBottom;
let leftMargin = 0;
let rightMargin = 0;
// Use wrapText to determine which side text flows on (same as rectsToFloatingZones in renderPage.ts)
const wt = img.wrapText ?? 'bothSides';
if (wt === 'right') {
// Text flows on RIGHT only -> image blocks the left side
leftMargin = rectRight;
} else if (wt === 'left') {
// Text flows on LEFT only -> image blocks the right side
rightMargin = contentWidth - (img.x - img.distLeft);
} else {
// bothSides / largest: use image position to determine which side it blocks
if (img.side === 'left') {
leftMargin = rectRight;
} else {
rightMargin = contentWidth - (img.x - img.distLeft);
}
}
return { leftMargin, rightMargin, topY: rectTop, bottomY: rectBottom };
});
const behindFloatingImages = cellFloatingImages.filter(floatingImageIsBehindDoc);
if (behindFloatingImages.length > 0) {
contentEl.appendChild(
renderFloatingImagesLayer(behindFloatingImages, doc, {
layerClass: 'layout-cell-floating-images-layer',
itemClass: 'layout-cell-floating-image',
sizing: 'fullSize',
layerMode: 'behind',
})
);
}
}
let cumulativeY = 0;
let previousParagraphAfter = 0;
for (let i = 0; i < cell.blocks.length; i++) {
const block = cell.blocks[i];
const measure = cellMeasure.blocks[i];
if (block?.kind === 'paragraph' && measure?.kind === 'paragraph') {
const paragraphBlock = block as ParagraphBlock;
let paragraphMeasure = measure as ParagraphMeasure;
const spacing = paragraphBlock.attrs?.spacing;
// Match body paginator: max-collapse adjacent paragraph spacing.
const effectiveSpaceBefore = Math.max(previousParagraphAfter, spacing?.before ?? 0);
cumulativeY += effectiveSpaceBefore;
// Re-measure with floating zones if floating images exist in this cell
if (floatingZones && floatingZones.length > 0) {
paragraphMeasure = measureParagraph(paragraphBlock, contentWidth, {
floatingZones,
paragraphYOffset: cumulativeY,
});
}
// Create synthetic fragment for the paragraph
const syntheticFragment: ParagraphFragment = {
kind: 'paragraph',
blockId: paragraphBlock.id,
x: 0,
y: 0,
width: contentWidth,
height: paragraphMeasure.totalHeight,
fromLine: 0,
toLine: paragraphMeasure.lines.length,
pmStart: paragraphBlock.pmStart,
pmEnd: paragraphBlock.pmEnd,
};
const cellContext = { ...context, insideTableCell: true as const };
const fragEl = renderParagraphFragment(
syntheticFragment,
paragraphBlock,
paragraphMeasure,
cellContext,
{ document: doc }
);
fragEl.style.position = 'relative';
if (effectiveSpaceBefore > 0) {
fragEl.style.marginTop = `${effectiveSpaceBefore}px`;
}
contentEl.appendChild(fragEl);
cumulativeY += paragraphMeasure.totalHeight;
previousParagraphAfter = spacing?.after ?? 0;
} else if (block?.kind === 'table' && measure?.kind === 'table') {
// Nested table - render in normal document flow.
// Avoid cumulative marginTop offsets here: cell content already flows vertically,
// and compounding offsets can produce enormous heights on deeply nested tables.
const tableBlock = block as TableBlock;
const tableMeasure = measure as TableMeasure;
const effectiveSpaceBefore = previousParagraphAfter;
const nestedTableEl = renderNestedTable(tableBlock, tableMeasure, context, doc);
nestedTableEl.style.position = 'relative';
if (effectiveSpaceBefore > 0) {
nestedTableEl.style.marginTop = `${effectiveSpaceBefore}px`;
}
contentEl.appendChild(nestedTableEl);
cumulativeY += effectiveSpaceBefore + ((measure as TableMeasure).totalHeight ?? 0);
previousParagraphAfter = 0;
}
}
if (previousParagraphAfter > 0) {
contentEl.style.paddingBottom = `${previousParagraphAfter}px`;
}
const frontFloatingImages = cellFloatingImages.filter((img) => !floatingImageIsBehindDoc(img));
if (frontFloatingImages.length > 0) {
contentEl.appendChild(
renderFloatingImagesLayer(frontFloatingImages, doc, {
layerClass: 'layout-cell-floating-images-layer',
itemClass: 'layout-cell-floating-image',
sizing: 'fullSize',
layerMode: 'front',
})
);
}
return contentEl;
}
/**
* Render a nested table (within a cell)
*/
function renderNestedTable(
block: TableBlock,
measure: TableMeasure,
context: RenderContext,
doc: Document
): HTMLElement {
const tableEl = doc.createElement('div');
tableEl.className = `${TABLE_CLASS_NAMES.table} layout-nested-table`;
// Positioning (relative, not absolute)
tableEl.style.position = 'relative';
tableEl.style.width = `${measure.totalWidth}px`;
tableEl.style.display = 'block';
if (block.justification === 'center') {
tableEl.style.marginLeft = 'auto';
tableEl.style.marginRight = 'auto';
} else if (block.justification === 'right') {
tableEl.style.marginLeft = 'auto';
} else if (block.indent) {
tableEl.style.marginLeft = `${block.indent}px`;
}
// Store metadata
tableEl.dataset.blockId = String(block.id);
if (block.pmStart !== undefined) {
tableEl.dataset.pmStart = String(block.pmStart);
}
if (block.pmEnd !== undefined) {
tableEl.dataset.pmEnd = String(block.pmEnd);
}
// Whole-table tracked insertion / deletion — every row carries a
// tracked marker from the SAME (author, date). The revision ids
// need not match (foreign editors mint a fresh id per row), so the
// (author, date) tuple is the right fingerprint. Paint ONE tall bar
// on the table and tell renderTableRow to skip the per-row bar.
const firstRow = block.rows[0];
const sharedTrIns = firstRow?.trackedIns;
const sharedTrDel = firstRow?.trackedDel;
const sameBurst = (a: RevisionInfo | undefined, b: RevisionInfo | undefined): boolean =>
!!a && !!b && (a.author ?? '') === (b.author ?? '') && (a.date ?? null) === (b.date ?? null);
const wholeTableTracked =
block.rows.length > 0 &&
block.rows.every((r) => {
if (sharedTrIns) return sameBurst(r.trackedIns, sharedTrIns);
if (sharedTrDel) return sameBurst(r.trackedDel, sharedTrDel);
return false;
});
if (wholeTableTracked) {
const tableRev = (sharedTrIns ?? sharedTrDel) as RevisionInfo;
const kind = sharedTrIns ? 'ins' : 'del';
tableEl.classList.add('ep-revision-table', `ep-revision-${kind}`);
tableEl.dataset.revisionId = String(tableRev.revisionId);
tableEl.dataset.revisionAuthor = tableRev.author;
if (tableRev.date) tableEl.dataset.revisionDate = tableRev.date;
}
const rowYPositions = buildRowYPositions(measure.rows);
// Track spanning cells across rows
const spanningCells = new Map<string, SpanningCell>();
// Render all rows
for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex++) {
const row = block.rows[rowIndex];
const rowMeasure = measure.rows[rowIndex];
if (!row || !rowMeasure) continue;
const rowEl = renderTableRow(
row,
rowMeasure,
rowIndex,
rowYPositions[rowIndex] ?? 0,
measure.columnWidths,
block.rows.length,
context,
doc,
spanningCells,
rowYPositions,
undefined,
wholeTableTracked
);
tableEl.appendChild(rowEl);
}
// Match the rounded row stack so the outer box and the rows agree to the px.
tableEl.style.height = `${rowYPositions[block.rows.length] ?? 0}px`;
return tableEl;
}
/**
* Render a single table cell
*/
function renderTableCell(
cell: TableCell,
cellMeasure: TableCellMeasure,
x: number,
rowHeight: number,
borderFlags: {
isFirstRow: boolean;
isLastRow: boolean;
isFirstCol: boolean;
isLastCol: boolean;
},
context: RenderContext,
doc: Document,
/** Row-level tracked revision id (if any). When the cell's tracked
* marker shares this id, the row visual already covers it — suppress
* the per-cell border / background to avoid stacking 2-3 green visuals
* on the same cell. */
parentRowRevisionId?: number
): HTMLElement {
const cellEl = doc.createElement('div');
cellEl.className = TABLE_CLASS_NAMES.cell;
if (cell.trackedMarker && cell.trackedMarker.info.revisionId !== parentRowRevisionId) {
applyRevisionAttrs(cellEl, 'cell', cell.trackedMarker.kind, cell.trackedMarker.info);
}
// Positioning
cellEl.style.position = 'absolute';
cellEl.style.left = `${x}px`;
cellEl.style.top = '0';
cellEl.style.width = `${cellMeasure.width}px`;
cellEl.style.height = `${rowHeight}px`;
cellEl.style.overflow = 'hidden';
cellEl.style.boxSizing = 'border-box';
// Use per-cell padding from DOCX margins, default to Word's visual rendering
const padTop = cell.padding?.top ?? 1;
const padRight = cell.padding?.right ?? 7;
const padBottom = cell.padding?.bottom ?? 1;
const padLeft = cell.padding?.left ?? 7;
cellEl.style.padding = `${padTop}px ${padRight}px ${padBottom}px ${padLeft}px`;
// Apply borders - use cell borders if available, otherwise no border
if (cell.borders) {
// Collapse shared borders to avoid double-thick lines.
// Strategy: "bottom wins" for rows, "right wins" for columns.
// Each cell's bottom border represents the shared edge with the row below.
// Each cell's right border represents the shared edge with the column to its right.
// Only the first row draws its top border (table's top edge).
// Only the first column draws its left border (table's left edge).
if (borderFlags.isFirstRow) {
applyBorder(cellEl, 'top', cell.borders.top);
}
applyBorder(cellEl, 'right', cell.borders.right);
applyBorder(cellEl, 'bottom', cell.borders.bottom);
if (borderFlags.isFirstCol) applyBorder(cellEl, 'left', cell.borders.left);
}
// No default border - cells without explicit borders should be borderless
// Background color
if (cell.background) {
cellEl.style.backgroundColor = cell.background;
}
// `w:noWrap` (§17.4.30): forbid soft-wrapping inside the cell. We apply
// it on the cell box so descendants pick it up by inheritance — paragraph
// lines remain a single visual line that may grow the cell's effective
// content width past its measured size.
if (cell.noWrap) {
cellEl.style.whiteSpace = 'nowrap';
}
// Vertical alignment. When the content fills or overflows the cell box
// (e.g. a vertically-merged cell whose content was distributed to span its
// rows), Word top-anchors it — vAlign only positions the leftover slack.
// Forcing top here also keeps the painted lines aligned with the break
// offsets the paginator computed (which assume top-anchored content).
const contentFillsBox = (cellMeasure.height ?? 0) >= rowHeight - 0.5;
if (cell.verticalAlign && !contentFillsBox) {
cellEl.style.display = 'flex';
cellEl.style.flexDirection = 'column';
switch (cell.verticalAlign) {
case 'top':
cellEl.style.justifyContent = 'flex-start';
break;
case 'center':
cellEl.style.justifyContent = 'center';
break;
case 'bottom':
cellEl.style.justifyContent = 'flex-end';
break;
}
}
// Render cell content
const contentEl = renderCellContent(cell, cellMeasure, context, doc);
cellEl.appendChild(contentEl);
// Store PM positions for selection
if (cell.blocks.length > 0) {
const firstBlock = cell.blocks[0];
const lastBlock = cell.blocks[cell.blocks.length - 1];
if (firstBlock && 'pmStart' in firstBlock && firstBlock.pmStart !== undefined) {
cellEl.dataset.pmStart = String(firstBlock.pmStart);
}
if (lastBlock && 'pmEnd' in lastBlock && lastBlock.pmEnd !== undefined) {
cellEl.dataset.pmEnd = String(lastBlock.pmEnd);
}
}
return cellEl;
}
/**
* Track cells that span multiple rows
*/
type SpanningCell = {
cell: TableCell;
cellMeasure: TableCellMeasure;
columnIndex: number;
startRow: number;
rowSpan: number;
colSpan: number;
x: number;
totalHeight: number;
};
/** A merged cell resolved for cross-fragment re-emit (grid placement + x). */
type GridCell = {
rowIndex: number;
cellIndex: number;
columnIndex: number;
x: number;
colSpan: number;
rowSpan: number;
cell: TableCell;
};
/**
* Resolve each cell's column index (via the shared grid resolver) and add its
* pixel x offset from this table's column widths.
*/
function computeCellGrid(block: TableBlock, columnWidths: number[]): GridCell[] {
return resolveCellGrid(block).map((g) => {
let x = 0;
for (let c = 0; c < g.columnIndex; c++) x += columnWidths[c] ?? 0;
return {
rowIndex: g.rowIndex,
cellIndex: g.cellIndex,
columnIndex: g.columnIndex,
x,
colSpan: g.colSpan,
rowSpan: g.rowSpan,
cell: block.rows[g.rowIndex]!.cells[g.cellIndex]!,
};
});
}
/**
* Render a table row with rowSpan support
*/
function renderTableRow(
row: TableBlock['rows'][number],
rowMeasure: TableMeasure['rows'][number],
rowIndex: number,
y: number,
columnWidths: number[],
totalRows: number,
context: RenderContext,
doc: Document,
spanningCells?: Map<string, SpanningCell>,
rowYPositions?: number[],
isFirstRowInFragment?: boolean,
/** When the parent table already carries a whole-table revision bar,
* the per-row bar would double-paint. Suppress. */
suppressRowRevisionVisual?: boolean
): HTMLElement {
const rowEl = doc.createElement('div');
rowEl.className = TABLE_CLASS_NAMES.row;
// Tracked-row marker (sidebar reads the same data attrs as cells).
// Prefer `del` when both flags are present (rare; "ins of a row that
// was later marked deleted" — the deletion is the more recent action).
if (!suppressRowRevisionVisual) {
if (row.trackedDel) {
applyRevisionAttrs(rowEl, 'row', 'del', row.trackedDel);
} else if (row.trackedIns) {
applyRevisionAttrs(rowEl, 'row', 'ins', row.trackedIns);
}
}
// Use the pixel-rounded row height (diff of rounded row offsets) so the row
// box edges — and the borders on them — sit on whole pixels. Falls back to
// the measured height when row offsets aren't supplied (defensive).
const renderedRowHeight =
rowYPositions && rowYPositions.length > rowIndex + 1
? (rowYPositions[rowIndex + 1] ?? 0) - (rowYPositions[rowIndex] ?? 0)
: rowMeasure.height;
// Positioning
rowEl.style.position = 'absolute';
rowEl.style.left = '0';
rowEl.style.top = `${y}px`;
rowEl.style.width = '100%';
rowEl.style.height = `${renderedRowHeight}px`;
// Data attributes
rowEl.dataset.rowIndex = String(rowIndex);
// Build set of columns occupied by spanning cells from previous rows
const occupiedColumns = new Set<number>();
if (spanningCells) {
for (const [, spanCell] of spanningCells) {
// Check if this spanning cell covers the current row
if (spanCell.startRow < rowIndex && spanCell.startRow + spanCell.rowSpan > rowIndex) {
for (let c = 0; c < spanCell.colSpan; c++) {
occupiedColumns.add(spanCell.columnIndex + c);
}
}
}
}
// Render cells
// Track actual column index separately from cell index
// because cells with colSpan > 1 span multiple columns
let x = 0;
let columnIndex = 0;
// Skip columns occupied by spanning cells
while (occupiedColumns.has(columnIndex)) {
x += columnWidths[columnIndex] ?? 0;
columnIndex++;
}
for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) {
const cell = row.cells[cellIndex];
const cellMeasure = rowMeasure.cells[cellIndex];
if (!cell || !cellMeasure) continue;
const colSpan = cell.colSpan ?? 1;
const rowSpan = cell.rowSpan ?? 1;
// Calculate cell height - for spanning cells, use total height of spanned rows
let cellHeight = renderedRowHeight;
if (rowSpan > 1 && rowYPositions) {
cellHeight = 0;
for (let r = rowIndex; r < rowIndex + rowSpan && r < rowYPositions.length - 1; r++) {
cellHeight += (rowYPositions[r + 1] ?? 0) - (rowYPositions[r] ?? 0);
}
// Fallback if rowYPositions doesn't have enough entries
if (cellHeight === 0) {
cellHeight = rowMeasure.height * rowSpan;
}
}
const isFirstRow = rowIndex === 0 || isFirstRowInFragment === true;
const isLastRow = rowIndex + rowSpan >= totalRows;
const isFirstCol = columnIndex === 0;
const isLastCol = columnIndex + colSpan >= columnWidths.length;
const cellEl = renderTableCell(
cell,
cellMeasure,
x,
cellHeight,
{ isFirstRow, isLastRow, isFirstCol, isLastCol },
context,
doc,
row.trackedIns?.revisionId ?? row.trackedDel?.revisionId
);
cellEl.dataset.cellIndex = String(cellIndex);
cellEl.dataset.columnIndex = String(columnIndex);
// Store rowSpan info for styling
if (rowSpan > 1) {
cellEl.dataset.rowSpan = String(rowSpan);
}
rowEl.appendChild(cellEl);
// Track this cell as spanning if it spans multiple rows
if (rowSpan > 1 && spanningCells) {
const key = `${rowIndex}-${columnIndex}`;
spanningCells.set(key, {
cell,
cellMeasure,
columnIndex,
startRow: rowIndex,
rowSpan,
colSpan,
x,
totalHeight: cellHeight,
});
}
// Move x by the width of columns this cell spans
for (let c = 0; c < colSpan && columnIndex + c < columnWidths.length; c++) {
x += columnWidths[columnIndex + c] ?? 0;
}
// Advance column index by colSpan
columnIndex += colSpan;
// Skip columns occupied by spanning cells
while (occupiedColumns.has(columnIndex)) {
x += columnWidths[columnIndex] ?? 0;
columnIndex++;
}
}
return rowEl;
}
/**
* Render a table fragment to DOM
*
* @param fragment - The table fragment to render
* @param block - The full table block
* @param measure - The full table measure
* @param context - Rendering context
* @param options - Rendering options
* @returns The table DOM element
*/
export function renderTableFragment(
fragment: TableFragment,
block: TableBlock,
measure: TableMeasure,
context: RenderContext,
options: RenderTableFragmentOptions = {}
): HTMLElement {
const doc = options.document ?? document;
const tableEl = doc.createElement('div');
tableEl.className = TABLE_CLASS_NAMES.table;
// Outer positioning: body's per-page layout uses `absolute` (caller sets
// x/y via applyFragmentStyles); HF / textbox flow blocks vertically and
// pass `positioning: 'flow'` so the table participates in normal document
// flow instead. Pre-PR (#379) those callers had to overwrite the inline
// style after the renderer call.
tableEl.style.position = context.positioning === 'flow' ? 'relative' : 'absolute';
tableEl.style.width = `${fragment.width}px`;
// Height is set below from the rounded row stack (`visibleHeight`) once the
// window geometry is known — fragment.height (engine, unrounded) can be ~1px
// short of the painter's rounded rows and would clip the bottom border.
tableEl.style.overflow = 'hidden';
// Store metadata
tableEl.dataset.blockId = String(fragment.blockId);
tableEl.dataset.fromRow = String(fragment.fromRow);
tableEl.dataset.toRow = String(fragment.toRow);
if (fragment.pmStart !== undefined) {
tableEl.dataset.pmStart = String(fragment.pmStart);
}
if (fragment.pmEnd !== undefined) {
tableEl.dataset.pmEnd = String(fragment.pmEnd);
}
// Whole-table tracked insertion / deletion — every row carries a
// tracked marker from the same (author, date) burst. Foreign editors
// mint a fresh revisionId per row, so the (author, date) tuple is the
// right fingerprint here; matching on id alone would miss the case.
const firstFragRow = block.rows[0];
const sharedFragIns = firstFragRow?.trackedIns;
const sharedFragDel = firstFragRow?.trackedDel;
const sameBurstFrag = (a: RevisionInfo | undefined, b: RevisionInfo | undefined): boolean =>
!!a && !!b && (a.author ?? '') === (b.author ?? '') && (a.date ?? null) === (b.date ?? null);
const fragWholeTableTracked =
block.rows.length > 0 &&
block.rows.every((r) => {
if (sharedFragIns) return sameBurstFrag(r.trackedIns, sharedFragIns);
if (sharedFragDel) return sameBurstFrag(r.trackedDel, sharedFragDel);
return false;
});
if (fragWholeTableTracked) {
const tableRev = (sharedFragIns ?? sharedFragDel) as RevisionInfo;
const kind = sharedFragIns ? 'ins' : 'del';
tableEl.classList.add('ep-revision-table', `ep-revision-${kind}`);
tableEl.dataset.revisionId = String(tableRev.revisionId);
tableEl.dataset.revisionAuthor = tableRev.author;
if (tableRev.date) tableEl.dataset.revisionDate = tableRev.date;
// The bar belongs in the document margin (matches paragraph change
// bar visual), so let the ::before pseudo at left:-10px extend past the
// table's left edge into the page padding. Only widen the X axis — the
// window relies on vertical clipping to hide off-window rows / a row that
// broke mid-content, so overflow-y must stay hidden.
tableEl.style.overflowX = 'visible';
tableEl.style.overflowY = 'hidden';
}
// Add column resize handles at each column boundary
let handleX = 0;
for (let col = 0; col < measure.columnWidths.length - 1; col++) {
handleX += measure.columnWidths[col] ?? 0;
const handle = doc.createElement('div');
handle.className = TABLE_CLASS_NAMES.resizeHandle;
handle.style.position = 'absolute';
handle.style.left = `${handleX - 3}px`;
handle.style.top = '0';
handle.style.width = '6px';
handle.style.height = '100%';
handle.style.cursor = 'col-resize';
handle.style.zIndex = '10';
handle.dataset.columnIndex = String(col);
handle.dataset.tableBlockId = String(fragment.blockId);
if (fragment.pmStart !== undefined) {
handle.dataset.tablePmStart = String(fragment.pmStart);
}
tableEl.appendChild(handle);
}
const rowYPositions = buildRowYPositions(measure.rows);
// Resolve cell grid placement once (column index + x per cell).
const grid = computeCellGrid(block, measure.columnWidths);
// Render repeated header rows for continuation fragments at the very top of
// the fragment, in their own coordinate space above the windowed body.
const headerRowCount = fragment.headerRowCount ?? 0;
let headerHeight = 0;
if (headerRowCount > 0 && fragment.continuesFromPrev) {
const headerSpans = new Map<string, SpanningCell>();
for (let hdrIdx = 0; hdrIdx < headerRowCount; hdrIdx++) {
const hdrRow = block.rows[hdrIdx];
const hdrRowMeasure = measure.rows[hdrIdx];
if (!hdrRow || !hdrRowMeasure) continue;
const rowEl = renderTableRow(
hdrRow,
hdrRowMeasure,
hdrIdx,
headerHeight,
measure.columnWidths,
block.rows.length,
context,
doc,
headerSpans,
rowYPositions,
hdrIdx === 0, // first header row draws top border
fragWholeTableTracked
);
rowEl.dataset.repeatedHeader = 'true';
tableEl.appendChild(rowEl);
headerHeight += hdrRowMeasure.height;
}
}
// This fragment shows a vertical window of the table starting at `winTop`
// (full-table coordinates). Body rows render translated by `-winTop` and the
// table's `overflow:hidden` clips anything outside the window — so a row that
// broke mid-content (topClip) or a tall cell spilling past the page bottom
// are clipped automatically, and the slice continues on the next fragment.
const winTop = (rowYPositions[fragment.fromRow] ?? 0) + (fragment.topClip ?? 0);
const toFragmentY = (fullY: number): number => headerHeight + (fullY - winTop);
// Visible height of this fragment's window. For a clean bottom (a real row
// boundary) use the rounded row stack so the last row's bottom border sits
// exactly on the clip edge (not 1px past it, which overflow:hidden would eat);
// for a mid-content break, clip at the rounded fragment height.
const visibleHeight =
fragment.bottomClip !== undefined
? Math.round(fragment.height)
: toFragmentY(rowYPositions[fragment.toRow] ?? 0);
tableEl.style.height = `${visibleHeight}px`;
// Track spanning cells across rows within this fragment.
const spanningCells = new Map<string, SpanningCell>();
// Re-emit vertically-merged cells whose restart row is on an EARLIER
// fragment but whose span reaches into this one. This keeps their column
// occupied (so body cells keep their grid columns) and flows the merged
// content across the break: the cell is positioned at its true (negative)
// top, and overflow:hidden hides the slice already shown on the prior page.
const drawsHeaderRows = headerRowCount > 0 && fragment.continuesFromPrev;
for (const g of grid) {
if (g.rowSpan <= 1) continue;
if (g.rowIndex >= fragment.fromRow) continue; // starts in this fragment → its row draws it
if (g.rowIndex + g.rowSpan <= fragment.fromRow) continue; // ends before this fragment
// A merged cell whose restart row is a repeated header is already drawn by
// the header pass above — don't re-emit it (would double-paint).
if (drawsHeaderRows && g.rowIndex < headerRowCount) continue;
const cellMeasure = measure.rows[g.rowIndex]?.cells?.[g.cellIndex];
if (!cellMeasure) continue;
let spanHeight = 0;
for (let r = g.rowIndex; r < g.rowIndex + g.rowSpan && r < rowYPositions.length - 1; r++) {
spanHeight += (rowYPositions[r + 1] ?? 0) - (rowYPositions[r] ?? 0);
}
spanningCells.set(`${g.rowIndex}-${g.columnIndex}`, {
cell: g.cell,
cellMeasure,
columnIndex: g.columnIndex,
startRow: g.rowIndex,
rowSpan: g.rowSpan,
colSpan: g.colSpan,
x: g.x,
totalHeight: spanHeight,
});
const isLastRow = g.rowIndex + g.rowSpan >= block.rows.length;
const isFirstCol = g.columnIndex === 0;
const isLastCol = g.columnIndex + g.colSpan >= measure.columnWidths.length;
const cellEl = renderTableCell(
g.cell,
cellMeasure,
g.x,
spanHeight,
{ isFirstRow: false, isLastRow, isFirstCol, isLastCol },
context,
doc
);
cellEl.style.top = `${toFragmentY(rowYPositions[g.rowIndex] ?? 0)}px`;
cellEl.dataset.columnIndex = String(g.columnIndex);
// Synthetic continuation slice: not directly selectable (the editable cell
// lives on the fragment that owns its restart row).
cellEl.dataset.vmergeContinuation = 'true';
delete cellEl.dataset.pmStart;
delete cellEl.dataset.pmEnd;
tableEl.appendChild(cellEl);
}
// Render content rows from fragment.fromRow to fragment.toRow in window coords.
for (let rowIndex = fragment.fromRow; rowIndex < fragment.toRow; rowIndex++) {
const row = block.rows[rowIndex];
const rowMeasure = measure.rows[rowIndex];
if (!row || !rowMeasure) continue;
// A clean continuation boundary draws the row's top border; a row that
// broke mid-content (topClip) does not (its top is above the window anyway).
const isFirstRowInFragment =
headerRowCount > 0 && fragment.continuesFromPrev
? false
: fragment.continuesFromPrev && rowIndex === fragment.fromRow && !fragment.topClip;
const rowEl = renderTableRow(
row,
rowMeasure,
rowIndex,
toFragmentY(rowYPositions[rowIndex] ?? 0),
measure.columnWidths,
block.rows.length,
context,
doc,
spanningCells,
rowYPositions,
isFirstRowInFragment,
fragWholeTableTracked
);
tableEl.appendChild(rowEl);
}
// Close a fragment at a page break with a horizontal border on the cut edge,
// the way Word does — otherwise the cell's own top/bottom border is off-window
// (clipped) and the fragment looks open at the break. Emit one rule per column
// (using the cell active in that column) so per-column border styles, colSpans,
// merged columns, and borderless columns are all respected.
//
// `onlySpanning` limits drawing to cells that actually cross the edge — used at
// a clean row boundary, where ordinary cells already drew their own border and
// only a vertically-merged cell spanning into the next/prev fragment is open.
const drawCutEdge = (
cutRow: number,
side: 'top' | 'bottom',
topY: number,
onlySpanning: boolean
) => {
for (const g of grid) {
// Cell must be present in (or span through) the cut row.
if (g.rowIndex > cutRow || g.rowIndex + g.rowSpan - 1 < cutRow) continue;
if (onlySpanning) {
const crosses =
side === 'bottom' ? g.rowIndex + g.rowSpan - 1 > cutRow : g.rowIndex < cutRow;
if (!crosses) continue;
}
const spec = g.cell.borders?.[side];
if (!isVisibleBorder(spec)) continue;
let width = 0;
for (let c = 0; c < g.colSpan; c++) width += measure.columnWidths[g.columnIndex + c] ?? 0;
tableEl.appendChild(makeCutBorder(doc, { x: g.x, topY, width, edge: side, border: spec }));
}
};
// Top edge: a row broken mid-content (topClip) closes every column; a clean
// continuation only needs the merged cells that span in from the prior page.
if (fragment.topClip) drawCutEdge(fragment.fromRow, 'top', headerHeight, false);
else if (fragment.continuesFromPrev) drawCutEdge(fragment.fromRow, 'top', headerHeight, true);
// Bottom edge: same, mirrored. Anchor to the element's actual bottom
// (`visibleHeight`) so the rule sits exactly on the clip edge.
if (fragment.bottomClip !== undefined)
drawCutEdge(fragment.toRow - 1, 'bottom', visibleHeight, false);
else if (fragment.continuesOnNext) drawCutEdge(fragment.toRow - 1, 'bottom', visibleHeight, true);
// Row resize handles at row boundaries that fall inside the visible window.
for (let rowIdx = fragment.fromRow; rowIdx < fragment.toRow - 1; rowIdx++) {
const boundaryY = toFragmentY(rowYPositions[rowIdx + 1] ?? 0);
if (boundaryY <= headerHeight || boundaryY >= fragment.height) continue;
const rowHandle = doc.createElement('div');
rowHandle.className = TABLE_CLASS_NAMES.rowResizeHandle;
rowHandle.style.position = 'absolute';
rowHandle.style.left = '0';
rowHandle.style.top = `${boundaryY - 3}px`;
rowHandle.style.width = '100%';
rowHandle.style.height = '6px';
rowHandle.style.cursor = 'row-resize';
rowHandle.style.zIndex = '10';
rowHandle.dataset.rowIndex = String(rowIdx);
rowHandle.dataset.tableBlockId = String(fragment.blockId);
if (fragment.pmStart !== undefined) {
rowHandle.dataset.tablePmStart = String(fragment.pmStart);
}
tableEl.appendChild(rowHandle);
}
// Bottom edge handle — only on the fragment that ends the table.
const endsTable = fragment.toRow === block.rows.length && fragment.bottomClip === undefined;
if (endsTable) {
const bottomHandle = doc.createElement('div');
bottomHandle.className = TABLE_CLASS_NAMES.tableEdgeHandleBottom;
bottomHandle.style.position = 'absolute';
bottomHandle.style.left = '0';
bottomHandle.style.top = `${toFragmentY(rowYPositions[fragment.toRow] ?? 0) - 3}px`;
bottomHandle.style.width = '100%';
bottomHandle.style.height = '6px';
bottomHandle.style.cursor = 'row-resize';
bottomHandle.style.zIndex = '10';
bottomHandle.dataset.rowIndex = String(block.rows.length - 1);
bottomHandle.dataset.tableBlockId = String(fragment.blockId);
bottomHandle.dataset.isEdge = 'bottom';
if (fragment.pmStart !== undefined) {
bottomHandle.dataset.tablePmStart = String(fragment.pmStart);
}
tableEl.appendChild(bottomHandle);
}
// Right edge handle (only on fragments containing the last row)
if (endsTable) {
const totalWidth = measure.columnWidths.reduce((w, cw) => w + cw, 0);
const rightHandle = doc.createElement('div');
rightHandle.className = TABLE_CLASS_NAMES.tableEdgeHandleRight;
rightHandle.style.position = 'absolute';
rightHandle.style.left = `${totalWidth - 3}px`;
rightHandle.style.top = '0';
rightHandle.style.width = '6px';
rightHandle.style.height = '100%';
rightHandle.style.cursor = 'col-resize';
rightHandle.style.zIndex = '10';
rightHandle.dataset.columnIndex = String(measure.columnWidths.length - 1);
rightHandle.dataset.tableBlockId = String(fragment.blockId);
rightHandle.dataset.isEdge = 'right';
if (fragment.pmStart !== undefined) {
rightHandle.dataset.tablePmStart = String(fragment.pmStart);
}
tableEl.appendChild(rightHandle);
}
return tableEl;
}