Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.
**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.
**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.
**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.
The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1149 lines
41 KiB
React
1149 lines
41 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 { MoveNoteDialog } from "./ui/MoveNoteDialog.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_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);
|
|
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);
|
|
// { title, value, onSave } while a chapter is being named or renamed.
|
|
const [namingChapter, setNamingChapter] = useState(null);
|
|
// The note whose destination is being chosen, if any.
|
|
const [movingNote, setMovingNote] = 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);
|
|
/* 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. */
|
|
const rearrangeNotes = useCallback(
|
|
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
|
|
[data, activeFolder, activeChapter]
|
|
);
|
|
|
|
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);
|
|
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;
|
|
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_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, activeChapter),
|
|
[allSeparators, activeChapter]
|
|
);
|
|
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) !== activeChapter), ...visible]);
|
|
|
|
const addSeparator = () =>
|
|
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
|
|
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 (activeChapter && !inlineChapters && !columnChapters)
|
|
list = activeNotes.filter((n) => n.chapterId === activeChapter);
|
|
/* 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,
|
|
activeChapter,
|
|
inlineChapters,
|
|
columnChapters,
|
|
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 ? activeChapter : 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. */
|
|
const confirm = (dialog) =>
|
|
setConfirmDialog({
|
|
...dialog,
|
|
onConfirm: async () => {
|
|
await dialog.onConfirm();
|
|
setConfirmDialog(null);
|
|
},
|
|
});
|
|
|
|
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. */
|
|
const addChapter = () => {
|
|
if (!activeNotebook) return;
|
|
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
|
|
};
|
|
|
|
const chapterContextMenu = (e, chapter) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setCtxMenu({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
items: [
|
|
{
|
|
label: "Rename",
|
|
icon: I.edit,
|
|
action: () =>
|
|
setNamingChapter({
|
|
title: "Rename chapter",
|
|
value: chapter.name,
|
|
onSave: (name) => data.renameChapter(chapter.id, name),
|
|
}),
|
|
},
|
|
{
|
|
label: "Colour",
|
|
icon: I.edit,
|
|
submenu: [
|
|
{ label: "None", colorDot: T.faint, action: () => data.recolourChapter(chapter.id, null) },
|
|
...CHAPTER_COLORS.map((c) => ({
|
|
label: c.label,
|
|
colorDot: c.value,
|
|
action: () => data.recolourChapter(chapter.id, c.value),
|
|
})),
|
|
],
|
|
},
|
|
{ divider: true },
|
|
{
|
|
label: "Remove chapter",
|
|
icon: I.trash,
|
|
danger: true,
|
|
action: () =>
|
|
setConfirmDialog({
|
|
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: () => {
|
|
if (activeChapter === chapter.id) setActiveChapter(null);
|
|
void 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}
|
|
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${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={inlineChapters || columnChapters ? null : openChapter}
|
|
onOpenChapter={setActiveChapter}
|
|
onAddChapter={activeNotebook ? addChapter : null}
|
|
onChapterContextMenu={chapterContextMenu}
|
|
expandedChapter={inlineChapters ? activeChapter : null}
|
|
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
|
|
onMoveNote={setMovingNote}
|
|
onReorder={rearrangeNotes}
|
|
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
|
|
style={{
|
|
width: 268,
|
|
flexShrink: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
borderRight: `1px solid ${T.borderLight}`,
|
|
background: T.panel || "transparent",
|
|
}}
|
|
>
|
|
<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)}
|
|
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}
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
{movingNote && (
|
|
<MoveNoteDialog
|
|
note={movingNote}
|
|
notebooks={notebooks}
|
|
chapters={data.chapters}
|
|
onMove={(id, folderId, chapterId) => data.moveNote(id, folderId, chapterId)}
|
|
onClose={() => setMovingNote(null)}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{namingChapter && (
|
|
<NameDialog
|
|
title={namingChapter.title}
|
|
value={namingChapter.value}
|
|
label="Chapter name"
|
|
confirmLabel={namingChapter.value ? "Rename" : "Create"}
|
|
onSave={namingChapter.onSave}
|
|
onClose={() => setNamingChapter(null)}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{confirmDialog && (
|
|
<ConfirmDialog
|
|
title={confirmDialog.title}
|
|
message={confirmDialog.message}
|
|
confirmLabel={confirmDialog.confirmLabel}
|
|
onConfirm={confirmDialog.onConfirm}
|
|
onCancel={() => setConfirmDialog(null)}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x}
|
|
y={ctxMenu.y}
|
|
items={ctxMenu.items}
|
|
onClose={() => setCtxMenu(null)}
|
|
dark={dark}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|