Marks, lines, and a table that breaks

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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-16 23:23:16 +02:00
1 parent 0c312e53c2
commit f1d3e38f8f
29 files changed
+1335 -149

No files matched your search

+42
View File
@@ -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…`
+1 -1
View File
@@ -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
```
+63 -10
View File
@@ -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 });
});
+102
View File
@@ -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. */
+34
View File
@@ -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. */
+1 -1
View File
@@ -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
+130
View File
@@ -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);
}
+122 -90
View File
@@ -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) => (
<ChapterNotes
T={T}
notes={chapterNotes}
separators={chapterSeparators}
reorderable={canReorder(sortMode, isSearching) && !!activeNotebook}
activeNote={activeNote}
renamingSeparator={renamingSeparator}
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
onAddSeparator={addChapterSeparator}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
compact={compact}
/>
);
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
@@ -783,48 +888,7 @@ export default function App() {
paddingLeft: 8,
}}
>
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
{chapterContents(true)}
</div>
);
@@ -1014,50 +1078,7 @@ export default function App() {
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterNotes.length === 0 && (
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
Nothing in here yet.
</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "9px 10px",
borderRadius: 9,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
{chapterContents(false)}
</div>
</div>
)}
@@ -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 && (
<IconPicker
title={pickingIcon.title}
value={pickingIcon.value}
colour={pickingIcon.colour}
onSave={pickingIcon.onSave}
onClose={() => setPickingIcon(null)}
dark={dark}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}
+29 -2
View File
@@ -21,6 +21,12 @@
--ne-accent: #0969da;
--ne-surface: #ffffff;
--ne-subtle: #f6f8fa;
/* A table's header row, which needs to read as one at a glance. Its own
tokens because "slightly off the page colour" is enough for a code block
sitting in the middle of text and not enough for a row that has to be told
from the rows under it. */
--ne-th-bg: #eaeef2;
--ne-th-rule: #afb8c1;
}
.note-editor[data-theme="dark"] {
--ne-text: #e6edf3;
@@ -30,6 +36,10 @@
--ne-accent: #388bfd;
--ne-surface: #0d1117;
--ne-subtle: #161b22;
/* Dark is where this mattered: #161b22 on a #0d1117 sheet is a difference you
have to look for, so a header row read as just another row. */
--ne-th-bg: #262c36;
--ne-th-rule: #6a7480;
}
.note-editor .ProseMirror {
@@ -167,6 +177,20 @@
pointer-events: none;
}
/* The spacer that carries a table across the gap between two sheets. It is a
row, so it inherits every cell rule below — and has to shed all of them, or
the gap shows a tall bordered box. */
.note-editor .ProseMirror tr.ne-page-spacer-row,
.note-editor .ProseMirror tr.ne-page-spacer-row td {
border: none !important;
padding: 0 !important;
background: transparent !important;
}
.note-editor .ProseMirror tr.ne-page-spacer-row td::before,
.note-editor .ProseMirror tr.ne-page-spacer-row td::after {
content: none !important;
}
/* ── Tables ── */
.note-editor .ProseMirror table {
border-collapse: collapse;
@@ -184,9 +208,12 @@
min-width: 1em;
}
.note-editor .ProseMirror th {
background: var(--ne-subtle);
font-weight: 600;
background: var(--ne-th-bg);
font-weight: 700;
text-align: left;
/* The rule under the header does most of the work — the fill alone cannot be
made much stronger without the header turning into a banner. */
border-bottom: 2px solid var(--ne-th-rule);
}
.note-editor .ProseMirror .selectedCell::after {
content: "";
+132 -14
View File
@@ -17,14 +17,24 @@ import { Decoration, DecorationSet } from "@tiptap/pm/view";
* note is still one body.
*
* The unit is a whole block. A paragraph that would be split across the gap
* moves down entire, which is what a page break means on paper. A single block
* taller than a page — a long table, a big image — is left where it is and runs
* over the gap: splitting one would mean laying out its insides ourselves.
* moves down entire, 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 page leaving half a sheet blank, or — being taller than a page — has
* nowhere to jump to and lies across the gap regardless. 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 rather than a block: a `div` inside a `tbody` is not a
* thing, and a `tr` with one borderless cell pushes the rows after it down
* exactly as needed.
*
* Everything else too tall to fit anywhere — a full-page image — is left where
* it is: moving it down only puts the same problem one page further on.
*/
export const paginationKey = new PluginKey("pagination");
const SPACER_CLASS = "ne-page-spacer";
const SPACER_ROW_CLASS = "ne-page-spacer-row";
const px = (value) => {
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: [],
})
}
)
)
);
+3 -3
View File
@@ -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 },
@@ -146,6 +146,7 @@ export function EditorPane({
linkProps,
onBack,
onUpdate,
onPickIcon,
onToggleStar,
onTrash,
onRestore,
@@ -192,6 +193,31 @@ export function EditorPane({
}}
>
{isMobile && <BackButton T={T} onClick={onBack} />}
{/* 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. */}
<button
onClick={onPickIcon}
data-note-icon={note.icon || "none"}
title="Choose an icon for this note"
aria-label="Choose an icon for this note"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: note.icon ? 17 : 14,
lineHeight: 1,
color: note.iconColor || T.faint,
}}
>
{note.icon || I.note}
</button>
<input
type="text"
aria-label="Note title"
+3 -1
View File
@@ -2,12 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../../syncEngine";
import { VersionPreview } from "../../editor/VersionPreview.jsx";
import { I } from "../../ui/icons.jsx";
import { useBackdropClose } from "../../ui/backdrop";
/* ═══════ VERSION HISTORY ═══════ */
/* Past states of a note, newest first. The server keeps a snapshot of what a
* note looked like before each editing session, so an overwrite that is only
* noticed later can still be undone. */
export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, renderDrawing }) {
const backdrop = useBackdropClose(onClose);
const [versions, setVersions] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
@@ -110,7 +112,7 @@ export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, ren
zIndex: 2000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
onClick={(e) => e.stopPropagation()}
+192 -10
View File
@@ -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 (
<div
data-separator-rule={style || "solid"}
style={{
flex: 1,
minWidth: 8,
height: shape.height,
borderRadius: shape.borderRadius,
...(shape.solid
? { background: colour }
: { borderTop: `${shape.borderTop} ${colour}` }),
}}
/>
);
}
/**
* 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. */}
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
{/* The two rules always share what is left over equally. */}
<Rule colour={colour} style={separator.style} />
{/* 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
</div>
)}
</div>
<div style={{ height: 2, borderRadius: 2, background: T.faint, flex: 1, minWidth: 8 }} />
<Rule colour={colour} style={separator.style} />
</div>
);
}
@@ -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. */}
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
<span
data-chapter-mark
style={{
display: "flex",
alignItems: "center",
color: chapter.color || T.accent,
flexShrink: 0,
fontSize: chapter.icon ? 15 : undefined,
lineHeight: 1,
}}
>
{chapter.icon || I.books}
</span>
<span
style={{
flex: 1,
@@ -245,6 +291,129 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
);
}
/**
* What is inside a chapter: its notes, with its own lines drawn between them.
*
* One component for both places a chapter can be shown — the column beside the
* list and the unfolded block under its row — because they are the same list in
* two frames, and a chapter that could be divided in one and not the other would
* be two different things wearing one name.
*
* A chapter's separators are its own. Each carries the list it was drawn in, so
* the notebook's lines stay in the notebook and these stay here.
*/
export function ChapterNotes({
T,
notes,
separators,
reorderable,
activeNote,
renamingSeparator,
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
onAddSeparator,
onSelectNote,
onNoteContextMenu,
compact = false,
}) {
// Lines only mean something where the order is the user's own, exactly as in
// the notebook's list — in "newest first" the notes slide past them.
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
return (
<>
{entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{entries.map((entry) =>
entry.kind === "separator" ? (
<SeparatorRow
key={entry.id}
T={T}
separator={entry.separator}
editing={renamingSeparator === entry.id}
onStartRename={onStartRenameSeparator}
onRename={onRenameSeparator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
) : (
<button
key={entry.id}
data-chapter-note={entry.id}
onClick={() => onSelectNote(entry.id)}
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: compact ? "8px 10px" : "9px 10px",
borderRadius: compact ? 8 : 9,
border: `1px solid ${activeNote === entry.id ? T.accent + "55" : "transparent"}`,
background: activeNote === entry.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: entry.note.iconColor || T.faint,
flexShrink: 0,
fontSize: entry.note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{entry.note.icon || (entry.note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{entry.note.title}
</span>
</button>
)
)}
{/* 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 && (
<button
onClick={onAddSeparator}
data-add-chapter-separator
title="Insert a separator line in this chapter"
style={{
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
borderRadius: 8,
padding: "4px 9px",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
marginTop: 6,
}}
>
+ Separator
</button>
)}
</>
);
}
/**
* How this notebook's notes are ordered.
*
@@ -868,8 +1037,21 @@ export function NoteList({
width: "100%",
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{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. */}
<span
data-note-mark
style={{
display: "flex",
alignItems: "center",
color: note.iconColor || T.faint,
flexShrink: 0,
fontSize: note.icon ? 14 : undefined,
lineHeight: 1,
}}
>
{note.trashed ? I.trash : note.icon || (note.type === "pdf" ? I.pdf : I.note)}
</span>
<span
data-note-title
+8
View File
@@ -41,6 +41,8 @@ export interface NoteData {
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
toggleStar: (id: string) => Promise<void>;
/** 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<void>;
trashNote: (id: string) => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
@@ -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,
+46 -2
View File
@@ -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<void> {
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<Chapter[]> {
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<void> {
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<void> {
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<void> {
const note = await db.getNote(id);
if (!note) return;
+18
View File
@@ -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;
+3 -1
View File
@@ -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}
>
<div
style={{
+3 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import * as sync from "../syncEngine";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
const MIN_PASSWORD_LENGTH = 6;
const USERNAME_RE = /^[a-zA-Z0-9._-]{3,32}$/;
@@ -23,6 +24,7 @@ const fmtDate = (d) =>
* 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}
>
<div
style={{
+3 -1
View File
@@ -1,7 +1,9 @@
import { useMemo } from "react";
import { useBackdropClose } from "./backdrop";
/* ═══════ Shared Components ═══════ */
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onCancel, dark }) {
const backdrop = useBackdropClose(onCancel);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
@@ -28,7 +30,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onCancel}
{...backdrop}
>
<div
style={{
+5 -2
View File
@@ -11,6 +11,7 @@ import {
} from "../export/exportNotes";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
import { useBackdropClose } from "./backdrop";
/**
* Export as Markdown, PDF or PNG.
@@ -32,6 +33,8 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
const [hasInk, setHasInk] = useState(null);
const [hasDrawing, setHasDrawing] = useState(null); // null = still looking
const [busy, setBusy] = useState(null);
// An export in flight is not interrupted by a stray click beside the dialog.
const backdrop = useBackdropClose(() => !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}
>
<div
data-export-dialog
@@ -298,7 +301,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</div>
</div>
<button
onClick={() => !busy && onClose()}
{...backdrop}
style={{
display: "flex",
alignItems: "center",
+275
View File
@@ -0,0 +1,275 @@
import { useMemo, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing the mark a note or a chapter wears beside its name.
*
* A fixed list rather than the system emoji keyboard. That keyboard is a
* different thing on every machine — on Android it is the one for typing, which
* means opening it inside a text field the app does not have — and it offers
* three thousand pictures for a decision that is "which of these is my invoice
* notebook". A few dozen, searchable by what they are for, is the whole job.
*
* The words are what makes it usable: nobody hunts a pictogram grid by shape.
* They are also the reason the list is written out rather than generated.
*/
const ICONS = [
{ char: "📄", words: "note paper document default" },
{ char: "📝", words: "note write draft memo" },
{ char: "📌", words: "pin important stick" },
{ char: "⭐", words: "star favourite important" },
{ char: "❗", words: "important urgent attention" },
{ char: "✅", words: "done finished checked task" },
{ char: "🕒", words: "later waiting time pending" },
{ char: "💡", words: "idea thought concept" },
{ char: "🔧", words: "tool fix repair maintenance" },
{ char: "🔨", words: "build work project diy" },
{ char: "🏠", words: "house home flat property" },
{ char: "🔌", words: "power electricity socket energy" },
{ char: "💧", words: "water plumbing bill" },
{ char: "🔥", words: "heating gas fire boiler" },
{ char: "🚗", words: "car vehicle travel garage" },
{ char: "🧾", words: "receipt invoice bill accounting" },
{ char: "💶", words: "money euro payment bank finance" },
{ char: "🏦", words: "bank account finance" },
{ char: "📊", words: "chart numbers report statistics" },
{ char: "📅", words: "calendar date appointment plan" },
{ char: "✈️", words: "travel holiday flight trip" },
{ char: "🍳", words: "cooking recipe kitchen food" },
{ char: "🛒", words: "shopping list groceries" },
{ char: "🎁", words: "gift present birthday" },
{ char: "🎵", words: "music song audio" },
{ char: "📷", words: "photo picture camera" },
{ char: "🎬", words: "film video movie watch" },
{ char: "📚", words: "book reading study library" },
{ char: "🎓", words: "school study course learning" },
{ char: "💼", words: "work job office business" },
{ char: "🖥️", words: "computer server it hardware" },
{ char: "🌐", words: "web internet network domain" },
{ char: "🔒", words: "password secret secure private" },
{ char: "🐳", words: "docker container server" },
{ char: "🧠", words: "thinking notes knowledge brain" },
{ char: "❤️", words: "love personal family" },
{ char: "🐾", words: "pet animal dog cat" },
{ char: "🌱", words: "garden plant growing nature" },
{ char: "⚽", words: "sport football hobby" },
{ char: "🏃", words: "running fitness health sport" },
{ char: "💊", words: "health medicine doctor" },
{ char: "🩺", words: "doctor health medical appointment" },
{ char: "📦", words: "package delivery order storage" },
{ char: "🗂️", words: "files archive folder sorting" },
];
/**
* The picker.
*
* `value` and `colour` are what the row wears now; "None" is always offered,
* because a mark that cannot be taken off again is a trap.
*/
export function IconPicker({ title, value = null, colour = null, onSave, onClose, dark }) {
const [query, setQuery] = useState("");
const [picked, setPicked] = useState(value);
const [tint, setTint] = useState(colour);
const backdrop = useBackdropClose(onClose);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
field: dark ? "#0d1117" : "#f6f8fa",
}),
[dark]
);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ICONS;
return ICONS.filter((i) => i.words.includes(q) || i.char === q);
}, [query]);
const save = (char, colourValue) => {
onSave(char, colourValue);
onClose();
};
return (
<div
data-icon-picker
{...backdrop}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 4000,
padding: 16,
}}
>
<div
style={{
width: "100%",
maxWidth: 420,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 16,
padding: 20,
fontFamily: "'DM Sans',sans-serif",
}}
>
<h3 style={{ margin: "0 0 12px", fontSize: 16, fontWeight: 700, color: T.text }}>{title}</h3>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && onClose()}
aria-label="Search icons"
placeholder="Search — invoice, house, travel…"
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
outline: "none",
boxSizing: "border-box",
}}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(42px, 1fr))",
gap: 6,
marginTop: 14,
maxHeight: 240,
overflowY: "auto",
}}
>
{shown.map((icon) => (
<button
key={icon.char}
type="button"
data-icon-option={icon.char}
title={icon.words.split(" ")[0]}
onClick={() => setPicked(icon.char)}
style={{
height: 42,
borderRadius: 10,
cursor: "pointer",
fontSize: 21,
lineHeight: 1,
border: picked === icon.char ? `2px solid ${T.accent}` : `1px solid ${T.border}`,
background: picked === icon.char ? `${T.accent}22` : "transparent",
}}
>
{icon.char}
</button>
))}
{shown.length === 0 && (
<div style={{ gridColumn: "1 / -1", fontSize: 12, color: T.muted, padding: "10px 2px" }}>
Nothing by that name.
</div>
)}
</div>
{/* 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. */}
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, marginBottom: 7 }}>
Colour (for the row, and for marks without one of their own)
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
const on = (c.value ?? null) === (tint ?? null);
return (
<button
key={c.label}
type="button"
data-icon-colour={c.value ?? "none"}
title={c.label}
aria-label={c.label}
onClick={() => setTint(c.value ?? null)}
style={{
width: 26,
height: 26,
borderRadius: 8,
cursor: "pointer",
border: on ? `2px solid ${T.text}` : `1px solid ${T.border}`,
background: c.value ?? "transparent",
color: T.muted,
fontSize: 10,
lineHeight: 1,
}}
>
{c.value ? "" : "—"}
</button>
);
})}
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 18 }}>
<button
type="button"
data-icon-clear
onClick={() => save(null, null)}
style={{
padding: "8px 14px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.muted,
cursor: "pointer",
fontSize: 13,
marginRight: "auto",
}}
>
No icon
</button>
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Cancel
</button>
<button
type="button"
data-icon-save
onClick={() => save(picked, tint)}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: T.accent,
color: "#fff",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
}}
>
Save
</button>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -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}
>
<form
onSubmit={submit}
+3 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBackdropClose } from "./backdrop";
/**
* Asking for one name.
@@ -28,6 +29,7 @@ export function NameDialog({
}) {
const [name, setName] = useState(value);
const [color, setColor] = useState(initialColor);
const backdrop = useBackdropClose(onClose);
const inputRef = useRef(null);
const T = useMemo(
@@ -58,7 +60,7 @@ export function NameDialog({
return (
<div
data-name-dialog
onClick={onClose}
{...backdrop}
style={{
position: "fixed",
inset: 0,
+5 -2
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { COVERS, PATTERNS, coverBackground, coverParts } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Choosing a cover: its colour, then the pattern over it.
@@ -76,6 +77,7 @@ function CoverChooser({ T, cover, pattern, onCover, onPattern }) {
}
export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxVersions }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState(notebook.name);
/* Colour and pattern are separate choices now. A notebook saved when they
were one list holds an index into that list, so it is read back as the pair
@@ -113,7 +115,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
@@ -237,6 +239,7 @@ export function EditNotebookModal({ notebook, onClose, onSave, dark, defaultMaxV
}
export function CreateNotebookModal({ onClose, onCreate, dark }) {
const backdrop = useBackdropClose(onClose);
const [name, setName] = useState("");
const [sel, setSel] = useState(0);
const [pat, setPat] = useState(0);
@@ -262,7 +265,7 @@ export function CreateNotebookModal({ onClose, onCreate, dark }) {
zIndex: 1000,
backdropFilter: "blur(6px)",
}}
onClick={onClose}
{...backdrop}
>
<div
style={{
+3 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { coverBackground } from "./constants.jsx";
import { useBackdropClose } from "./backdrop";
/**
* Asks which notebook something should go into.
@@ -33,6 +34,7 @@ const recalledNotebook = () => {
};
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}
>
<div
onClick={(e) => e.stopPropagation()}
+32
View File
@@ -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 };
}
+14
View File
@@ -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. */
+34 -5
View File
@@ -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