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
+124
-30
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user