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

@@ -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) {