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:
1 parent
9507761bce
commit
4013e959d6
12 files changed
+295
-101
No files matched your search
@@ -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…`
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-4
@@ -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();
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user