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>
1219 lines
45 KiB
React
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>
|
|
);
|
|
}
|