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:
marcandClaude Opus 5 committed 2026-08-09 01:38:03 +02:00
1 parent 3acb8b66a4
commit 0a0864c58b
27 files changed
+2962 -86

No files matched your search

@@ -435,6 +435,16 @@ export function NoteEditor({
display: "flex",
flexDirection: "column",
height: "100%",
/* Fill the pane rather than the toolbar.
*
* As a flex item with the default `flex: 0 1 auto` this box was as wide
* as its widest row — the toolbar. Switching to Draw swaps a dozen
* formatting buttons for six drawing ones, the toolbar's natural width
* collapses, and the page took it with it: the paper measurably shrank
* from 733px to 460px on the same screen. Growing to the pane makes the
* width a property of the window, which is what it always looked like. */
flex: 1,
minWidth: 0,
background: isMobileViewport
? T.bg
: dark
@@ -464,6 +474,7 @@ export function NoteEditor({
T={{ ...T, danger: "#e5534b" }}
icons={I}
compact={isMobileViewport}
dark={dark}
/>
) : (
<>
+154 -20
View File
@@ -1,8 +1,104 @@
import { useEffect, useRef, useState } from "react";
import { I } from "../../ui/icons.jsx";
import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { SORT_LABELS, SORT_MODES } from "./noteSort";
/** The middle column: every note in the selected folder, newest or best match first. */
const iconButton = (T) => ({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
});
/**
* How this notebook's notes are ordered.
*
* The chosen order is stored with the notebook, so it is the same on every
* device and still there next week — a reference book stays alphabetical, a
* journal stays newest-first, without either being re-chosen on each visit.
*/
function SortMenu({ T, mode, onChange, disabled }) {
const [open, setOpen] = useState(false);
const box = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => {
if (!box.current?.contains(e.target)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [open]);
return (
<div style={{ position: "relative" }} ref={box}>
<button
onClick={() => setOpen((v) => !v)}
disabled={disabled}
title={disabled ? "Sortierung" : `Sortierung: ${SORT_LABELS[mode]}`}
aria-label="Sort notes"
data-sort-button={mode}
style={{ ...iconButton(T), opacity: disabled ? 0.4 : 1, color: open ? T.accent : T.muted }}
>
{I.sort}
</button>
{open && (
<div
style={{
position: "absolute",
top: 38,
right: 0,
minWidth: 190,
background: T.card,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 6,
zIndex: 300,
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
}}
>
{SORT_MODES.map((m) => (
<button
key={m}
data-sort-option={m}
onClick={() => {
onChange(m);
setOpen(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "7px 10px",
borderRadius: 8,
border: "none",
background: m === mode ? T.accentSoft : "transparent",
color: m === mode ? T.accent : T.text,
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
<span style={{ width: 12 }}>{m === mode ? "✓" : ""}</span>
{SORT_LABELS[m]}
</button>
))}
</div>
)}
</div>
);
}
/** The middle column: every note in the selected folder, in the chosen order. */
export function NoteList({
T,
isMobile,
@@ -12,11 +108,16 @@ export function NoteList({
isTrashView,
trashedCount,
snippetFor,
sortMode,
onSortModeChange,
reorderable,
dnd,
onToggleSidebar,
onSelectNote,
onNoteContextMenu,
onCreateNote,
onImportPdf,
onImportMarkdown,
onEmptyTrash,
}) {
return (
@@ -41,7 +142,7 @@ export function NoteList({
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<button
onClick={onToggleSidebar}
style={{
@@ -59,28 +160,35 @@ export function NoteList({
>
{I.menu}
</button>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
<h2
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</h2>
</div>
<div style={{ display: "flex", gap: 4 }}>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{!isTrashView && <SortMenu T={T} mode={sortMode} onChange={onSortModeChange} />}
{!isTrashView && (
<button
onClick={onImportPdf}
title="Import PDF"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
onClick={onImportMarkdown}
title="Notizen importieren (Markdown oder ZIP)"
data-import-button
style={iconButton(T)}
>
{I.import}
</button>
)}
{!isTrashView && (
<button onClick={onImportPdf} title="Import PDF" style={iconButton(T)}>
{I.pdf}
</button>
)}
@@ -129,6 +237,23 @@ export function NoteList({
</div>
</div>
{/* Said once, where it matters: the arrangement is being edited, and the
search box would otherwise silently take that ability away. */}
{sortMode === "manual" && !isTrashView && (
<div
style={{
padding: "5px 16px",
fontSize: 10.5,
color: T.faint,
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{reorderable
? "Eigene Reihenfolge — zum Umsortieren ziehen"
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
</div>
)}
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
{notes.length === 0 ? (
<div
@@ -172,10 +297,16 @@ export function NoteList({
) : (
notes.map((note) => {
const snippet = snippetFor(note.id);
const isTarget = reorderable && dnd?.overId === note.id;
return (
<button
key={note.id}
data-note-row={note.id}
draggable={reorderable}
onDragStart={reorderable ? (e) => dnd.onDragStart(e, note.id) : undefined}
onDragOver={reorderable ? (e) => dnd.onDragOver(e, note.id) : undefined}
onDrop={reorderable ? (e) => dnd.onDrop(e, note.id) : undefined}
onDragEnd={reorderable ? dnd.onDragEnd : undefined}
onClick={() => onSelectNote(note.id)}
onContextMenu={(e) => onNoteContextMenu(e, note)}
style={{
@@ -185,8 +316,9 @@ export function NoteList({
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
borderTop: isTarget ? `2px solid ${T.accent}` : undefined,
background: activeNote === note.id ? T.accentSoft : "transparent",
cursor: "pointer",
cursor: reorderable ? "grab" : "pointer",
marginBottom: 2,
textAlign: "left",
}}
@@ -194,7 +326,9 @@ export function NoteList({
<div
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
>
<span style={{ fontSize: 13 }}>{note.trashed ? "🗑️" : "📝"}</span>
<span style={{ fontSize: 13 }}>
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
</span>
<span
style={{
fontSize: 13,
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { canReorder, reindex, sortNotes } from "./noteSort";
import type { NoteMeta } from "../../types";
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
id,
title: id,
folderId: "nb",
starred: false,
trashed: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...over,
});
const ids = (list: NoteMeta[]) => list.map((n) => n.id);
describe("sortNotes", () => {
const notes = [
note("b", { updatedAt: "2026-05-02T00:00:00Z", createdAt: "2026-01-03T00:00:00Z", sortOrder: 2 }),
note("a", { updatedAt: "2026-05-03T00:00:00Z", createdAt: "2026-01-01T00:00:00Z", sortOrder: 1 }),
note("c", { updatedAt: "2026-05-01T00:00:00Z", createdAt: "2026-01-02T00:00:00Z", sortOrder: 0 }),
];
it("puts the most recently changed first", () => {
expect(ids(sortNotes(notes, "updated"))).toEqual(["a", "b", "c"]);
});
it("reverses that for oldest", () => {
expect(ids(sortNotes(notes, "oldest"))).toEqual(["c", "b", "a"]);
});
it("sorts by creation date, which is not the same as by change date", () => {
expect(ids(sortNotes(notes, "created"))).toEqual(["b", "c", "a"]);
});
it("follows the stored positions when manual", () => {
expect(ids(sortNotes(notes, "manual"))).toEqual(["c", "a", "b"]);
});
it("does not mutate the list it was given", () => {
const original = [...notes];
sortNotes(notes, "title");
expect(notes).toEqual(original);
});
it("sorts titles the way German expects, ignoring case and numbering", () => {
const list = [
note("3", { title: "Übung 10" }),
note("1", { title: "übung 2" }),
note("2", { title: "Alpha" }),
];
expect(sortNotes(list, "title").map((n) => n.title)).toEqual(["Alpha", "übung 2", "Übung 10"]);
});
it("orders notes that have never been dragged newest first, not arbitrarily", () => {
// Everything imported at once carries position 0; without a tie-break the
// list would come out in whatever order the store happened to return.
const fresh = [
note("old", { updatedAt: "2026-01-01T00:00:00Z" }),
note("new", { updatedAt: "2026-06-01T00:00:00Z" }),
note("mid", { updatedAt: "2026-03-01T00:00:00Z" }),
];
expect(ids(sortNotes(fresh, "manual"))).toEqual(["new", "mid", "old"]);
});
it("is stable across repeated sorts of an entirely tied list", () => {
const tied = [note("z"), note("m"), note("a")];
const once = ids(sortNotes(tied, "manual"));
expect(ids(sortNotes([...tied].reverse(), "manual"))).toEqual(once);
});
});
describe("reindex", () => {
const all = [
note("a", { sortOrder: 0 }),
note("b", { sortOrder: 1 }),
note("c", { sortOrder: 2 }),
note("d", { sortOrder: 3 }),
];
it("moves a note onto the position of the one it was dropped on", () => {
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
});
it("works upwards as well as downwards", () => {
expect(reindex(all, "a", "c")).toEqual(["b", "c", "a", "d"]);
});
it("reports nothing to do when a note is dropped on itself", () => {
expect(reindex(all, "b", "b")).toBeNull();
});
it("reports nothing to do for a note that is not in the notebook", () => {
expect(reindex(all, "ghost", "b")).toBeNull();
});
/* The point of the whole design: an arrangement has to survive being looked
* away from. This is the case that used to go wrong in note apps — reorder
* against a filtered list and the hidden notes keep positions that now
* collide with the visible ones. */
it("keeps the arrangement intact when the view was filtered in between", () => {
const arranged = reindex(all, "d", "a")!;
const positioned = all.map((n) => ({ ...n, sortOrder: arranged.indexOf(n.id) }));
// The user searches, sees only two notes, then goes back to manual order.
const filtered = positioned.filter((n) => n.id === "b" || n.id === "d");
expect(ids(sortNotes(filtered, "manual"))).toEqual(["d", "b"]);
// Clearing the search must show exactly what was arranged.
expect(ids(sortNotes(positioned, "manual"))).toEqual(arranged);
});
it("renumbers every note in the notebook, not only the ones on screen", () => {
const order = reindex(all, "c", "a")!;
expect(order).toHaveLength(all.length);
expect(new Set(order)).toEqual(new Set(ids(all)));
});
});
describe("canReorder", () => {
it("allows dragging only in manual order", () => {
expect(canReorder("manual", false)).toBe(true);
expect(canReorder("updated", false)).toBe(false);
});
it("refuses while a search is narrowing the list", () => {
expect(canReorder("manual", true)).toBe(false);
});
});
+89
View File
@@ -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;