diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index d5b9084..0587804 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -15,7 +15,7 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table- - **`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. -- **`tableCellAlign.ts`** (`TableCellAlign`) — `verticalAlign` (`middle`/`bottom`, null = top, the ODF and Word default) on `tableCell`/`tableHeader`, rendered as the cell's CSS `vertical-align`. A callout table's label sits mid-cell with it, at the row's top without. Round-trips to ODF `style:vertical-align` (import via `StyleResolver.cellVerticalAlign`, export via odf-kit's `CellOptions.verticalAlign` — it mints one cell style per property set, so a differing cell gets its own) and DOCX `w:vAlign` (Word's `center` is our `middle`; `top`/`both` stay the default). +- **`tableCellAlign.ts`** (`TableCellAlign`) — `verticalAlign` (`middle`/`bottom`, null = top, the ODF and Word default) on `tableCell`/`tableHeader`, rendered as the cell's CSS `vertical-align`. A callout table's label sits mid-cell with it, at the row's top without. Round-trips to ODF `style:vertical-align` (import via `StyleResolver.cellVerticalAlign`, export via odf-kit's `CellOptions.verticalAlign` — it mints one cell style per property set, so a differing cell gets its own) and DOCX `w:vAlign` (Word's `center` is our `middle`; `top`/`both` stay the default). Set from `TableToolbar.svelte`'s three buttons via `setCellAttribute`, which covers a whole `CellSelection`. - **`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. diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index 5450cf2..fcc9513 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -47,6 +47,7 @@ Both anchors differ: a wheel zoom holds the **point under the cursor** fixed (`p ## Headers & footers (`HeaderFooterLayer.svelte`, `storage/headerFooter.ts`) One header and one footer (`HfDoc` = a single-paragraph TipTap doc per zone, persisted to `odf-editor-header`/`-footer`), repeated on every page. Double-clicking a page margin — or the Layout-panel "Edit header/footer" buttons — opens the zone for editing; clicking back into the body (`onFocus`) or Escape ends it. The edge→zone distance (header from top, footer from bottom) is user-configurable in cm via the Layout panel (`HfDistances`, persisted to `odf-editor-hf-distances`, default `HF_DISTANCE_CM` 1.27cm, clamped 0–10 and below the body margin). +- **Fields:** the bar beside the active zone inserts the `pageField.ts` atoms — page number, page count, chapter (the running head, level 1). Each is patched per page, so the same zone shows a different value on every page. - **Rendering:** `HeaderFooterLayer.svelte` mounts inside the scaled `.paper` (like `.band-layer`), positioning per-page zone boxes in unscaled doc px. A zone's out-of-flow frame (`wrap` ≠ inline) is hidden in the box and painted per page by `.hf-bg-layer` at `z-index: -1` — hence `.paper`, not `.tiptap`, paints the page surface, so the image lands between the sheet and the body text. Inactive zones render as static HTML (`generateHTML` + `hfExtensions`); the active zone hosts the single live TipTap editor. `App.svelte`'s `activeEditor`/`activeTick` route the top toolbars to `hfEditor` while a zone is active, so all body formatting works on the header/footer with no toolbar changes. ## Debug tooling (dev only) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 7ae31c2..314f5b4 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -82,6 +82,12 @@ return out; } + // Pagination refreshes the starts; inserting a chapter field into a zone isn't a + // pagination event, so collect them as soon as a zone asks for them. + $effect(() => { + chapterStarts = wantsChapters ? collectChapterStarts() : []; + }); + // Apply the page margins + orientation to the :root CSS vars (visual padding, // page dimensions, and pagination all read these). DOM-only, safe in effects. $effect(() => { diff --git a/src/lib/components/HeaderFooterLayer.svelte b/src/lib/components/HeaderFooterLayer.svelte index 679973d..9f4b96b 100644 --- a/src/lib/components/HeaderFooterLayer.svelte +++ b/src/lib/components/HeaderFooterLayer.svelte @@ -346,7 +346,7 @@ activeTrailingPx = tb && getComputedStyle(tb).display !== 'none' && Number.isFinite(lineH) ? lineH : 0; }); - function insertField(kind: 'pageNumber' | 'pageCount') { + function insertField(kind: 'pageNumber' | 'pageCount' | 'chapterField') { hfEditor?.chain().focus().insertContent({ type: kind }).run(); } @@ -407,6 +407,7 @@ {t().hf.insert} + diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index fd710b0..a570170 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -2,6 +2,7 @@ import type { Editor, ChainedCommands } from '@tiptap/core'; import { CellSelection } from '@tiptap/pm/tables'; import { isHeaderStyled } from '../editor/extensions/tableHeaderRow'; + import type { CellVerticalAlign } from '../editor/extensions/tableCellAlign'; import ColorPicker from './ColorPicker.svelte'; import TableBorderPicker from './TableBorderPicker.svelte'; import TableStylePicker from './TableStylePicker.svelte'; @@ -38,29 +39,35 @@ const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row')); const isHeaderCol = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'column')); - // Background colour of the selected cell(s): the uniform value, '' if mixed, null if - // none. Drives the ColorPicker's "current" swatch (re-evaluated per `tick`). - function cellBg(): string | null { + // One attribute over the selected cell(s): the uniform value, '' if they disagree, + // null if there is no cell. Re-evaluated per `tick`. + function cellAttr(name: string): string | null { if (!editor) return null; const sel = editor.state.selection; - const colors = new Set(); + const values = new Set(); if (sel instanceof CellSelection) { - sel.forEachCell((cell) => colors.add((cell.attrs.backgroundColor as string) ?? null)); + sel.forEachCell((cell) => values.add((cell.attrs[name] as string) ?? null)); } else { const from = sel.$from; for (let d = from.depth; d > 0; d--) { const role = from.node(d).type.spec.tableRole; if (role === 'cell' || role === 'header_cell') { - colors.add((from.node(d).attrs.backgroundColor as string) ?? null); + values.add((from.node(d).attrs[name] as string) ?? null); break; } } } - if (colors.size === 0) return null; - if (colors.size > 1) return ''; - return [...colors][0] ?? null; + if (values.size === 0) return null; + if (values.size > 1) return ''; + return [...values][0] ?? null; + } + const currentCellColor = $derived(tick >= 0 ? cellAttr('backgroundColor') : null); + // Where the content sits in the cell box; null = top, the default in both formats. + const cellVAlign = $derived(tick >= 0 ? cellAttr('verticalAlign') : null); + + function setVAlign(v: CellVerticalAlign | null) { + editor?.chain().focus().setCellAttribute('verticalAlign', v).run(); } - const currentCellColor = $derived(tick >= 0 ? cellBg() : null); function applyCellColor(color: string) { editor?.chain().focus().setCellAttribute('backgroundColor', color).run(); @@ -200,6 +207,28 @@ + {#each [ + { v: null, label: t().table.cellAlignTop, d: 'M5 5h8M5 8h5' }, + { v: 'middle', label: t().table.cellAlignMiddle, d: 'M5 7.5h8M5 10.5h5' }, + { v: 'bottom', label: t().table.cellAlignBottom, d: 'M5 10h8M5 13h5' }, + ] as const as a} + + {/each} + + +