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
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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