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