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

+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from "react";
import { COVERS } from "./constants.jsx";
/**
* Asks which notebook something should go into.
*
* "All Notes", "Starred" and the dashboard are views, not places — a note
* created while one of them is open has no notebook to belong to. The app used
* to answer that silently with `notebooks[0]`, so a note or an imported PDF
* landed in whichever notebook happened to sort first. Nothing was lost, but
* finding it again meant knowing that rule.
*
* The last choice is remembered, so the common case — always the same notebook —
* is one Enter away, while the answer stays visible instead of being guessed.
*/
const LAST_KEY = "inknote_last_notebook";
export const rememberNotebook = (id) => {
try {
localStorage.setItem(LAST_KEY, id);
} catch {
/* private mode: the default simply is not remembered */
}
};
const recalledNotebook = () => {
try {
return localStorage.getItem(LAST_KEY);
} catch {
return null;
}
};
export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPick, onClose, dark }) {
const remembered = recalledNotebook();
const [selected, setSelected] = useState(
() => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null
);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
row: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose();
if (e.key === "Enter" && selected) confirm();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const confirm = () => {
if (!selected) return;
rememberNotebook(selected);
onPick(selected);
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.bg,
borderRadius: 16,
padding: 24,
width: 400,
maxWidth: "92vw",
border: `1px solid ${T.border}`,
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
fontFamily: "'DM Sans',sans-serif",
}}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
marginBottom: 4,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{title}
</div>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>In welches Notizbuch soll es?</div>
<div
role="listbox"
aria-label="Notebook"
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 }}
>
{notebooks.map((nb) => {
const active = nb.id === selected;
return (
<button
key={nb.id}
role="option"
aria-selected={active}
data-notebook-option={nb.id}
onClick={() => setSelected(nb.id)}
onDoubleClick={confirm}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${active ? T.accent + "66" : "transparent"}`,
background: active ? T.accent + "1a" : T.row,
color: T.text,
cursor: "pointer",
fontSize: 13,
textAlign: "left",
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: 3,
flexShrink: 0,
background: COVERS[nb.cover % COVERS.length].bg,
}}
/>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{nb.name}
</span>
{nb.id === remembered && (
<span style={{ fontSize: 10, color: T.muted }}>zuletzt benutzt</span>
)}
</button>
);
})}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 18 }}>
<button
onClick={onClose}
style={{
padding: "8px 18px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Abbrechen
</button>
<button
onClick={confirm}
disabled={!selected}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: selected ? T.accent : T.border,
color: "#fff",
cursor: selected ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}