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:
1 parent
4bb86bc5ac
commit
b2a5f051fe
12 files changed
+406
-291
No files matched your search
+91
-35
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user