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:
1 parent
24402dd372
commit
dab1d662c9
7 files changed
+153
-63
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user