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,