From fed53772aa107756b627bacc4ff04db9cef27016 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 11 Aug 2026 17:17:13 +0200 Subject: [PATCH] Set the note left, count its pages, choose its typeface, align its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column sat in the middle, so it moved as the window was resized and the start of every line landed somewhere new. It is against the margin now. The toolbar counts pages in the words the PDF reader uses, with a hairline where each ends. Pages are drawn, never stored: the note stays one document, Markdown has no page, and a check asserts none reaches the stored body. The current page is the one under the middle of the window — measuring from the top claimed "page 1" while you stood at the foot of a two-page note, because a window is shorter than a page. The typeface is a per-account setting rather than a per-browser one, because a choice about how your own notes are set should follow you to the phone. The server takes serif, sans or mono and drops anything else; that column is written by a browser, so it is an input like any other. Alignment is a small extension of our own — one attribute, a parse rule, a render rule — so the schema every stored note is validated against stays in files this project owns. Markdown has no alignment: what CommonMark allows is embedded HTML, so non-left blocks travel as `
`, the one form most readers still honour, and the importer puts it back on the paragraphs. Left is never written. The full run caught a crash I had put in: PdfWorkspace used `noteFont` without taking it as a prop, which the build cannot see and the type checker does not reach in .jsx. It killed the notes panel beside a PDF, and `offline` failed with it because the whole app was broken. 224 unit tests, 263 browser checks across twelve suites. Co-Authored-By: Claude Opus 5 --- backend/server.js | 41 +++++++ e2e/editor.mjs | 121 ++++++++++++++++++++ frontend/src/App.jsx | 4 + frontend/src/editor/EditorToolbar.jsx | 23 ++++ frontend/src/editor/editor.css | 18 ++- frontend/src/editor/extensions.js | 2 + frontend/src/editor/textAlign.js | 74 ++++++++++++ frontend/src/editor/useNoteEditor.jsx | 3 +- frontend/src/export/markdown.ts | 22 +++- frontend/src/export/print.ts | 11 +- frontend/src/features/notes/EditorPane.jsx | 3 + frontend/src/features/notes/NoteEditor.jsx | 68 +++++++++++ frontend/src/features/pdf/PdfNotesPanel.jsx | 2 + frontend/src/features/pdf/PdfWorkspace.jsx | 11 +- frontend/src/import/markdownImport.test.ts | 56 +++++++++ frontend/src/import/markdownImport.ts | 28 +++++ frontend/src/syncEngine.ts | 5 + frontend/src/types.ts | 9 ++ frontend/src/ui/AccountMenu.jsx | 45 ++++++++ frontend/src/ui/icons.jsx | 60 ++++++++++ readme.md | 40 ++++++- 21 files changed, 637 insertions(+), 9 deletions(-) create mode 100644 frontend/src/editor/textAlign.js diff --git a/backend/server.js b/backend/server.js index 56ff0bd..0379e67 100644 --- a/backend/server.js +++ b/backend/server.js @@ -269,13 +269,31 @@ function normalizeUsername(raw) { } /** The fields of a user row that may leave the server. Never the password hash. */ +/** What a client is allowed to know about a user. */ const publicUser = (row) => ({ id: row.id, username: row.username, displayName: row.display_name, isAdmin: !!row.is_admin, + preferences: readPreferences(row.preferences), }); +/* Interface settings the client may store. Anything else is dropped rather than + * kept: this column is written by the browser, so it is an input like any other, + * and a fixed shape is what stops it becoming a place to put arbitrary data. */ +const NOTE_FONTS = ["serif", "sans", "mono"]; + +function readPreferences(raw) { + let parsed = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + parsed = null; + } + const font = parsed?.noteFont; + return { noteFont: NOTE_FONTS.includes(font) ? font : "serif" }; +} + // Login used to be e-mail based. Rebuild the table with a username column and // derive each existing user's username from the local part of their address. (function migrateEmailToUsername() { @@ -364,6 +382,15 @@ if (!tableColumns("users").includes("is_admin")) { db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"); } +/* Per-account interface settings, as JSON. + * + * On the account rather than in the browser's storage: a choice about how your + * own notes are set should follow you to the phone, not be made again on every + * device. Only presentation lives here — nothing the server acts on. */ +if (!tableColumns("users").includes("preferences")) { + db.exec("ALTER TABLE users ADD COLUMN preferences TEXT"); +} + /** * Makes sure exactly one way in exists. * @@ -624,6 +651,20 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => { res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId))); }); +app.put("/api/auth/preferences", authMiddleware, (req, res) => { + const { noteFont } = req.body || {}; + if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) { + return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` }); + } + const current = readPreferences( + db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences + ); + const next = { ...current, ...(noteFont !== undefined ? { noteFont } : {}) }; + db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?") + .run(JSON.stringify(next), req.userId); + res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId))); +}); + app.put("/api/auth/username", authMiddleware, async (req, res, next) => { try { const { newUsername, password } = req.body; diff --git a/e2e/editor.mjs b/e2e/editor.mjs index 442974b..4bc1029 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -3,6 +3,7 @@ * freshly typed content. */ import { OUT, + api, check, createNote, createNotebook, @@ -130,6 +131,126 @@ try { ); check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload"); await p.screenshot({ path: `${OUT}/editor-after-reload.png` }); + /* ── how many pages the writing fills ── */ + // Pages are a reading aid, not a change to the document: the note stays one + // body, and Markdown has no page. The height of one is the same 3:4 the canvas + // uses, so "page 2" means the same thing writing or drawing. + await createNote(p); + await setTitle(p, "Langer Text"); + check( + (await p.locator("[data-note-position]").innerText()).includes("of 1"), + "a fresh note is one page", + await p.locator("[data-note-position]").innerText() + ); + + await p.locator(".ProseMirror").click(); + // Enough for several pages. With only two, the middle of the window never + // leaves the first one — a window is shorter than a page — so the check would + // be testing arithmetic rather than behaviour. + for (let i = 0; i < 220; i++) { + await p.keyboard.type(`Absatz ${i + 1}: Dichtungen altern durch Wärme.`); + await p.keyboard.press("Enter"); + } + await p.waitForTimeout(3500); + + const position = () => p.locator("[data-note-position]").innerText(); + const scrollTo = async (fraction) => { + await p.evaluate((f) => { + const pm = document.querySelector(".ProseMirror"); + let el = pm.parentElement; + while (el && getComputedStyle(el).overflowY !== "auto") el = el.parentElement; + el.scrollTop = (el.scrollHeight - el.clientHeight) * f; + }, fraction); + await p.waitForTimeout(900); + return position(); + }; + + const total = Number((await position()).match(/of (\d+)/)?.[1] || 0); + check(total > 2, "writing past the end of a page makes more of them", await position()); + check( + (await p.locator("[data-note-position]").count()) === 1, + "and the count is shown the way the PDF reader shows it" + ); + + check((await scrollTo(0)).startsWith("Page 1"), "at the top it says page one", await position()); + const nearEnd = await scrollTo(0.9); + check( + Number(nearEnd.match(/Page (\d+)/)[1]) > 1, + "scrolling down moves the count on", + nearEnd + ); + + /* The note is still one document — pages are drawn, not stored. */ + const stored = (await api(p, "GET", "/notes")).body[0]; + const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content; + check(!/"type"\s*:\s*"page"/.test(body), "no page ever reaches the stored document"); + + /* ── alignment ── */ + // Left is the default and is never written into the document, so an untouched + // paragraph reads as left without carrying an attribute for it. + await createNote(p); + await setTitle(p, "Ausrichtung"); + await typeInEditor(p, "Diese Zeile wandert."); + check( + (await p.locator('[data-align="left"]').count()) === 1, + "the toolbar offers alignment" + ); + + await p.locator('[data-align="center"]').click(); + await p.waitForTimeout(1800); + check( + (await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center", + "choosing centre centres the paragraph" + ); + + await reopenNote(p, "Ausrichtung"); + check( + (await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center", + "and it survives a reload, so it is in the document and not on the screen" + ); + + const alignedBody = (await api(p, "GET", "/notes")).body; + const alignedNote = alignedBody.find((n) => n.title === "Ausrichtung"); + const alignedContent = (await api(p, "GET", `/notes/${alignedNote.id}`)).body.content; + check(/"textAlign"\s*:\s*"center"/.test(alignedContent), "stored on the paragraph itself"); + + await p.locator('[data-align="left"]').click(); + await p.waitForTimeout(1800); + check( + (await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "start", + "and going back to left leaves no alignment behind", + await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign) + ); + + /* ── the typeface follows the account ── */ + await p.getByTitle("Account").click(); + await p.waitForTimeout(800); + await p.locator('[data-note-font="mono"]').click(); + await p.waitForTimeout(1500); + await p.keyboard.press("Escape"); + await p.waitForTimeout(1200); + check( + /mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)), + "choosing a typeface changes the note text", + await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily) + ); + + // On the account, not in this browser — so it is there on the next device. + const me = await api(p, "GET", "/auth/me"); + check(me.body?.preferences?.noteFont === "mono", "and is stored on the account", JSON.stringify(me.body?.preferences)); + + await p.reload({ waitUntil: "networkidle" }); + await p.waitForTimeout(3000); + // A reload lands on the dashboard, so the notebook has to be opened again. + await p.locator("button").filter({ hasText: "Editor Book" }).first().click(); + await p.waitForTimeout(1500); + await p.locator("[data-note-row]").first().click(); + await p.waitForTimeout(2500); + check( + /mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)), + "and survives a reload" + ); + } catch (e) { check(false, "suite ran to the end", e.message); } finally { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2993206..9748918 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -84,6 +84,9 @@ export default function App() { const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive(); const { canInstall, promptInstall } = usePwaInstall(); + // Presentation only, and it lives on the account so it follows to the phone. + const noteFont = user?.preferences?.noteFont || "serif"; + const data = useNoteData(user, activeNote); const { notes, notebooks, noteContents, backlinks } = data; const search = useSearchIndex(user, notes, searchQuery); @@ -661,6 +664,7 @@ export default function App() { + {divider} + {[ + ["left", icons.alignLeft, "Align left"], + ["center", icons.alignCenter, "Centre"], + ["right", icons.alignRight, "Align right"], + ].map(([value, icon, title]) => ( + + ))} + + )} + {compact ? (
+ + {/* Kept on the account rather than in this browser: a choice about + how your own notes are set should follow you to the phone, not + be made again on every device. The export is untouched — this + is presentation and nothing else. */} +
+ +
+ {[ + ["serif", "Serif", "'Newsreader',Georgia,serif"], + ["sans", "Sans", "'DM Sans',system-ui,sans-serif"], + ["mono", "Mono", "ui-monospace,Menlo,Consolas,monospace"], + ].map(([value, label, stack]) => { + const active = (user.preferences?.noteFont || "serif") === value; + return ( + + ); + })} +
+
)} diff --git a/frontend/src/ui/icons.jsx b/frontend/src/ui/icons.jsx index 0f66230..79e6743 100644 --- a/frontend/src/ui/icons.jsx +++ b/frontend/src/ui/icons.jsx @@ -561,6 +561,66 @@ export const I = { ), + alignLeft: ( + + + + + + ), + alignCenter: ( + + + + + + ), + alignRight: ( + + + + + + ), + alignJustify: ( + + + + + + ), dots: ( diff --git a/readme.md b/readme.md index bc6eeea..55a4c0c 100644 --- a/readme.md +++ b/readme.md @@ -328,7 +328,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | Suite | Checks | What it holds down | | --- | --- | --- | | `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly | -| `editor` | 17 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload | +| `editor` | 31 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload | | `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes | | `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable | | `drawing` | 7 | strokes survive the poller, and match across four viewport widths | @@ -369,6 +369,44 @@ state of their own, so they can be read on their own. --- +## Setting a note + +**Left-aligned, with the column against the margin.** A column that floats in the +middle moves as the window is resized, so the start of every line lands somewhere +new; against the margin it is always in the same place. About 73 characters to +the line — measured, not guessed. + +**The typeface is a per-account setting** (Account → Profile): serif, sans or +mono. On the account rather than in the browser, because a choice about how your +own notes are set should follow you to the phone rather than be made again on +every device. The server accepts those three values and drops anything else — +that column is written by a browser, so it is an input like any other. + +**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same +words the PDF reader uses, and a hairline marks where each one ends. A page is +the same 3:4 the canvas uses, so "page 2" means the same thing writing or +drawing. The note itself stays one continuous document — Markdown has no page, +and a test asserts none ever reaches the stored body. The current page is the one +under the *middle* of the window: measuring from the top said "page 1" while you +stood at the foot of a two-page note, because a window is shorter than a page. + +The drawing still covers only the first page. On a PDF the strokes are kept per +page; on a note there is one sheet. + +### Alignment + +Markdown is often said to "basically be HTML". It is not — it allows *embedded* +HTML, and has no alignment of its own. So alignment works fully in the app, in +print and in the PDF, and in a `.md` file it can only travel as a tag: + +- Non-left blocks are wrapped in `
`, the one form GitHub and most + other readers still honour. A reader that does not know it shows the text, + which is the right way to fail. +- Left is never written. It is the default, and wrapping every paragraph in a div + would turn an ordinary note into markup for nothing. +- The importer reads the wrapper back and puts the alignment **on the + paragraphs**, which is where the editor keeps it — not on a container. + ## Interface language English throughout. It had drifted into two languages — the frame in English,