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

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