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

+124 -30
View File
@@ -18,9 +18,12 @@ import { AccountMenu } from "./ui/AccountMenu.jsx";
import { AuthScreen } from "./ui/AuthScreen.jsx";
import { AdminPanel } from "./ui/AdminPanel.jsx";
import { ExportDialog } from "./ui/ExportDialog.jsx";
import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx";
import { ImportDialog } from "./ui/ImportDialog.jsx";
import { Sidebar } from "./ui/Sidebar.jsx";
import { Dashboard } from "./features/notebooks/Dashboard.jsx";
import { NoteList } from "./features/notes/NoteList.jsx";
import { DEFAULT_SORT, canReorder, sortNotes } from "./features/notes/noteSort";
import { EditorPane } from "./features/notes/EditorPane.jsx";
import { HistoryModal } from "./features/notes/HistoryModal.jsx";
import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx";
@@ -54,6 +57,14 @@ export default function App() {
const [showAdmin, setShowAdmin] = useState(false);
// { notes, label } — set while the export dialog is up.
const [exporting, setExporting] = useState(null);
// { title, run } — set while the app is asking which notebook to use.
const [pickingNotebook, setPickingNotebook] = useState(null);
// Set while files are being imported; holds the progress and then the result.
const [importing, setImporting] = useState(null);
/* Sort order for the views that are not notebooks. Those have nowhere to
store a choice — "All Notes" is not a thing that can carry a setting — so it
lasts for the session and no longer. */
const [virtualSort, setVirtualSort] = useState({});
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
@@ -68,6 +79,7 @@ export default function App() {
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
const noteDnd = useNotebookDnd(data.reorderNotes);
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
@@ -153,6 +165,23 @@ export default function App() {
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
const activeNotebook = notebooks.find((nb) => nb.id === activeFolder) || null;
/* A real notebook remembers its order on the server; a view keeps one for the
session. Either way the note list is told a single mode. */
const sortMode = activeNotebook
? activeNotebook.sortMode || DEFAULT_SORT
: virtualSort[activeFolder] || DEFAULT_SORT;
const setSortMode = useCallback(
(mode) => {
if (activeNotebook) void data.updateNotebook(activeNotebook.id, { sortMode: mode });
else setVirtualSort((p) => ({ ...p, [activeFolder]: mode }));
},
[activeNotebook, activeFolder, data]
);
const isSearching = searchQuery.trim().length > 0;
const filteredNotes = useMemo(() => {
let list;
if (activeFolder === "trash") list = trashedNotes;
@@ -171,8 +200,8 @@ export default function App() {
const q = searchQuery.toLowerCase();
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]);
return sortNotes(list, sortMode);
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -234,46 +263,84 @@ export default function App() {
);
/* ── Actions ── */
/**
* Picks a file and imports it as a PDF note.
* Runs something that needs a notebook to put a note in.
*
* "All Notes", "Starred" and the dashboard are views, not places. The app used
* to answer that silently with the first notebook, so a note or an imported
* file landed somewhere the user had not chosen and could only find by knowing
* that rule. Now it asks — once, remembering the answer as the default.
*/
const withNotebook = useCallback(
(title, run) => {
if (!VIRTUAL_FOLDERS.includes(activeFolder)) {
rememberNotebook(activeFolder);
return run(activeFolder);
}
if (notebooks.length === 0) return;
// Nothing to ask about when there is only one place it could go.
if (notebooks.length === 1) return run(notebooks[0].id);
setPickingNotebook({ title, run });
},
[activeFolder, notebooks]
);
/**
* Opens a file chooser.
*
* The input is created and thrown away per use rather than living in the
* markup: a hidden input that persists keeps its previous selection, and
* choosing the same file twice in a row would then fire no change event.
*/
const importPdf = () => {
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
if (!folderId) return;
const chooseFiles = (accept, multiple, onChosen) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf,.pdf";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const note = await data.importPdf(folderId, file);
if (activeFolder === "dashboard") setActiveFolder(folderId);
openNote(note.id);
} catch (e) {
setConfirmDialog({
title: "Import fehlgeschlagen",
message: e?.message || "Die Datei konnte nicht importiert werden.",
confirmLabel: "OK",
onConfirm: () => setConfirmDialog(null),
});
}
input.accept = accept;
input.multiple = multiple;
input.onchange = () => {
const files = [...(input.files || [])];
if (files.length) onChosen(files);
};
input.click();
};
const createNote = async () => {
const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder;
if (!folderId) return;
const note = await data.createNote(folderId);
if (activeFolder === "dashboard") setActiveFolder(folderId);
openNote(note.id);
};
const reportFailure = (message) =>
setConfirmDialog({
title: "Import fehlgeschlagen",
message,
confirmLabel: "OK",
onConfirm: () => setConfirmDialog(null),
});
const importPdf = () =>
withNotebook("PDF importieren", (folderId) =>
chooseFiles("application/pdf,.pdf", false, async ([file]) => {
try {
const note = await data.importPdf(folderId, file);
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
openNote(note.id);
} catch (e) {
reportFailure(e?.message || "Die Datei konnte nicht importiert werden.");
}
})
);
/** Markdown files, or a ZIP written by this app's export. */
const importMarkdown = () =>
withNotebook("Notizen importieren", (folderId) =>
chooseFiles(".md,.markdown,.txt,.zip,text/markdown,application/zip", true, (files) => {
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
setImporting({ files, folderId });
})
);
const createNote = () =>
withNotebook("Neue Notiz", async (folderId) => {
const note = await data.createNote(folderId);
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
openNote(note.id);
});
/** Runs a destructive action behind a confirmation prompt. */
const confirm = (dialog) =>
@@ -518,11 +585,16 @@ export default function App() {
isTrashView={isTrashView}
trashedCount={trashedNotes.length}
snippetFor={search.snippetFor}
sortMode={sortMode}
onSortModeChange={setSortMode}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
dnd={noteDnd}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
onCreateNote={createNote}
onImportPdf={importPdf}
onImportMarkdown={importMarkdown}
onEmptyTrash={emptyTrash}
/>
)}
@@ -581,6 +653,28 @@ export default function App() {
dark={dark}
/>
)}
{pickingNotebook && (
<NotebookPicker
notebooks={notebooks}
title={pickingNotebook.title}
dark={dark}
onClose={() => setPickingNotebook(null)}
onPick={(id) => {
const { run } = pickingNotebook;
setPickingNotebook(null);
run(id);
}}
/>
)}
{importing && (
<ImportDialog
files={importing.files}
folderId={importing.folderId}
dark={dark}
onDone={() => data.reload()}
onClose={() => setImporting(null)}
/>
)}
{exporting && (
<ExportDialog
notes={exporting.notes}