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

+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 }}
>