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

+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,