Files
InkNote/frontend/src/App.jsx
T
marcandClaude Opus 5 c96885d5b3 A notebook opens in its own order
The hand-made order is where a notebook's own arrangement lives:
separators show, rows can be put where they belong, nothing slides about
by itself. Starting in "last changed" made both of those look broken
until the sort menu was found — pressing "+ Separator" there drew a line
the order cannot display, so the button appeared dead. A notebook nobody
has arranged still reads newest-first, because untouched rows all carry
position 0, so nothing is lost by starting there. The views keep "last
changed": they are questions asked of every notebook at once and have
nowhere to keep an arrangement. The button that draws a line is now only
offered in the order that can show one.

Removing a chapter left you stuck in the confirmation. Every destructive
action goes through one helper that takes the question down again; this
one set the dialog directly and bypassed it, so the chapter was removed
and the prompt stayed over the app. Closing now happens where the dialog
is rendered, so it happens whoever asked and however they asked.

The suites did not catch that, and the reason is worth keeping: they
read the list straight through the overlay, which is in the DOM the
whole time, and passed while the screen was in fact stuck. Both now
assert the question is gone afterwards.

The separator is no wider than its name. It sat in a fixed 132px slot —
chosen so the row would not jump when renaming began — with a short name
floating in a gap it did not fill.

Thirteen suites green: 345 browser checks, 241 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:45:03 +02:00

1219 lines
45 KiB
React

import { useCallback, useEffect, useMemo, useState } from "react";
import * as sync from "./syncEngine";
import { useNoteData } from "./hooks/useNoteData";
import { useSearchIndex } from "./hooks/useSearchIndex";
import { useResponsive } from "./hooks/useResponsive";
import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { NameDialog } from "./ui/NameDialog.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
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_NOTEBOOK_SORT,
DEFAULT_SORT,
canReorder,
sortModesFor,
sortNotes,
separatorsFor,
withNewSeparator,
} 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";
import {
CANON_W,
CANON_H,
PREVIEW_DRAWING_W,
drawnSheets,
sheetStrokes,
} from "./features/drawing/strokes.js";
/** Views that are not a notebook and therefore cannot hold a new note. */
const VIRTUAL_FOLDERS = ["all", "starred", "trash", "dashboard"];
const EMPTY_BODY = { content: "", drawingData: null, drawingStrokes: null };
/**
* Root of the application.
*
* Holds the things that describe *where the user is* — the open notebook, the
* open note, the search box, whichever dialog is up — and hands the data itself
* to `useNoteData`. Rendering is delegated to the three panes below.
*/
export default function App() {
const [dark, setDark] = useState(true);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [syncStatus, setSyncStatus] = useState("synced");
const [activeFolder, setActiveFolder] = useState("dashboard");
const [activeNote, setActiveNote] = useState(null);
/* Which chapter of the open notebook is being looked into; null is the
notebook itself. A chapter is a folder inside a notebook, so this is a
second step of "where the user is", not a second notebook. */
const [activeChapter, setActiveChapter] = useState(null);
/* Whether that chapter is the place being worked in.
*
* Open beside or below, a chapter does not replace the notebook's list — both
* are on screen at once, and "which of the two am I in" is then a real
* question. It used to be answered silently by "a chapter is open", so a new
* note went into the chapter however far away the user had since clicked. */
const [inChapter, setInChapter] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showNewNb, setShowNewNb] = useState(false);
const [editingNb, setEditingNb] = useState(null);
const [showAccount, setShowAccount] = useState(false);
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({});
// Which separator is being renamed. Held here because the context menu that
// starts it lives here too.
const [renamingSeparator, setRenamingSeparator] = useState(null);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
/* The one dialog that asks for a name — a chapter's, with its colour, or a
separator's. Held here because both are started from menus that live here. */
const [naming, setNaming] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
// Bumped on a version restore to force a fresh editor on the restored body.
const [restoreEpoch, setRestoreEpoch] = useState(0);
const T = useMemo(() => makeTheme(dark), [dark]);
const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive();
const { canInstall, reason: installReason, promptInstall } = usePwaInstall();
// Presentation only, and it lives on the account so it follows to the phone.
const noteFont = user?.preferences?.noteFont || "serif";
/* How a chapter opens: a further column beside the list, or unfolded under
its own row. A phone has room for neither, so there it fills the screen —
which is what "column" already did before there was a choice. */
const chapterView = user?.preferences?.chapterView || "column";
const inlineChapters = chapterView === "inline" && !isMobile;
const columnChapters = chapterView === "column" && !isMobile;
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
/* ── Session ── */
useEffect(() => {
(async () => {
try {
const u = await sync.restoreSession();
if (u) {
setUser(u);
await data.reload();
try {
await sync.fullSync();
await data.reload();
} catch (e) {
if (e.message === "AUTH") setUser(null);
}
}
} catch {
/* a broken session must not block the app from starting */
}
setLoading(false);
})();
}, []);
const onLogin = async (u) => {
setUser(u);
try {
await sync.fullSync();
} catch {
/* first sync can fail offline; local data still loads */
}
await data.reload();
};
const onLogout = async () => {
await sync.logout();
setUser(null);
data.reset();
setActiveNote(null);
setActiveFolder("dashboard");
};
/* ── Navigation ── */
const openFolder = useCallback(
(id) => {
setActiveFolder(id);
setActiveChapter(null);
setInChapter(false);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
setMobileView("list");
}
},
[isMobile, setSidebarOpen, setMobileView]
);
const openNote = useCallback(
(id) => {
setActiveNote(id);
if (isMobile) setMobileView("editor");
},
[isMobile, setMobileView]
);
const closeNote = useCallback(() => {
setActiveNote(null);
if (isMobile) setMobileView("list");
}, [isMobile, setMobileView]);
// Adopts a location the user reached with the browser's back button.
const applyNavState = useCallback(
(s) => {
setActiveFolder(s.folder);
setActiveNote(s.note);
setMobileView(s.view);
},
[setMobileView]
);
useNavigationHistory({ folder: activeFolder, note: activeNote, view: mobileView }, isMobile, applyNavState);
/* ── Derived note lists ── */
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
/* Chapters of the notebook in hand, with how many notes each holds — the
count is what makes a chapter worth opening rather than a guess. */
const folderChapters = useMemo(() => {
const list = data.chapters.filter((c) => c.notebookId === activeFolder);
return list.map((c) => ({
...c,
noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length,
}));
}, [data.chapters, activeFolder, notes]);
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
/* Which list the middle column is *showing*.
*
* Only the fallback replaces it: beside or below, the notebook's own list
* carries on and the chapter is a second thing on screen. Told apart from
* `activeChapter` after a separator drawn in a notebook vanished the moment a
* chapter was opened — the list still showed the notebook, but everything
* around it had switched to the chapter, so the notebook's own separators
* were filtered out and a drag in that list wrote nothing at all. */
const listChapter = inlineChapters || columnChapters ? null : activeChapter;
const listIsChapter = !!listChapter;
/* Where a new note is made. In the fallback the list is the chapter, so there
is nothing to choose; beside or below it follows the click. */
const targetChapter = listChapter ?? (inChapter ? activeChapter : null);
/* Both places are on screen at once, and only then is it worth saying which
one is meant. With no chapter open there is nothing to tell apart. */
const showsWhere = !!openChapter && !listIsChapter;
/* Notes reorder through their own list, which is the only place that knows
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
hook: they are dragged with a mouse, in the sidebar and on the dashboard.
Scoped to the list on screen, not to the chapter that happens to be open
beside it — otherwise a drag among the notebook's own rows is measured
against the chapter's notes, finds nothing, and does nothing. */
const rearrangeNotes = useCallback(
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, listChapter),
[data, activeFolder, listChapter]
);
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 rawSortMode = activeNotebook
? activeNotebook.sortMode || DEFAULT_NOTEBOOK_SORT
: virtualSort[activeFolder] || DEFAULT_SORT;
// A view has nowhere to keep a hand-made arrangement, so it cannot be in one —
// even if an older version of the app left "manual" behind on a notebook that
// has since been looked at through "All Notes".
const sortMode = !activeNotebook && rawSortMode === "manual" ? DEFAULT_SORT : rawSortMode;
const setSortMode = useCallback(
(mode) => {
if (activeNotebook) void data.updateNotebook(activeNotebook.id, { sortMode: mode });
else setVirtualSort((p) => ({ ...p, [activeFolder]: mode }));
},
[activeNotebook, activeFolder, data]
);
/* ── Separators ──
* Lines the user draws between notes. They belong to the notebook's own
* arrangement, so they are stored on it and only ever shown in manual order. */
const allSeparators = activeNotebook?.separators || [];
/* Only the ones drawn in the list being looked at — the notebook itself, or
the chapter that is open. */
const separators = useMemo(
() => separatorsFor(allSeparators, listChapter),
[allSeparators, listChapter]
);
const writeSeparators = (next) =>
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
/* Written back whole: the notebook stores one list, and the ones belonging to
the other chapters have to survive a change made in this one. */
const writeVisible = (visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
/* One press draws the line; it is named afterwards from its own menu, like it
is renamed. A dialog in front of it asked a question about something that
reads perfectly well unnamed. */
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter));
const renameSeparator = (id, label) =>
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{ label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) },
{ divider: true },
{
label: "Remove separator",
icon: I.trash,
danger: true,
action: () =>
confirm({
title: "Remove separator",
message: separator.label
? `Remove the separator "${separator.label}"? The notes stay.`
: "Remove this separator? The notes stay.",
confirmLabel: "Remove",
onConfirm: () => removeSeparator(separator.id),
}),
},
],
});
};
const isSearching = searchQuery.trim().length > 0;
const filteredNotes = useMemo(() => {
let list;
if (activeFolder === "trash") list = trashedNotes;
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
else if (listChapter) list = activeNotes.filter((n) => n.chapterId === listChapter);
/* In the notebook itself, a note filed into a chapter is *in* that chapter
and not loose here — otherwise it would appear twice and moving it would
look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
// order instead of being re-sorted by date.
if (search.results) {
const rank = new Map(search.results.map((h, i) => [h.id, i]));
return list.filter((n) => rank.has(n.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id));
}
// Until the index answers, fall back to matching titles only.
const q = searchQuery.toLowerCase();
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return sortNotes(list, sortMode);
}, [
activeFolder,
listChapter,
searchQuery,
search.results,
activeNotes,
trashedNotes,
sortMode,
]);
/* The notes of the chapter being looked into, in the same order they would
have in a list of their own. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, 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
// it arrives, rather than blocking on a fetch.
const openNoteData = noteMeta ? { ...noteMeta, ...EMPTY_BODY, ...noteContents[activeNote] } : null;
const folderName = useMemo(() => {
const builtIn = DEFAULT_FOLDERS.find((f) => f.id === activeFolder);
if (builtIn) return builtIn.name;
return notebooks.find((n) => n.id === activeFolder)?.name || "Notes";
}, [activeFolder, notebooks]);
/* ── Note links ── */
// Candidates for the @-menu. Notes rank above notebooks, trashed notes never
// appear, and the open note cannot link to itself.
const searchLinkTargets = useCallback(
(query) => {
const q = (query || "").toLowerCase().trim();
const match = (name) => !q || name.toLowerCase().includes(q);
return [
...notes
.filter((n) => !n.trashed && n.id !== activeNote && match(n.title || "Untitled"))
.slice(0, 8)
.map((n) => ({ id: n.id, label: n.title || "Untitled", kind: "note" })),
...notebooks
.filter((nb) => match(nb.name))
.slice(0, 4)
.map((nb) => ({ id: nb.id, label: nb.name, kind: "notebook" })),
];
},
[notes, notebooks, activeNote]
);
// A link stores the label it had when inserted; this keeps it showing the
// target's current title, and returns null once the target is gone.
const resolveLinkLabel = useCallback(
(id) => {
const n = notes.find((x) => x.id === id && !x.trashed);
if (n) return n.title || "Untitled";
return notebooks.find((x) => x.id === id)?.name ?? null;
},
[notes, notebooks]
);
const openLinkTarget = useCallback(
(id, kind) => {
if (kind === "notebook") return openFolder(id);
const target = notes.find((n) => n.id === id);
if (!target) return;
if (target.folderId) setActiveFolder(target.folderId);
openNote(id);
},
[notes, openFolder, openNote]
);
const linkProps = useMemo(
() => ({ openLinkTarget, searchLinkTargets, resolveLinkLabel }),
[openLinkTarget, searchLinkTargets, resolveLinkLabel]
);
/* ── Actions ── */
/**
* 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 chooseFiles = (accept, multiple, onChosen) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept;
input.multiple = multiple;
input.onchange = () => {
const files = [...(input.files || [])];
if (files.length) onChosen(files);
};
input.click();
};
const reportFailure = (message) =>
setConfirmDialog({
title: "Import failed",
message,
confirmLabel: "OK",
onConfirm: () => setConfirmDialog(null),
});
const importPdf = () =>
withNotebook("Upload PDF", (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 || "The file could not be imported.");
}
})
);
/** Markdown files, or a ZIP written by this app's export. */
const importMarkdown = () =>
withNotebook("Import notes", (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("New note", async (folderId) => {
/* A note made while a chapter is open belongs to that chapter — but only
if it is a chapter of the notebook being written into, which it is not
when the notebook had to be asked for. */
const chapterId = folderId === activeFolder ? targetChapter : null;
const note = await data.createNote(folderId, chapterId);
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
openNote(note.id);
});
/**
* Runs a destructive action behind a confirmation prompt.
*
* Only sets the question. Closing it again is done where the dialog is
* rendered, so it happens whoever asked and however they asked: this used to
* be wrapped here instead, and the one caller that set the dialog directly —
* removing a chapter — did its work and then left the prompt standing over
* the app with nothing but a reload to get out of.
*/
const confirm = (dialog) => setConfirmDialog(dialog);
const trashNote = (id) =>
confirm({
title: "Move to Trash",
message: `Move "${notes.find((n) => n.id === id)?.title}" to trash?`,
confirmLabel: "Move to Trash",
onConfirm: async () => {
await data.trashNote(id);
if (activeNote === id) closeNote();
},
});
const restoreNote = async (id) => {
await data.restoreNote(id);
if (activeNote === id) closeNote();
};
const permanentDeleteNote = (id) =>
confirm({
title: "Delete Permanently",
message: `Permanently delete "${notes.find((n) => n.id === id)?.title}"? This cannot be undone.`,
onConfirm: async () => {
await data.permanentDeleteNote(id);
if (activeNote === id) closeNote();
},
});
const emptyTrash = () =>
confirm({
title: "Empty Trash",
message: `Permanently delete all ${trashedNotes.length} trashed notes?`,
onConfirm: async () => {
await data.emptyTrash();
setActiveNote(null);
},
});
const createNotebook = async (fields) => {
const nb = await data.createNotebook(fields);
setShowNewNb(false);
setActiveFolder(nb.id);
};
const updateNotebook = async (id, updates) => {
await data.updateNotebook(id, updates);
setEditingNb(null);
};
const deleteNotebook = (id) => {
const nb = notebooks.find((n) => n.id === id);
const count = activeNotes.filter((n) => n.folderId === id).length;
confirm({
title: "Delete Notebook",
message: `Delete "${nb?.name}"${count > 0 ? ` and trash its ${count} notes` : ""} permanently?`,
onConfirm: async () => {
await data.deleteNotebook(id);
if (activeFolder === id) setActiveFolder("dashboard");
},
});
};
/* ── Context menus ── */
const notebookContextMenu = (e, nb) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{ label: "Edit", icon: I.edit, action: () => setEditingNb(nb) },
{
label: "Export...",
icon: I.export,
action: () =>
setExporting({
label: nb.name,
notes: activeNotes.filter((n) => n.folderId === nb.id),
}),
},
{ divider: true },
{ label: "Delete Notebook", icon: I.trash, danger: true, action: () => deleteNotebook(nb.id) },
],
});
};
/**
* "Move to..." — the other notebooks, and the chapters of this one.
*
* Both in one list because both answer the same question: where should this
* note live. A chapter of the notebook it is already in is one step; another
* notebook is another, and takes it out of whatever chapter it was in, since
* a chapter belongs to the book it is in.
*/
const moveSubmenu = (note) => {
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
const targets = [
...(note.chapterId
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
: []),
...here.map((c) => ({
label: c.name,
icon: I.books,
action: () => data.moveNote(note.id, note.folderId, c.id),
})),
...(here.length || note.chapterId ? [{ divider: true }] : []),
...notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
label: nb.name,
colorDot: coverBackground(nb.cover, nb.coverPattern),
action: () => data.moveNote(note.id, nb.id, null),
})),
];
// A lone divider is not a menu.
const usable = targets.filter((t) => !t.divider);
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
};
/* ── Chapters ──
* Folders inside a notebook. Named on creation, because a chapter with no name
* is a row nobody can tell from the next one — unlike a separator, which is a
* line and reads perfectly well unnamed. */
/**
* Opens a chapter, or closes the one already open.
*
* Opening it also makes it the place being worked in, which is what pressing
* a chapter means; clicking back into the notebook's list undoes that without
* closing the chapter, so both stay readable side by side.
*/
const openChapterView = (id) => {
const next = activeChapter === id ? null : id;
setActiveChapter(next);
setInChapter(!!next);
};
/* Named and coloured in one go. The colour is what tells one chapter from the
next, so asking for it while the chapter is being made is one decision
rather than a second visit through the right-click menu. */
const addChapter = () => {
if (!activeNotebook) return;
setNaming({
title: "New chapter",
label: "Chapter name",
value: "",
colors: CHAPTER_COLORS,
color: null,
onSave: (name, color) => data.createChapter(activeNotebook.id, name, color),
});
};
const chapterContextMenu = (e, chapter) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
/* One entry, not two. The name and the colour are asked for together
when a chapter is made, so changing them is the same dialog again —
a second "Colour" submenu beside it offered half of what this does
and read as a different thing. */
{
label: "Rename or recolour",
icon: I.edit,
action: () =>
setNaming({
title: "Chapter",
label: "Chapter name",
value: chapter.name,
colors: CHAPTER_COLORS,
color: chapter.color ?? null,
onSave: (name, color) => data.editChapter(chapter.id, { name, color }),
}),
},
{ divider: true },
{
label: "Remove chapter",
icon: I.trash,
danger: true,
action: () =>
confirm({
title: "Remove chapter?",
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
chapter.noteCount === 1 ? "" : "s"
} stay — they go back to the notebook.`,
confirmLabel: "Remove",
danger: true,
onConfirm: async () => {
if (activeChapter === chapter.id) {
setActiveChapter(null);
setInChapter(false);
}
await data.deleteChapter(chapter.id);
},
}),
},
],
});
};
const noteContextMenu = (e, note) => {
e.preventDefault();
e.stopPropagation();
const items = note.trashed
? [
{ label: "Restore", icon: I.restore, action: () => restoreNote(note.id) },
{ divider: true },
{
label: "Delete Permanently",
icon: I.trash,
danger: true,
action: () => permanentDeleteNote(note.id),
},
]
: [
{
label: note.starred ? "Unstar" : "Star",
icon: note.starred ? I.starFill : I.star,
action: () => data.toggleStar(note.id),
},
...moveSubmenu(note),
{
label: "Export...",
icon: I.export,
action: () => setExporting({ label: note.title, notes: [note] }),
},
{ divider: true },
{ label: "Move to Trash", icon: I.trash, danger: true, action: () => trashNote(note.id) },
];
setCtxMenu({ x: e.clientX, y: e.clientY, items });
};
/* ── Render ── */
if (loading)
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
fontSize: 16,
}}
>
Loading...
</div>
);
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
so it reads as "inside this" rather than as more of the same list. */
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
data-chapter-current={inChapter ? "on" : "off"}
/* Pressing inside the unfolded chapter says "I am in here" — and must not
then travel on to the list around it, which says the opposite. */
onPointerDown={(e) => {
e.stopPropagation();
setInChapter(true);
}}
style={{
margin: "2px 0 8px 22px",
borderLeft: `2px solid ${inChapter ? chapter.color || T.accent : `${chapter.color || T.accent}55`}`,
paddingLeft: 8,
}}
>
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
);
const showList = !(isMobile && mobileView === "editor" && activeNote);
const showEditor = !isMobile || mobileView === "editor";
return (
<div
style={{
display: "flex",
height: "100vh",
width: "100vw",
overflow: "hidden",
background: T.bg,
color: T.text,
fontFamily: FONT_UI,
}}
>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=DM+Sans:wght@400;500;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:${T.borderLight};border-radius:3px}
button:hover{opacity:0.88}
input::placeholder{color:${T.faint}}
[contenteditable]:empty:before{content:'Start writing...';color:${T.faint};pointer-events:none}
[draggable]{user-select:none}
@media(max-width:767px){
::-webkit-scrollbar{display:none}
body{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
}
`}</style>
<Sidebar
T={T}
dark={dark}
onToggleDark={() => setDark(!dark)}
isMobile={isMobile}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
activeFolder={activeFolder}
onSelectFolder={openFolder}
notebooks={notebooks}
activeNotes={activeNotes}
trashedCount={trashedNotes.length}
onNewNotebook={() => setShowNewNb(true)}
onOpenAccount={() => setShowAccount(true)}
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
canInstall={canInstall}
installReason={installReason}
onInstall={promptInstall}
syncStatus={syncStatus}
online={sync.isOnline()}
/>
{activeFolder === "dashboard" ? (
<Dashboard
T={T}
isMobile={isMobile}
notebooks={notebooks}
activeNotes={activeNotes}
activeFolder={activeFolder}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onOpenNotebook={openFolder}
onOpenNote={(note) => {
setActiveFolder(note.folderId);
openNote(note.id);
}}
onNewNotebook={() => setShowNewNb(true)}
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
/>
) : (
<>
{showList && (
<NoteList
T={T}
isMobile={isMobile}
title={folderName}
notes={filteredNotes}
activeNote={activeNote}
isTrashView={isTrashView}
trashedCount={trashedNotes.length}
snippetFor={search.snippetFor}
query={searchQuery}
onQueryChange={setSearchQuery}
sortMode={sortMode}
sortModes={sortModesFor(!!activeNotebook)}
onSortModeChange={setSortMode}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
separators={separators}
onAddSeparator={addSeparator}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
/* Only in the fallback: there the list becomes the chapter, and
its header is the way back. Beside or below, the notebook's own
list carries on and keeps its chapter rows. */
openChapter={listIsChapter ? openChapter : null}
onOpenChapter={openChapterView}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onReorder={rearrangeNotes}
listKey={`${activeFolder}:${listChapter ?? ""}`}
isCurrent={showsWhere ? !inChapter : null}
onFocusList={showsWhere ? () => setInChapter(false) : undefined}
newNotePlace={
targetChapter ? folderChapters.find((c) => c.id === targetChapter)?.name : folderName
}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
onCreateNote={createNote}
onImportPdf={importPdf}
onImportMarkdown={importMarkdown}
onEmptyTrash={emptyTrash}
/>
)}
{/* The chapter's own column, beside the list — the way OneNote lays
out its pages. Only where there is room: on a phone the chapter
fills the screen instead, with a way back. */}
{columnChapters && openChapter && (
<div
data-chapter-column
data-chapter-current={inChapter ? "on" : "off"}
onPointerDown={() => setInChapter(true)}
style={{
width: 268,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${T.borderLight}`,
background: T.panel || "transparent",
// The same line the note list wears when it is the one in hand.
boxShadow: inChapter ? `inset 0 2px 0 ${T.accent}` : undefined,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 14px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
{I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 14,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{openChapter.name}
</span>
<button
onClick={() => {
setActiveChapter(null);
setInChapter(false);
}}
title="Close chapter"
aria-label="Close chapter"
data-chapter-close
style={{
border: "none",
background: "transparent",
color: T.muted,
cursor: "pointer",
display: "flex",
padding: 0,
}}
>
{I.x}
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterNotes.length === 0 && (
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
Nothing in here yet.
</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "9px 10px",
borderRadius: 9,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
</div>
)}
{showEditor && (
<EditorPane
T={T}
dark={dark}
noteFont={noteFont}
isMobile={isMobile}
note={openNoteData}
isTrashView={isTrashView}
restoreEpoch={restoreEpoch}
backlinks={backlinks}
linkProps={linkProps}
onBack={closeNote}
onUpdate={(u) => data.updateNote(activeNote, u)}
onToggleStar={() => data.toggleStar(activeNote)}
onTrash={() => trashNote(activeNote)}
onRestore={() => restoreNote(activeNote)}
onPermanentDelete={() => permanentDeleteNote(activeNote)}
onOpenHistory={() => setHistoryFor(activeNote)}
onExport={() => noteMeta && setExporting({ label: noteMeta.title, notes: [noteMeta] })}
onCreateNote={createNote}
/>
)}
</>
)}
{showNewNb && (
<CreateNotebookModal onClose={() => setShowNewNb(false)} onCreate={createNotebook} dark={dark} />
)}
{editingNb && (
<EditNotebookModal
notebook={editingNb}
onClose={() => setEditingNb(null)}
onSave={(u) => updateNotebook(editingNb.id, u)}
defaultMaxVersions={user?.defaultMaxVersions}
dark={dark}
/>
)}
{showAccount && (
<AccountMenu
user={user}
onClose={() => setShowAccount(false)}
onLogout={() => {
setShowAccount(false);
onLogout();
}}
onUserUpdate={setUser}
onOpenAdmin={() => {
setShowAccount(false);
setShowAdmin(true);
}}
onExportAll={() => {
setShowAccount(false);
setExporting({ label: "All Notes", notes: activeNotes });
}}
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}
notebooks={notebooks}
label={exporting.label}
dark={dark}
onClose={() => setExporting(null)}
/>
)}
{showAdmin && user?.isAdmin && (
<AdminPanel currentUser={user} onClose={() => setShowAdmin(false)} dark={dark} />
)}
{historyFor && (
<HistoryModal
noteId={historyFor}
noteTitle={notes.find((n) => n.id === historyFor)?.title}
dark={dark}
onClose={() => setHistoryFor(null)}
onRestored={(content) => {
data.setNoteContent(historyFor, content);
setRestoreEpoch((e) => e + 1);
}}
/* The first drawn page stands for the drawing in a thumbnail this
small. Asking for page one would show a blank sheet for a note whose
ink starts on page three. */
renderDrawing={(doc) => (
<DrawingLayer
strokes={sheetStrokes(doc, drawnSheets(doc)[0] ?? 1)}
width={PREVIEW_DRAWING_W}
height={Math.round((PREVIEW_DRAWING_W * CANON_H) / CANON_W)}
zIndex={1}
/>
)}
/>
)}
{naming && (
<NameDialog
title={naming.title}
value={naming.value}
label={naming.label ?? "Chapter name"}
colors={naming.colors}
color={naming.color}
confirmLabel={naming.confirmLabel ?? (naming.value ? "Save" : "Create")}
onSave={naming.onSave}
onClose={() => setNaming(null)}
dark={dark}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}
message={confirmDialog.message}
confirmLabel={confirmDialog.confirmLabel}
/* The one place the question is taken down, whatever it asked and
whoever set it. Leaving that to each caller left one of them
holding the screen hostage after doing its work. */
onConfirm={async () => {
await confirmDialog.onConfirm?.();
setConfirmDialog(null);
}}
onCancel={() => setConfirmDialog(null)}
dark={dark}
/>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenu.items}
onClose={() => setCtxMenu(null)}
dark={dark}
/>
)}
</div>
);
}