The same page on every screen

The text is laid out on a page of one fixed size and the sheet scaled to
the screen, so a phone breaks it exactly where a desktop does — twenty-one
lines to the page on both. The type size is computed from the page and the
line count rather than chosen.

A PDF's full screen is the browser window, not the screen, and its button
sits beside the zoom. "Recent Notes" is stamped by every write, not only by
a rename. And a PDF that will not open says which of the four reasons it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-26 13:23:03 +02:00
1 parent 9507761bce
commit 4013e959d6
12 files changed
+295 -101

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@
/* 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;
font-size: var(--ne-size, 15px);
line-height: var(--ne-line);
padding: 20px 24px;
min-height: 100%;
+14 -7
View File
@@ -87,7 +87,7 @@ function makeSpacerRow(height, delta, colspan) {
}
/** The rows of a table, with the document position of each. */
function tableRows(node, wrapper, domTop, seenBefore) {
function tableRows(node, wrapper, domTop, seenBefore, scale) {
const table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table");
const body = table?.querySelector(":scope > tbody");
if (!body) return null;
@@ -108,8 +108,8 @@ function tableRows(node, wrapper, domTop, seenBefore) {
rows.push({
index,
pos,
top: rect.top - domTop - seenBefore - inner,
height: rect.height,
top: (rect.top - domTop) / scale - seenBefore - inner,
height: rect.height / scale,
});
pos += node.child(index).nodeSize;
index++;
@@ -121,6 +121,13 @@ function tableRows(node, wrapper, domTop, seenBefore) {
function computeBreaks(view, geometry) {
const pageHeight = geometry?.pageHeight ?? 0;
const gap = geometry?.gap ?? 0;
/* The text is laid out on a page of a fixed size and the whole sheet is then
* scaled to whatever room the screen has — that is what makes a phone break
* the text in exactly the same places a desktop does. `getBoundingClientRect`
* reports what is on the screen, so every measured length is divided back out
* of that scaling: the page height above, the spacer heights below and the
* margins read from the computed style are all in page units. */
const scale = geometry?.scale || 1;
if (!(pageHeight > 0)) return null;
const pitch = pageHeight + gap;
@@ -156,8 +163,8 @@ function computeBreaks(view, geometry) {
const cs = getComputedStyle(child);
const block = {
pos: offset,
top: rect.top - domTop - seen,
height: rect.height,
top: (rect.top - domTop) / scale - seen,
height: rect.height / scale,
marginTop: px(cs.marginTop),
marginBottom: px(cs.marginBottom),
};
@@ -167,11 +174,11 @@ function computeBreaks(view, geometry) {
* they push everything after the table down as surely as a block spacer
* does, and the next block's natural position has to allow for it. */
if (node.type.name === "table") {
const measured = tableRows(node, child, domTop, seen);
const measured = tableRows(node, child, domTop, seen, scale);
if (measured) {
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
block.columns = measured.columns;
block.height = rect.height - measured.inner;
block.height = rect.height / scale - measured.inner;
seen += measured.inner;
}
}
+7 -3
View File
@@ -123,7 +123,7 @@ export function useNoteEditor({
}
/** The editable page itself. */
export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
export function EditorSurface({ editor, dark, font, minHeight, fontSize, onOpenNote, resolveLinkLabel, resolveLinkIcon }) {
/* Note links carry the label they were created with, so renaming a note would
* leave every reference to it showing the old title.
*
@@ -236,8 +236,12 @@ export function EditorSurface({ editor, dark, font, minHeight, onOpenNote, resol
data-theme={dark ? "dark" : "light"}
data-font={font || "serif"}
onClick={handleClick}
// The whole sheet is somewhere to write, so it says so under the pointer.
style={{ minHeight, cursor: "text" }}
/* The whole sheet is somewhere to write, so it says so under the pointer.
`--ne-size` is the type size *on the page*: a paged note sets it so that
a fixed number of lines fills a sheet, and the sheet is then scaled to
the screen. Panels that show a note without a page — the notes beside a
PDF, a version preview — leave it unset and keep the reading size. */
style={{ minHeight, cursor: "text", ...(fontSize ? { "--ne-size": `${fontSize}px` } : null) }}
>
<EditorContent editor={editor} />
</div>
+70 -19
View File
@@ -25,6 +25,31 @@ import {
} from "../drawing/strokes.js";
import { COLORS } from "../../ui/constants.jsx";
/* ── the page the text is laid out on ──
*
* One page, one size, everywhere: the text is set on a sheet of exactly these
* units and the whole sheet is then scaled to whatever width the screen offers.
* Laying it out in screen pixels instead is what made a phone and a desktop
* disagree — the same note broke into different lines and different pages on
* each, because a 340-pixel sheet held nothing like what a 760-pixel one did.
* Now the scale is the only difference between them.
*
* The shape is the drawing's own page, so "page 2" means the same sheet whether
* you write on it or draw on it.
*/
const PAGE_W = 760;
const PAGE_H = Math.round((PAGE_W * CANON_H) / CANON_W);
const PAGE_GAP = 16;
/* Twenty-one lines to the page, the way Samsung Notes rules its paper — and the
* type size follows from that rather than being chosen and hoped for. The two
* numbers below are what `editor.css` sets on the writing surface; if either
* moves there, the size recomputed here keeps the count. */
const PAGE_PAD = 20;
const LINE_HEIGHT = 1.7;
const LINES_PER_PAGE = 21;
const TEXT_SIZE = Math.floor((PAGE_H - PAGE_PAD * 2) / LINES_PER_PAGE / LINE_HEIGHT);
import { I } from "../../ui/icons.jsx";
import { BacklinksPanel } from "../../ui/BacklinksPanel.jsx";
@@ -85,20 +110,25 @@ export function NoteEditor({
// new vector strokes so migrating a note never loses what was already drawn.
const legacyRaster = note.drawingData && !hideDrawing;
/* The gap between two sheets.
/* How much of a page fits on this screen: the one number that separates a
* phone from a desktop. Everything about the writing itself — line breaks,
* page breaks, where a paragraph starts — is decided in page units and then
* multiplied by this. */
const scale = stageSize.w / PAGE_W;
/* The gap between two sheets, in page units like everything else.
*
* The same idea as the PDF reader's, which is the point: a page in a note and
* a page in a document should read as the same kind of thing. The text flow
* knows about it too — `editor/pagination.js` holds it open inside the
* document so no paragraph is ever cut in half by it. */
const PAGE_GAP = isMobileViewport ? 10 : 16;
const pitch = stageSize.h + PAGE_GAP;
const pitch = (PAGE_H + PAGE_GAP) * scale;
/* Handed to the editor, and read fresh on every measurement rather than
* captured, so resizing the window re-breaks the text without rebuilding the
* editor and losing the cursor. */
const pageGeometry = useMemo(
() => ({ pageHeight: stageSize.h, gap: PAGE_GAP }),
[stageSize.h, PAGE_GAP]
() => ({ pageHeight: PAGE_H, gap: PAGE_GAP, scale }),
[scale]
);
const commitSheets = (next) => {
@@ -244,6 +274,8 @@ export function NoteEditor({
const recount = () => {
const el = surface();
if (!el || !stageSize.h) return;
/* Read in page units: `scrollHeight` is layout, and the sheet's scaling is
* a transform, which layout knows nothing about. */
const height = el.scrollHeight;
const scroller = scrollerRef.current;
/* Counted in whole pitches — a page and the gap after it — because that is
@@ -251,14 +283,16 @@ export function NoteEditor({
* four of a two-page note is on the note, and a count that ignored it
* would report two pages while showing four, leaving the fourth with no
* canvas to be redrawn on. */
const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pitch) + 1);
const pagePitch = PAGE_H + PAGE_GAP;
const written = Math.max(1, Math.floor(Math.max(0, height - 1) / pagePitch) + 1);
const count = Math.max(written, drawnPages[drawnPages.length - 1] ?? 1);
/* 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 / pitch) + 1));
// The scroller is in screen units, so it is divided back into page ones.
const middle = scroller ? (scroller.scrollTop + scroller.clientHeight / 2) / scale : 0;
const current = Math.max(1, Math.min(count, Math.floor(middle / pagePitch) + 1));
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
};
@@ -273,7 +307,7 @@ export function NoteEditor({
scroller?.removeEventListener("scroll", recount);
textEditor.off("transaction", recount);
};
}, [textEditor, stageSize.h, pitch, mode, drawnPages]);
}, [textEditor, stageSize.h, scale, mode, drawnPages]);
/* ── DRAWING: painting ──
* Only the page under the pointer is repainted while a stroke is being drawn;
@@ -797,7 +831,7 @@ export function NoteEditor({
maxWidth: "100%",
// Every sheet plus the gaps between them, but not a gap trailing
// off the last one.
minHeight: canvasPages * pitch - PAGE_GAP,
minHeight: canvasPages * pitch - PAGE_GAP * scale,
margin: "0 auto",
}}
>
@@ -869,15 +903,32 @@ export function NoteEditor({
pointerEvents: mode === "text" ? "auto" : "none",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
font={noteFont}
minHeight={stageSize.h}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
{/* The sheet, at its own size, shrunk to fit the screen.
A transform rather than a smaller type size, because a type
size changes where the lines fall and that is exactly what has
to stay put: scaled this way the phone shows the same lines on
the same pages as the desktop, only smaller. Anchored top-left
so the scaling never moves the sheet away from the paper drawn
behind it. */}
<div
data-text-page-scale={scale}
style={{
width: PAGE_W,
transform: `scale(${scale})`,
transformOrigin: "top left",
}}
>
<EditorSurface
editor={textEditor}
dark={dark}
font={noteFont}
minHeight={PAGE_H}
fontSize={TEXT_SIZE}
onOpenNote={onOpenNote}
resolveLinkLabel={resolveLinkLabel}
resolveLinkIcon={resolveLinkIcon}
/>
</div>
</div>
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
{legacyRaster && (
+26 -8
View File
@@ -48,6 +48,26 @@ const visibilityButton = (T, shown) => ({
color: shown ? T.muted : T.accent,
});
/**
* Why the document did not open, in words.
*
* One message for every failure told a phone that could not reach the server and
* a phone whose file was missing exactly the same thing, and both of them a
* guess ("offline?") that was usually wrong. The reason is known here — say it,
* so a report of "it will not open" arrives with the cause attached.
*/
function openFailure(err) {
if (err?.message === "AUTH") return "Session expired — please sign in again.";
if (typeof navigator !== "undefined" && navigator.onLine === false)
return "You are offline, and this file has not been downloaded to this device yet.";
const status = /^HTTP (\d+)$/.exec(err?.message || "")?.[1];
if (status === "404") return "The server no longer has this file (404).";
if (status) return `The server would not send the file (HTTP ${status}).`;
if (err?.name === "TypeError") return "The server could not be reached.";
// A file that arrived but will not parse: broken, or not a PDF at all.
return `The file could not be opened: ${err?.name || "Error"}: ${err?.message || err}`;
}
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
const RENDER_MARGIN_PX = 1200;
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -172,7 +192,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink, pins, selectab
* proportions before anything is drawn, so the scrollbar is honest from the
* first frame instead of jumping as pages arrive.
*/
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, focusPin, onFocusHandled }) {
export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, zoomExtras, focusPin, onFocusHandled }) {
const [state, setState] = useState({ status: "loading" });
const [zoom, setZoom] = useState(1);
const [drawing, setDrawing] = useState(false);
@@ -400,13 +420,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
// console, where it can be read when someone reports "it does not open".
console.error(`[pdf] could not open document: ${err?.name || "Error"}: ${err?.message || err}`);
if (!cancelled) {
setState({
status: "error",
message:
err?.message === "AUTH"
? "Session expired — please sign in again."
: "The file could not be opened. Offline and never loaded?",
});
setState({ status: "error", message: openFailure(err) });
}
});
@@ -818,6 +832,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
+
</button>
{/* How big the document is drawn, and how much room it gets — the same
question asked twice, so the controls stand together. */}
{zoomExtras}
{/* What is shown on top of the document, kept together at the end of
the bar. Drawings and margin notes are two halves of one question —
"how much of my own work do I want to see right now" — and they used
+23 -46
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PdfViewer } from "./PdfViewer.jsx";
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
@@ -43,7 +43,6 @@ export function PdfWorkspace({
}) {
const [showNotes, setShowNotes] = useState(false);
const [full, setFull] = useState(false);
const rootRef = useRef(null);
// Set when a link in the notes points at a marker; the viewer clears it once
// it has scrolled there.
const [focusPin, setFocusPin] = useState(null);
@@ -66,36 +65,17 @@ export function PdfWorkspace({
/* ── the document, and nothing else ──
*
* Two things at once, because either alone is half a job. The browser's own
* full screen puts the document past the address bar and the window frame,
* which is the point of asking on a laptop or a tablet; and the app is laid
* out as an overlay over itself, which is what actually removes the sidebar,
* the note list and the title bar. A browser that refuses the first — an
* iPhone does — still gets the second.
* The app laid out as an overlay over itself: the document fills the browser
* window, and the sidebar, the note list and the title bar are gone. The
* browser's *own* full screen is deliberately not asked for — it swallows the
* tabs and the address bar too, which is more than was wanted here; this is
* about giving the page to the document, not the screen.
*/
const enterFull = () => {
setFull(true);
rootRef.current?.requestFullscreen?.().catch(() => {
/* refused or unavailable: the overlay is the whole feature anyway */
});
};
const leaveFull = useCallback(() => {
setFull(false);
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
}, []);
const enterFull = () => setFull(true);
const leaveFull = useCallback(() => setFull(false), []);
/* Leaving the browser's full screen — with its own Escape, with F11, by any
route — must take the overlay with it, or the app would be left pretending
to be full screen inside an ordinary window. */
useEffect(() => {
const onChange = () => {
if (!document.fullscreenElement) setFull(false);
};
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
/* And Escape works even where there was never a browser full screen to leave. */
/* Escape gets out. A way out that you can only know about is not a way out —
so the button stays visible in the toolbar as well. */
useEffect(() => {
if (!full) return undefined;
const onKey = (e) => {
@@ -198,26 +178,22 @@ export function PdfWorkspace({
</button>
);
/* Both controls in the viewer's own toolbar, beside the zoom — a second row
for two buttons would cost a strip of reading height on every screen. */
const toolbarExtras = (
<>
<button
data-pdf-fullscreen={full ? "on" : "off"}
onClick={() => (full ? leaveFull() : enterFull())}
title={full ? "Leave full screen (Esc)" : "Full screen"}
style={barButton(full)}
>
{full ? <ShrinkIcon /> : <ExpandIcon />}
</button>
{notesToggle}
</>
/* Beside the zoom rather than out on its own: both answer "how much of this
screen does the document get". */
const fullToggle = (
<button
data-pdf-fullscreen={full ? "on" : "off"}
onClick={() => (full ? leaveFull() : enterFull())}
title={full ? "Leave full screen (Esc)" : "Full screen"}
style={barButton(full)}
>
{full ? <ShrinkIcon /> : <ExpandIcon />}
</button>
);
return (
<div
ref={rootRef}
data-pdf-workspace
style={{
flex: 1,
@@ -235,7 +211,8 @@ export function PdfWorkspace({
note={note}
dark={dark}
onUpdate={onUpdate}
notesToggle={toolbarExtras}
notesToggle={notesToggle}
zoomExtras={fullToggle}
focusPin={focusPin}
onFocusHandled={() => setFocusPin(null)}
/>
+17 -1
View File
@@ -217,6 +217,20 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
/**
* Marks a note as changed *now* in the list everything on screen reads from.
*
* The store behind `sync` already stamps the note on every write, but nothing
* on screen reads the store: the lists read this state. Only a title change
* used to stamp it here — so writing in a note left "Recent Notes" and the
* "Last changed" sort showing the order from the last reload, which is exactly
* the moment those two are supposed to react to.
*/
const touch = useCallback((id: string) => {
const updatedAt = new Date().toISOString();
setNotes((p) => p.map((n) => (n.id === id ? { ...n, updatedAt } : n)));
}, []);
const updateNote = useCallback((id: string, edit: NoteEdit) => {
if (edit.title !== undefined) {
const title = edit.title;
@@ -228,15 +242,17 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (edit.content !== undefined) {
const content = edit.content;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
touch(id);
void sync.updateNoteContent(id, content, edit.links, edit.attachments);
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
}
if (edit.drawingStrokes !== undefined) {
const drawingStrokes = edit.drawingStrokes;
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
touch(id);
void sync.updateNoteDrawing(id, drawingStrokes);
}
}, []);
}, [touch]);
/** Replaces a body wholesale — used when restoring an older version. */
const setNoteContent = useCallback((id: string, content: NoteContent) => {
+3 -1
View File
@@ -255,7 +255,9 @@ const api = {
this.forgetToken();
throw new SyncError("AUTH");
}
if (!r.ok) throw new SyncError("REQUEST_FAILED");
// The status travels with the failure: "it would not open on my phone" is a
// different bug at 404 than at 502, and without it both look the same.
if (!r.ok) throw new SyncError("REQUEST_FAILED", `HTTP ${r.status}`);
return r.blob();
},
};