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}