Set the note left, count its pages, choose its typeface, align its text

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 `<div align="…">`, 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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-11 17:17:13 +02:00
1 parent 830af6532a
commit fed53772aa
21 files changed
+637 -9

No files matched your search

+41
View File
@@ -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;
+121
View File
@@ -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 {
+4
View File
@@ -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() {
<EditorPane
T={T}
dark={dark}
noteFont={noteFont}
isMobile={isMobile}
note={openNoteData}
isTrashView={isTrashView}
+23
View File
@@ -289,6 +289,29 @@ export function EditorToolbar({ editor, T, icons, compact, dark }) {
* There were fourteen controls in a row that scrolled sideways, so the
* ones at the end could only be reached by dragging the bar — and nothing
* said they were there. A menu is one tap and shows what it holds. */}
{/* Left is the default, so its button reads active on an untouched
paragraph — the state is the document's, not a mode we remember. */}
{!compact && (
<>
{divider}
{[
["left", icons.alignLeft, "Align left"],
["center", icons.alignCenter, "Centre"],
["right", icons.alignRight, "Align right"],
].map(([value, icon, title]) => (
<button
key={value}
data-align={value}
style={btn(editor.isActive({ textAlign: value }))}
title={title}
onClick={() => editor.chain().focus().setTextAlign(value).run()}
>
{icon}
</button>
))}
</>
)}
{compact ? (
<div style={{ position: "relative" }} data-more-menu>
<button
+16 -2
View File
@@ -5,7 +5,16 @@
* properties the editor container sets per theme. */
.note-editor {
--ne-font: "Newsreader", Georgia, serif;
--ne-text: #1f2328;
}
.note-editor[data-font="sans"] {
--ne-font: "DM Sans", system-ui, sans-serif;
}
.note-editor[data-font="mono"] {
--ne-font: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
}
.note-editor {
--ne-muted: #656d76;
--ne-faint: #afb8c1;
--ne-border: #d0d7de;
@@ -29,11 +38,16 @@
point where the eye reliably finds the start of the next line. Capped here
rather than on the container so it holds on a page as well as off one. */
max-width: 527px;
margin-left: auto;
/* Left, not centred. 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. */
margin-left: 0;
margin-right: auto;
outline: none;
color: var(--ne-text);
font-family: "Newsreader", Georgia, serif;
/* Set per account — see UserPreferences. The stack is the fallback chain, so
an unset preference still reads as the serif it always was. */
font-family: var(--ne-font, "Newsreader", Georgia, serif);
font-size: 15px;
line-height: 1.7;
padding: 20px 24px;
+2
View File
@@ -12,6 +12,7 @@ import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import { NoteLink } from "./noteLink.js";
import { TextAlign } from "./textAlign.js";
import { AttachmentImage } from "./attachmentImage.js";
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
@@ -38,6 +39,7 @@ export function buildExtensions({ search = () => [], placeholder = "Start writin
TableHeader,
TableCell,
NoteLink.configure({ search }),
TextAlign.configure({ types: ["paragraph", "heading"] }),
];
}
+74
View File
@@ -0,0 +1,74 @@
import { Extension } from "@tiptap/core";
/**
* How a paragraph or heading is aligned.
*
* Written out rather than pulled in. The official extension does the same
* handful of things — one attribute, a parse rule, a render rule, two commands —
* and this way the schema stays in files this project owns, which matters
* because every stored note is validated against it.
*
* `left` is the default and is never written into the document: an attribute on
* every paragraph in every note would be pure weight, and a note that has never
* been aligned should not look aligned when read by anything else.
*/
export const ALIGNMENTS = ["left", "center", "right", "justify"];
export const DEFAULT_ALIGN = "left";
export const TextAlign = Extension.create({
name: "textAlign",
addOptions() {
return { types: ["paragraph", "heading"] };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
textAlign: {
default: DEFAULT_ALIGN,
parseHTML: (el) => {
const value = el.style.textAlign || el.getAttribute("align") || DEFAULT_ALIGN;
return ALIGNMENTS.includes(value) ? value : DEFAULT_ALIGN;
},
renderHTML: (attrs) =>
attrs.textAlign && attrs.textAlign !== DEFAULT_ALIGN
? { style: `text-align: ${attrs.textAlign}` }
: {},
},
},
},
];
},
addCommands() {
return {
setTextAlign:
(alignment) =>
({ commands }) => {
if (!ALIGNMENTS.includes(alignment)) return false;
return this.options.types
.map((type) => commands.updateAttributes(type, { textAlign: alignment }))
.some(Boolean);
},
unsetTextAlign:
() =>
({ commands }) =>
this.options.types.map((type) => commands.resetAttributes(type, "textAlign")).some(Boolean),
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
"Mod-Shift-j": () => this.editor.commands.setTextAlign("justify"),
};
},
});
export default TextAlign;
+2 -1
View File
@@ -106,7 +106,7 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, minHeight, onOpenNote, resolveLinkLabel }) {
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
@@ -161,6 +161,7 @@ export function EditorSurface({ editor, dark, minHeight, onOpenNote, resolveLink
<div
className="note-editor"
data-theme={dark ? "dark" : "light"}
data-font={font || "serif"}
onClick={handleClick}
style={{ minHeight }}
>
+20 -2
View File
@@ -99,16 +99,34 @@ function inlineNode(n: DocNode, o: MarkdownOptions): string {
/* ── blocks ── */
/**
* Markdown has no alignment.
*
* It is often said to "basically be HTML"; it is not. What CommonMark allows is
* *embedded* HTML, so a centred paragraph can only travel as a tag. `align` is
* used rather than a style attribute because that is the one form GitHub and
* most other readers still honour — and a reader that does not simply shows the
* text, which is the right way to fail.
*
* Left-aligned blocks are written plainly. Wrapping every paragraph in a div
* would turn an ordinary note into markup for the sake of a default.
*/
function withAlignment(n: DocNode, lines: string[]): string[] {
const value = n.attrs?.textAlign;
if (!value || value === "left" || lines.every((l) => !l.trim())) return lines;
return [`<div align="${value}">`, "", ...lines, "", "</div>"];
}
function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
switch (n.type) {
case "paragraph": {
const text = inline(n.content, o);
return text.trim() ? [escapeLineStart(indent + text)] : [""];
return text.trim() ? withAlignment(n, [escapeLineStart(indent + text)]) : [""];
}
case "heading": {
const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return [`${"#".repeat(level)} ${inline(n.content, o)}`];
return withAlignment(n, [`${"#".repeat(level)} ${inline(n.content, o)}`]);
}
case "blockquote":
+9 -2
View File
@@ -54,16 +54,23 @@ function blocks(nodes: DocNode[] | undefined, resolveImage: (id: string) => stri
return nodes.map((n) => blockHtml(n, resolveImage)).join("\n");
}
/** `style="text-align: …"`, or nothing at all when the block is left aligned. */
function alignAttr(n: DocNode): string {
const value = n.attrs?.textAlign;
return value && value !== "left" ? ` style="text-align: ${esc(value)}"` : "";
}
function blockHtml(n: DocNode, img: (id: string) => string | null): string {
const kids = () => blocks(n.content, img);
switch (n.type) {
case "paragraph": {
const body = inline(n.content, img);
return body.trim() ? `<p>${body}</p>` : "<p>&nbsp;</p>";
const a = alignAttr(n);
return body.trim() ? `<p${a}>${body}</p>` : "<p>&nbsp;</p>";
}
case "heading": {
const l = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return `<h${l}>${inline(n.content, img)}</h${l}>`;
return `<h${l}${alignAttr(n)}>${inline(n.content, img)}</h${l}>`;
}
case "blockquote":
return `<blockquote>${kids()}</blockquote>`;
@@ -137,6 +137,7 @@ function TrashedNote({ T, isMobile, note, onBack, onRestore, onPermanentDelete }
export function EditorPane({
T,
dark,
noteFont,
isMobile,
note,
isTrashView,
@@ -241,6 +242,7 @@ export function EditorPane({
<PdfWorkspace
note={note}
dark={dark}
noteFont={noteFont}
onUpdate={onUpdate}
onOpenNote={linkProps.openLinkTarget}
searchLinkTargets={linkProps.searchLinkTargets}
@@ -252,6 +254,7 @@ export function EditorPane({
note={note}
onUpdate={onUpdate}
dark={dark}
noteFont={noteFont}
onOpenNote={linkProps.openLinkTarget}
searchLinkTargets={linkProps.searchLinkTargets}
resolveLinkLabel={linkProps.resolveLinkLabel}
@@ -29,6 +29,7 @@ export function NoteEditor({
note,
onUpdate,
dark,
noteFont,
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
@@ -52,6 +53,14 @@ export function NoteEditor({
const dirtyRef = useRef(false);
const SAVE_DEBOUNCE_MS = 600;
const [stageSize, setStageSize] = useState({ w: CANON_W, h: CANON_H });
/* How many pages the writing fills, and which one is being looked at.
*
* Pages here are a reading aid, not a change to the document: the note stays
* one continuous body, and Markdown has no concept of a page anyway. The
* height of one is the same 3:4 the canvas uses, so "page 2" means the same
* thing whether you are writing or drawing. */
const [pages, setPages] = useState({ count: 1, current: 1 });
const scrollerRef = useRef(null);
/* ── DRAWING STATE ──
* strokesRef is what the pointer handlers read and write synchronously;
@@ -203,6 +212,40 @@ export function NoteEditor({
}, SAVE_DEBOUNCE_MS);
};
/* Recounted whenever the text could have changed height — on every editor
* transaction, on resize, and once the fonts have actually loaded, since a
* fallback face measures differently from the real one. */
useEffect(() => {
const surface = () => scrollerRef.current?.querySelector(".ProseMirror");
const recount = () => {
const el = surface();
const pageH = stageSize.h;
if (!el || !pageH) return;
const height = el.scrollHeight;
const scroller = scrollerRef.current;
const count = Math.max(1, Math.ceil(height / pageH));
/* The page under the middle of the viewport is the one you are reading —
* the same rule the PDF reader uses. Measuring from the top instead said
* "page 1" while you were looking at the foot of a two-page note, because
* a window is shorter than a page. */
const middle = scroller ? scroller.scrollTop + scroller.clientHeight / 2 : 0;
const current = Math.max(1, Math.min(count, Math.floor(middle / pageH) + 1));
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
};
recount();
const scroller = scrollerRef.current;
scroller?.addEventListener("scroll", recount, { passive: true });
document.fonts?.ready?.then(recount).catch(() => {});
if (!textEditor || textEditor.isDestroyed) return () => scroller?.removeEventListener("scroll", recount);
textEditor.on("transaction", recount);
return () => {
scroller?.removeEventListener("scroll", recount);
textEditor.off("transaction", recount);
};
}, [textEditor, stageSize.h, mode]);
/* ── DRAWING: painting ── */
const renderFrame = () => {
const c = canvasRef.current;
@@ -477,6 +520,13 @@ export function NoteEditor({
>
{mb("text", I.text, "Write")}
{mb("draw", I.draw, "Draw")}
{/* Same wording as the PDF reader, because it means the same thing. */}
<span
data-note-position
style={{ fontSize: 11.5, color: T.muted, whiteSpace: "nowrap", flexShrink: 0, marginLeft: 2 }}
>
Page {pages.current} of {pages.count}
</span>
<div style={{ width: 1, height: 22, background: T.border, margin: "0 2px", flexShrink: 0 }} />
{mode === "text" ? (
<EditorToolbar
@@ -656,6 +706,7 @@ export function NoteEditor({
style={{ flex: 1, position: "relative", overflow: "hidden" }}
>
<div
ref={scrollerRef}
style={{
position: "absolute",
inset: 0,
@@ -681,6 +732,22 @@ export function NoteEditor({
boxShadow: needsPage && !isMobileViewport ? "0 16px 36px rgba(0,0,0,0.14)" : "none",
}}
>
{/* A hairline where each page ends, so the count in the toolbar
corresponds to something you can see. Drawn behind the text and
deaf to the pointer — it marks the paper, it is not part of it. */}
{pages.count > 1 && mode === "text" && (
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
backgroundImage: `linear-gradient(to bottom, transparent calc(100% - 1px), ${T.border} 100%)`,
backgroundSize: `100% ${stageSize.h}px`,
opacity: 0.55,
}}
/>
)}
<div
style={{
minHeight: needsPage ? stageSize.h : undefined,
@@ -691,6 +758,7 @@ export function NoteEditor({
<EditorSurface
editor={textEditor}
dark={dark}
font={noteFont}
minHeight={needsPage ? stageSize.h : 0}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
@@ -20,6 +20,7 @@ import { FONT_UI } from "../../ui/theme";
export function PdfNotesPanel({
note,
dark,
noteFont,
onUpdate,
onOpenNote,
searchLinkTargets,
@@ -113,6 +114,7 @@ export function PdfNotesPanel({
<EditorSurface
editor={editor}
dark={dark}
font={noteFont}
minHeight={200}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
+10 -1
View File
@@ -19,7 +19,15 @@ const MOBILE_BREAKPOINT = 900;
* height on every screen and put one of the document's controls somewhere none
* of the others were.
*/
export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTargets, resolveLinkLabel }) {
export function PdfWorkspace({
note,
dark,
noteFont,
onUpdate,
onOpenNote,
searchLinkTargets,
resolveLinkLabel,
}) {
const [showNotes, setShowNotes] = useState(false);
// Set when a link in the notes points at a marker; the viewer clears it once
// it has scrolled there.
@@ -142,6 +150,7 @@ export function PdfWorkspace({ note, dark, onUpdate, onOpenNote, searchLinkTarge
<PdfNotesPanel
note={note}
dark={dark}
noteFont={noteFont}
onUpdate={onUpdate}
onOpenNote={openTarget}
searchLinkTargets={searchTargets}
@@ -292,3 +292,59 @@ describe("round trip", () => {
expect(markdownToNote(md).imagePaths).toEqual(["bilder/abc123.png"]);
});
});
describe("alignment", () => {
/* Markdown has no alignment of its own — it is often said to "basically be
* HTML", which it is not. What CommonMark allows is embedded HTML, so a
* centred paragraph can only travel as a tag. */
const centred = (text: string): DocNode => ({
type: "doc",
content: [{ type: "paragraph", attrs: { textAlign: "center" }, content: [{ type: "text", text }] }],
});
it("is written as the one wrapper most readers still honour", () => {
const md = noteToMarkdown(centred("Mitte"));
expect(md).toContain('<div align="center">');
expect(md).toContain("Mitte");
});
it("is not written at all for left, which is the default", () => {
const md = noteToMarkdown({
type: "doc",
content: [{ type: "paragraph", attrs: { textAlign: "left" }, content: [{ type: "text", text: "x" }] }],
});
expect(md).not.toContain("<div");
});
it("comes back on the paragraph, not on a container", () => {
const back = markdownToNote(noteToMarkdown(centred("Mitte"))).doc;
expect(back.content![0]).toMatchObject({ type: "paragraph", attrs: { textAlign: "center" } });
expect(plain(back)).toBe("Mitte");
});
it("survives on a heading too", () => {
const source: DocNode = {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 2, textAlign: "right" },
content: [{ type: "text", text: "Kopf" }],
},
],
};
const back = markdownToNote(noteToMarkdown(source)).doc;
expect(back.content![0]).toMatchObject({ type: "heading", attrs: { textAlign: "right" } });
});
it("carries several blocks out of one wrapper", () => {
const parsed = markdownToNote('<div align="center">\n\nEins\n\nZwei\n\n</div>').doc;
expect(parsed.content).toHaveLength(2);
expect(parsed.content!.every((n) => n.attrs?.textAlign === "center")).toBe(true);
});
it("leaves an unknown alignment alone rather than storing it", () => {
const parsed = markdownToNote('<div align="diagonal">\n\nText\n\n</div>').doc;
expect(parsed.content!.some((n) => n.attrs?.textAlign)).toBe(false);
});
});
+28
View File
@@ -223,6 +223,10 @@ function tableCells(line: string): string[] {
return cells.map((c) => c.trim());
}
/** `<div align="center">` — the only way alignment can travel in a .md file. */
const ALIGN_OPEN = /^\s*<div\s+align=["']?(left|center|right|justify)["']?\s*>\s*$/i;
const ALIGN_CLOSE = /^\s*<\/div>\s*$/i;
function parseBlocks(lines: string[], images: string[]): DocNode[] {
const out: DocNode[] = [];
let i = 0;
@@ -235,6 +239,30 @@ function parseBlocks(lines: string[], images: string[]): DocNode[] {
continue;
}
/* An alignment wrapper. Its contents are ordinary blocks; the alignment is
* applied to each of them afterwards, which is what the editor stores — the
* attribute lives on the paragraph, not on a container. */
const align = ALIGN_OPEN.exec(line);
if (align) {
const inner: string[] = [];
i++;
let depth = 1;
while (i < lines.length) {
if (ALIGN_OPEN.test(lines[i]!)) depth++;
else if (ALIGN_CLOSE.test(lines[i]!)) {
depth--;
if (depth === 0) break;
}
inner.push(lines[i]!);
i++;
}
i++; // the closing tag
for (const node of parseBlocks(inner, images)) {
out.push({ ...node, attrs: { ...(node.attrs ?? {}), textAlign: align[1]!.toLowerCase() } });
}
continue;
}
/* fenced code — taken verbatim, including anything that looks like markup */
const fence = FENCE.exec(line);
if (fence) {
+5
View File
@@ -18,6 +18,7 @@ import type {
StrokeDoc,
SyncStatus,
User,
UserPreferences,
AttachmentInfo,
} from "./types";
@@ -368,6 +369,10 @@ export async function logout(): Promise<void> {
}
/* ─── Account ─── */
export async function updatePreferences(preferences: Partial<UserPreferences>): Promise<User> {
return api.put<User>("/auth/preferences", preferences);
}
export async function updateProfile(displayName: string): Promise<User> {
const u = await api.put<User>("/auth/profile", { displayName });
await db.setMeta("user", u);
+9
View File
@@ -100,12 +100,21 @@ export interface Notebook {
_pendingCreate?: boolean;
}
/** How the note body is set. Presentation only — the export is unaffected. */
export type NoteFont = "serif" | "sans" | "mono";
export interface UserPreferences {
noteFont: NoteFont;
}
export interface User {
id: string;
username: string;
displayName: string;
/** Admins may create, delete and reset the password of other accounts. */
isAdmin?: boolean;
/** Interface settings, kept on the account so they follow you to the phone. */
preferences?: UserPreferences;
}
/** A user row as the admin panel sees it. */
+45
View File
@@ -4,6 +4,13 @@ import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
const chooseFont = async (noteFont) => {
try {
onUserUpdate(await sync.updatePreferences({ noteFont }));
} catch {
/* the choice is presentation; failing to store it must not block the panel */
}
};
const [tab, setTab] = useState("profile"); // profile | username | password
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
@@ -260,6 +267,44 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
>
{saving ? "Saving..." : "Save Changes"}
</button>
{/* 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. */}
<div style={{ marginTop: 22 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Note typeface
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
{[
["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 (
<button
key={value}
data-note-font={value}
onClick={() => chooseFont(value)}
style={{
padding: "10px 6px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontFamily: stack,
fontSize: 13,
}}
>
{label}
</button>
);
})}
</div>
</div>
</>
)}
+60
View File
@@ -561,6 +561,66 @@ export const I = {
<polyline points="2 12 12 17 22 12" />
</svg>
),
alignLeft: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="14" y2="12" />
<line x1="3" y1="18" x2="18" y2="18" />
</svg>
),
alignCenter: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="7" y1="12" x2="17" y2="12" />
<line x1="5" y1="18" x2="19" y2="18" />
</svg>
),
alignRight: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="10" y1="12" x2="21" y2="12" />
<line x1="6" y1="18" x2="21" y2="18" />
</svg>
),
alignJustify: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
),
dots: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="1.7" />
+39 -1
View File
@@ -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 `<div align="…">`, 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,