feat: insert a chapter field, and set where a cell's content sits
Both round-tripped through save/load already but had no way in: the header/footer bar gains a Chapter button next to page number/count, and the table toolbar three buttons for the cell's vertical alignment.
This commit is contained in:
1 parent
e2179b75fb
commit
d407febd4b
7 files changed
+59
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
</script>
|
||||
@@ -407,6 +407,7 @@
|
||||
<span class="hf-bar-label">{t().hf.insert}</span>
|
||||
<button class="hf-bar-btn" title={t().hf.pageNumberTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageNumber')}>{t().hf.pageNumber}</button>
|
||||
<button class="hf-bar-btn" title={t().hf.pageCountTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('pageCount')}>{t().hf.pageCount}</button>
|
||||
<button class="hf-bar-btn" title={t().hf.chapterTitle} onmousedown={(e) => e.preventDefault()} onclick={() => insertField('chapterField')}>{t().hf.chapter}</button>
|
||||
<span class="hf-bar-sep"></span>
|
||||
<button class="hf-bar-btn hf-bar-done" title={t().hf.doneTitle} onmousedown={(e) => e.preventDefault()} onclick={() => (hfActive = null)}>{t().hf.done}</button>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>();
|
||||
const values = new Set<string | null>();
|
||||
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 @@
|
||||
|
||||
<span class="tt-sep"></span>
|
||||
|
||||
{#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}
|
||||
<button
|
||||
class="tt-btn"
|
||||
class:active={cellVAlign === a.v}
|
||||
title={a.label}
|
||||
aria-label={a.label}
|
||||
aria-pressed={cellVAlign === a.v}
|
||||
onclick={() => setVAlign(a.v)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="15" height="15" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d={a.d} stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<span class="tt-sep"></span>
|
||||
|
||||
<ColorPicker
|
||||
{editor}
|
||||
currentColor={currentCellColor}
|
||||
|
||||
@@ -283,6 +283,9 @@ const de: Messages = {
|
||||
splitCells: 'Zellen teilen…',
|
||||
splitCellsAria: 'Zellen teilen',
|
||||
cellShading: 'Zellenschattierung',
|
||||
cellAlignTop: 'Zellinhalt oben ausrichten',
|
||||
cellAlignMiddle: 'Zellinhalt vertikal zentrieren',
|
||||
cellAlignBottom: 'Zellinhalt unten ausrichten',
|
||||
chooseCellColor: 'Zellenfarbe wählen',
|
||||
noFill: 'Keine Füllung',
|
||||
insertTable: 'Tabelle einfügen',
|
||||
@@ -433,6 +436,8 @@ const de: Messages = {
|
||||
pageNumberTitle: 'Aktuelle Seitenzahl einfügen',
|
||||
pageCount: 'Seitenanzahl',
|
||||
pageCountTitle: 'Gesamtseitenanzahl einfügen',
|
||||
chapter: 'Kapitel',
|
||||
chapterTitle: 'Überschrift des auf der Seite laufenden Kapitels einfügen',
|
||||
done: 'Fertig',
|
||||
doneTitle: 'Bearbeiten der Kopf-/Fußzeile beenden',
|
||||
},
|
||||
|
||||
@@ -287,6 +287,9 @@ const en = {
|
||||
splitCells: 'Split cells…',
|
||||
splitCellsAria: 'Split cells',
|
||||
cellShading: 'Cell shading',
|
||||
cellAlignTop: 'Align cell content top',
|
||||
cellAlignMiddle: 'Centre cell content vertically',
|
||||
cellAlignBottom: 'Align cell content bottom',
|
||||
chooseCellColor: 'Choose cell color',
|
||||
noFill: 'No fill',
|
||||
insertTable: 'Insert table',
|
||||
@@ -437,6 +440,8 @@ const en = {
|
||||
pageNumberTitle: 'Insert the current page number',
|
||||
pageCount: 'Page count',
|
||||
pageCountTitle: 'Insert the total page count',
|
||||
chapter: 'Chapter',
|
||||
chapterTitle: 'Insert the heading of the chapter running on the page',
|
||||
done: 'Done',
|
||||
doneTitle: 'Finish editing the header/footer',
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user