feat: the table sort gets its three keys and a sort type

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.
This commit is contained in:
Steffen Becker committed 2026-08-15 10:12:24 +02:00
1 parent 24402dd372
commit dab1d662c9
7 files changed
+153 -63

No files matched your search

+51 -34
View File
@@ -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<SortKey[]>([
{ 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<HTMLSelectElement | null>(null);
let root = $state<HTMLElement | null>(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(); }
}
</script>
<div class="sort-dialog" role="dialog" tabindex="-1" aria-label={t().table.sortDialogLabel} onkeydown={onKeydown}>
<label class="sort-row">
<span>{t().table.sortBy}</span>
<select bind:this={firstField} bind:value={pick}>
{#each Array.from({ length: columns }, (_, i) => i) as i}
<option value={i}>{colName(i)}</option>
{/each}
</select>
</label>
<div class="sort-dirs" role="radiogroup" aria-label={t().table.sortDialogLabel}>
{#each [{ v: false, label: t().table.sortAscending }, { v: true, label: t().table.sortDescending }] as d}
<label class="sort-radio">
<input type="radio" value={d.v} bind:group={descending} />
<span>{d.label}</span>
</label>
<div
class="sort-dialog"
bind:this={root}
role="dialog"
tabindex="-1"
aria-label={t().table.sortDialogLabel}
onkeydown={onKeydown}
>
<div class="sort-keys">
{#each keys as key, i}
<span class="sort-label">{i === 0 ? t().table.sortBy : t().table.sortThenBy}</span>
<select bind:value={key.column} aria-label={i === 0 ? t().table.sortBy : t().table.sortThenBy}>
{#if i > 0}<option value={-1}>{t().table.sortNone}</option>{/if}
{#each Array.from({ length: columns }, (_, c) => c) as c}
<option value={c}>{colName(c)}</option>
{/each}
</select>
<select bind:value={key.type} aria-label={t().table.sortType} disabled={key.column < 0}>
{#each types as ty}<option value={ty.v}>{ty.label}</option>{/each}
</select>
<select bind:value={key.descending} aria-label={t().table.sortDirection} disabled={key.column < 0}>
<option value={false}>{t().table.sortAscending}</option>
<option value={true}>{t().table.sortDescending}</option>
</select>
{/each}
</div>
<label class="sort-radio">
@@ -60,9 +81,7 @@
<div class="sort-actions">
<span class="sort-spacer"></span>
<button class="sort-cancel" onclick={onClose}>{t().common.cancel}</button>
<button class="sort-apply" onclick={() => onApply({ column: pick, descending, headerRow: header })}>
{t().common.ok}
</button>
<button class="sort-apply" onclick={apply}>{t().common.ok}</button>
</div>
</div>
@@ -78,16 +97,17 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
}
.sort-row {
display: flex;
.sort-keys {
display: grid;
grid-template-columns: auto auto auto auto;
align-items: center;
gap: 0.5rem;
gap: 0.35rem 0.4rem;
font-size: 0.75rem;
color: var(--color-text);
}
select {
min-width: 5rem;
min-width: 4.5rem;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
@@ -96,10 +116,7 @@
font-size: 0.85rem;
}
.sort-dirs {
display: flex;
gap: 0.8rem;
}
select:disabled { opacity: 0.5; }
.sort-radio {
display: flex;
+55 -19
View File
@@ -6,9 +6,9 @@ import { cellGrid } from './tableFormula';
import { parseCellNumber, type NumberLocale } from '../../utils/tableFormula';
import { tableLanguage, tableNumberLocale } from '../../storage/tableOptions.svelte';
// LibreOffice's Table ▸ Sort and Word's Table Layout ▸ Sort, reduced to the one
// sort key both offer first. The type is detected per cell rather than chosen:
// a column of numbers sorts numerically, anything else by the document's collation.
// LibreOffice's Table ▸ Sort and Word's Table Layout ▸ Sort: up to three keys, each
// with its own direction and sort type. `auto` is the detected type — a cell that
// reads as a number sorts numerically, anything else by the document's collation.
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -18,10 +18,18 @@ declare module '@tiptap/core' {
}
}
export type TableSortOptions = {
export type SortType = 'auto' | 'number' | 'text';
export type SortKey = {
/** 0-based grid column the rows are ordered by. */
column: number;
descending: boolean;
type: SortType;
};
export type TableSortOptions = {
/** The keys in order; the second decides only where the first ties. */
keys: SortKey[];
/** Keep the first row where it is, as both sort dialogs offer. */
headerRow: boolean;
};
@@ -37,29 +45,58 @@ export function canSortTable(table: PMNode): boolean {
return ok;
}
export type SortCollators = { natural: Intl.Collator; plain: Intl.Collator };
// Alphanumeric is the plain collation — it puts "10" before "2", which is the whole
// point of picking it over the detected type; detected, a number stays a number.
export function sortCollators(lang: string): SortCollators {
return {
natural: new Intl.Collator(lang, { numeric: true, sensitivity: 'variant' }),
plain: new Intl.Collator(lang, { sensitivity: 'variant' }),
};
}
type Cell = { text: string; num: number | null };
function compareCells(a: Cell, b: Cell, key: SortKey, coll: SortCollators): number {
const dir = key.descending ? -1 : 1;
if (a.num != null && b.num != null) return dir * (a.num - b.num);
// Sorted numerically, a cell that is no number sorts last either way, as an empty
// cell does in every spreadsheet.
if (key.type === 'number') return a.num != null ? -1 : b.num != null ? 1 : 0;
// Detected, a number goes ahead of text: the order a mixed column sorts in has to be
// decided, and this is the one every spreadsheet takes.
if (key.type === 'auto' && a.num != null) return -dir;
if (key.type === 'auto' && b.num != null) return dir;
return dir * (key.type === 'text' ? coll.plain : coll.natural).compare(a.text, b.text);
}
export function sortedRows(
table: PMNode,
opts: TableSortOptions,
loc: NumberLocale,
collator: Intl.Collator,
coll: SortCollators,
): PMNode[] {
const grid = cellGrid(table);
const rows: PMNode[] = [];
table.forEach((row) => rows.push(row));
const first = opts.headerRow ? 1 : 0;
const keyed = rows.slice(first).map((row, i) => {
const text = grid.nodeAt({ row: first + i, col: opts.column })?.textContent.trim() ?? '';
return { row, text, num: parseCellNumber(text, loc) };
});
const dir = opts.descending ? -1 : 1;
// Stable, so rows sharing a key keep the order they were typed in.
const keys = opts.keys.filter((k) => k.column >= 0);
if (!keys.length) return rows;
const keyed = rows.slice(first).map((row, i) => ({
row,
cells: keys.map((k) => {
const text = grid.nodeAt({ row: first + i, col: k.column })?.textContent.trim() ?? '';
return { text, num: k.type === 'text' ? null : parseCellNumber(text, loc) };
}),
}));
// Stable, so rows sharing every key keep the order they were typed in.
keyed.sort((a, b) => {
if (a.num != null && b.num != null) return dir * (a.num - b.num);
// A number ahead of text: the order a mixed column sorts in has to be decided,
// and this is the one every spreadsheet takes.
if (a.num != null) return -dir;
if (b.num != null) return dir;
return dir * collator.compare(a.text, b.text);
for (let i = 0; i < keys.length; i++) {
const c = compareCells(a.cells[i], b.cells[i], keys[i], coll);
if (c) return c;
}
return 0;
});
return [...rows.slice(0, first), ...keyed.map((k) => k.row)];
}
@@ -76,8 +113,7 @@ export const TableSort = Extension.create({
const rect = selectedRect(state);
if (!canSortTable(rect.table)) return false;
if (dispatch) {
const collator = new Intl.Collator(tableLanguage(), { numeric: true, sensitivity: 'variant' });
const rows = sortedRows(rect.table, options, tableNumberLocale(), collator);
const rows = sortedRows(rect.table, options, tableNumberLocale(), sortCollators(tableLanguage()));
const table = rect.table.type.create(rect.table.attrs, rows, rect.table.marks);
const from = rect.tableStart - 1;
dispatch(state.tr.replaceWith(from, from + rect.table.nodeSize, table));
+7
View File
@@ -382,6 +382,13 @@ const de: Messages = {
sortAria: 'Sortieren',
sortDialogLabel: 'Tabelle sortieren',
sortBy: 'Sortieren nach',
sortThenBy: 'Dann nach',
sortNone: '(keine)',
sortDirection: 'Reihenfolge',
sortType: 'Sortierart',
sortTypeAuto: 'Automatisch',
sortTypeNumber: 'Numerisch',
sortTypeText: 'Alphanumerisch',
sortAscending: 'Aufsteigend',
sortDescending: 'Absteigend',
sortHeaderRow: 'Erste Zeile ist eine Kopfzeile',
+7
View File
@@ -386,6 +386,13 @@ const en = {
sortAria: 'Sort',
sortDialogLabel: 'Sort table',
sortBy: 'Sort by',
sortThenBy: 'Then by',
sortNone: '(none)',
sortDirection: 'Order',
sortType: 'Sort type',
sortTypeAuto: 'Automatic',
sortTypeNumber: 'Numerically',
sortTypeText: 'Alphanumerically',
sortAscending: 'Ascending',
sortDescending: 'Descending',
sortHeaderRow: 'First row is a header',