A filed note stays filed, and the handles are asked for

Three things that all came from chapters being thought through too late.

A note put into a chapter came back out of it a few seconds later. The
server had it right the whole time; every sync rebuilds the local copy
from the server's answer, and the function doing that never carried
chapter_id — so each sync handed back a note belonging to no chapter and
the notebook took it in again. The bin's query left the column out too,
which would have unfiled anything restored from it.

A separator drawn in a notebook vanished when a chapter was opened.
"Which chapter is open" and "which list is on screen" were one value.
Opened beside or below, the list still shows the notebook — but
everything around it had switched to the chapter, so the notebook's own
separators were filtered away and a drag among its rows was measured
against the chapter's notes, found nothing, and did nothing.

And where a new note lands is now visible and yours to choose: pressing
in either place makes it the one being worked in, marked with a line
along its top and the word "here", and the + button names it.

Rearranging becomes a mode, switched on beside the sort menu — until
then no row wears a grip and none can be dragged. Filing a note is back
in the row's own menu, where starring and exporting already are; the
button that had appeared in every row is gone with its dialog. The
account panel closes with Escape.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 16:59:29 +02:00
1 parent 4bb86bc5ac
commit b2a5f051fe
12 files changed
+406 -291

No files matched your search

+91 -35
View File
@@ -13,7 +13,6 @@ 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";
@@ -68,6 +67,13 @@ export default function App() {
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);
@@ -91,8 +97,6 @@ export default function App() {
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);
@@ -114,13 +118,6 @@ export default function App() {
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), []);
@@ -169,6 +166,7 @@ export default function App() {
(id) => {
setActiveFolder(id);
setActiveChapter(null);
setInChapter(false);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
@@ -214,6 +212,33 @@ export default function App() {
}));
}, [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";
@@ -243,18 +268,18 @@ export default function App() {
/* 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]
() => 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) !== activeChapter), ...visible]);
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== listChapter), ...visible]);
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
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));
@@ -293,8 +318,7 @@ export default function App() {
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);
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. */
@@ -314,9 +338,7 @@ export default function App() {
return sortNotes(list, sortMode);
}, [
activeFolder,
activeChapter,
inlineChapters,
columnChapters,
listChapter,
searchQuery,
search.results,
activeNotes,
@@ -468,7 +490,7 @@ export default function App() {
/* 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 chapterId = folderId === activeFolder ? targetChapter : null;
const note = await data.createNote(folderId, chapterId);
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
openNote(note.id);
@@ -605,6 +627,19 @@ export default function App() {
* 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);
};
const addChapter = () => {
if (!activeNotebook) return;
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
@@ -721,8 +756,24 @@ export default function App() {
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 }}
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,
}}
>
{inChapter && (
<div style={{ fontSize: 10, color: T.accent, fontWeight: 700, padding: "2px 10px 4px" }}>
· new notes land here
</div>
)}
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
@@ -866,14 +917,19 @@ export default function App() {
/* 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}
openChapter={listIsChapter ? openChapter : null}
onOpenChapter={openChapterView}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onMoveNote={setMovingNote}
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}
@@ -889,6 +945,8 @@ export default function App() {
{columnChapters && openChapter && (
<div
data-chapter-column
data-chapter-current={inChapter ? "on" : "off"}
onPointerDown={() => setInChapter(true)}
style={{
width: 268,
flexShrink: 0,
@@ -896,6 +954,8 @@ export default function App() {
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
@@ -924,8 +984,14 @@ export default function App() {
>
{openChapter.name}
</span>
{inChapter && (
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
)}
<button
onClick={() => setActiveChapter(null)}
onClick={() => {
setActiveChapter(null);
setInChapter(false);
}}
title="Close chapter"
aria-label="Close chapter"
data-chapter-close
@@ -1103,16 +1169,6 @@ export default function App() {
)}
/>
)}
{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}
+91 -49
View File
@@ -42,9 +42,11 @@ const DropLine = ({ T }) => (
*
* A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
* row has to keep scrolling the list, and holding it has to keep opening the
* menu. So touch gets its own target, wide enough to hit and only there while a
* hand-made arrangement is in effect — which is the only time moving a row means
* anything.
* menu. So touch gets its own target, wide enough to hit.
*
* Only there while rearranging is switched on. Standing in every row all the
* time it was six dots of clutter beside every title, and a row could be pulled
* out of place by a press that was meant to open it.
*/
const Grip = ({ T, ...rest }) => (
<div
@@ -68,36 +70,6 @@ const Grip = ({ T, ...rest }) => (
</div>
);
/**
* Moving a note, in one press.
*
* It used to be a right-click into a submenu — two steps before the list of
* places appeared at all, and on a phone a long press followed by a flyout.
* Sitting on the row beside the grip, it says what it does and takes one tap.
*/
const MoveButton = ({ T, onClick }) => (
<button
data-move-note
onClick={onClick}
title="Move to another notebook or chapter"
aria-label="Move note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
flexShrink: 0,
border: "none",
background: "transparent",
color: T.faint,
cursor: "pointer",
padding: 0,
}}
>
{I.move}
</button>
);
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
@@ -135,7 +107,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
/* Room above it, so it reads as the end of what came before rather than
as decoration attached to the next note. */
padding: "14px 8px 8px",
cursor: "grab",
cursor: grip ? "grab" : "default",
}}
>
{grip}
@@ -343,7 +315,7 @@ function SortMenu({ T, mode, modes, onChange, disabled }) {
* putting something new in here — and they had grown into three buttons side by
* side, two of which were icons you had to hover to identify.
*/
function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
function AddMenu({ T, place, onCreateNote, onImportPdf, onImportMarkdown }) {
const [open, setOpen] = useState(false);
const box = useRef(null);
@@ -388,7 +360,8 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
<div style={{ position: "relative" }} ref={box}>
<button
onClick={() => setOpen((v) => !v)}
title="New Note"
// Names the place, so it is clear before pressing where the note lands.
title={place ? `New note in ${place}` : "New Note"}
data-add-menu={open ? "on" : "off"}
style={{
display: "flex",
@@ -430,6 +403,32 @@ function AddMenu({ T, onCreateNote, onImportPdf, onImportMarkdown }) {
);
}
/**
* Switches rearranging on.
*
* Rows are dragged rarely and read constantly, so the handles are not there by
* default: the list stays a list, and taking hold of a row is something asked
* for. While it is on, and only then, every row shows its grip and can be
* carried; pressing a row does not open it by accident.
*/
const ReorderToggle = ({ T, on, onToggle }) => (
<button
onClick={onToggle}
data-reorder-toggle={on ? "on" : "off"}
title={on ? "Done rearranging" : "Rearrange the rows"}
aria-label="Rearrange the rows"
aria-pressed={on}
style={{
...iconButton(T),
color: on ? "#fff" : T.muted,
background: on ? T.accent : "transparent",
borderColor: on ? T.accent : T.border,
}}
>
{on ? I.check : I.grip}
</button>
);
/** The middle column: every note in the selected folder, in the chosen order. */
export function NoteList({
T,
@@ -459,8 +458,14 @@ export function NoteList({
onChapterContextMenu,
expandedChapter,
renderChapterNotes,
onMoveNote,
onReorder,
/** Identifies the list on screen, so rearranging does not stay on across a move to another. */
listKey,
/** Whether this pane is the one being worked in; null when there is nothing to tell apart. */
isCurrent,
onFocusList,
/** The place a new note would land in, named on the + button. */
newNotePlace,
onToggleSidebar,
onSelectNote,
onNoteContextMenu,
@@ -469,10 +474,21 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
/* Rearranging is a mode, switched on for as long as it is being done.
*
* It ends by itself when the list is left: coming back to a notebook to find
* every row wearing a handle, because it was rearranged last week, is not
* where anybody left off. */
const [reordering, setReordering] = useState(false);
// Off again on the way to another list, and whenever a hand-made arrangement
// stops being in effect at all — an order the app chose cannot be dragged.
useEffect(() => setReordering(false), [listKey, reorderable]);
const moving = !!reorderable && reordering;
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
browsers never fire from touch — on a phone the arrangement could not be
changed at all, with nothing on screen to say why. */
const dnd = useReorderDrag(!!reorderable, onReorder);
const dnd = useReorderDrag(moving, onReorder);
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
@@ -484,6 +500,7 @@ export function NoteList({
return (
<div
data-list-current={isCurrent === null || isCurrent === undefined ? undefined : isCurrent ? "on" : "off"}
style={{
width: isMobile ? "100%" : 300,
minWidth: isMobile ? 0 : 300,
@@ -493,6 +510,8 @@ export function NoteList({
flexDirection: "column",
background: T.bg,
fontFamily: FONT_UI,
// A line along the top of whichever pane is being worked in.
boxShadow: isCurrent ? `inset 0 2px 0 ${T.accent}` : undefined,
}}
>
<div
@@ -541,12 +560,21 @@ export function NoteList({
>
{openChapter ? openChapter.name : title}
</h2>
{/* Said in words as well as in the line above, because a coloured edge
is only a hint until you know what it means. */}
{isCurrent && (
<span style={{ fontSize: 10, color: T.accent, fontWeight: 700, flexShrink: 0 }}>· here</span>
)}
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{reorderable && (
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
)}
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<AddMenu
T={T}
place={newNotePlace}
onCreateNote={onCreateNote}
onImportPdf={onImportPdf}
onImportMarkdown={onImportMarkdown}
@@ -615,12 +643,20 @@ export function NoteList({
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 6,
padding: "5px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{/* Only while it is on, and then it says what to do rather than
explaining the order the sort menu already names. */}
{moving && (
<span style={{ flex: 1, minWidth: 0, fontSize: 11, color: T.accent, fontWeight: 600 }}>
Drag the rows into place
</span>
)}
{/* A chapter divides the notebook; a separator divides a list. They
sit together because both are "add something that is not a note",
and apart from the + button, which makes notes. */}
@@ -666,7 +702,16 @@ export function NoteList({
</div>
)}
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
{/* Pressing in the list itself — on a note or on the empty space below
them — says "I am in the notebook", and a new note goes there.
Deliberately not the whole pane: the + button sits in the header, and
taking the focus back on the way to pressing it would mean a note
could never be made in the open chapter at all. */}
<div
data-note-list-body
onPointerDown={onFocusList}
style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}
>
{notes.length === 0 ? (
<div
style={{
@@ -718,14 +763,14 @@ export function NoteList({
? mergeEntries(notes, separators, shownChapters)
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
).map((entry) => {
const showDropLine = reorderable && dnd.overId === entry.id;
const showDropLine = moving && dnd.overId === entry.id;
/* The whole row, indicator included, is the drop target.
*
* Putting these on the inner element left the indicator itself as
* a dead strip: it pushes the row down as it appears, so the
* pointer ended up over the line rather than over the row, and
* releasing there dropped on nothing. */
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
const rowProps = moving ? dnd.rowProps(entry.id) : {};
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
@@ -733,7 +778,7 @@ export function NoteList({
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
{moving && <Grip T={T} {...dnd.gripProps} />}
<ChapterRow
T={T}
chapter={entry.chapter}
@@ -752,7 +797,7 @@ export function NoteList({
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<SeparatorRow
grip={<Grip T={T} {...dnd.gripProps} />}
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
@@ -770,10 +815,7 @@ export function NoteList({
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
{onMoveNote && !note.trashed && (
<MoveButton T={T} onClick={() => onMoveNote(note)} />
)}
{moving && <Grip T={T} {...dnd.gripProps} />}
<button
data-note-row={note.id}
onClick={() => onSelectNote(note.id)}
@@ -787,7 +829,7 @@ export function NoteList({
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
background: activeNote === note.id ? T.accentSoft : "transparent",
cursor: reorderable ? "grab" : "pointer",
cursor: moving ? "grab" : "pointer",
marginBottom: 2,
textAlign: "left",
}}
@@ -848,7 +890,7 @@ export function NoteList({
})}
{/* Dropping below the last row moves something to the end, which
otherwise needs an awkward drop onto the very last note. */}
{reorderable && (
{moving && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>
+7
View File
@@ -498,6 +498,13 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
id: n.id,
title: n.title,
folderId: n.notebook_id,
/* Where it is filed inside the notebook.
*
* Missing here, a note put into a chapter came back out of it seconds later:
* the server had it right, but every sync replaces the local mirror with
* what this function returns — and it returned a note belonging to no
* chapter, so the notebook's own list took it back. */
chapterId: n.chapter_id ?? null,
starred: !!n.starred,
trashed,
createdAt: n.created_at,
+9 -1
View File
@@ -1,9 +1,17 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
/* Escape closes it, like every other panel over the app. Only clicking beside
it worked, which on a panel this wide means aiming at a sliver of page. */
useEffect(() => {
const key = (e) => e.key === "Escape" && onClose();
document.addEventListener("keydown", key);
return () => document.removeEventListener("keydown", key);
}, [onClose]);
const choosePreference = async (change) => {
try {
onUserUpdate(await sync.updatePreferences(change));
-180
View File
@@ -1,180 +0,0 @@
import { useMemo } from "react";
import { I } from "./icons.jsx";
import { coverBackground } from "./constants.jsx";
/**
* Where a note should go.
*
* Moving used to live only under a right-click, behind a submenu — two steps
* before the list of places even appeared, and on a phone a long press followed
* by a flyout. This is the same list, reached by one press of a button on the
* row.
*
* Everything is on one screen: the chapters of the notebook the note is already
* in, and then the other notebooks. Sending it to another notebook takes it out
* of whatever chapter it was in, because a chapter belongs to the book it is in
* — which is worth saying here rather than leaving to be discovered.
*/
export function MoveNoteDialog({ note, notebooks, chapters, onMove, onClose, dark }) {
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
row: dark ? "#0d1117" : "#f6f8fa",
hover: dark ? "#21262d" : "#eaeef2",
}),
[dark]
);
const here = chapters.filter((c) => c.notebookId === note.folderId);
const others = notebooks.filter((nb) => nb.id !== note.folderId);
const go = (folderId, chapterId) => {
onMove(note.id, folderId, chapterId);
onClose();
};
const Row = ({ children, onClick, disabled, dot, icon }) => (
<button
onClick={onClick}
disabled={disabled}
data-move-target
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "11px 12px",
borderRadius: 10,
border: "none",
background: disabled ? T.row : "transparent",
color: disabled ? T.muted : T.text,
cursor: disabled ? "default" : "pointer",
fontSize: 13,
textAlign: "left",
}}
onMouseEnter={(e) => !disabled && (e.currentTarget.style.background = T.hover)}
onMouseLeave={(e) => !disabled && (e.currentTarget.style.background = "transparent")}
>
{dot && <div style={{ width: 12, height: 16, borderRadius: 3, background: dot, flexShrink: 0 }} />}
{icon && <span style={{ display: "flex", opacity: 0.7, flexShrink: 0 }}>{icon}</span>}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{children}</span>
{disabled && <span style={{ marginLeft: "auto", fontSize: 11, flexShrink: 0 }}>here now</span>}
</button>
);
const heading = (text) => (
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: 0.6,
textTransform: "uppercase",
color: T.muted,
margin: "12px 12px 4px",
}}
>
{text}
</div>
);
return (
<div
data-move-dialog
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 400,
maxHeight: "80vh",
display: "flex",
flexDirection: "column",
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: "20px 10px 12px",
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 12px 4px", fontSize: 16, fontWeight: 700, color: T.text }}>Move note</h3>
<div
style={{
margin: "0 12px 8px",
fontSize: 12,
color: T.muted,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{note.title || "Untitled"}
</div>
<div style={{ overflowY: "auto", flex: 1 }}>
{here.length > 0 && heading("In this notebook")}
{here.length > 0 && (
<Row icon={I.note} disabled={!note.chapterId} onClick={() => go(note.folderId, null)}>
The notebook itself
</Row>
)}
{here.map((c) => (
<Row
key={c.id}
icon={I.books}
disabled={note.chapterId === c.id}
onClick={() => go(note.folderId, c.id)}
>
{c.name}
</Row>
))}
{others.length > 0 && heading("Another notebook")}
{others.map((nb) => (
<Row key={nb.id} dot={coverBackground(nb.cover, nb.coverPattern)} onClick={() => go(nb.id, null)}>
{nb.name}
</Row>
))}
{here.length === 0 && others.length === 0 && (
<div style={{ padding: "18px 12px", fontSize: 12.5, color: T.muted }}>
There is nowhere else to put it yet — make another notebook, or a chapter in this one.
</div>
)}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", padding: "10px 12px 0" }}>
<button
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
</div>
</div>
</div>
);
}