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) => (
{ + 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 && ( +
+ · new notes land here +
+ )} {chapterNotes.length === 0 && (
Nothing in here yet.
)} @@ -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 && (
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, }} >
{openChapter.name} + {inChapter && ( + · here + )} -); - /** 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 }) {
+); + /** 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 (
{openChapter ? openChapter.name : title} + {/* 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 && ( + · here + )}
+ {reorderable && ( + setReordering((v) => !v)} /> + )} {!isTrashView && } {!isTrashView && ( + {/* Only while it is on, and then it says what to do rather than + explaining the order the sort menu already names. */} + {moving && ( + + Drag the rows into place + + )} {/* 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({
)} -
+ {/* 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. */} +
{notes.length === 0 ? (
{ - 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({
{showDropLine && }
- {reorderable && } + {moving && } {showDropLine && } } + grip={moving ? : null} T={T} separator={entry.separator} editing={renamingSeparator === entry.id} @@ -770,10 +815,7 @@ export function NoteList({
{showDropLine && }
- {reorderable && } - {onMoveNote && !note.trashed && ( - onMoveNote(note)} /> - )} + {moving && } - ); - - const heading = (text) => ( -
- {text} -
- ); - - return ( -
-
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", - }} - > -

Move note

-
- {note.title || "Untitled"} -
- -
- {here.length > 0 && heading("In this notebook")} - {here.length > 0 && ( - go(note.folderId, null)}> - The notebook itself - - )} - {here.map((c) => ( - go(note.folderId, c.id)} - > - {c.name} - - ))} - - {others.length > 0 && heading("Another notebook")} - {others.map((nb) => ( - go(nb.id, null)}> - {nb.name} - - ))} - - {here.length === 0 && others.length === 0 && ( -
- There is nowhere else to put it yet — make another notebook, or a chapter in this one. -
- )} -
- -
- -
-
-
- ); -} diff --git a/readme.md b/readme.md index 97f6dcb..27b3b02 100644 --- a/readme.md +++ b/readme.md @@ -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/ one file per distinct upload, encrypted +/srv/inknote/uploads/drawing_.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,