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

+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { importFiles } from "../import/importNotes";
/**
* Runs an import and says plainly what came of it.
*
* The report is the point. An import can succeed in the sense that no error was
* thrown and still not be what the user expected — a drawing arrives as a flat
* picture, an image the archive did not contain cannot be restored. Those are
* stated afterwards rather than left to be discovered, because the moment to
* learn that a drawing is no longer editable is now, while the original export
* is still on disk.
*/
export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
const [progress, setProgress] = useState({ done: 0, total: 0, label: "" });
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const started = useRef(false);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
warning: dark ? "#d29922" : "#9a6700",
danger: dark ? "#f85149" : "#cf222e",
track: dark ? "#21262d" : "#e5e7eb",
}),
[dark]
);
useEffect(() => {
// React runs effects twice in development; importing twice would create
// every note a second time.
if (started.current) return;
started.current = true;
importFiles(files, folderId, setProgress)
.then((r) => {
setResult(r);
onDone?.(r);
})
.catch((e) => setError(e?.message || "Der Import ist fehlgeschlagen."));
}, [files, folderId, onDone]);
const done = result || error;
const pct = progress.total ? Math.round((progress.done / progress.total) * 100) : 0;
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={done ? onClose : undefined}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.bg,
borderRadius: 16,
padding: 24,
width: 440,
maxWidth: "92vw",
border: `1px solid ${T.border}`,
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
fontFamily: "'DM Sans',sans-serif",
color: T.text,
}}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
marginBottom: 14,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{done ? "Import abgeschlossen" : "Notizen werden importiert…"}
</div>
{!done && (
<>
<div style={{ height: 6, borderRadius: 3, background: T.track, overflow: "hidden" }}>
<div
style={{
width: `${pct}%`,
height: "100%",
background: T.accent,
transition: "width .2s",
}}
/>
</div>
<div style={{ fontSize: 12, color: T.muted, marginTop: 8 }}>
{progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"}
{progress.label ? ` — ${progress.label}` : ""}
</div>
</>
)}
{error && <div style={{ fontSize: 13, color: T.danger }}>{error}</div>}
{result && (
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
<div>
<strong>{result.notes.length}</strong>{" "}
{result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"}
</div>
{result.flattenedDrawings > 0 && (
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
{result.flattenedDrawings === 1
? "Eine Zeichnung kam als Bild zurück"
: `${result.flattenedDrawings} Zeichnungen kamen als Bilder zurück`}{" "}
— im Archiv liegen sie als PNG, und aus Pixeln lassen sich die Striche nicht zurückrechnen.
Sie sind sichtbar und exportierbar, aber nicht mehr mit dem Stift zu bearbeiten.
</div>
)}
{result.missingImages > 0 && (
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
{result.missingImages} {result.missingImages === 1 ? "Bild lag" : "Bilder lagen"} nicht im
Archiv. Die Stelle in der Notiz bleibt erhalten, das Bild fehlt.
</div>
)}
{result.failed.length > 0 && (
<div style={{ color: T.danger, marginTop: 10, fontSize: 12.5 }}>
Nicht gelesen: {result.failed.join(", ")}
</div>
)}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 20 }}>
<button
onClick={onClose}
disabled={!done}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: done ? T.accent : T.track,
color: done ? "#fff" : T.muted,
cursor: done ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{done ? "Fertig" : "Läuft…"}
</button>
</div>
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Attaching a web address to the selected text.
*
* This replaces `window.prompt`. That dialog cannot be styled, cannot be
* reached by a phone keyboard's paste button on every browser, gives no room to
* say *why* an address was refused, and — worst here — it is modal to the whole
* browser, so it steals focus from the editor and takes the selection with it on
* some engines. A real dialog also lets an existing link be removed with a
* button instead of the folklore of "leave the box empty".
*
* Only `http`, `https` and `mailto` are allowed. `javascript:` in an href is a
* script that runs with the page's own privileges when a note is clicked, and
* notes are exported, shared and re-imported; the check lives here and in the
* editor schema both, because either one alone is one mistake from a hole.
*/
const ALLOWED = /^(https?|mailto):/i;
export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) {
const [url, setUrl] = useState(initialUrl);
const [error, setError] = useState("");
const inputRef = useRef(null);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
danger: dark ? "#f85149" : "#cf222e",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
useEffect(() => {
const onKey = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const submit = (e) => {
e?.preventDefault();
const value = url.trim();
if (!value) {
setError("Bitte eine Adresse eingeben — oder den Link entfernen.");
return;
}
// A bare "example.com" is what people type; assume https rather than
// refusing something whose meaning is perfectly clear.
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`;
if (!ALLOWED.test(candidate)) {
setError("Nur http-, https- und mailto-Adressen sind erlaubt.");
return;
}
onSubmit(candidate);
};
const button = (kind) => ({
padding: "8px 18px",
borderRadius: 10,
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
border: kind === "primary" ? "none" : `1px solid ${T.border}`,
background: kind === "primary" ? T.accent : "transparent",
color: kind === "primary" ? "#fff" : kind === "danger" ? T.danger : T.text,
});
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}
>
<form
onSubmit={submit}
onClick={(e) => e.stopPropagation()}
style={{
background: T.bg,
borderRadius: 16,
padding: 24,
width: 430,
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: 14,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{initialUrl ? "Link bearbeiten" : "Link einfügen"}
</div>
<input
ref={inputRef}
type="text"
aria-label="Link address"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setError("");
}}
placeholder="https://example.com"
spellCheck={false}
autoComplete="url"
style={{
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${error ? T.danger : T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
outline: "none",
}}
/>
<div style={{ minHeight: 18, marginTop: 6, fontSize: 11.5, color: error ? T.danger : T.muted }}>
{error || "http, https oder mailto"}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 12 }}>
{initialUrl && (
<button type="button" onClick={onRemove} style={{ ...button("danger"), marginRight: "auto" }}>
Link entfernen
</button>
)}
<button type="button" onClick={onClose} style={button()}>
Abbrechen
</button>
<button type="submit" style={button("primary")}>
{initialUrl ? "Ändern" : "Einfügen"}
</button>
</div>
</form>
</div>
);
}
+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>
);
}
+72 -2
View File
@@ -470,6 +470,9 @@ export const I = {
<path d="M14 18v-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2z" />
</svg>
),
/* Export saves a file to the device, so the arrow points down into the tray.
* It used to point up and out, which is the shape every other program uses
* for uploading or sharing — the opposite of what this button does. */
export: (
<svg
width="16"
@@ -482,8 +485,75 @@ export const I = {
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
<polyline points="8 11 12 15 16 11" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
/* Reading a file in — the mirror of `export`, for the Markdown importer. */
import: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="16 7 12 3 8 7" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
),
eye: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
eyeOff: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
),
sort: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="7" x2="16" y2="7" />
<line x1="4" y1="12" x2="13" y2="12" />
<line x1="4" y1="17" x2="10" y2="17" />
<polyline points="17 14 20 17 23 14" />
<line x1="20" y1="17" x2="20" y2="7" />
</svg>
),
users: (