Files
edentext/docs/architecture/tables.md
T
Steffen Becker ca3767f3d4 feat: a cell keeps its own margins where they differ from the table's
w:tcMar and an ODF cell style's own fo:padding were both dropped: the
table's value (in ODF the first cell's) stood for every cell.

cellPadding is now an attr on tableCell/tableHeader too, measured against
the table's rather than the editor default, so only a real disagreement
carries one. --cell-pad inherits, so the cell's own declaration wins for
its own td and the CSS is unchanged.

Checked against LibreOffice: a 1.2cm cell in a 0.3cm table puts its text
at 332.2pt where the grid column starts at 297.6.
2026-08-08 17:47:06 +00:00

16 KiB
Raw Blame History

Tables

Covers the table*.ts extensions in src/lib/editor/extensions/ and the table-style family (src/lib/styles/tableStyles.ts, components/TableStylePicker.svelte).

Table extensions

  • tableView.ts (TableView) — custom table node view. Unlike TipTap's built-in view it renders the <colgroup> with percentage widths and keeps the table at width:100%, so the table fills its wrapper (by default the full text width, matching the ODT export) and stays responsive to margin/orientation changes. The per-column colwidth cell attrs are treated as proportional weights (only ratios matter; null = equal share). The table's own marginLeft/marginRight attrs (cm, from the outer-edge drag) go on the .tableWrapper — that's what narrows the table.

  • tableRow.ts (ResizableTableRow) — TableRow extended with a rowHeight attr (unscaled doc px @96dpi), rendered as a CSS min-height (height on <tr>), exported as style:min-row-height. Table (unlike TableKit) does NOT auto-add child extensions, so ResizableTableRow must be listed explicitly.

  • tableColumnResize.ts (TableColumnResize) — Word-style column drag: dragging an inner border trades width between two columns only (their sum stays constant, table width fixed). Live feedback pokes the <col> elements; release writes new weights into colwidth. The table's outer left/right edges are draggable too (Word/LibreOffice): they move that edge alone — the pure edgeResize helper shifts the global marginLeft/marginRight attrs it adds on table (cm, >= 0, so the table never leaves the text area) and resizes only the adjacent column, so every other gridline stays put; the minimum is CELL_MIN_PX. borderAt classifies a hovered border as inner/left/right, the handle decoration mirrors to the cell's left side (.column-resize-handle.left), and the commit writes colwidth + the table attrs in one transaction. TableView renders the margins on the wrapper; the attrs' own renderHTML emits them (plus a matching width: calc(...)) for the static HTML paths (PDF export, clipboard). They round-trip as ODF fo:margin-left/-right + style:width (applyTableMargins) and DOCX w:tblInd + the narrower w:tblGrid.

  • tableRowResize.ts (TableRowResize) — Word-style row-height drag (changes the row above the grid line; min-height semantics). Yields to column resize at a corner.

  • tableSplit.ts (TableSplit) — Word/LibreOffice "Split Cells…": adds splitCellInto(cols, rows) (prosemirror-tables only ships splitCell = un-merge). Reads the grid via TableMap, expands it (duplicating the cell region's right-edge column / bottom-edge row so every other cell crossing a new grid line grows its span — the bridging Word does), partitions the region into cols×rows sub-cells (first keeps the original content), recomputes colwidth weights, and rebuilds the table node in one step. Merge uses extension-table's built-in mergeCells (drag/shift-click CellSelection via the always-on tableEditing plugin). Both are surfaced by TableToolbar.svelte (Merge button + Split button → TableSplitDialog.svelte).

  • tableCellBackground.ts (TableCellBackground) — adds a backgroundColor attr to tableCell/tableHeader (rendered as the cell's CSS background), set via extension-table's built-in setCellAttribute (so it applies to a whole CellSelection or the cursor's cell). TableToolbar.svelte exposes it via a reused ColorPicker (cell shading); round-trips to ODF fo:background-color (export/import below). Survives split/merge (attrs are carried through).

  • tableCellBorders.ts (TableCellBorders) — Word/LibreOffice border control: per-side attrs borderTop/Right/Bottom/Left on tableCell/tableHeader (null = the table default 0.5pt black, 'none', or canonical '<W>pt solid #RRGGBB'; rendered as per-side inline CSS — pt→px, min 1px — plus data-border-* for HTML parse). setTableBorders(preset, spec) applies a preset (all/outer/inner/innerH/innerV/top/bottom/left/right; spec null = no border, the default 0.5pt-black spec normalizes to null) to the selected cell region (selectedRect), writing both sides of every affected boundary — including the facing side of neighbour cells outside the region — so collapsed borders never disagree between adjacent cells. Surfaced by TableBorderPicker.svelte (dropdown in TableToolbar.svelte: preset grid + line width + line color; presets apply with the picker's current pen settings). Word-like button states: activeBorderPresets(state, pen) marks a preset active when every boundary it targets renders exactly the pen border — width and color, where a boundary's effective border is the collapse winner of its two facing cell sides and attr null counts as the 0.5pt-black default — so a fresh table lights the presets up under the default pen and a thicker pen turns them off; 'none' is active when the region is borderless. Clicking an active preset toggles those borders off; an inactive one applies the pen. The panel stays open on preset clicks so the states visibly update. Round-trips to ODF fo:border-* (export → odf-kit CellOptions.border*, null falls back to the table's default border; import via StyleResolver.cellBorders + borderAttrFromOdf — an undeclared side ⇒ 'none', the 0.5pt-black default ⇒ null with unit tolerance, non-solid styles coerced to solid) and DOCX w:tcBorders (import also resolves table-level w:tblBorders by cell position; sides nothing declares stay null = default).

  • tableCellPadding.ts (TableCellPadding) — cell margins, a global cellPadding attr [top, right, bottom, left] in cm on table and on tableCell/tableHeader. null = inherit: a table's from the default [0, 0.19, 0, 0.19] (Word's own, and editor.css's fallback), a cell's from its table — so cellPaddingAttr(sides, base) takes the baseline it is measured against, and only a real disagreement carries an attr. A side within SAME_CM (0.005cm) of that baseline is the baseline, or Word's 108 twips (0.1905cm) and LibreOffice's 0.191cm would tag every table. Rendered as one --cell-pad custom property (the attr's renderHTML for the static paths, TableView for the node view, since it bypasses renderHTML) that td/th { padding: var(--cell-pad, 0 0.19cm) } reads — the property inherits, so a cell's own declaration wins for its own td. DOCX: w:tblPr/w:tblCellMar direct, else the table style's (DocxStyles.tableCellMar walks w:basedOn), and w:tcPr/w:tcMar per cell (cellMarginsCm reads either, w:type="nil" being an explicit zero); export writes Table.margins + TableCell.margins. ODF has no table-level cell margin — it sits on each cell's style, so import takes the first cell's fo:padding* for the table and measures the rest against it, and export rides each cell's value on it as a CSS-style TRBL shorthand that expandCellPadding splits per side (odf-kit only writes the fo:padding shorthand, and ODF allows it only one length). Getting this wrong is not cosmetic: a 0.71cm bibliography marker column lost 0.38cm to the old constant and wrapped "[2] " onto three lines.

  • tableHeaderRow.ts (TableHeaderRow) — "header row" as a styling preset (not the structural ODF header that repeats per page, by design): toggleHeaderRowStyle sets/clears a light-grey fill (HEADER_SHADE #F2F2F2) on the first row's cells; isHeaderStyled(state, axis) drives the toolbar toggle's active state. A table carrying a table style owns these two areas through its Table Style Options instead: there toggleHeaderStyle/isHeaderStyled read and write headerRow/firstColumn in the table's tableLook (delegating to setTableLook), so the toolbar button and the gallery's checkbox are one state and can't disagree. Both also read their label from the same key (styles.regions.*), so they can't drift apart in wording either — the button used to say "header column" where the checkbox said "first column". The fill is the marker and round-trips natively as fo:background-color, so the toggle state survives reopen with no table:table-header-rows (⇒ Word/LibreOffice don't repeat it). Bold is presentational, exactly like a heading: tableCellBackground.ts tags a header-shaded cell with class="cell-header" and editor.css renders .cell-header bold — so all its text is bold (existing, typed, pasted, IME) without storing marks, and it stays editable via the fontWeight:'normal' override (the Toolbar.svelte Bold button treats isInHeaderCell like a heading: toggles setFontWeight('normal')/unsetFontWeight). Export (export/odt.ts) bakes bold onto header-shaded cells' runs (applyRuns(..., forceBold), skipping fontWeight:'normal' runs) so Word/LibreOffice match; import (import/odt.ts) converts those cells' runs with boldByDefault (bold run → no mark, normal run → fontWeight:'normal'), mirroring headings. TableToolbar.svelte exposes the toggle button.

  • tableStyle.ts (TableStyle) — the table style family's editor half (model in styles/tableStyles.ts, see below): global attrs tableStyle on table (⇄ data-table-style) and region on tableCell/tableHeader (⇄ data-region, a space-separated region list). setTableStyle(name|null) writes the attr and materializes the style into the cells in one transaction — paintTable walks the TableMap, resolves each cell via resolveTableCell (merged cells by their top-left findCell box) and writes backgroundColor/the four border attrs/region, so export, PDF and clipboard need no new code. It only writes cells that differ, which makes it idempotent — the condition the re-band plugin depends on. That plugin (appendTransaction) repaints a styled table when its row/column shape changes (Word's re-banding); deliberately not on every transaction, or a manually shaded cell would be repainted away immediately. refreshTableStyles() repaints every styled table and is called from Editor.svelte's document-stylesheet $effect — the choke point every registry change (edit, rename, reset, import) passes through. activeTableStyle/cellRegionText feed the gallery and the Bold button (a style's bold region behaves like a heading, see tableHeaderRow.ts). TableView sets data-table-style itself, since the node view bypasses renderHTML.

We deliberately keep TipTap's resizable: false (so its own columnResizing plugin isn't loaded) and supply the custom view + drag plugins instead.

Table styles (third style family)

  • Table styles are the third family (sheet.table, model in styles/tableStyles.ts — its own shape, and no inheritance, because LibreOffice's table AutoFormats have none, so resolveStyle/styleOrder stay untouched). A style holds an outer border, an innerBorder (optionally split into innerBorderH/innerBorderV, which is what "row lines only" needs) and per-region props (fill, text, per-side borders): TABLE_REGIONS lists the conditional areas in Word's ascending precedence (bandedColumn < bandedRow < lastColumn < firstColumn < lastRow < headerRow), with wholeTable as the base. resolveTableCell(style, coords, look) is the single source of truth for all four consumers (apply, CSS, export bake, preview tiles): it layers the matching regions, counts banding over body rows (a header row doesn't shift the stripes) and decides each border per grid line from both sides, so two facing cells never disagree once collapsed. Table Style Options are Word's per-table w:tblLook: a TableLook (one flag per region) stored space-separated in the table's tableLook attr, absent ⇒ DEFAULT_TABLE_LOOK (Word's header row + first column + banded rows). A region paints only when the style defines it and the look enables it — which also decides whether banding skips the header row/first column, so toggling the header shifts every stripe. setTableLook(region, on) flips one and repaints; the look is part of shapeOf, so the re-band plugin sees it change. Every built-in defines all six areas, so each toggle visibly does something; the gallery's checkboxes grey out the ones the current style doesn't paint, and its tiles preview the table's own look. The colourful families (the four box lists, both accent tables) fill first/last column and the total row with a mid tint between header and band, so switching one on reads as clearly as the header row; the grey Simple * families and Academic/Financial keep those areas text-only, as Word's plain and rule-based styles do. The built-ins mirror the families Word and LibreOffice ship: Simple Grid (+ Rows / Columns), Simple List Shaded / Columns, Plain Table, Box List Blue/Green/Red/Yellow, Grid Table Accent, List Table Accent, Academic, Financial. A style may declare its own look — the options it is about, applied over the table's on assignment (styleLook): the list/grid Columns variants switch the bands to columns, Academic drops them (it is a booktabs table) and Financial turns the total row on. Without it the two banding variants would be indistinguishable, since every style defines all six areas. tests/unit/table-style.test.ts asserts no two built-ins render identically — the invariant that catches exactly that. The registry is versioned (STYLE_SHEET_VERSION), so adding or changing one needs a bump. Split of responsibilities: fill and borders are materialized into cell attrs (they already round-trip); text formatting is presentational, rendered by tableStyleCss from the cell's region attr — the same call the repo already makes for header-row bold, so typed/pasted/IME text inherits it. The rules must also target the cell's blocks (… :is(td,th)[data-region~="X"] :is(p,h1,…,li)), or the Standard paragraph rule outranks them on font/size/color. The region attr is stored rather than derived in CSS because pageBreaks.ts injects spacer <tr>s that would break any nth-child banding. ODF/DOCX: the definition lives in the app registry, not the file — ODF has no banding concept (style:family="table" carries width/align/margins only) and LibreOffice Writer bakes its AutoFormats as direct cell formatting too. So only the name and the look travel: the look rides on ODF's own per-area table:use-first-row-styles/-last-row-/-first-column-/ -last-column-/-banding-rows-/-banding-columns-styles attributes on <table:table> (ODF_LOOK_ATTRS, shared by both sides) and on DOCX's native w:tblLook (its band flags are inverted). Without it a reopened file would repaint under the default look, since refreshTableStyles runs on every load. applyTableStyleNames walks the table elements with a lookahead, not \b — a hyphen is a word boundary, so \b would also match <table:table-cell> and misalign the per-table counter. Export mints an empty named style:family="table" in styles.xml (applyNamedStyles) and points the table's automatic Table{n} style at it via style:parent-style-name (applyTableStyleNames, mirroring applyTableMargins); DOCX writes w:tblStyle plus a name-only w:style w:type="table" through the docx lib's importedStyles (it has no tableStyles option, and w:tblStylePr is not emitted). The region's text is baked onto the runs on both exports (the forceBold channel widened to TextProps). Import reads the name back (namedAncestor(…, 'table') / w:tblStyle → DocxStyles.tableStyleName); the regions are then re-derived by refreshTableStyles, and App.svelte keeps sheet.table across an import because the file never carried it. Baked bold is only suppressed for HEADER_SHADE cells (the existing boldByDefault path) — a foreign style's bold survives as direct formatting.

  • Table-style gallery: TableStylePicker.svelte in the floating TableToolbar — tiles drawn by previewCellCss (the same resolver), so a preview can't disagree with what applying does, plus the six Table Style Options as checkboxes beside them. Each preview cell also carries a schematic text line (previewTextCss: thickness = bold, slant = italic, colour = the region's): without it an emphasis-only area such as first/last column or the total row is invisible in a tile, since the tiles hold no real text — only fills and borders would show.