diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c383a9..d68083d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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…` diff --git a/backend/server.js b/backend/server.js index e531548..a2b462a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); }); diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index e8efc47..473806c 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -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 ); diff --git a/e2e/export.mjs b/e2e/export.mjs index 409f2ca..1dcb6c1 100644 --- a/e2e/export.mjs +++ b/e2e/export.mjs @@ -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); }; diff --git a/e2e/organise.mjs b/e2e/organise.mjs index c329d52..d037156 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -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( diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index 93baca6..680b750 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -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(); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3ab2c24..7a0ea72 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) => (