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
|
## 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…`
|
**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"
|
"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. */
|
/* The note is still one document — pages are drawn, not stored. */
|
||||||
const stored = (await api(p, "GET", "/notes")).body[0];
|
const stored = (await api(p, "GET", "/notes")).body[0];
|
||||||
const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content;
|
const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content;
|
||||||
|
|||||||
+4
-4
@@ -271,10 +271,10 @@ try {
|
|||||||
await p.waitForTimeout(1500);
|
await p.waitForTimeout(1500);
|
||||||
|
|
||||||
/* ── the document, and nothing else ──
|
/* ── the document, and nothing else ──
|
||||||
* Two things at once: the browser's own full screen, which puts the page past
|
* The app laid out as an overlay over itself: the document takes the whole
|
||||||
* the window frame, and an overlay over the app, which is what actually
|
* browser window, and the sidebar, the note list and the title bar go. The
|
||||||
* removes the sidebar, the list and the title bar. The second is the half that
|
* browser's own full screen is deliberately not asked for — this is about
|
||||||
* can be measured here — and the half a browser can refuse. */
|
* giving the page to the document, not the screen. */
|
||||||
const workspaceBox = () =>
|
const workspaceBox = () =>
|
||||||
p.locator("[data-pdf-workspace]").evaluate((el) => {
|
p.locator("[data-pdf-workspace]").evaluate((el) => {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
/* Set per account — see UserPreferences. The stack is the fallback chain, so
|
/* Set per account — see UserPreferences. The stack is the fallback chain, so
|
||||||
an unset preference still reads as the serif it always was. */
|
an unset preference still reads as the serif it always was. */
|
||||||
font-family: var(--ne-font, "Newsreader", Georgia, serif);
|
font-family: var(--ne-font, "Newsreader", Georgia, serif);
|
||||||
font-size: 15px;
|
font-size: var(--ne-size, 15px);
|
||||||
line-height: var(--ne-line);
|
line-height: var(--ne-line);
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function makeSpacerRow(height, delta, colspan) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The rows of a table, with the document position of each. */
|
/** 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 table = wrapper.matches?.("table") ? wrapper : wrapper.querySelector(":scope table");
|
||||||
const body = table?.querySelector(":scope > tbody");
|
const body = table?.querySelector(":scope > tbody");
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
@@ -108,8 +108,8 @@ function tableRows(node, wrapper, domTop, seenBefore) {
|
|||||||
rows.push({
|
rows.push({
|
||||||
index,
|
index,
|
||||||
pos,
|
pos,
|
||||||
top: rect.top - domTop - seenBefore - inner,
|
top: (rect.top - domTop) / scale - seenBefore - inner,
|
||||||
height: rect.height,
|
height: rect.height / scale,
|
||||||
});
|
});
|
||||||
pos += node.child(index).nodeSize;
|
pos += node.child(index).nodeSize;
|
||||||
index++;
|
index++;
|
||||||
@@ -121,6 +121,13 @@ function tableRows(node, wrapper, domTop, seenBefore) {
|
|||||||
function computeBreaks(view, geometry) {
|
function computeBreaks(view, geometry) {
|
||||||
const pageHeight = geometry?.pageHeight ?? 0;
|
const pageHeight = geometry?.pageHeight ?? 0;
|
||||||
const gap = geometry?.gap ?? 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;
|
if (!(pageHeight > 0)) return null;
|
||||||
const pitch = pageHeight + gap;
|
const pitch = pageHeight + gap;
|
||||||
|
|
||||||
@@ -156,8 +163,8 @@ function computeBreaks(view, geometry) {
|
|||||||
const cs = getComputedStyle(child);
|
const cs = getComputedStyle(child);
|
||||||
const block = {
|
const block = {
|
||||||
pos: offset,
|
pos: offset,
|
||||||
top: rect.top - domTop - seen,
|
top: (rect.top - domTop) / scale - seen,
|
||||||
height: rect.height,
|
height: rect.height / scale,
|
||||||
marginTop: px(cs.marginTop),
|
marginTop: px(cs.marginTop),
|
||||||
marginBottom: px(cs.marginBottom),
|
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
|
* 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. */
|
* does, and the next block's natural position has to allow for it. */
|
||||||
if (node.type.name === "table") {
|
if (node.type.name === "table") {
|
||||||
const measured = tableRows(node, child, domTop, seen);
|
const measured = tableRows(node, child, domTop, seen, scale);
|
||||||
if (measured) {
|
if (measured) {
|
||||||
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
|
block.rows = measured.rows.map((r) => ({ ...r, pos: offset + 1 + r.pos }));
|
||||||
block.columns = measured.columns;
|
block.columns = measured.columns;
|
||||||
block.height = rect.height - measured.inner;
|
block.height = rect.height / scale - measured.inner;
|
||||||
seen += measured.inner;
|
seen += measured.inner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function useNoteEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The editable page itself. */
|
/** 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
|
/* Note links carry the label they were created with, so renaming a note would
|
||||||
* leave every reference to it showing the old title.
|
* 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-theme={dark ? "dark" : "light"}
|
||||||
data-font={font || "serif"}
|
data-font={font || "serif"}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
// The whole sheet is somewhere to write, so it says so under the pointer.
|
/* The whole sheet is somewhere to write, so it says so under the pointer.
|
||||||
style={{ minHeight, cursor: "text" }}
|
`--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} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,31 @@ import {
|
|||||||
} from "../drawing/strokes.js";
|
} from "../drawing/strokes.js";
|
||||||
import { COLORS } from "../../ui/constants.jsx";
|
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 { I } from "../../ui/icons.jsx";
|
||||||
import { BacklinksPanel } from "../../ui/BacklinksPanel.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.
|
// new vector strokes so migrating a note never loses what was already drawn.
|
||||||
const legacyRaster = note.drawingData && !hideDrawing;
|
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
|
* 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
|
* 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
|
* knows about it too — `editor/pagination.js` holds it open inside the
|
||||||
* document so no paragraph is ever cut in half by it. */
|
* document so no paragraph is ever cut in half by it. */
|
||||||
const PAGE_GAP = isMobileViewport ? 10 : 16;
|
const pitch = (PAGE_H + PAGE_GAP) * scale;
|
||||||
const pitch = stageSize.h + PAGE_GAP;
|
|
||||||
/* Handed to the editor, and read fresh on every measurement rather than
|
/* Handed to the editor, and read fresh on every measurement rather than
|
||||||
* captured, so resizing the window re-breaks the text without rebuilding the
|
* captured, so resizing the window re-breaks the text without rebuilding the
|
||||||
* editor and losing the cursor. */
|
* editor and losing the cursor. */
|
||||||
const pageGeometry = useMemo(
|
const pageGeometry = useMemo(
|
||||||
() => ({ pageHeight: stageSize.h, gap: PAGE_GAP }),
|
() => ({ pageHeight: PAGE_H, gap: PAGE_GAP, scale }),
|
||||||
[stageSize.h, PAGE_GAP]
|
[scale]
|
||||||
);
|
);
|
||||||
|
|
||||||
const commitSheets = (next) => {
|
const commitSheets = (next) => {
|
||||||
@@ -244,6 +274,8 @@ export function NoteEditor({
|
|||||||
const recount = () => {
|
const recount = () => {
|
||||||
const el = surface();
|
const el = surface();
|
||||||
if (!el || !stageSize.h) return;
|
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 height = el.scrollHeight;
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
/* Counted in whole pitches — a page and the gap after it — because that is
|
/* 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
|
* 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
|
* would report two pages while showing four, leaving the fourth with no
|
||||||
* canvas to be redrawn on. */
|
* 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);
|
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 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
|
* 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
|
* "page 1" while you were looking at the foot of a two-page note, because
|
||||||
* a window is shorter than a page. */
|
* a window is shorter than a page. */
|
||||||
const middle = scroller ? scroller.scrollTop + scroller.clientHeight / 2 : 0;
|
// The scroller is in screen units, so it is divided back into page ones.
|
||||||
const current = Math.max(1, Math.min(count, Math.floor(middle / pitch) + 1));
|
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 }));
|
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -273,7 +307,7 @@ export function NoteEditor({
|
|||||||
scroller?.removeEventListener("scroll", recount);
|
scroller?.removeEventListener("scroll", recount);
|
||||||
textEditor.off("transaction", recount);
|
textEditor.off("transaction", recount);
|
||||||
};
|
};
|
||||||
}, [textEditor, stageSize.h, pitch, mode, drawnPages]);
|
}, [textEditor, stageSize.h, scale, mode, drawnPages]);
|
||||||
|
|
||||||
/* ── DRAWING: painting ──
|
/* ── DRAWING: painting ──
|
||||||
* Only the page under the pointer is repainted while a stroke is being drawn;
|
* Only the page under the pointer is repainted while a stroke is being drawn;
|
||||||
@@ -797,7 +831,7 @@ export function NoteEditor({
|
|||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
// Every sheet plus the gaps between them, but not a gap trailing
|
// Every sheet plus the gaps between them, but not a gap trailing
|
||||||
// off the last one.
|
// off the last one.
|
||||||
minHeight: canvasPages * pitch - PAGE_GAP,
|
minHeight: canvasPages * pitch - PAGE_GAP * scale,
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -869,15 +903,32 @@ export function NoteEditor({
|
|||||||
pointerEvents: mode === "text" ? "auto" : "none",
|
pointerEvents: mode === "text" ? "auto" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditorSurface
|
{/* The sheet, at its own size, shrunk to fit the screen.
|
||||||
editor={textEditor}
|
A transform rather than a smaller type size, because a type
|
||||||
dark={dark}
|
size changes where the lines fall and that is exactly what has
|
||||||
font={noteFont}
|
to stay put: scaled this way the phone shows the same lines on
|
||||||
minHeight={stageSize.h}
|
the same pages as the desktop, only smaller. Anchored top-left
|
||||||
onOpenNote={onOpenNote}
|
so the scaling never moves the sheet away from the paper drawn
|
||||||
resolveLinkLabel={resolveLinkLabel}
|
behind it. */}
|
||||||
resolveLinkIcon={resolveLinkIcon}
|
<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>
|
</div>
|
||||||
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
|
{/* Legacy raster drawing from before vector strokes — kept underneath so nothing is lost */}
|
||||||
{legacyRaster && (
|
{legacyRaster && (
|
||||||
|
|||||||
@@ -48,6 +48,26 @@ const visibilityButton = (T, shown) => ({
|
|||||||
color: shown ? T.muted : T.accent,
|
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. */
|
/** Pages this far outside the viewport are drawn ahead of being scrolled to. */
|
||||||
const RENDER_MARGIN_PX = 1200;
|
const RENDER_MARGIN_PX = 1200;
|
||||||
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
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
|
* proportions before anything is drawn, so the scrollbar is honest from the
|
||||||
* first frame instead of jumping as pages arrive.
|
* 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 [state, setState] = useState({ status: "loading" });
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [drawing, setDrawing] = useState(false);
|
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, 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}`);
|
console.error(`[pdf] could not open document: ${err?.name || "Error"}: ${err?.message || err}`);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setState({
|
setState({ status: "error", message: openFailure(err) });
|
||||||
status: "error",
|
|
||||||
message:
|
|
||||||
err?.message === "AUTH"
|
|
||||||
? "Session expired — please sign in again."
|
|
||||||
: "The file could not be opened. Offline and never loaded?",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -818,6 +832,10 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady, notesToggle, foc
|
|||||||
+
|
+
|
||||||
</button>
|
</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
|
{/* 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 —
|
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
|
"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 { PdfViewer } from "./PdfViewer.jsx";
|
||||||
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
import { PdfNotesPanel } from "./PdfNotesPanel.jsx";
|
||||||
@@ -43,7 +43,6 @@ export function PdfWorkspace({
|
|||||||
}) {
|
}) {
|
||||||
const [showNotes, setShowNotes] = useState(false);
|
const [showNotes, setShowNotes] = useState(false);
|
||||||
const [full, setFull] = 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
|
// Set when a link in the notes points at a marker; the viewer clears it once
|
||||||
// it has scrolled there.
|
// it has scrolled there.
|
||||||
const [focusPin, setFocusPin] = useState(null);
|
const [focusPin, setFocusPin] = useState(null);
|
||||||
@@ -66,36 +65,17 @@ export function PdfWorkspace({
|
|||||||
|
|
||||||
/* ── the document, and nothing else ──
|
/* ── the document, and nothing else ──
|
||||||
*
|
*
|
||||||
* Two things at once, because either alone is half a job. The browser's own
|
* The app laid out as an overlay over itself: the document fills the browser
|
||||||
* full screen puts the document past the address bar and the window frame,
|
* window, and the sidebar, the note list and the title bar are gone. The
|
||||||
* which is the point of asking on a laptop or a tablet; and the app is laid
|
* browser's *own* full screen is deliberately not asked for — it swallows the
|
||||||
* out as an overlay over itself, which is what actually removes the sidebar,
|
* tabs and the address bar too, which is more than was wanted here; this is
|
||||||
* the note list and the title bar. A browser that refuses the first — an
|
* about giving the page to the document, not the screen.
|
||||||
* iPhone does — still gets the second.
|
|
||||||
*/
|
*/
|
||||||
const enterFull = () => {
|
const enterFull = () => setFull(true);
|
||||||
setFull(true);
|
const leaveFull = useCallback(() => setFull(false), []);
|
||||||
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(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* Leaving the browser's full screen — with its own Escape, with F11, by any
|
/* Escape gets out. A way out that you can only know about is not a way out —
|
||||||
route — must take the overlay with it, or the app would be left pretending
|
so the button stays visible in the toolbar as well. */
|
||||||
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. */
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!full) return undefined;
|
if (!full) return undefined;
|
||||||
const onKey = (e) => {
|
const onKey = (e) => {
|
||||||
@@ -198,26 +178,22 @@ export function PdfWorkspace({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Both controls in the viewer's own toolbar, beside the zoom — a second row
|
/* Beside the zoom rather than out on its own: both answer "how much of this
|
||||||
for two buttons would cost a strip of reading height on every screen. */
|
screen does the document get". */
|
||||||
const toolbarExtras = (
|
const fullToggle = (
|
||||||
<>
|
<button
|
||||||
<button
|
data-pdf-fullscreen={full ? "on" : "off"}
|
||||||
data-pdf-fullscreen={full ? "on" : "off"}
|
onClick={() => (full ? leaveFull() : enterFull())}
|
||||||
onClick={() => (full ? leaveFull() : enterFull())}
|
title={full ? "Leave full screen (Esc)" : "Full screen"}
|
||||||
title={full ? "Leave full screen (Esc)" : "Full screen"}
|
style={barButton(full)}
|
||||||
style={barButton(full)}
|
>
|
||||||
>
|
{full ? <ShrinkIcon /> : <ExpandIcon />}
|
||||||
{full ? <ShrinkIcon /> : <ExpandIcon />}
|
</button>
|
||||||
</button>
|
|
||||||
{notesToggle}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
|
||||||
data-pdf-workspace
|
data-pdf-workspace
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -235,7 +211,8 @@ export function PdfWorkspace({
|
|||||||
note={note}
|
note={note}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
notesToggle={toolbarExtras}
|
notesToggle={notesToggle}
|
||||||
|
zoomExtras={fullToggle}
|
||||||
focusPin={focusPin}
|
focusPin={focusPin}
|
||||||
onFocusHandled={() => setFocusPin(null)}
|
onFocusHandled={() => setFocusPin(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -217,6 +217,20 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
|
|
||||||
/* ── Notes ── */
|
/* ── 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) => {
|
const updateNote = useCallback((id: string, edit: NoteEdit) => {
|
||||||
if (edit.title !== undefined) {
|
if (edit.title !== undefined) {
|
||||||
const title = edit.title;
|
const title = edit.title;
|
||||||
@@ -228,15 +242,17 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
|||||||
if (edit.content !== undefined) {
|
if (edit.content !== undefined) {
|
||||||
const content = edit.content;
|
const content = edit.content;
|
||||||
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
|
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } }));
|
||||||
|
touch(id);
|
||||||
void sync.updateNoteContent(id, content, edit.links, edit.attachments);
|
void sync.updateNoteContent(id, content, edit.links, edit.attachments);
|
||||||
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
|
searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content);
|
||||||
}
|
}
|
||||||
if (edit.drawingStrokes !== undefined) {
|
if (edit.drawingStrokes !== undefined) {
|
||||||
const drawingStrokes = edit.drawingStrokes;
|
const drawingStrokes = edit.drawingStrokes;
|
||||||
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
|
setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } }));
|
||||||
|
touch(id);
|
||||||
void sync.updateNoteDrawing(id, drawingStrokes);
|
void sync.updateNoteDrawing(id, drawingStrokes);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [touch]);
|
||||||
|
|
||||||
/** Replaces a body wholesale — used when restoring an older version. */
|
/** Replaces a body wholesale — used when restoring an older version. */
|
||||||
const setNoteContent = useCallback((id: string, content: NoteContent) => {
|
const setNoteContent = useCallback((id: string, content: NoteContent) => {
|
||||||
|
|||||||
@@ -255,7 +255,9 @@ const api = {
|
|||||||
this.forgetToken();
|
this.forgetToken();
|
||||||
throw new SyncError("AUTH");
|
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();
|
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
|
the left edge. Centred, the same leftover space reads as two margins and needs no
|
||||||
explaining.
|
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
|
**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
|
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"
|
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
|
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
|
**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
|
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
|
nothing. Canvases are sized in device pixels and scaled down by CSS, which is
|
||||||
what keeps text sharp on a high-density screen.
|
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 means the whole window, not the whole screen.** The workspace is
|
||||||
full screen — which is what puts the document past the address bar and the window
|
laid out as an overlay over the app: the document takes the browser window, and
|
||||||
frame, and the reason to want it on a laptop or a tablet — *and* lays the
|
the sidebar, the note list and the title bar go. The browser's own full screen is
|
||||||
workspace out as an overlay over the app, which is what actually removes the
|
deliberately not asked for — it swallows the tabs and the address bar too, which
|
||||||
sidebar, the note list and the title bar. Either alone is half the job, and a
|
is more than was wanted; this is about giving the page to the document, not the
|
||||||
browser that refuses the first (an iPhone does) still gets the second. Escape
|
screen. Escape leaves, and so does the button, which sits beside the zoom while
|
||||||
leaves, and so does the button, which stays in the toolbar while full: a way out
|
full: both answer "how much room does the document get", and a way out that is
|
||||||
that is only known and not visible is not a way out. Leaving the browser's full
|
only known and not visible is not a way out.
|
||||||
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.
|
|
||||||
|
|
||||||
**Zooming holds something still.** A zoom changes how tall the document is, so
|
**Zooming holds something still.** A zoom changes how tall the document is, so
|
||||||
the scroll position means something different afterwards — left alone, every step
|
the scroll position means something different afterwards — left alone, every step
|
||||||
|
|||||||
Reference in new issue
Block a user