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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user