From 4013e959d6c86f3fcca9095445dc02dc59651b1c Mon Sep 17 00:00:00 2001 From: marc Date: Wed, 26 Aug 2026 13:23:03 +0200 Subject: [PATCH] The same page on every screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 35 +++++++++ e2e/editor.mjs | 70 +++++++++++++++++ e2e/pdf.mjs | 8 +- frontend/src/editor/editor.css | 2 +- frontend/src/editor/pagination.js | 21 +++-- frontend/src/editor/useNoteEditor.jsx | 10 ++- frontend/src/features/notes/NoteEditor.jsx | 89 +++++++++++++++++----- frontend/src/features/pdf/PdfViewer.jsx | 34 +++++++-- frontend/src/features/pdf/PdfWorkspace.jsx | 69 ++++++----------- frontend/src/hooks/useNoteData.ts | 18 ++++- frontend/src/syncEngine.ts | 4 +- readme.md | 36 ++++++--- 12 files changed, 295 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb7ef7..f904682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,41 @@ a rebuild is not bit-identical. --- +## 2026-08-26 — The same page on every screen + +**Images** — web `sha256:e475c080…`, server `sha256:49a98c66…` + +The server image carries no change of its own; `build.sh` builds both. + +**Changed** +- **Twenty-one lines to the page, and the same twenty-one everywhere.** The text + is now laid out on a page of one fixed size and the whole sheet is scaled to + the screen, instead of being set in screen pixels. Set that way, a phone and a + desktop disagreed about everything that matters — the same note broke into + different lines on different pages depending on what you read it on, and a + phone page held eighteen lines where a desktop held far more. The type size is + computed from the page and the line count rather than chosen, so the count + survives a change to either; a desktop therefore shows larger writing on a + smaller-looking sheet, the way a ruled notebook app does. +- **Full screen for a PDF means the browser window, not the screen.** Asking the + browser for its own full screen took the tabs and the address bar with it, + which was more than was wanted. The overlay over the app — the part that + removes the sidebar, the list and the title bar — is the whole feature now. + The button moved next to the zoom, where the other question about how much + room the document gets already lives. + +**Fixed** +- **"Recent Notes" is now sorted by when you last wrote in a note**, not by when + you last renamed one. The store always stamped every write; the lists on screen + read the app's own copy of the notes, and writing *in* a note left that copy + untouched — so the dashboard and the "Last changed" sort showed the order from + the last reload. +- **A PDF that will not open says why.** One message covered every failure and + guessed at the cause ("offline?"), which was usually wrong and left a report of + "it will not open on my phone" with nothing in it. The status now travels with + the failure: a missing file, a server that refused, a server that could not be + reached and a file that will not parse are four different messages. + ## 2026-08-25 — Colour, a page you can see the edge of, and a document on its own **Images** — web `sha256:3e1b47e1…`, server `sha256:89e474cc…` diff --git a/e2e/editor.mjs b/e2e/editor.mjs index 1db74ac..b74f305 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -261,6 +261,76 @@ try { "and the gap stays blank — no placeholder printed at the foot of the page" ); + /* ── twenty-one lines, and the same twenty-one everywhere ── + * + * Two claims. First, a page holds the number of lines a ruled page holds — + * it held eighteen on a phone, which is what started this. Second, and the + * one that matters: the phone breaks the text in exactly the same places the + * desktop does. It did not, because the text used to be set in screen pixels: + * a 340-pixel sheet fitted nothing like what a 760-pixel one did, so the same + * note had different lines on different pages depending on what you read it + * on. The text is now laid out on a page of a fixed size and the whole sheet + * scaled, so the only thing a narrow screen changes is how big it looks. + * + * Measured in page units — every length divided by the sheet's own scale — + * because screen pixels are precisely what must not agree. */ + const pageUnits = () => + p.evaluate(() => { + const scale = Number(document.querySelector("[data-text-page-scale]").dataset.textPageScale); + const pm = document.querySelector(".ProseMirror"); + const cs = getComputedStyle(pm); + const sheet = document.querySelector("[data-page-sheet]").getBoundingClientRect(); + const top = pm.getBoundingClientRect().top; + const blocks = [...pm.children] + .filter((el) => !el.classList.contains("ne-page-spacer")) + .slice(0, 40) + .map((el) => { + const r = el.getBoundingClientRect(); + return { y: Math.round((r.top - top) / scale), h: Math.round(r.height / scale) }; + }); + return { + lines: + (sheet.height / scale - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)) / + parseFloat(cs.lineHeight), + blocks, + }; + }); + + const onDesktop = await pageUnits(); + check( + Math.floor(onDesktop.lines) === 21, + "a page holds twenty-one lines", + `${onDesktop.lines.toFixed(2)} fit` + ); + + const phone = { width: 390, height: 844 }; + const desktop = p.viewportSize(); + await p.setViewportSize(phone); + await p.waitForTimeout(2000); + /* A phone shows one pane at a time, so the note may have been left behind in + the list when the window narrowed. Open it again before measuring. */ + if ((await p.locator("[data-text-page-scale]").count()) === 0) await reopenNote(p, "Langer Text"); + await p.waitForSelector("[data-text-page-scale]", { timeout: 15000 }); + await p.waitForTimeout(3000); + const onPhone = await pageUnits(); + check( + Math.floor(onPhone.lines) === 21, + "on a phone as well — the same page, not a smaller one", + `${onPhone.lines.toFixed(2)} fit` + ); + const moved = onDesktop.blocks + .map((b, i) => ({ i, b, o: onPhone.blocks[i] })) + .filter(({ b, o }) => !o || Math.abs(b.y - o.y) > 1 || Math.abs(b.h - o.h) > 1) + .map(({ i, b, o }) => `#${i}: ${b.y}/${b.h} vs ${o ? `${o.y}/${o.h}` : "missing"}`); + check( + onPhone.blocks.length === onDesktop.blocks.length && moved.length === 0, + "and every paragraph falls in the same place on both, to the page unit", + moved.slice(0, 4).join(" | ") + ); + await p.setViewportSize(desktop); + await p.waitForTimeout(2000); + if ((await p.locator(".ProseMirror").count()) === 0) await reopenNote(p, "Langer Text"); + /* 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; diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index 78e7bb5..70ac974 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -271,10 +271,10 @@ try { await p.waitForTimeout(1500); /* ── the document, and nothing else ── - * Two things at once: the browser's own full screen, which puts the page past - * the window frame, and an overlay over the app, which is what actually - * removes the sidebar, the list and the title bar. The second is the half that - * can be measured here — and the half a browser can refuse. */ + * The app laid out as an overlay over itself: the document takes the whole + * browser window, and the sidebar, the note list and the title bar go. The + * browser's own full screen is deliberately not asked for — this is about + * giving the page to the document, not the screen. */ const workspaceBox = () => p.locator("[data-pdf-workspace]").evaluate((el) => { const r = el.getBoundingClientRect(); diff --git a/frontend/src/editor/editor.css b/frontend/src/editor/editor.css index b1144d0..57179be 100644 --- a/frontend/src/editor/editor.css +++ b/frontend/src/editor/editor.css @@ -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%; diff --git a/frontend/src/editor/pagination.js b/frontend/src/editor/pagination.js index 225970b..2f59af4 100644 --- a/frontend/src/editor/pagination.js +++ b/frontend/src/editor/pagination.js @@ -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; } } diff --git a/frontend/src/editor/useNoteEditor.jsx b/frontend/src/editor/useNoteEditor.jsx index 47f2b53..e3f4c1f 100644 --- a/frontend/src/editor/useNoteEditor.jsx +++ b/frontend/src/editor/useNoteEditor.jsx @@ -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) }} > diff --git a/frontend/src/features/notes/NoteEditor.jsx b/frontend/src/features/notes/NoteEditor.jsx index aa42a20..038e27d 100644 --- a/frontend/src/features/notes/NoteEditor.jsx +++ b/frontend/src/features/notes/NoteEditor.jsx @@ -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", }} > - + {/* 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. */} +
+ +
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */} {legacyRaster && ( diff --git a/frontend/src/features/pdf/PdfViewer.jsx b/frontend/src/features/pdf/PdfViewer.jsx index b19c2e3..8c4435b 100644 --- a/frontend/src/features/pdf/PdfViewer.jsx +++ b/frontend/src/features/pdf/PdfViewer.jsx @@ -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 + + {/* 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 diff --git a/frontend/src/features/pdf/PdfWorkspace.jsx b/frontend/src/features/pdf/PdfWorkspace.jsx index 586747b..b6c72d4 100644 --- a/frontend/src/features/pdf/PdfWorkspace.jsx +++ b/frontend/src/features/pdf/PdfWorkspace.jsx @@ -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({ ); - /* 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 = ( - <> - - {notesToggle} - + /* Beside the zoom rather than out on its own: both answer "how much of this + screen does the document get". */ + const fullToggle = ( + ); return (
setFocusPin(null)} /> diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index 28d9838..fe628ef 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -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) => { diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index c9fdaec..5c6f8cc 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -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(); }, }; diff --git a/readme.md b/readme.md index f533997..91f9211 100644 --- a/readme.md +++ b/readme.md @@ -675,11 +675,27 @@ block a meaning, which only moved the problem — the whole sheet sat hard again the left edge. Centred, the same leftover space reads as two margins and needs no explaining. +**Twenty-one lines to the page, and the same twenty-one on every screen.** The +text is laid out on a page of one fixed size — 760 × 1013 units, the drawing's +own proportions — and the whole sheet is then scaled to whatever width the screen +has. Set in screen pixels instead, as it was, a phone and a desktop disagreed +about everything that matters: a 340-pixel sheet held eighteen lines where a +760-pixel one held far more, so the same note broke into different lines on +different pages depending on what you read it on. Now the only thing a narrow +screen changes is how big the page looks. The type size is not chosen but +computed from the page and the line count, so the count survives a change to +either. A desktop therefore shows larger writing on a smaller-looking sheet, the +way a ruled notebook app does. + **The dashboard leads with what you were last writing.** The six most recently edited notes come first and the shelf of notebooks second, because coming back to a note is the common case and choosing a notebook is the occasional one. "Recent" is sorted by time here whatever order the notebooks themselves are in — it is a -question about time, and a hand-made arrangement is the wrong answer to it. +question about time, and a hand-made arrangement is the wrong answer to it. The +time it sorts by is stamped on every write, not only on a rename: the store +behind the sync engine always did that, but the lists on screen read the app's +own copy of the notes, and writing *in* a note used to leave that copy untouched +— so "Recent" showed the order from the last reload. **Tapping the blank part of a page starts writing there.** The text is only as tall as what has been written, so most of a fresh page is *beside* the editable @@ -926,16 +942,14 @@ page is expensive to draw and cheap to hold, so scrolling back up repaints nothing. Canvases are sized in device pixels and scaled down by CSS, which is what keeps text sharp on a high-density screen. -**Full screen is two things at once.** The button asks the browser for its own -full screen — which is what puts the document past the address bar and the window -frame, and the reason to want it on a laptop or a tablet — *and* lays the -workspace out as an overlay over the app, which is what actually removes the -sidebar, the note list and the title bar. Either alone is half the job, and a -browser that refuses the first (an iPhone does) still gets the second. Escape -leaves, and so does the button, which stays in the toolbar while full: a way out -that is only known and not visible is not a way out. Leaving the browser's full -screen by any other route takes the overlay with it, or the app would be left -pretending to be full screen inside an ordinary window. +**Full screen means the whole window, not the whole screen.** The workspace is +laid out as an overlay over the app: the document takes the browser window, and +the sidebar, the note list and the title bar go. 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; this is about giving the page to the document, not the +screen. Escape leaves, and so does the button, which sits beside the zoom while +full: both answer "how much room does the document get", and a way out that is +only known and not visible is not a way out. **Zooming holds something still.** A zoom changes how tall the document is, so the scroll position means something different afterwards — left alone, every step