From dab1d662c923accaf93f2c3fb73d13819ca331ed Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 10:12:24 +0200 Subject: [PATCH] feat: the table sort gets its three keys and a sort type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both dialogs offer three sort keys and let you pick the type; only the first key was offered, and the type was always the detected one. A later key decides only where the ones before tie, and the sort stays stable. Alphanumeric is the plain collation ("10" before "2") — that is what choosing it over the detected type is for, so the two collators differ in `numeric`. Sorted numerically, a cell that is no number sorts last either way, as an empty cell does in a spreadsheet. --- CHANGELOG.md | 4 +- docs/architecture/tables.md | 2 +- src/lib/components/TableSortDialog.svelte | 85 ++++++++++++++--------- src/lib/editor/extensions/tableSort.ts | 74 +++++++++++++++----- src/lib/i18n/locales/de.ts | 7 ++ src/lib/i18n/locales/en.ts | 7 ++ tests/unit/table-formula.test.ts | 37 ++++++++-- 7 files changed, 153 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2582f07..76d3827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +62,7 @@ **Insert** - Tables: insert via size picker, Word-style row/column drag-resize, add / delete rows & columns, delete table, cell borders, merge cells and split cells (N×M, Word/LibreOffice-style), cell background shading, header row / first column toggles, named table styles (see Styles); a table splits cleanly across page boundaries - Repeat the header row: the first row is drawn again at the top of every page the table continues on, as in Word and LibreOffice. Round-trips to ODF `table:table-header-rows` and Word's `w:tblHeader`. A row that fits a page never splits anyway (the editor paginates a table between its rows), so Word's "don't allow row to break" needs nothing -- Sort a table's rows (LibreOffice's Table ▸ Sort, Word's Layout ▸ Sort): by any column, ascending or descending, optionally keeping the first row as a header. The type is detected per cell rather than chosen — a column of numbers sorts numerically, anything else by the document language's collation — where both dialogs make you pick; and only the first of their three sort keys is offered +- Sort a table's rows (LibreOffice's Table ▸ Sort, Word's Layout ▸ Sort): the three keys both dialogs offer, each by any column, ascending or descending and with its own sort type — automatic (a cell that reads as a number sorts numerically, anything else by the document language's collation), numerically, or alphanumerically, which is the plain collation that puts "10" before "2". A later key decides only where the ones before it tie, rows alike in every key keep the order they were typed in, and the first row can be kept in place as a header - Formulas in a cell (LibreOffice's Table ▸ Formula, Word's Layout ▸ Formula): `=SUM(ABOVE)`, `=AVERAGE(A1:A3)`, `=A1*2` — SUM, PRODUCT, AVERAGE, MIN, MAX, COUNT, ABS, INT, SIGN, MOD and ROUND over cell references, ranges and Word's directions, with arithmetic and parentheses. A formula cell shows its result on the field shade both word processors use and recomputes as the cells it reads change, a formula reading another formula included. It rides the cell as ODF's `table:formula` in LibreOffice's own language (`ooow:sum `, a direction resolved to the range it stands for) with the result cached as `office:value`, and as Word's `=` field inside the cell — both re-read - Number recognition (LibreOffice's Table ▸ Number Recognition): with it on, a cell whose text reads as a number is rewritten in the document language's number format when the cursor leaves it — `007,50` becomes `7,5`. **Off** by default, as it is in LibreOffice, and the parsing a formula does is independent of it, exactly as there - Table border control (Word/LibreOffice-style): per-side cell borders with presets (all / outside / inside / single edges / none), line width and color; buttons show active states matching the current pen and toggle borders off; round-trips to ODF `fo:border-*` and DOCX `w:tcBorders` @@ -154,7 +154,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-14. **Missing while writing** - Hyphenation's zone and ladder count (`fo:hyphenation-ladder-count`, `w:hyphenationZone`) — CSS exposes neither -- A sort dialog's second and third key, and its choice of sort type over the detected one; a formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table +- A formula's own number format (LibreOffice's Number Format dialog per cell — the general format is the only one here), and a formula reaching into another table - A list level's own hanging indent: the marker sits at the 0.635 cm both exports write (`LIST_HANGING_CM`), so a wider *left*-set marker overflows where Word moves the text to the next list tab (a **right**-set one — `w:lvlJc`, which is what the built-in Roman numberings use — grows into the margin and is fine). Reading the value back naively also moves the markers of our own ODT exports — LibreOffice draws its own flat hanging at exactly the value odf-kit writes for level 2; see `tests/render-parity/README.md` before building on it - Linked / chained text frames - Find & Replace by formatting or by style (the regular-expression half is implemented) diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 07deb52..7717f39 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -17,7 +17,7 @@ Covers the `table*.ts` extensions in `src/lib/editor/extensions/` and the table- - **`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. - **`tableFormula.ts`** (`TableFormula`) — a cell's **formula**, on the cell itself, which is where ODF puts it. The stored language is **Word's** (`SUM(ABOVE)`, `A1*2`, no leading `=`), the wider of the two; `utils/tableFormula.ts` holds the whole pure half — the tokenizer, the recursive-descent evaluator (`SUM PRODUCT AVERAGE MIN MAX COUNT ABS INT SIGN MOD ROUND`, ranges, Word's four directions, `+ - * / %` and parentheses) and the two translations. A **direction stops at the first cell holding no number**, a range does not. `evaluateTable` fills every formula cell in one pass, resolving a formula that reads another recursively behind a visited set, so a chain settles at once and a cycle yields 0 rather than looping. The `appendTransaction` recomputes on any doc change and writes the result as the cell's own text (keeping the first run's marks), **off the undo stack**; `editor.css` shades `td[data-formula]` the way both word processors shade a field, since typing over a result does not last. **ODF**: `applyCellFormulas` writes `table:formula="ooow:sum "` (ABOVE resolved to the range it stands for — LibreOffice has no word for it) plus `office:value-type="float"`/`office:value`. The `ooow:` in that value is a **namespace prefix**: undeclared, LibreOffice reads it as part of the formula and prints "Expression is faulty", so `ensureOoowNamespace` declares it (probed both ways). **DOCX**: the cell's first paragraph becomes one `SimpleField` `=SUM(ABOVE)` with the result cached; import reads the instruction back off the cell. LibreOffice's own formula engine parses a cell's **text** in its locale regardless of number recognition (probed), which is why the editor formats a result in the document language. -- **`tableSort.ts`** (`TableSort`) — LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort on the one key both put first: `sortedRows` is pure (rows in, rows out), stable, orders numerically where both cells parse as numbers and by `Intl.Collator` otherwise, and keeps a header row in place. `canSortTable` refuses a table with a vertically merged cell — such a cell belongs to more than one row, so there are no rows to reorder. The command rebuilds the table node in one transaction. +- **`tableSort.ts`** (`TableSort`) — LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort with the three keys both offer, each with its own direction and sort type; a later key decides only where the ones before it tie, and column `-1` is the key that is off. `sortedRows` is pure (rows in, rows out) and stable, so rows alike in every key keep the order they were typed in. The type is `auto` (numeric where both cells parse as numbers, else the collation), `number` — where a cell that is no number sorts last either way, as an empty cell does in a spreadsheet — or `text`, which is the **plain** collation: "10" before "2" is the whole point of choosing it, so `sortCollators` builds one collator with `numeric: true` and one without. `canSortTable` refuses a table with a vertically merged cell — such a cell belongs to more than one row, so there are no rows to reorder. The command rebuilds the table node in one transaction. - **Number recognition** (`storage/tableOptions.svelte.ts`) — LibreOffice's Table ▸ Number Recognition, **off** by default there (its `Writer/Table/Input/NumberRecognition` schema default) and so here. On, a cell whose text reads as a number is rewritten in the document language's general number format when the **cursor leaves it**, which is where LibreOffice applies it. It is an app option, not part of the file — LibreOffice keeps it in its own registry too — so only its effect travels. `setTableLanguage` is pushed from `App.svelte`'s language effect; the exporters take the same locale from the `language` argument instead, staying store-free. - **`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`. diff --git a/src/lib/components/TableSortDialog.svelte b/src/lib/components/TableSortDialog.svelte index 5f39b16..da4b144 100644 --- a/src/lib/components/TableSortDialog.svelte +++ b/src/lib/components/TableSortDialog.svelte @@ -2,9 +2,9 @@ import { untrack } from 'svelte'; import { t } from '../i18n/i18n.svelte'; import { colName } from '../utils/tableFormula'; - import type { TableSortOptions } from '../editor/extensions/tableSort'; - // LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort, on the one key both put - // first. The parent mounts it and holds the cell selection the sort runs against. + import type { SortKey, SortType, TableSortOptions } from '../editor/extensions/tableSort'; + // LibreOffice's Table ▸ Sort / Word's Layout ▸ Sort: the three keys both offer, the + // later ones off at column "none". The parent holds the cell selection it runs against. let { columns, column = 0, @@ -21,36 +21,57 @@ } = $props(); // Seeded from the cell the dialog opened on; from there the fields are the user's. - let pick = $state(untrack(() => Math.min(Math.max(0, column), Math.max(0, columns - 1)))); + const seed = untrack(() => Math.min(Math.max(0, column), Math.max(0, columns - 1))); + let keys = $state([ + { column: seed, descending: false, type: 'auto' }, + { column: -1, descending: false, type: 'auto' }, + { column: -1, descending: false, type: 'auto' }, + ]); let header = $state(untrack(() => headerRow)); - let descending = $state(false); - let firstField = $state(null); + let root = $state(null); $effect(() => { - queueMicrotask(() => firstField?.focus()); + queueMicrotask(() => root?.querySelector('select')?.focus()); }); + const types: { v: SortType; label: string }[] = $derived([ + { v: 'auto', label: t().table.sortTypeAuto }, + { v: 'number', label: t().table.sortTypeNumber }, + { v: 'text', label: t().table.sortTypeText }, + ]); + + const apply = () => onApply({ keys: keys.filter((k) => k.column >= 0), headerRow: header }); + function onKeydown(e: KeyboardEvent) { - if (e.key === 'Enter') { e.preventDefault(); onApply({ column: pick, descending, headerRow: header }); } + if (e.key === 'Enter') { e.preventDefault(); apply(); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } } -