Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3acb8b66a4
commit
0a0864c58b
27 files changed
+2962
-86
No files matched your search
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The order notes appear in inside a notebook.
|
||||
*
|
||||
* Kept as a pure function so the awkward part can be tested: the promise that
|
||||
* switching away to another order — or searching, which imposes its own — and
|
||||
* then switching back leaves the user's own arrangement exactly as they left it.
|
||||
*
|
||||
* That promise is only keepable if the manual order lives in the notes and is
|
||||
* never re-derived from whatever happens to be on screen. Hence `reindex`, which
|
||||
* takes the *whole* notebook and refuses to renumber a filtered subset: doing so
|
||||
* would give a hidden note the same position as a visible one and quietly
|
||||
* scramble everything that was not being looked at.
|
||||
*/
|
||||
|
||||
import type { NoteMeta, SortMode } from "../../types";
|
||||
|
||||
export const DEFAULT_SORT: SortMode = "updated";
|
||||
|
||||
export const SORT_LABELS: Record<SortMode, string> = {
|
||||
manual: "Eigene Reihenfolge",
|
||||
updated: "Zuletzt geändert",
|
||||
oldest: "Am ältesten",
|
||||
created: "Zuletzt erstellt",
|
||||
title: "Titel A–Z",
|
||||
};
|
||||
|
||||
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
|
||||
|
||||
const time = (value: string | undefined) => {
|
||||
const t = Date.parse(value ?? "");
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
};
|
||||
|
||||
const byTitle = (a: NoteMeta, b: NoteMeta) =>
|
||||
(a.title || "").localeCompare(b.title || "", "de", { sensitivity: "base", numeric: true });
|
||||
|
||||
/**
|
||||
* Sorts a list without mutating it.
|
||||
*
|
||||
* Every comparison ends in a tie-break that cannot itself tie: two notes created
|
||||
* in the same second, or two untouched imports that all carry position 0, must
|
||||
* still come out in the same order on every render, or the list shuffles under
|
||||
* the cursor as React re-renders.
|
||||
*/
|
||||
export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): NoteMeta[] {
|
||||
const stable = (a: NoteMeta, b: NoteMeta) => a.id.localeCompare(b.id);
|
||||
|
||||
const compare: Record<SortMode, (a: NoteMeta, b: NoteMeta) => number> = {
|
||||
// Notes that have never been dragged share position 0; newest first within
|
||||
// that group, so a fresh note appears at the top rather than at random.
|
||||
manual: (a, b) =>
|
||||
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
|
||||
updated: (a, b) => time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
|
||||
oldest: (a, b) => time(a.updatedAt) - time(b.updatedAt) || stable(a, b),
|
||||
created: (a, b) => time(b.createdAt) - time(a.createdAt) || stable(a, b),
|
||||
title: (a, b) => byTitle(a, b) || stable(a, b),
|
||||
};
|
||||
|
||||
return [...notes].sort(compare[mode] ?? compare[DEFAULT_SORT]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full notebook renumbered after a note was dragged onto another.
|
||||
*
|
||||
* `all` must be every note in the notebook, not the filtered view. Returns the
|
||||
* ids in their new order, or null when the move changes nothing — which spares
|
||||
* the caller a pointless write.
|
||||
*/
|
||||
export function reindex(all: NoteMeta[], dragId: string, targetId: string): string[] | null {
|
||||
if (dragId === targetId) return null;
|
||||
const ordered = sortNotes(all, "manual");
|
||||
const from = ordered.findIndex((n) => n.id === dragId);
|
||||
const to = ordered.findIndex((n) => n.id === targetId);
|
||||
if (from < 0 || to < 0) return null;
|
||||
|
||||
const [moved] = ordered.splice(from, 1);
|
||||
if (!moved) return null;
|
||||
ordered.splice(to, 0, moved);
|
||||
return ordered.map((n) => n.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the manual order may be changed right now.
|
||||
*
|
||||
* Dragging while a search is narrowing the list would reorder against a view the
|
||||
* hidden notes are not part of — so it is simply not offered, rather than being
|
||||
* offered and then producing an order nobody arranged.
|
||||
*/
|
||||
export const canReorder = (mode: SortMode, filtering: boolean) => mode === "manual" && !filtering;
|
||||
Reference in new issue
Block a user