From f1d3e38f8f1a5bc04891a958536ff0186085e4af Mon Sep 17 00:00:00 2001 From: marc Date: Sun, 16 Aug 2026 23:23:16 +0200 Subject: [PATCH] Marks, lines, and a table that breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long table lay straight across the gap between two sheets with a row cut in half, and a shorter one jumped to the next sheet whole leaving the rest of the first blank. Tables are broken between their rows now, which is where a table breaks on paper. The spacer there is a tr with one borderless cell: a div inside a tbody is not something the browser keeps. Its own spacer rows are subtracted again on the next measurement and counted into the shift every block below inherits — miss that and everything under a broken table is measured a page too high. A dialog closed in the middle of an edit if a selection was dragged past its edge: the click lands on the nearest common ancestor of press and release, which is the backdrop, and every dialog read that as "clicked beside me". They remember where the press began now. All ten. A note and a chapter can wear a mark of their own beside the name, chosen from a short list searched by what a thing is for rather than from the system emoji keyboard — a different thing on every machine, and three thousand pictures for a decision that is "which of these is the invoice notebook". A separator takes its own colour and one of five shapes, and a chapter can be divided like the notebook it sits in: both places a chapter is shown draw it with one component now, so it cannot be divisible in the column and not in the unfolded block. Also: a chapter's colour could not be cleared, because the update rode on COALESCE and null there means "leave it alone". A table's header row is legible on the dark theme. Three entries in the table's own menu were still German. Opening a notebook opens its first note, on a wide screen only — on a phone the list is the screen. Fourteen suites green: 364 browser checks, 241 unit tests. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 42 +++ CLAUDE.md | 2 +- backend/server.js | 73 ++++- e2e/chapters.mjs | 102 +++++++ e2e/organise.mjs | 34 +++ e2e/run.sh | 2 +- e2e/tables.mjs | 130 +++++++++ frontend/src/App.jsx | 212 ++++++++------ frontend/src/editor/editor.css | 31 ++- frontend/src/editor/pagination.js | 146 +++++++++- frontend/src/editor/tableMenu.js | 6 +- frontend/src/features/notes/EditorPane.jsx | 26 ++ frontend/src/features/notes/HistoryModal.jsx | 4 +- frontend/src/features/notes/NoteList.jsx | 202 +++++++++++++- frontend/src/hooks/useNoteData.ts | 8 + frontend/src/syncEngine.ts | 48 +++- frontend/src/types.ts | 18 ++ frontend/src/ui/AccountMenu.jsx | 4 +- frontend/src/ui/AdminPanel.jsx | 4 +- frontend/src/ui/ConfirmDialog.jsx | 4 +- frontend/src/ui/ExportDialog.jsx | 7 +- frontend/src/ui/IconPicker.jsx | 275 +++++++++++++++++++ frontend/src/ui/LinkDialog.jsx | 4 +- frontend/src/ui/NameDialog.jsx | 4 +- frontend/src/ui/NotebookModals.jsx | 7 +- frontend/src/ui/NotebookPicker.jsx | 4 +- frontend/src/ui/backdrop.js | 32 +++ frontend/src/ui/constants.jsx | 14 + readme.md | 39 ++- 29 files changed, 1335 insertions(+), 149 deletions(-) create mode 100644 e2e/tables.mjs create mode 100644 frontend/src/ui/IconPicker.jsx create mode 100644 frontend/src/ui/backdrop.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c9a51..d225caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,48 @@ not from the output of `docker push`. --- +## 2026-08-16 — Marks, lines, and a table that breaks + +**Images** — web `sha256:…`, server `sha256:…` + +The server image changed: `icon` on notes and chapters, `icon_color` on notes, +colour and style on separators, and a fix to clearing a chapter's colour. + +**Added** +- **A note and a chapter can wear a mark of their own** beside the name, chosen + from a short list searched by what a thing is *for* — "invoice", "house", + "travel" — rather than from the system emoji keyboard, which is a different + thing on every machine. The same glyph shows in the list and above the editor. + A note's mark takes a colour too. +- **A separator takes its own colour and one of five shapes** — solid, dashed, + dotted, double, thick. Three lines through one notebook read as the same line + repeated when they are all faint grey. +- **A chapter can be divided like the notebook it sits in.** Both places a + chapter is shown now draw it with one component, so it cannot be divisible in + the column and not in the unfolded block. + +**Fixed** +- **A long table lay straight across the gap between two sheets**, with a row cut + in half — and a shorter one jumped to the next sheet whole, leaving the rest of + the first blank. Tables are broken **between their rows** now, which is where a + table breaks on paper. The spacer there is a `tr` with one borderless cell: a + `div` inside a `tbody` is not something the browser keeps. +- **A dialog closed in the middle of an edit** if a selection was dragged past + its edge. The click lands on the nearest common ancestor of press and release — + the backdrop — and every dialog read that as "clicked beside me". They now + remember where the press began. All ten of them. +- **A chapter's colour could not be cleared.** "None" sent null, and the update + rode on `COALESCE`, which reads null as "leave it alone". +- **Three entries in the table's right-click menu were still German.** + +**Changed** +- **A table's header row is legible on the dark theme**: its own fill rather than + the general "slightly off the page" grey, and a rule under it. +- **Opening a notebook opens its first note**, on a wide screen. On a phone the + list is the screen, so opening a note there would hide the notebook. + +--- + ## 2026-08-12 — A notebook opens in its own order **Images** — web `sha256:c077f0e0…`, server `sha256:c13af5d5…` diff --git a/CLAUDE.md b/CLAUDE.md index 349ea97..c038903 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ Beides muss vor einem Push grün sein. # Unit (~4 s) docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run -# Browser, zwölf Suiten – braucht den Dev-Stack +# Browser, vierzehn Suiten – braucht den Dev-Stack ./e2e/run.sh # alle ./e2e/run.sh editor drawing # einzelne ``` diff --git a/backend/server.js b/backend/server.js index a2b462a..520e4ef 100644 --- a/backend/server.js +++ b/backend/server.js @@ -456,6 +456,19 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id); `); +/* A mark of its own, so a row can be found by its shape rather than by reading + * every title. One short string — an emoji is one or two code points, and + * anything longer than a handful is not a mark. */ +if (!tableColumns("notes").includes("icon")) { + db.exec("ALTER TABLE notes ADD COLUMN icon TEXT"); +} +if (!tableColumns("chapters").includes("icon")) { + db.exec("ALTER TABLE chapters ADD COLUMN icon TEXT"); +} +if (!tableColumns("notes").includes("icon_color")) { + db.exec("ALTER TABLE notes ADD COLUMN icon_color TEXT"); +} + /* Added after chapters shipped, so an existing table needs it too. */ if (!tableColumns("chapters").includes("color")) { db.exec("ALTER TABLE chapters ADD COLUMN color TEXT"); @@ -927,6 +940,9 @@ const SORT_MODES = ["manual", "updated", "oldest", "created", "title"]; /* An upper bound on the arrangement, so a malformed client cannot store an * unbounded blob on a notebook. */ const MAX_SEPARATORS = 200; + +/** The ways a separator's rule can be drawn. Anything else is left off. */ +const SEPARATOR_STYLES = ["solid", "dashed", "dotted", "double", "thick"]; const MAX_SEPARATOR_LABEL = 80; /** Keeps only what a separator is allowed to be. */ @@ -943,6 +959,10 @@ function cleanSeparators(input) { * itself. `order` counts the notes before it, and that only means anything * within one list. */ chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null, + // Presentation, and it comes from a browser: a colour has to look like one + // and a style has to be one we draw, or it is simply left off. + color: cleanColor(s.color), + style: SEPARATOR_STYLES.includes(s.style) ? s.style : null, })); } @@ -1080,14 +1100,14 @@ function isBigShrink(previous, next) { /* ─── Note routes ─── */ app.get("/api/notes", authMiddleware, (req, res) => { - const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); + const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId); res.json(notes); }); app.get("/api/notes/trash", authMiddleware, (req, res) => { // `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); + const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, icon, icon_color, 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); }); @@ -1234,7 +1254,7 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => { }); app.put("/api/notes/:id", authMiddleware, (req, res) => { - const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body; + const { title, content, notebookId, chapterId, starred, icon, iconColor, baseUpdatedAt, links, attachments } = req.body; const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!note) return res.status(404).json({ error: "Not found" }); @@ -1289,6 +1309,15 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => { WHERE id = ?`) .run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id); + /* Cleared as deliberately as it is set: null means "take the mark off", which + COALESCE above cannot express. */ + if (icon !== undefined) { + db.prepare("UPDATE notes SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id); + } + if (iconColor !== undefined) { + db.prepare("UPDATE notes SET icon_color = ? WHERE id = ?").run(cleanColor(iconColor), req.params.id); + } + if (chapterId !== undefined) { db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id); } else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) { @@ -1713,16 +1742,30 @@ const MAX_CHAPTER_NAME = 120; const cleanColor = (value) => typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null; +/** + * A row's own mark: one emoji, or nothing. + * + * Length is counted in code points, not in UTF-16 units — a single emoji is + * routinely two of the latter, and a flag or a family several more. Cut at eight + * so one grapheme with its modifiers fits and a pasted sentence does not. + */ +const cleanIcon = (value) => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return [...trimmed].slice(0, 8).join(""); +}; + app.get("/api/chapters", authMiddleware, (req, res) => { res.json( db - .prepare("SELECT id, notebook_id, name, color, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at") + .prepare("SELECT id, notebook_id, name, color, icon, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at") .all(req.userId) ); }); app.post("/api/chapters", authMiddleware, (req, res) => { - const { notebookId, name, color } = req.body || {}; + const { notebookId, name, color, icon } = req.body || {}; const label = String(name ?? "").trim(); if (!label) return res.status(400).json({ error: "name required" }); if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" }); @@ -1736,8 +1779,8 @@ app.post("/api/chapters", authMiddleware, (req, res) => { .prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?") .get(notebookId).n; const id = uuid(); - db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run(id, req.userId, notebookId, label, cleanColor(color), order, req.deviceId); + db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + .run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId); broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId); res.json({ id, notebookId, name: label, sortOrder: order }); }); @@ -1761,7 +1804,7 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => { }); app.put("/api/chapters/:id", authMiddleware, (req, res) => { - const { name, sortOrder, color } = req.body || {}; + const { name, sortOrder, color, icon } = req.body || {}; const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!chapter) return res.status(404).json({ error: "Not found" }); @@ -1771,8 +1814,18 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => { if (!label) return res.status(400).json({ error: "name required" }); if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" }); } - db.prepare("UPDATE chapters SET name = COALESCE(?, name), color = COALESCE(?, color), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?") - .run(label ?? null, color === undefined ? null : cleanColor(color), sortOrder ?? null, req.deviceId, req.params.id); + db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?") + .run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id); + + /* Colour and mark are set and *cleared* by the same field, so neither can ride + * on COALESCE the way the name does: null means "take it off", and COALESCE + * reads null as "leave it alone" — so choosing "None" quietly did nothing. */ + if (color !== undefined) { + db.prepare("UPDATE chapters SET color = ? WHERE id = ?").run(cleanColor(color), req.params.id); + } + if (icon !== undefined) { + db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id); + } broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId); res.json({ success: true }); }); diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index 2f12e07..8cb9f70 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -254,6 +254,61 @@ try { await p.locator("[data-chapter-row]").first().click(); await p.waitForTimeout(1200); + /* ── a chapter can be divided too ── + * It is a list like the notebook is. Its lines are its own: each carries the + * list it was drawn in, so the notebook's stay outside and these stay here. */ + /* Pressing a chapter row toggles it, so "make sure it is open" cannot be a + single click — whether it opens or closes depends on what was open before. */ + const openChapterBlock = async () => { + for (let i = 0; i < 3; i++) { + if (await p.locator("[data-chapter-notes]").count()) return true; + await p.locator("[data-chapter-row]").first().click(); + await p.waitForTimeout(1100); + } + return (await p.locator("[data-chapter-notes]").count()) > 0; + }; + check(await openChapterBlock(), "a chapter unfolds under its row"); + const insideBefore = await p.locator("[data-chapter-notes] [data-separator]").count(); + await p.locator("[data-add-chapter-separator]").click(); + await p.waitForTimeout(2200); + check( + (await p.locator("[data-chapter-notes] [data-separator]").count()) === insideBefore + 1, + "a line can be drawn inside a chapter", + String(await p.locator("[data-chapter-notes] [data-separator]").count()) + ); + const drawnIn = JSON.parse( + (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Haus")?.separators || "[]" + ); + check( + drawnIn.some((sep) => sep.chapterId) && drawnIn.some((sep) => !sep.chapterId), + "and it belongs to the chapter, while the notebook keeps its own", + JSON.stringify(drawnIn.map((sep) => [sep.label, sep.chapterId])) + ); + await p.locator("[data-chapter-row]").first().click(); + await p.waitForTimeout(1000); + + /* ── a dialog does not close because a selection ended outside it ── + * Select a word in the field, drag a little past the edge, let go: the click + * lands on the backdrop — the nearest ancestor of press and release — and the + * dialog used to take that for "clicked beside me" and vanish mid-edit. */ + await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); + await p.waitForTimeout(600); + await p.getByText("Rename or recolour", { exact: true }).click(); + await p.waitForTimeout(700); + const field = p.getByLabel("Chapter name"); + const box = await field.boundingBox(); + await p.mouse.move(box.x + box.width - 12, box.y + box.height / 2); + await p.mouse.down(); + await p.mouse.move(box.x - 220, box.y + box.height / 2, { steps: 12 }); + await p.mouse.up(); + await p.waitForTimeout(800); + check( + (await p.locator("[data-name-dialog]").count()) === 1, + "a selection dragged past the edge does not close the dialog" + ); + await p.keyboard.press("Escape"); + await p.waitForTimeout(600); + /* ── its own colour, afterwards ── * The same dialog that named it. There used to be a "Colour" submenu beside * this entry as well: two things in the menu, one of them offering half of @@ -276,6 +331,53 @@ try { JSON.stringify(coloured.map((c) => [c.name, c.color])) ); + /* ── a mark of its own ── + * A row found by its shape rather than by reading every title. The same glyph + * shows in the list and in the editor, so a note is recognised by one sign in + * both places. */ + await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); + await p.waitForTimeout(600); + await p.getByText("Icon", { exact: true }).click(); + await p.waitForTimeout(700); + await p.getByLabel("Search icons").fill("invoice"); + await p.waitForTimeout(400); + const offered = await p.locator("[data-icon-option]").count(); + check(offered > 0 && offered < 20, "icons are searched by what they are for", `${offered} shown`); + await p.locator("[data-icon-option]").first().click(); + await p.locator("[data-icon-save]").click(); + await p.waitForTimeout(2200); + const marked = (await api(p, "GET", "/chapters")).body.find((c) => c.icon); + check(!!marked?.icon, "a chapter keeps the mark it was given", JSON.stringify([marked?.name, marked?.icon])); + check( + (await p.locator("[data-chapter-mark]").first().innerText()).trim() === marked.icon, + "and wears it in the list" + ); + + /* The same for a note, from the button left of its title. */ + await p.locator("[data-note-row]").first().click(); + await p.waitForTimeout(1200); + await p.locator("[data-note-icon]").click(); + await p.waitForTimeout(700); + await p.getByLabel("Search icons").fill("house"); + await p.waitForTimeout(400); + await p.locator("[data-icon-option]").first().click(); + await p.locator('[data-icon-colour="#2da44e"]').click(); + await p.locator("[data-icon-save]").click(); + await p.waitForTimeout(2500); + const iconed = (await api(p, "GET", "/notes")).body.find((n) => n.icon); + check(!!iconed?.icon && iconed?.icon_color === "#2da44e", "a note keeps its mark and its colour", JSON.stringify([iconed?.title, iconed?.icon, iconed?.icon_color])); + + /* It survives the trip to the server and back — the mark is on the note, not + in this browser. */ + await p.reload(); + await p.waitForTimeout(4000); + await openNotebook(); + check( + (await p.locator("[data-note-icon]").getAttribute("data-note-icon")) === iconed.icon || + (await p.locator("[data-note-mark]").first().innerText()).trim() === iconed.icon, + "and still wears it after a reload" + ); + /* ── a chapter is not a wastebasket ── * Removing one must never remove what it held: the notes come back to the * notebook, where they can be seen and filed again. */ diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 726762c..ac79394 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -265,6 +265,40 @@ try { await p.waitForTimeout(800); check((await p.locator("[data-separator]").count()) === 1, "and back on the way in"); + /* ── a line can be given its own colour and shape ── + * A notebook divided into three parts is easier to read at a glance when the + * lines differ; grey-on-grey they all look like the same line repeated. */ + await p.locator("[data-separator]").click({ button: "right" }); + await p.waitForTimeout(600); + await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click(); + await p.waitForTimeout(500); + await p.locator("[data-submenu] button").filter({ hasText: "Teal" }).first().click(); + await p.waitForTimeout(2000); + await p.locator("[data-separator]").click({ button: "right" }); + await p.waitForTimeout(600); + await p.locator("[data-submenu-toggle]").filter({ hasText: "Line" }).first().click(); + await p.waitForTimeout(500); + await p.locator("[data-submenu] button").filter({ hasText: "Dashed" }).first().click(); + await p.waitForTimeout(2200); + + const stored = (await api(p, "GET", "/notebooks")).body.find((b) => b.name === "Ordnung"); + const seps = JSON.parse(stored?.separators || "[]"); + check( + seps[0]?.color === "#1f9c96" && seps[0]?.style === "dashed", + "a separator keeps its own colour and line style, on the server", + JSON.stringify(seps) + ); + // Drawn, not just stored: the rule is a dashed border rather than a filled bar. + const drawn = await p.locator("[data-separator-rule]").first().evaluate((el) => { + const cs = getComputedStyle(el); + return { style: cs.borderTopStyle, colour: cs.borderTopColor }; + }); + check( + drawn.style === "dashed" && /31,\s*156,\s*150/.test(drawn.colour), + "and it is drawn that way, not only recorded", + JSON.stringify(drawn) + ); + /* Removing is a right-click behind a confirmation. The × used to sit a few pixels from the label you click to rename, so tidying the list and destroying part of it were neighbouring gestures. */ diff --git a/e2e/run.sh b/e2e/run.sh index 71969f8..2500904 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")" IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble" URL="${INKNOTE_URL:-http://localhost:3000}" SUITES=("$@") -[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters offline sync) +[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters tables offline sync) mkdir -p shots if [ ! -d node_modules ]; then diff --git a/e2e/tables.mjs b/e2e/tables.mjs new file mode 100644 index 0000000..5c072ad --- /dev/null +++ b/e2e/tables.mjs @@ -0,0 +1,130 @@ +/* Tables, and what happens to one at the edge of a sheet. + * + * The writing surface is drawn as separate sheets with a gap between them. Text + * is pushed past that gap a block at a time, which is right for a paragraph and + * wrong for a table: moved whole, a table either jumps to the next sheet leaving + * the rest of this one blank, or — being longer than a sheet — has nowhere to + * jump to and lies straight across the gap with a row cut in half. + * + * So the claim under test is that a long table is broken *between its rows*, and + * that no row is left straddling a gap. + */ +import { OUT, check, createNote, createNotebook, launch, report, setTitle, withFreshUser } from "./lib.mjs"; + +/** Where each sheet sits, and therefore where the gaps are. */ +const sheetBands = (page) => + page.evaluate(() => + [...document.querySelectorAll("[data-page-sheet]")].map((el) => { + const r = el.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom }; + }) + ); + +/** Every table row on screen, with the band it occupies. */ +const rowBands = (page) => + page.evaluate(() => + [...document.querySelectorAll(".ProseMirror table tr:not(.ne-page-spacer-row)")].map((el) => { + const r = el.getBoundingClientRect(); + return { top: r.top, bottom: r.bottom, text: el.textContent.trim().slice(0, 12) }; + }) + ); + +const browser = await launch(); +let session; +try { + session = await withFreshUser(browser, "tables"); + const p = session.page; + + await createNotebook(p, "Tabellen"); + await p.locator("button").filter({ hasText: "Tabellen" }).first().click(); + await p.waitForTimeout(700); + await createNote(p); + await setTitle(p, "Lange Tabelle"); + + /* Enough text to push the table down the sheet, then a table long enough to + run past the bottom of it — the two cases that used to go wrong. */ + await p.locator(".ProseMirror").click(); + for (let i = 0; i < 12; i++) { + await p.keyboard.type(`Zeile ${i} vor der Tabelle. `); + await p.keyboard.press("Enter"); + } + await p.waitForTimeout(500); + + await p.locator("[data-table-menu] button").first().click(); + await p.waitForTimeout(400); + await p.getByText("Insert 3 × 3 table", { exact: true }).click(); + await p.waitForTimeout(1200); + check((await p.locator(".ProseMirror table").count()) === 1, "a table can be inserted"); + + /* Rows are added from the menu on the table itself, which is also the thing + Marc uses — and the labels there are English like the rest of the app. */ + await p.locator(".ProseMirror table td").first().click(); + await p.waitForTimeout(300); + for (let i = 0; i < 26; i++) { + await p.keyboard.type(`R${i}`); + await p.locator(".ProseMirror table").click({ button: "right" }); + await p.waitForTimeout(220); + await p.getByText("Insert row below", { exact: true }).click(); + await p.waitForTimeout(220); + } + await p.waitForTimeout(2500); + + check( + (await p.locator("[data-submenu-toggle], [role=menu]").count()) >= 0 && + !/Kopfzeile|Zellen verbinden|Zelle teilen/.test(await p.locator("body").innerText()), + "the table's own menu speaks the same language as the rest of the app" + ); + + const bands = await sheetBands(p); + check(bands.length >= 2, "the table runs past the first sheet", `${bands.length} sheets`); + + const spacers = await p.locator("tr.ne-page-spacer-row").count(); + check(spacers >= 1, "the table is broken between its rows rather than moved whole", String(spacers)); + + /* The point of it all: nothing is left lying across a gap. A row may end + exactly on a sheet's edge — that is not a straddle — so a pixel of slack. */ + const rows = await rowBands(p); + const gaps = bands.slice(0, -1).map((b, i) => ({ from: b.bottom, to: bands[i + 1].top })); + const straddling = rows.filter((r) => gaps.some((g) => r.bottom > g.from + 1 && r.top < g.to - 1)); + check( + straddling.length === 0, + "and no row is left lying across the gap between two sheets", + straddling.map((r) => r.text).join(", ") + ); + + await p.screenshot({ path: `${OUT}/tables-split.png`, fullPage: true }); + + /* The header has to be legible on the dark theme, where the fill it had was a + difference you could only find by looking for it. */ + // The table was inserted with a header row, so there is one to measure — the + // menu entry toggles it *off*, which would leave nothing to look at. + const header = await p.locator(".ProseMirror table th").first().evaluate((el) => { + const cs = getComputedStyle(el); + const cell = getComputedStyle(el.closest("table").querySelector("td") || el); + return { head: cs.backgroundColor, body: cell.backgroundColor, rule: cs.borderBottomWidth }; + }); + check( + header.head !== header.body && parseFloat(header.rule) >= 2, + "a header row is filled differently from the body and underlined", + JSON.stringify(header) + ); + + /* Written, nothing is lost: the spacers are decorations, so the body the + server keeps has the table and no page furniture in it. */ + await p.waitForTimeout(2000); + const body = await p.evaluate(async () => { + const auth = { Authorization: "Bearer " + localStorage.getItem("inknote_token") }; + const notes = await (await fetch("/api/notes", { headers: auth })).json(); + const mine = notes.find((n) => n.title === "Lange Tabelle"); + return (await (await fetch(`/api/notes/${mine.id}`, { headers: auth })).json()).content; + }); + check(!/ne-page-spacer/.test(body || ""), "and no page furniture reaches the stored note"); + check(/table/.test(body || ""), "which does hold the table itself", String(body || "").slice(0, 60)); +} catch (e) { + check(false, "suite ran to the end", e.message); +} finally { + await session?.cleanup(); + const code = report(); + await browser.close(); + process.exit(code); +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7da7842..97dcb09 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,11 +8,12 @@ import { usePwaInstall } from "./hooks/usePwaInstall"; import { useNotebookDnd } from "./hooks/useNotebookDnd"; import { useNavigationHistory } from "./hooks/useNavigationHistory"; -import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx"; +import { CHAPTER_COLORS, DEFAULT_FOLDERS, SEPARATOR_STYLES, coverBackground } from "./ui/constants.jsx"; 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 { IconPicker } from "./ui/IconPicker.jsx"; import { ContextMenu } from "./ui/ContextMenu.jsx"; import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx"; import { AccountMenu } from "./ui/AccountMenu.jsx"; @@ -23,7 +24,7 @@ import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx"; import { ImportDialog } from "./ui/ImportDialog.jsx"; import { Sidebar } from "./ui/Sidebar.jsx"; import { Dashboard } from "./features/notebooks/Dashboard.jsx"; -import { NoteList } from "./features/notes/NoteList.jsx"; +import { ChapterNotes, NoteList } from "./features/notes/NoteList.jsx"; import { DEFAULT_NOTEBOOK_SORT, DEFAULT_SORT, @@ -99,6 +100,9 @@ export default function App() { /* The one dialog that asks for a name — a chapter's, with its colour, or a separator's. Held here because both are started from menus that live here. */ const [naming, setNaming] = useState(null); + /* { title, value, colour, onSave } while a mark is being chosen for a note or + a chapter — one picker, because it is one decision either way. */ + const [pickingIcon, setPickingIcon] = 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); @@ -277,17 +281,30 @@ export default function App() { 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) !== listChapter), ...visible]); + const writeIn = (chapterId, visible) => + writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== chapterId), ...visible]); + const writeVisible = (visible) => writeIn(listChapter, visible); /* One press draws the line; it is named afterwards from its own menu, like it is renamed. A dialog in front of it asked a question about something that reads perfectly well unnamed. */ const addSeparator = () => writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", listChapter)); + /* The same act inside a chapter. A chapter is a list like the notebook is, so + it can be divided like one — its lines carry its id and stay in it. */ + const addChapterSeparator = () => + activeChapter && + writeIn( + activeChapter, + withNewSeparator(chapterSeparators, `sep-${Date.now().toString(36)}`, "", activeChapter) + ); const renameSeparator = (id, label) => writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s))); const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id)); + /* Colour and pattern are presentation, so they are written the same way the + label is: onto the notebook's one list, leaving the others untouched. */ + const restyleSeparator = (id, changes) => + writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, ...changes } : s))); const separatorContextMenu = (e, separator) => { e.preventDefault(); @@ -297,6 +314,26 @@ export default function App() { y: e.clientY, items: [ { label: "Rename", icon: I.edit, action: () => setRenamingSeparator(separator.id) }, + { + label: "Colour", + icon: I.edit, + submenu: [ + { label: "None", colorDot: T.faint, action: () => restyleSeparator(separator.id, { color: null }) }, + ...CHAPTER_COLORS.map((c) => ({ + label: c.label, + colorDot: c.value, + action: () => restyleSeparator(separator.id, { color: c.value }), + })), + ], + }, + { + label: "Line", + icon: I.edit, + submenu: SEPARATOR_STYLES.map((st) => ({ + label: st.label, + action: () => restyleSeparator(separator.id, { style: st.value }), + })), + }, { divider: true }, { label: "Remove separator", @@ -357,6 +394,25 @@ export default function App() { () => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []), [activeChapter, activeNotes, sortMode] ); + /* And the lines drawn inside it, which are its own — the notebook's stay in + the notebook, and each chapter's stay in that chapter. */ + const chapterSeparators = useMemo( + () => separatorsFor(allSeparators, activeChapter), + [allSeparators, activeChapter] + ); + + /* Opening a notebook opens its first note with it. + * + * On a wide screen the editor is already there, and leaving it on "Select a + * note to edit" wastes two thirds of the window and a click on the thing you + * almost always want. On a phone the list *is* the screen, so opening a note + * would replace it and hide the notebook you just asked to see. */ + useEffect(() => { + if (isMobile || activeNote || isTrashView) return; + if (activeFolder === "dashboard" || isSearching) return; + const first = filteredNotes[0]; + if (first) setActiveNote(first.id); + }, [isMobile, activeNote, isTrashView, activeFolder, activeChapter, isSearching, filteredNotes]); const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null; // The editor opens as soon as the metadata is known; the body fills in when @@ -501,6 +557,19 @@ export default function App() { openNote(note.id); }); + /* A note's own mark. Written through its own call rather than with the title: + the title write is debounced behind typing, and a mark chosen with one press + would then arrive late or not at all. */ + const pickNoteIcon = () => { + if (!noteMeta) return; + setPickingIcon({ + title: "Icon for this note", + value: noteMeta.icon ?? null, + colour: noteMeta.iconColor ?? null, + onSave: (icon, colour) => data.setNoteIcon(noteMeta.id, icon, colour), + }); + }; + /** * Runs a destructive action behind a confirmation prompt. * @@ -685,6 +754,20 @@ export default function App() { onSave: (name, color) => data.editChapter(chapter.id, { name, color }), }), }, + /* The mark is its own entry: a name and a colour are typed and picked + from a short list, a mark is hunted for in a grid — one dialog doing + both would be a form. */ + { + label: "Icon", + icon: I.edit, + action: () => + setPickingIcon({ + title: `Icon for "${chapter.name}"`, + value: chapter.icon ?? null, + colour: chapter.color ?? null, + onSave: (icon, colour) => data.editChapter(chapter.id, { icon, color: colour }), + }), + }, { divider: true }, { label: "Remove chapter", @@ -767,6 +850,28 @@ export default function App() { // On a phone the list and the editor take turns; on a desktop both are shown. /* A chapter's notes, unfolded under its own row. Indented and against a rule, so it reads as "inside this" rather than as more of the same list. */ + /* What a chapter holds, wherever it is shown. The column beside the list and + this unfolded block are the same list in two frames, so they draw it with + the same component — a chapter divisible in one and not the other would be + two different things under one name. */ + const chapterContents = (compact) => ( + + ); + const renderChapterNotes = (chapter) => (
- {chapterNotes.length === 0 && ( -
Nothing in here yet.
- )} - {chapterNotes.map((n) => ( - - ))} + {chapterContents(true)}
); @@ -1014,50 +1078,7 @@ export default function App() {
- {chapterNotes.length === 0 && ( -
- Nothing in here yet. -
- )} - {chapterNotes.map((n) => ( - - ))} + {chapterContents(false)}
)} @@ -1074,6 +1095,7 @@ export default function App() { linkProps={linkProps} onBack={closeNote} onUpdate={(u) => data.updateNote(activeNote, u)} + onPickIcon={pickNoteIcon} onToggleStar={() => data.toggleStar(activeNote)} onTrash={() => trashNote(activeNote)} onRestore={() => restoreNote(activeNote)} @@ -1188,6 +1210,16 @@ export default function App() { dark={dark} /> )} + {pickingIcon && ( + setPickingIcon(null)} + dark={dark} + /> + )} {confirmDialog && ( { const n = parseFloat(value); @@ -55,6 +65,59 @@ function makeSpacer(height, delta) { * Returns `null` when there is nothing to measure against — no geometry yet, or * a page too short to hold anything. */ +/** + * The spacer inside a table. + * + * A row, because that is what a table's children are: anything else is either + * dropped by the browser or wrapped in an anonymous cell of its own. The cell + * carries the height and is stripped of the borders every other cell has, so the + * gap between two sheets stays empty. + */ +function makeSpacerRow(height, delta, colspan) { + const tr = document.createElement("tr"); + tr.className = SPACER_ROW_CLASS; + tr.setAttribute("aria-hidden", "true"); + tr.contentEditable = "false"; + tr.dataset.delta = String(delta); + const td = document.createElement("td"); + td.colSpan = Math.max(1, colspan || 1); + td.style.height = `${Math.max(0, Math.round(height))}px`; + tr.appendChild(td); + return tr; +} + +/** The rows of a table, with the document position of each. */ +function tableRows(node, wrapper, domTop, seenBefore) { + const table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table"); + const body = table?.querySelector(":scope > tbody"); + if (!body) return null; + + const rows = []; + let inner = 0; + let index = 0; + // Positions are walked rather than looked up: a row's place in the document is + // the table's own position plus the size of every row before it. + let pos = 0; + for (const el of body.children) { + if (el.classList?.contains(SPACER_ROW_CLASS)) { + inner += Number(el.dataset.delta) || 0; + continue; + } + if (index >= node.childCount) break; + const rect = el.getBoundingClientRect(); + rows.push({ + index, + pos, + top: rect.top - domTop - seenBefore - inner, + height: rect.height, + }); + pos += node.child(index).nodeSize; + index++; + } + const columns = table.querySelector(":scope > colgroup")?.children.length || 1; + return { rows, inner, columns }; +} + function computeBreaks(view, geometry) { const pageHeight = geometry?.pageHeight ?? 0; const gap = geometry?.gap ?? 0; @@ -71,9 +134,9 @@ function computeBreaks(view, geometry) { const domTop = dom.getBoundingClientRect().top; - // Top-level document positions, in the order they are rendered. - const positions = []; - view.state.doc.forEach((_node, offset) => positions.push(offset)); + // Top-level nodes and their positions, in the order they are rendered. + const nodes = []; + view.state.doc.forEach((node, offset) => nodes.push({ node, offset })); /* Each block's position **as if no spacer existed**. A spacer shifts * everything after it by exactly its recorded delta, so subtracting the @@ -87,16 +150,33 @@ function computeBreaks(view, geometry) { seen += Number(child.dataset.delta) || 0; continue; } - if (index >= positions.length) break; + if (index >= nodes.length) break; + const { node, offset } = nodes[index]; const rect = child.getBoundingClientRect(); const cs = getComputedStyle(child); - blocks.push({ - pos: positions[index], + const block = { + pos: offset, top: rect.top - domTop - seen, height: rect.height, marginTop: px(cs.marginTop), marginBottom: px(cs.marginBottom), - }); + }; + + /* A table is measured row by row, since that is where it can be broken. + * Its own spacer rows are subtracted as we go, and then added to `seen`: + * they push everything after the table down as surely as a block spacer + * does, and the next block's natural position has to allow for it. */ + if (node.type.name === "table") { + const measured = tableRows(node, child, domTop, seen); + if (measured) { + block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos })); + block.columns = measured.columns; + block.height = rect.height - measured.inner; + seen += measured.inner; + } + } + + blocks.push(block); index++; } @@ -105,6 +185,40 @@ function computeBreaks(view, geometry) { for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const top = block.top + shift; + + /* ── a table breaks between its rows ── + * The row that would straddle the gap starts the next page instead, and the + * rows above it stay where they are. Moving the table whole either wastes + * the rest of the sheet or, for a table longer than a page, cannot help at + * all — which is why a long one used to lie straight across the gap. */ + if (block.rows?.length) { + let inner = 0; + for (const row of block.rows) { + // `shift` is what the blocks above pushed down; `inner` is what the + // breaks already placed inside this table added on top of that. + const rowTop = row.top + shift + inner; + const rowPage = Math.floor(rowTop / pitch); + const rowEnd = rowPage * pitch + pageHeight - padBottom; + if (rowTop + row.height <= rowEnd + 0.5) continue; + // A row taller than a whole page cannot be helped by moving it. + if (row.height > usable) continue; + const delta = (rowPage + 1) * pitch + padTop - rowTop; + if (delta <= 0) continue; + /* The first row is the table's own top edge: pushing *inside* the table + * there would leave its border hanging above the break, so the whole + * table moves, exactly as any other block would. */ + if (row.index === 0) { + if (i === 0) continue; + breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - Math.min(blocks[i - 1].marginBottom, block.marginTop)) }); + } else { + breaks.push({ pos: row.pos, delta, height: delta, row: true, columns: block.columns }); + } + inner += delta; + } + shift += inner; + continue; + } + // Taller than a page however it is placed — moving it down would only put // the same problem on the next page, one page further from the top. if (block.height > usable) continue; @@ -133,20 +247,24 @@ const sameBreaks = (a, b) => !!a && !!b && a.length === b.length && - a.every((x, i) => x.pos === b[i].pos && Math.abs(x.height - b[i].height) < 1); + a.every((x, i) => x.pos === b[i].pos && !!x.row === !!b[i].row && Math.abs(x.height - b[i].height) < 1); const decorationsFor = (doc, breaks) => DecorationSet.create( doc, breaks.map((b) => - Decoration.widget(b.pos, () => makeSpacer(b.height, b.delta), { + Decoration.widget( + b.pos, + () => (b.row ? makeSpacerRow(b.height, b.delta, b.columns) : makeSpacer(b.height, b.delta)), + { // Height in the key, so a spacer that has to change size is rebuilt // rather than reused at its old height. - key: `page-break-${b.pos}-${Math.round(b.height)}`, + key: `page-break-${b.row ? "row" : "block"}-${b.pos}-${Math.round(b.height)}`, side: -1, ignoreSelection: true, marks: [], - }) + } + ) ) ); diff --git a/frontend/src/editor/tableMenu.js b/frontend/src/editor/tableMenu.js index 540bf59..a90dd26 100644 --- a/frontend/src/editor/tableMenu.js +++ b/frontend/src/editor/tableMenu.js @@ -38,13 +38,13 @@ export function tableMenuItems(editor) { { label: "Insert column left", action: run((c) => c.addColumnBefore()) }, { label: "Insert column right", action: run((c) => c.addColumnAfter()) }, { divider: true }, - { label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) }, + { label: "Header row on/off", action: run((c) => c.toggleHeaderRow()) }, ]; // mergeCells and splitCell are mutually exclusive in practice: one needs a // multi-cell selection, the other a cell that was merged earlier. - if (can("mergeCells")) items.push({ label: "Zellen verbinden", action: run((c) => c.mergeCells()) }); - if (can("splitCell")) items.push({ label: "Zelle teilen", action: run((c) => c.splitCell()) }); + if (can("mergeCells")) items.push({ label: "Merge cells", action: run((c) => c.mergeCells()) }); + if (can("splitCell")) items.push({ label: "Split cell", action: run((c) => c.splitCell()) }); items.push( { divider: true }, diff --git a/frontend/src/features/notes/EditorPane.jsx b/frontend/src/features/notes/EditorPane.jsx index 46e93a9..d25203c 100644 --- a/frontend/src/features/notes/EditorPane.jsx +++ b/frontend/src/features/notes/EditorPane.jsx @@ -146,6 +146,7 @@ export function EditorPane({ linkProps, onBack, onUpdate, + onPickIcon, onToggleStar, onTrash, onRestore, @@ -192,6 +193,31 @@ export function EditorPane({ }} > {isMobile && } + {/* The note's own mark, left of its name — where the eye goes first and + where the same mark sits in the list. Pressing it opens the picker. */} +
e.stopPropagation()} diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index fcba287..1768a0e 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -76,6 +76,40 @@ const MIDDLE_MIN = 54; /** Widest, so a long name cannot squeeze the rules away to nothing. */ const MIDDLE_MAX = 170; +/** + * How a separator's rule is drawn. + * + * Built as a border rather than a background, because that is the only way to + * get dashes and dots without an image — and `double` needs the height to carry + * two lines and the gap between them, or the browser draws one. + */ +const RULE_STYLES = { + solid: { height: 2, borderRadius: 2, solid: true }, + dashed: { height: 0, borderTop: "2px dashed" }, + dotted: { height: 0, borderTop: "3px dotted" }, + double: { height: 0, borderTop: "4px double" }, + thick: { height: 4, borderRadius: 2, solid: true }, +}; + +/** One half of the line, left or right of the label. */ +function Rule({ colour, style }) { + const shape = RULE_STYLES[style] || RULE_STYLES.solid; + return ( +
+ ); +} + /** * A line drawn between notes, with an optional name. * @@ -89,6 +123,9 @@ const MIDDLE_MAX = 170; */ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) { const [draft, setDraft] = useState(separator.label); + // Its own colour when it has been given one; the list's faint grey otherwise, + // which is what every separator looked like before there was a choice. + const colour = separator.color || T.faint; useEffect(() => { if (editing) setDraft(separator.label); @@ -115,11 +152,8 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex }} > {grip} - {/* The two rules always share what is left over equally, and the middle - keeps one width whether it is showing a label or an input. Sizing it to - its contents made the whole row jump sideways the moment renaming - started, which is exactly when the eye is on it. */} -
+ {/* The two rules always share what is left over equally. */} + {/* Sized to the name it holds, between a floor and a ceiling. A fixed slot kept the row from jumping when renaming began, at the cost of a short name floating in the middle of a gap it did not fill. */} @@ -173,7 +207,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex width: "100%", border: "1px solid transparent", background: "transparent", - color: separator.label ? T.text : T.muted, + color: separator.label ? separator.color || T.text : T.muted, fontSize: 11, fontWeight: 700, letterSpacing: 0.6, @@ -189,7 +223,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
)}
-
+
); } @@ -224,7 +258,19 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) { > {/* Its own colour, so a shelf of chapters can be told apart at a glance — the point of having them is that they are different places. */} - {I.books} + + {chapter.icon || I.books} + + {entries.length === 0 && ( +
Nothing in here yet.
+ )} + {entries.map((entry) => + entry.kind === "separator" ? ( + + ) : ( + + ) + )} + {/* A chapter can be divided like the notebook it sits in. Only where a + line can be seen, same as in the list outside. */} + {reorderable && onAddSeparator && ( + + )} + + ); +} + /** * How this notebook's notes are ordered. * @@ -868,8 +1037,21 @@ export function NoteList({ width: "100%", }} > - - {note.trashed ? I.trash : note.type === "pdf" ? I.pdf : I.note} + {/* Its own mark when it has one, otherwise the kind of + thing it is. The same glyph the editor shows, so a note + is recognised by the same sign in both places. */} + + {note.trashed ? I.trash : note.icon || (note.type === "pdf" ? I.pdf : I.note)} void; setNoteContent: (id: string, content: NoteContent) => void; toggleStar: (id: string) => Promise; + /** The note's own mark beside its title, and the colour it is drawn in. */ + setNoteIcon: (id: string, icon: string | null, iconColor: string | null) => Promise; trashNote: (id: string) => Promise; restoreNote: (id: string) => Promise; permanentDeleteNote: (id: string) => Promise; @@ -249,6 +251,11 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD setNoteContents((p) => ({ ...p, [id]: content })); }, []); + const setNoteIcon = useCallback(async (id: string, icon: string | null, iconColor: string | null) => { + setNotes((p) => p.map((n) => (n.id === id ? { ...n, icon, iconColor } : n))); + await sync.updateNoteIcon(id, icon, iconColor); + }, []); + const toggleStar = useCallback(async (id: string) => { const starred = await sync.toggleNoteStar(id); setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n))); @@ -427,6 +434,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD updateNote, setNoteContent, toggleStar, + setNoteIcon, trashNote, restoreNote, permanentDeleteNote, diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index f0b2220..5fc1e49 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -84,6 +84,7 @@ interface ServerChapter { notebook_id: string; name: string; color?: string | null; + icon?: string | null; sort_order?: number; } @@ -92,6 +93,8 @@ interface ServerNote { title: string; notebook_id: string | null; chapter_id?: string | null; + icon?: string | null; + icon_color?: string | null; starred?: number | boolean; created_at: string; updated_at?: string; @@ -127,6 +130,9 @@ export interface NoteUpdate { */ chapterId?: string | null; starred?: boolean; + /** The note's own mark, and its colour. Null on either takes it off. */ + icon?: string | null; + iconColor?: string | null; links?: string[]; /** Attachments the body embeds, so ones nothing uses can be released. */ attachments?: string[]; @@ -505,6 +511,8 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta { * 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, + icon: n.icon ?? null, + iconColor: n.icon_color ?? null, starred: !!n.starred, trashed, createdAt: n.created_at, @@ -538,7 +546,10 @@ type QueuedAction = } | { action: "deleteNotebook"; data: { id: string } } | { action: "createChapter"; data: { localId: string; notebookId: string; name: string; color?: string | null } } - | { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number; color?: string | null } } + | { + action: "updateChapter"; + data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null }; + } | { action: "deleteChapter"; data: { id: string } } | { action: "createNote"; @@ -677,6 +688,7 @@ async function processEntry(entry: QueueEntry): Promise { name: a.data.name, sortOrder: a.data.sortOrder, color: a.data.color, + icon: a.data.icon, }); break; case "deleteChapter": @@ -810,6 +822,7 @@ export async function fetchChapters(): Promise { notebookId: c.notebook_id, name: c.name, color: c.color ?? null, + icon: c.icon ?? null, sortOrder: c.sort_order || 0, })); const local = await db.getAllChapters(); @@ -849,7 +862,7 @@ export async function createChapter( export async function updateChapter( id: string, - updates: { name?: string; sortOrder?: number; color?: string | null } + updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null } ): Promise { const chapter = (await db.getAllChapters()).find((c) => c.id === id); if (!chapter) return; @@ -1149,6 +1162,37 @@ export async function createNote( return note; } +/** + * Sets or clears a note's own mark. + * + * Its own call rather than a field on the title write: the mark is chosen from a + * picker, one press at a time, and debouncing it behind the title's timer would + * make it arrive late or not at all. + */ +export async function updateNoteIcon( + id: string, + icon: string | null, + iconColor: string | null +): Promise { + const note = await db.getNote(id); + if (!note) return; + const updated: NoteMeta = { ...note, icon, iconColor }; + await db.putNote(updated); + notifyData("note", updated); + + const updates = { icon, iconColor }; + if (_online && api.token && !updated._pendingCreate) { + try { + await api.put(`/notes/${id}`, updates); + return; + } catch { + /* queue it below */ + } + } + await enqueue({ action: "updateNote", data: { id, updates } }); + notifyStatus("pending"); +} + export async function updateNoteTitle(id: string, title: string): Promise { const note = await db.getNote(id); if (!note) return; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 10a2cd1..ffe0ec1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -47,6 +47,9 @@ export interface NoteMeta { folderId: string | null; /** Chapter within the notebook; null means the notebook itself. */ chapterId?: string | null; + /** Its own mark beside the title, and the colour that mark is drawn in. */ + icon?: string | null; + iconColor?: string | null; starred: boolean; trashed: boolean; createdAt: string; @@ -82,6 +85,12 @@ export type SortMode = "manual" | "updated" | "oldest" | "created" | "title"; * `order` is "how many notes come before me", so it stays meaningful as the * notes around it are renamed, edited or opened. */ +/** A mark a row can wear beside its name, with a colour of its own. */ +export interface RowIcon { + icon?: string | null; + iconColor?: string | null; +} + export interface Separator { id: string; label: string; @@ -92,8 +101,15 @@ export interface Separator { * anything within one list. */ chapterId?: string | null; + /** Its own colour, `#rrggbb`, or null for the list's own faint grey. */ + color?: string | null; + /** How the rule is drawn: solid, dashed, dotted, double or a thick band. */ + style?: SeparatorStyle; } +/** The ways a separator's rule can be drawn. */ +export type SeparatorStyle = "solid" | "dashed" | "dotted" | "double" | "thick"; + /** * A folder inside a notebook. * @@ -107,6 +123,8 @@ export interface Chapter { name: string; /** `#rrggbb` for its icon, so a shelf of chapters can be told apart. */ color?: string | null; + /** An emoji standing in for the default folder mark. */ + icon?: string | null; sortOrder: number; /** Set while a chapter created offline has not reached the server yet. */ _pendingCreate?: boolean; diff --git a/frontend/src/ui/AccountMenu.jsx b/frontend/src/ui/AccountMenu.jsx index 0835142..00e6240 100644 --- a/frontend/src/ui/AccountMenu.jsx +++ b/frontend/src/ui/AccountMenu.jsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import * as sync from "../syncEngine"; import { I } from "./icons.jsx"; +import { useBackdropClose } from "./backdrop"; /* ═══════ ACCOUNT MENU ═══════ */ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) { + const backdrop = useBackdropClose(onClose); /* 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(() => { @@ -131,7 +133,7 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin zIndex: 2000, backdropFilter: "blur(6px)", }} - onClick={onClose} + {...backdrop} >
* makes the user type the name. */ export function AdminPanel({ currentUser, onClose, dark }) { + const backdrop = useBackdropClose(onClose); const [users, setUsers] = useState(null); const [err, setErr] = useState(""); const [msg, setMsg] = useState(""); @@ -176,7 +178,7 @@ export function AdminPanel({ currentUser, onClose, dark }) { backdropFilter: "blur(6px)", fontFamily: FONT_UI, }} - onClick={onClose} + {...backdrop} >
({ bg: dark ? "#161b22" : "#fff", @@ -28,7 +30,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi zIndex: 2000, backdropFilter: "blur(4px)", }} - onClick={onCancel} + {...backdrop} >
!busy && onClose()); const [progress, setProgress] = useState(null); const [err, setErr] = useState(""); const [done, setDone] = useState(""); @@ -256,7 +259,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) { backdropFilter: "blur(6px)", fontFamily: FONT_UI, }} - onClick={() => !busy && onClose()} + {...backdrop} >
+ ))} + {shown.length === 0 && ( +
+ Nothing by that name. +
+ )} +
+ + {/* The tint is for the marks that are drawn from the app's own set — a + coloured emoji ignores it, which is why it is offered quietly. */} +
+
+ Colour (for the row, and for marks without one of their own) +
+
+ {[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => { + const on = (c.value ?? null) === (tint ?? null); + return ( + + ); + })} +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/ui/LinkDialog.jsx b/frontend/src/ui/LinkDialog.jsx index 4a6f26c..bebfac1 100644 --- a/frontend/src/ui/LinkDialog.jsx +++ b/frontend/src/ui/LinkDialog.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useBackdropClose } from "./backdrop"; /** * Attaching a web address to the selected text. @@ -18,6 +19,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; const ALLOWED = /^(https?|mailto):/i; export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) { + const backdrop = useBackdropClose(onClose); const [url, setUrl] = useState(initialUrl); const [error, setError] = useState(""); const inputRef = useRef(null); @@ -88,7 +90,7 @@ export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark zIndex: 2000, backdropFilter: "blur(4px)", }} - onClick={onClose} + {...backdrop} >
{ }; export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", onPick, onClose, dark }) { + const backdrop = useBackdropClose(onClose); const remembered = recalledNotebook(); const [selected, setSelected] = useState( () => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null @@ -77,7 +79,7 @@ export function NotebookPicker({ notebooks, title, confirmLabel = "Continue", on zIndex: 2000, backdropFilter: "blur(4px)", }} - onClick={onClose} + {...backdrop} >
e.stopPropagation()} diff --git a/frontend/src/ui/backdrop.js b/frontend/src/ui/backdrop.js new file mode 100644 index 0000000..40244b1 --- /dev/null +++ b/frontend/src/ui/backdrop.js @@ -0,0 +1,32 @@ +import { useCallback, useRef } from "react"; + +/** + * Clicking beside a dialog closes it — but only a click that *began* there. + * + * A bare `onClick={onClose}` on the backdrop looks right and is wrong in one + * ordinary case: select a word inside the dialog, drag a little past its edge, + * let go. The browser fires the click on the nearest common ancestor of press + * and release, which is the backdrop — so the dialog closed in the middle of + * editing, and whatever had been typed went with it. + * + * Remembering where the press landed is enough to tell the two apart: a real + * dismissal starts and ends on the backdrop, a selection starts inside it. + */ +export function useBackdropClose(onClose) { + const startedOutside = useRef(false); + + const onPointerDown = useCallback((e) => { + startedOutside.current = e.target === e.currentTarget; + }, []); + + const onClick = useCallback( + (e) => { + if (e.target !== e.currentTarget || !startedOutside.current) return; + startedOutside.current = false; + onClose?.(); + }, + [onClose] + ); + + return { onPointerDown, onClick }; +} diff --git a/frontend/src/ui/constants.jsx b/frontend/src/ui/constants.jsx index e44bf74..257f1e2 100644 --- a/frontend/src/ui/constants.jsx +++ b/frontend/src/ui/constants.jsx @@ -59,6 +59,20 @@ export const CHAPTER_COLORS = [ { label: "Pink", value: "#db61a2" }, ]; +/** + * The ways a separator's rule can be drawn. + * + * Named rather than shown as samples: at this size a dotted rule and a dashed + * one are two grey smudges, and the word says which is which. + */ +export const SEPARATOR_STYLES = [ + { label: "Solid", value: "solid" }, + { label: "Dashed", value: "dashed" }, + { label: "Dotted", value: "dotted" }, + { label: "Double", value: "double" }, + { label: "Thick", value: "thick" }, +]; + /* The icon is a key rather than an emoji: the sidebar draws it from the app's * own set, so it matches the stroke weight of everything around it and looks * the same on every machine. */ diff --git a/readme.md b/readme.md index 3225fb1..f94a3b7 100644 --- a/readme.md +++ b/readme.md @@ -439,9 +439,10 @@ 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` | 58 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | -| `chapters` | 36 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging | +| `organise` | 60 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | +| `chapters` | 46 | 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 | +| `tables` | 8 | a long table is broken between its rows, with none left lying across the gap, and no page furniture reaches the stored note | | `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 | Each suite creates a throwaway account through the admin API and deletes it @@ -526,9 +527,22 @@ appear in an export. Two tests hold that down — one asks whether any block sit in a gap, the other reads the stored body back and looks for them. The unit is a whole block. A paragraph that would be split moves down entire, -which is what a page break means on paper. A single block taller than a page — a -long table, a tall image — is left where it is and runs over the gap: splitting -one would mean laying out its insides ourselves. +which is what a page break means on paper. + +**A table is the exception, and has to be.** Moved whole it either jumps to the +next sheet leaving the rest of this one blank, or — being longer than a sheet — +has nowhere to jump to and lies straight across the gap with a row cut in half. +So a table is broken **between its rows**, which is where a table breaks on paper +too. The spacer there is a row of its own: a `div` inside a `tbody` is not a +thing the browser will keep, while a `tr` with one borderless cell pushes the +rows after it down exactly as far as needed. Its own spacer rows are subtracted +again on the next measurement, and counted into the shift every block below it +inherits — miss that and everything under a broken table is measured a page too +high. + +Anything else too tall to fit anywhere — a full-page image — is left where it is +and runs over the gap: moving it down only puts the same problem one sheet +further on. Two details that are easy to get wrong and were: @@ -1094,6 +1108,15 @@ never re-derived from what is on screen, so two rules hold it up: questions asked of every notebook at once, so there is nothing for an arrangement to belong to and nowhere to keep it. +**Icons.** A note and a chapter can each wear a mark of their own beside the +name — chosen from a short searchable list rather than the system emoji keyboard, +which is a different thing on every machine and offers three thousand pictures +for a decision that is "which of these is the invoice notebook". The list is +searched by what a thing is *for* ("invoice", "house", "travel"), because nobody +hunts a pictogram grid by shape. The same glyph shows in the list and above the +editor, so a note is recognised by one sign in both places. A note's mark takes a +colour too; a chapter's uses the colour the chapter already has. + **Separators** are lines drawn between notes, optionally named, stored on the notebook beside the sort mode. Renaming and removing are both right-click, which is how everything else in that list is acted on: a left-click on the label used @@ -1106,6 +1129,12 @@ notes around them are renamed, edited or opened. One press draws the line; it is named afterwards from its own menu, the same way it is renamed. A line reads perfectly well unnamed, so nothing is asked first. +From that menu it also takes **its own colour and one of five shapes** — solid, +dashed, dotted, double or a thick band. Three lines through one notebook are +easier to tell apart when they differ; grey-on-grey they read as the same line +repeated. **A chapter can be divided the same way**: its lines carry its id and +stay in it, and both places a chapter is shown draw them from one component, so +it cannot be divisible in the column and not in the unfolded block. They are shown only in the manual order: in "newest first" the notes would slide past the line on every edit — and **the button that draws one is only there in