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

+39
View File
@@ -8,6 +8,45 @@ not from the output of `docker push`.
---
## 2026-08-12 — A filed note stays filed
**Images** — web `sha256:…`, server `sha256:…`
The server image changed: the bin now hands back a note's chapter with it.
**Fixed**
- **A note put into a chapter came back out of it by itself**, a few seconds
later. The server had filed it correctly the whole time; the sync that follows
rebuilds every local note 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's list 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.
**Changed**
- **Rearranging is a mode now.** The handle button beside the sort menu switches
it on; until then no row shows its grip and none can be dragged, and it goes
off again by itself when the list is left. Six dots stood beside every title
all the time for something done rarely, and an ordinary press could carry a
row off.
- **Filing a note is back in the row's own menu** and the button that had
appeared in every row is gone, with the dialog behind it. It belongs with
starring, exporting and discarding — things done to a row, in one place.
- **Which place a new note lands in is now visible and yours to choose.** With a
chapter open beside or below, the notebook's list and the chapter are both on
screen: pressing in either makes it the one being worked in, marked with a line
along its top and the word *here*, and the + button names it. Before, a new
note went into the chapter however far away you had since clicked.
- **The account panel closes with Escape.** Only a click beside it did, which on
a panel that wide means aiming at a sliver of page.
---
## 2026-08-12 — Chapters as rows, in two views
**Images** — web `sha256:95657aaf…`, server `sha256:b4db0725…`
+3 -1
View File
@@ -1085,7 +1085,9 @@ app.get("/api/notes", authMiddleware, (req, res) => {
});
app.get("/api/notes/trash", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
// `chapter_id` travels with it: a note restored from the bin belongs back in
// the chapter it was filed in, not loose in the notebook.
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
res.json(notes);
});
+100 -15
View File
@@ -31,16 +31,22 @@ try {
await p.getByRole("button", { name: "Create", exact: true }).click();
await p.waitForTimeout(2000);
};
/* One press of the button on the row, then choose. It used to be a
right-click into a submenu — two steps before the places even appeared, and
on a phone a long press followed by a flyout. */
/* Filing a note is one of the things a row can have done to it, and they all
live in the row's own menu. A button for this one on every row put a second
icon beside every title, for something done once in a note's life. */
const moveTo = async (noteTitle, target) => {
const row = p.locator("[data-note-row]").filter({ hasText: noteTitle }).first();
await row.locator("xpath=preceding-sibling::button[@data-move-note]").first().click();
await p.waitForTimeout(700);
await p.locator("[data-move-target]").filter({ hasText: target }).first().click();
await row.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Move to" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: target }).first().click();
await p.waitForTimeout(2500);
};
const openNotebook = async () => {
await p.locator("button").filter({ hasText: "Haus" }).first().click();
await p.waitForTimeout(1200);
};
await createNotebook(p, "Haus");
await p.locator("button").filter({ hasText: "Haus" }).first().click();
@@ -62,8 +68,8 @@ try {
/* ── filing a note ── */
check(
(await p.locator("[data-move-note]").count()) === 2,
"every note has a move button, without a right-click",
(await p.locator("[data-move-note]").count()) === 0,
"filing is in the row's menu, not a button standing in every row",
String(await p.locator("[data-move-note]").count())
);
await moveTo("Stromrechnung", "Rechnungen");
@@ -73,6 +79,20 @@ try {
(await titles()).join(", ")
);
/* ── and stays filed ──
* It did not. The server had it right, but the sync that follows replaced
* every local note with one belonging to no chapter — so about five seconds
* after being filed, the note walked back out into the notebook by itself.
* A reload is the same journey: the mirror is rebuilt from the server. */
await p.reload();
await p.waitForTimeout(4000);
await openNotebook();
check(
(await titles()).join(",") === "Zählerstand",
"and is still in it after the next sync, not back in the notebook",
(await titles()).join(", ")
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
const inChapter = () =>
@@ -90,10 +110,37 @@ try {
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id));
/* ── which of the two am I in ──
* Beside the list, a chapter does not replace the notebook: both are on
* screen at once. A new note used to go into the chapter however far away the
* user had since clicked, and nothing said which of the two was meant. */
check(
(await p.locator('[data-chapter-current="on"]').count()) === 1,
"opening a chapter makes it the place being written into, and shows it"
);
// Into the empty space under the notebook's own notes.
await p.locator("[data-note-list-body]").click({ position: { x: 140, y: 330 } });
await p.waitForTimeout(700);
check(
(await p.locator('[data-list-current="on"]').count()) === 1 &&
(await p.locator('[data-chapter-current="off"]').count()) === 1,
"clicking into the notebook puts you back in the notebook, visibly"
);
await createNote(p);
await setTitle(p, "Gartenzaun");
await p.waitForTimeout(1500);
const loose = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Gartenzaun");
check(!loose?.chapter_id, "and a note made then is the notebook's, not the chapter's", String(loose?.chapter_id));
check(
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
"so it appears in the notebook's own list",
(await titles()).join(", ")
);
await p.locator("[data-chapter-close]").click();
await p.waitForTimeout(1200);
check(
(await titles()).join(",") === "Zählerstand",
(await titles()).sort().join(",") === "Gartenzaun,Zählerstand",
"and the notebook's own list never held the chapter's notes",
(await titles()).join(", ")
);
@@ -117,10 +164,16 @@ try {
await p.waitForTimeout(800);
await p.locator('[data-chapter-view="inline"]').click();
await p.waitForTimeout(1500);
await p.mouse.click(1380, 900); // outside the panel
await p.waitForTimeout(1200);
await p.locator("[data-chapter-row]").first().click();
/* Escape closes it. Only a click beside the panel did, which on one this wide
means aiming at a sliver of page. */
await p.keyboard.press("Escape");
await p.waitForTimeout(1200);
check(
(await p.locator("[data-chapter-view]").count()) === 0,
"the account panel closes with Escape, not only by clicking beside it"
);
// No second click: the chapter that was open stays open, and changing the
// setting changes only where it is shown.
check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row");
check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then");
@@ -134,11 +187,43 @@ try {
await p.waitForTimeout(300);
await p.locator('[data-sort-option="manual"]').click();
await p.waitForTimeout(1200);
/* Rearranging is asked for, and until it is, no row wears a handle.
* Six dots stood beside every title at all times for something done once in a
* while, and a press meant to open a note could carry it off instead. */
check(
(await p.locator("[data-reorder-grip]").count()) >= 2,
"a chapter has a grip like a note does",
(await p.locator("[data-reorder-grip]").count()) === 0,
"no row wears a handle until rearranging is switched on",
String(await p.locator("[data-reorder-grip]").count())
);
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(600);
check(
(await p.locator("[data-reorder-grip]").count()) >= 2,
"switched on, a chapter has a grip like a note does",
String(await p.locator("[data-reorder-grip]").count())
);
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(600);
check((await p.locator("[data-reorder-grip]").count()) === 0, "and they go away again when it is done");
/* ── a separator drawn in the notebook belongs to the notebook ──
* Opening a chapter used to take the notebook's own separators off the screen
* with it: the list still showed the notebook, but everything around it had
* quietly switched to the chapter. A line vanished for no reason anyone could
* see, and dragging in that list wrote nothing at all. */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 1, "a separator can be drawn in the notebook");
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1500);
check(
(await p.locator("[data-separator]").count()) === 1,
"and it is still there once a chapter is opened",
String(await p.locator("[data-separator]").count())
);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
/* ── its own colour ── */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
@@ -172,7 +257,7 @@ try {
);
const after = (await titles()).sort().join(",");
check(
after === "Stromrechnung,Wasserrechnung,Zählerstand",
after === "Gartenzaun,Stromrechnung,Wasserrechnung,Zählerstand",
"and every note it held is back in the notebook",
after
);
+1 -1
View File
@@ -46,7 +46,7 @@ function readZip(bytes) {
}
const openExportDialog = async (page) => {
await page.getByTitle("Export").click();
await page.getByTitle("Export", { exact: true }).click();
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await page.waitForTimeout(800);
};
+17 -2
View File
@@ -35,6 +35,18 @@ try {
await p.waitForTimeout(300);
await p.locator(`[data-sort-option="${mode}"]`).click();
await p.waitForTimeout(900);
/* Rows are only draggable while rearranging is switched on — the handles
are asked for rather than standing in every row. The suite drags a lot,
so it turns the mode on with the order it belongs to. */
if (mode === "manual") await startMoving();
};
const startMoving = async () => {
const toggle = p.locator("[data-reorder-toggle]");
if ((await toggle.count()) && (await toggle.getAttribute("data-reorder-toggle")) === "off") {
await toggle.click();
await p.waitForTimeout(500);
}
};
/* ── three notes, made in a known order ── */
@@ -280,7 +292,7 @@ try {
});
check(exported, "downloads can be captured");
await p.getByTitle("Export").click();
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(600);
await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click();
@@ -486,7 +498,10 @@ try {
const beforeTouch = await titles();
// The last row onto the first, so the order cannot come out the same by luck.
const rowCount = (await titles()).length;
check((await p.locator("[data-reorder-grip]").count()) >= rowCount, "every row has a grip to take hold of");
check(
(await p.locator("[data-reorder-grip]").count()) >= rowCount,
"with rearranging switched on, every row has a grip to take hold of"
);
await touchDrag(rowCount - 1, 0);
await p.waitForTimeout(2000);
check(
+2 -2
View File
@@ -336,7 +336,7 @@ try {
const originalBytes = readFileSync(PDF_PATH);
const openExport = async () => {
await p.getByTitle("Export").click();
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(1200);
};
@@ -611,7 +611,7 @@ try {
await captureDownloads(p);
await clearDownloads(p);
await p.getByTitle("Export").click();
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(800);
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
+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>
);
}
+46 -5
View File
@@ -332,6 +332,14 @@ devices get different starts, because they want different things:
The row under the finger is found with `elementFromPoint`, not with enter/leave
handlers — a finger generates no `pointerover` on anything it did not press.
**Rearranging is a mode**, switched on by the handle button beside the sort menu
and off again by itself when the list is left or the order stops being a
hand-made one. Only while it is on does a row wear its grip or move at all. Rows
are read constantly and dragged rarely, so six dots beside every title was
clutter that also made an ordinary press able to carry a row off. Filing a note
somewhere else is not part of it: that is one of the things done to a row from
the row's own menu, like starring, exporting and discarding.
**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
nobody had measured, and its submenu was pinned to `left: 100%` and opened only
on hover. On a phone the parent already sits against the right edge, so the list
@@ -431,8 +439,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 21 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back |
| `organise` | 53 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 31 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
@@ -881,9 +889,28 @@ on every keystroke. They are now separate blobs; the body only keeps an id.
| POST | `/api/attachments` | Upload a base64 data URL |
| GET | `/api/attachments/:id` | Fetch the bytes (owner only) |
> Removing an image from a note does not yet delete its blob: the body is
> encrypted, so the server cannot tell what is still referenced. Unused blobs
> cost disk space, nothing else.
### Where it all actually sits
Two directories, both bind-mounted from the host so that whatever backs up the
host backs up InkNote:
```
/srv/inknote/db/inknote.db every note, encrypted body and all
/srv/inknote/uploads/attachments/<sha> one file per distinct upload, encrypted
/srv/inknote/uploads/drawing_<id>.png legacy raster drawings
```
**There is no folder per user, and there is deliberately nothing on disk to read.**
Who owns what is a column in the database (`attachments.user_id`), not a path.
The file name is the SHA-256 of the *plaintext* — encryption uses a fresh IV each
time, so hashing the ciphertext would store the same photo twice — and the bytes
beside it are AES-256-GCM. So a file name tells you that two people uploaded the
same document, and nothing else: not who, not what, not which note.
Sorting uploads into per-user folders would name the owner in the clear on a disk
where nothing else is readable, and it would break the sharing that keeps one copy
of a file two accounts happen to have. Deleting stays safe without it: a blob goes
only when **no** row in `attachments` points at it any more, whoever owned them.
---
@@ -990,6 +1017,20 @@ should follow you to the phone.
A phone has room for neither. There the chapter fills the list and the header
button becomes a way back out, which is what it did before there was a choice.
**Which list is on screen is not the same as which chapter is open.** Beside and
below, the middle column still shows the notebook; only the phone's fallback
replaces it. The two were one value for a while, and 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.
**Which of the two you are in is a separate question again**, because both are on
screen at once. Opening a chapter puts you in it; pressing in the notebook's own
list puts you back in the notebook without closing the chapter. Whichever it is
wears a line along its top and the word *here*, and the + button names the place
it would make the note in. Before, a new note went into the chapter however far
away you had since clicked, and nothing on screen said so.
### A chapter is a row like any other
Chapters sit at the same level as notes and separators: same grip, same drag,