Write on paper: a sheet per page, with a gap between them
The writing surface was a column of text in an empty pane. A sheet was drawn behind it, but only once you started drawing — so writing looked like text floating in the middle of nothing, and the space either side of the column read as a void rather than as a margin. Now every page is a sheet of its own, centred, with a gap after it: the same way the PDF reader draws its pages, because a page in a note and a page in a document should read as the same kind of thing. The gap is the part that takes work. The browser knows nothing about the sheets and lays the text out as one flow, putting a line straight across the gap and cutting it in half. So editor/pagination.js measures the flow and pushes any block that would straddle a gap onto the next page, with a spacer of exactly the right height in front of it. A paragraph moves down entire, which is what a page break means on paper. A block taller than a page is left to run over, since splitting one would mean laying out its insides ourselves. The spacers are decorations, not content: they never enter the document, never reach the server and never appear in an export. Two checks hold that down — one asks whether any block sits in a gap, the other reads the stored body back and looks for them there. Two things that are easy to get wrong. The spacer's height is not the distance the text moves: margins that used to collapse into each other no longer do with something between them, so the layout gains that much for free and the spacer is made exactly that much shorter. And heights change with no transaction to announce it — a webfont landing, an image arriving, the window resizing — which a ResizeObserver on the editor element catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ce867c6d0
commit
97a3764d92
9 files changed
+492
-47
No files matched your search
@@ -8,6 +8,26 @@ not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Added**
|
||||
- **You write on paper.** Each page is a sheet of its own, centred, with a gap
|
||||
after it — the same way the PDF reader draws its pages. The sheet used to
|
||||
appear only once you started drawing, which left the writing mode looking like
|
||||
text floating in an empty pane, and made the space either side of the column
|
||||
read as a void rather than as a margin.
|
||||
- Text is broken against those sheets, so no paragraph is ever cut in half by a
|
||||
gap. The breaks are decorations: nothing enters the document, reaches the
|
||||
server, or appears in an export.
|
||||
|
||||
**Fixed**
|
||||
- **A note with a drawing could not be typed in at all.** The box wrapping each
|
||||
page's ink layer lay over the text and swallowed every click meant for it. The
|
||||
canvas inside was already deaf to the pointer; its wrapper was not. Introduced
|
||||
the same day, with per-page drawing.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-11 — Drawing on every page, and PDFs findable everywhere
|
||||
|
||||
**Images** — web `sha256:dc804f7b…`, server `sha256:0832ee2b…`
|
||||
|
||||
@@ -180,10 +180,43 @@ try {
|
||||
nearEnd
|
||||
);
|
||||
|
||||
/* ── the paper ──
|
||||
* Each page is a sheet of its own with a gap after it, the way the PDF reader
|
||||
* draws its pages. The gap is the part that has to be got right: the browser
|
||||
* knows nothing about our sheets and would lay a line of text straight across
|
||||
* it, so the text flow holds it open — see editor/pagination.js. */
|
||||
check(
|
||||
(await p.locator("[data-page-sheet]").count()) === Number((await position()).match(/of (\d+)/)[1]),
|
||||
"there is a sheet of paper for every page",
|
||||
`${await p.locator("[data-page-sheet]").count()} sheets, ${await position()}`
|
||||
);
|
||||
const straddling = await p.evaluate(() => {
|
||||
const pm = document.querySelector(".ProseMirror");
|
||||
const top = pm.getBoundingClientRect().top;
|
||||
const sheets = [...document.querySelectorAll("[data-page-sheet]")].map((el) => el.getBoundingClientRect());
|
||||
const gaps = sheets.slice(0, -1).map((r, i) => ({ from: r.bottom - top, to: sheets[i + 1].top - top }));
|
||||
const bad = [];
|
||||
for (const el of pm.children) {
|
||||
if (el.classList.contains("ne-page-spacer")) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.height === 0) continue;
|
||||
for (const g of gaps) {
|
||||
if (r.bottom - top > g.from + 0.5 && r.top - top < g.to - 0.5) bad.push((el.textContent || "").slice(0, 30));
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
});
|
||||
check(straddling.length === 0, "and no line of text is cut in half by a gap", straddling.join(" | "));
|
||||
check(
|
||||
(await p.locator(".ne-page-spacer").count()) > 0,
|
||||
"which takes holding the gap open inside the text itself"
|
||||
);
|
||||
|
||||
/* 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;
|
||||
check(!/"type"\s*:\s*"page"/.test(body), "no page ever reaches the stored document");
|
||||
check(!/page-spacer/.test(body), "and neither does anything holding a gap open");
|
||||
|
||||
/* ── alignment ── */
|
||||
// Left is the default and is never written into the document, so an untouched
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Throwaway: four pages, and what that stray placeholder is. */
|
||||
import { launch, withFreshUser, createNotebook, createNote, OUT } from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
const s = await withFreshUser(browser, "probe");
|
||||
const p = s.page;
|
||||
p.on("pageerror", (e) => console.log("PAGEERROR", String(e)));
|
||||
|
||||
await p.setViewportSize({ width: 1680, height: 950 });
|
||||
await createNotebook(p, "Papier");
|
||||
await createNote(p);
|
||||
await p.waitForTimeout(1500);
|
||||
await p.locator(".ProseMirror").click();
|
||||
// Paste rather than type: typing this much loses keystrokes and says nothing
|
||||
// about the layout, which is what is being looked at here.
|
||||
await p.evaluate(() => {
|
||||
const text = Array.from({ length: 160 }, (_, i) => `Absatz ${i + 1}: Dichtungen altern durch Wärme und Zeit.`).join("\n");
|
||||
const dt = new DataTransfer();
|
||||
dt.setData("text/plain", text);
|
||||
document.querySelector(".ProseMirror").dispatchEvent(
|
||||
new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
await p.waitForTimeout(4000);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
await p.evaluate(() => {
|
||||
const pm = document.querySelector(".ProseMirror");
|
||||
const pmTop = pm.getBoundingClientRect().top;
|
||||
const sheets = [...document.querySelectorAll("[data-page-sheet]")];
|
||||
const gaps = sheets.slice(0, -1).map((el, i) => ({
|
||||
from: el.getBoundingClientRect().bottom - pmTop,
|
||||
to: sheets[i + 1].getBoundingClientRect().top - pmTop,
|
||||
}));
|
||||
const inGap = [];
|
||||
for (const el of pm.children) {
|
||||
if (el.classList.contains("ne-page-spacer")) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.height === 0) continue;
|
||||
const top = r.top - pmTop,
|
||||
bottom = r.bottom - pmTop;
|
||||
for (const g of gaps) {
|
||||
if (bottom > g.from + 0.5 && top < g.to - 0.5) inGap.push((el.textContent || "").slice(0, 24));
|
||||
}
|
||||
}
|
||||
const placeholder = [...pm.querySelectorAll("[data-placeholder]")].map((el) => ({
|
||||
text: (el.textContent || "").slice(0, 20),
|
||||
first: el === pm.firstElementChild,
|
||||
top: Math.round(el.getBoundingClientRect().top - pmTop),
|
||||
}));
|
||||
return {
|
||||
blocks: [...pm.children].filter((c) => !c.classList.contains("ne-page-spacer")).length,
|
||||
height: pm.scrollHeight,
|
||||
sheets: sheets.length,
|
||||
spacers: [...document.querySelectorAll(".ne-page-spacer")].map((el) => el.offsetHeight),
|
||||
inGap,
|
||||
placeholder,
|
||||
position: document.querySelector("[data-note-position]")?.textContent,
|
||||
margins: (() => {
|
||||
const sheet = sheets[0].getBoundingClientRect();
|
||||
const col = pm.getBoundingClientRect();
|
||||
return {
|
||||
sheetW: Math.round(sheet.width),
|
||||
colW: Math.round(col.width),
|
||||
left: Math.round(col.left - sheet.left),
|
||||
right: Math.round(sheet.right - col.right),
|
||||
};
|
||||
})(),
|
||||
};
|
||||
}),
|
||||
null,
|
||||
1
|
||||
)
|
||||
);
|
||||
await p.screenshot({ path: `${OUT}/probe.png` });
|
||||
await s.cleanup();
|
||||
await browser.close();
|
||||
@@ -58,6 +58,17 @@
|
||||
.note-editor .ProseMirror > * + * {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
/* The gap between two sheets, held open inside the text flow so no paragraph is
|
||||
cut in half by it. Margins of its own would make the space it adds depend on
|
||||
its neighbours — see pagination.js, which computes the height on the
|
||||
assumption that it has none. */
|
||||
.note-editor .ProseMirror > .ne-page-spacer {
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.note-editor .ProseMirror p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,17 @@ import TableCell from "@tiptap/extension-table-cell";
|
||||
|
||||
import { NoteLink } from "./noteLink.js";
|
||||
import { TextAlign } from "./textAlign.js";
|
||||
import { Pagination } from "./pagination.js";
|
||||
import { AttachmentImage } from "./attachmentImage.js";
|
||||
|
||||
export function buildExtensions({ search = () => [], placeholder = "Start writing..." } = {}) {
|
||||
export function buildExtensions({
|
||||
search = () => [],
|
||||
placeholder = "Start writing...",
|
||||
// Where the page ends, read fresh on every measurement so a resize is enough
|
||||
// to re-break the text. Absent when there is no page to break against — the
|
||||
// static list used for parsing, for one.
|
||||
getGeometry = null,
|
||||
} = {}) {
|
||||
return [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
@@ -40,6 +48,7 @@ export function buildExtensions({ search = () => [], placeholder = "Start writin
|
||||
TableCell,
|
||||
NoteLink.configure({ search }),
|
||||
TextAlign.configure({ types: ["paragraph", "heading"] }),
|
||||
Pagination.configure({ getGeometry }),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
/**
|
||||
* Where one page ends and the next begins.
|
||||
*
|
||||
* The writing surface is drawn as separate sheets with a gap between them, the
|
||||
* way the PDF reader draws its pages. Text, however, is one continuous flow: the
|
||||
* browser knows nothing about our sheets and would happily lay a line of text
|
||||
* across the gap, cutting it in half.
|
||||
*
|
||||
* So the flow is measured and blocks that would straddle a gap are pushed past
|
||||
* it, by inserting a spacer of exactly the right height in front of them. The
|
||||
* spacers are **decorations, not content**: they never enter the document, never
|
||||
* reach the server, and never appear in an export. Markdown has no page, and a
|
||||
* note is still one body.
|
||||
*
|
||||
* The unit is a whole block. A paragraph that would be split across the gap
|
||||
* moves down entire, which is what a page break means on paper. A single block
|
||||
* taller than a page — a long table, a big image — is left where it is and runs
|
||||
* over the gap: splitting one would mean laying out its insides ourselves.
|
||||
*/
|
||||
|
||||
export const paginationKey = new PluginKey("pagination");
|
||||
|
||||
const SPACER_CLASS = "ne-page-spacer";
|
||||
|
||||
const px = (value) => {
|
||||
const n = parseFloat(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The spacer element.
|
||||
*
|
||||
* `delta` is how much the layout actually shifts, which is not the same as the
|
||||
* element's height — see `collapse` below. It is written onto the element
|
||||
* because the next measurement has to subtract it again to recover where the
|
||||
* text would sit without any spacers at all.
|
||||
*/
|
||||
function makeSpacer(height, delta) {
|
||||
const el = document.createElement("div");
|
||||
el.className = SPACER_CLASS;
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
el.contentEditable = "false";
|
||||
el.dataset.delta = String(delta);
|
||||
el.style.height = `${Math.max(0, Math.round(height))}px`;
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the current layout and works out where the breaks belong.
|
||||
*
|
||||
* Returns `null` when there is nothing to measure against — no geometry yet, or
|
||||
* a page too short to hold anything.
|
||||
*/
|
||||
function computeBreaks(view, geometry) {
|
||||
const pageHeight = geometry?.pageHeight ?? 0;
|
||||
const gap = geometry?.gap ?? 0;
|
||||
if (!(pageHeight > 0)) return null;
|
||||
const pitch = pageHeight + gap;
|
||||
|
||||
const dom = view.dom;
|
||||
if (!dom?.isConnected) return null;
|
||||
const style = getComputedStyle(dom);
|
||||
const padTop = px(style.paddingTop);
|
||||
const padBottom = px(style.paddingBottom);
|
||||
const usable = pageHeight - padTop - padBottom;
|
||||
if (usable <= 0) return null;
|
||||
|
||||
const domTop = dom.getBoundingClientRect().top;
|
||||
|
||||
// Top-level document positions, in the order they are rendered.
|
||||
const positions = [];
|
||||
view.state.doc.forEach((_node, offset) => positions.push(offset));
|
||||
|
||||
/* Each block's position **as if no spacer existed**. A spacer shifts
|
||||
* everything after it by exactly its recorded delta, so subtracting the
|
||||
* spacers seen so far undoes the last pass and leaves the natural layout —
|
||||
* which is what the simulation below has to start from. */
|
||||
const blocks = [];
|
||||
let seen = 0;
|
||||
let index = 0;
|
||||
for (const child of dom.children) {
|
||||
if (child.classList?.contains(SPACER_CLASS)) {
|
||||
seen += Number(child.dataset.delta) || 0;
|
||||
continue;
|
||||
}
|
||||
if (index >= positions.length) break;
|
||||
const rect = child.getBoundingClientRect();
|
||||
const cs = getComputedStyle(child);
|
||||
blocks.push({
|
||||
pos: positions[index],
|
||||
top: rect.top - domTop - seen,
|
||||
height: rect.height,
|
||||
marginTop: px(cs.marginTop),
|
||||
marginBottom: px(cs.marginBottom),
|
||||
});
|
||||
index++;
|
||||
}
|
||||
|
||||
const breaks = [];
|
||||
let shift = 0;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
const top = block.top + shift;
|
||||
// Taller than a page however it is placed — moving it down would only put
|
||||
// the same problem on the next page, one page further from the top.
|
||||
if (block.height > usable) continue;
|
||||
|
||||
const page = Math.floor(top / pitch);
|
||||
const pageEnd = page * pitch + pageHeight - padBottom;
|
||||
// Half a pixel of slack: a block that ends exactly on the line has not
|
||||
// crossed it, and rounding should not cost a page.
|
||||
if (top + block.height <= pageEnd + 0.5) continue;
|
||||
if (i === 0) continue; // nothing above it to be pushed past
|
||||
|
||||
const delta = (page + 1) * pitch + padTop - top;
|
||||
if (delta <= 0) continue;
|
||||
|
||||
/* Margins that used to collapse into each other no longer do, because the
|
||||
* spacer sits between them. The layout gains that much on top of the
|
||||
* spacer's own height, so the spacer is made exactly that much shorter. */
|
||||
const collapse = Math.min(blocks[i - 1].marginBottom, block.marginTop);
|
||||
breaks.push({ pos: block.pos, delta, height: Math.max(0, delta - collapse) });
|
||||
shift += delta;
|
||||
}
|
||||
return breaks;
|
||||
}
|
||||
|
||||
const sameBreaks = (a, b) =>
|
||||
!!a &&
|
||||
!!b &&
|
||||
a.length === b.length &&
|
||||
a.every((x, i) => x.pos === b[i].pos && Math.abs(x.height - b[i].height) < 1);
|
||||
|
||||
const decorationsFor = (doc, breaks) =>
|
||||
DecorationSet.create(
|
||||
doc,
|
||||
breaks.map((b) =>
|
||||
Decoration.widget(b.pos, () => makeSpacer(b.height, b.delta), {
|
||||
// Height in the key, so a spacer that has to change size is rebuilt
|
||||
// rather than reused at its old height.
|
||||
key: `page-break-${b.pos}-${Math.round(b.height)}`,
|
||||
side: -1,
|
||||
ignoreSelection: true,
|
||||
marks: [],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export function createPagination(getGeometry) {
|
||||
return new Plugin({
|
||||
key: paginationKey,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply(tr, old) {
|
||||
const next = tr.getMeta(paginationKey);
|
||||
if (next) return next;
|
||||
// Mapped rather than recomputed: typing moves the breaks with the text
|
||||
// until the measurement below catches up, so they do not jump about.
|
||||
return old.map(tr.mapping, tr.doc);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => paginationKey.getState(state),
|
||||
},
|
||||
view(view) {
|
||||
let last = null;
|
||||
let frame = 0;
|
||||
|
||||
const measure = () => {
|
||||
frame = 0;
|
||||
if (view.isDestroyed) return;
|
||||
const breaks = computeBreaks(view, getGeometry?.());
|
||||
if (!breaks || sameBreaks(last, breaks)) return;
|
||||
last = breaks;
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setMeta(paginationKey, decorationsFor(view.state.doc, breaks))
|
||||
// Not an edit: it must not be a step in the undo history, and it
|
||||
// must not mark the note dirty.
|
||||
.setMeta("addToHistory", false)
|
||||
);
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (frame) return;
|
||||
frame = requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
/* Heights change for reasons no transaction reports: a webfont finishing
|
||||
* loading, an image arriving, the window being resized. Watching the
|
||||
* element itself catches all three. */
|
||||
const observer =
|
||||
typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
|
||||
observer?.observe(view.dom);
|
||||
document.fonts?.ready?.then(schedule).catch(() => {});
|
||||
schedule();
|
||||
|
||||
return {
|
||||
update: schedule,
|
||||
destroy() {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The extension wrapper, so this can sit in the list with everything else. */
|
||||
export const Pagination = Extension.create({
|
||||
name: "pagination",
|
||||
addOptions() {
|
||||
return { getGeometry: null };
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [createPagination(this.options.getGeometry)];
|
||||
},
|
||||
});
|
||||
|
||||
export default Pagination;
|
||||
@@ -43,7 +43,15 @@ function insertDroppedImages(view, transfer, at, onErrorRef) {
|
||||
* The instance is returned rather than kept inside a component because the
|
||||
* toolbar lives elsewhere in the tree and needs the same editor to drive it.
|
||||
*/
|
||||
export function useNoteEditor({ noteId, content, editable = true, onChange, searchLinkTargets, onError }) {
|
||||
export function useNoteEditor({
|
||||
noteId,
|
||||
content,
|
||||
editable = true,
|
||||
onChange,
|
||||
searchLinkTargets,
|
||||
onError,
|
||||
pageGeometry,
|
||||
}) {
|
||||
// Held in refs so a changing callback identity never rebuilds the editor,
|
||||
// which would drop the cursor and the undo history mid-typing.
|
||||
const searchRef = useRef(searchLinkTargets);
|
||||
@@ -53,8 +61,17 @@ export function useNoteEditor({ noteId, content, editable = true, onChange, sear
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
// Read through a ref rather than captured: the page changes size with the
|
||||
// window, and rebuilding the editor to tell it so would drop the cursor.
|
||||
const geometryRef = useRef(pageGeometry);
|
||||
geometryRef.current = pageGeometry;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => buildExtensions({ search: (query) => (searchRef.current ? searchRef.current(query) : []) }),
|
||||
() =>
|
||||
buildExtensions({
|
||||
search: (query) => (searchRef.current ? searchRef.current(query) : []),
|
||||
getGeometry: () => geometryRef.current,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
@@ -84,14 +84,21 @@ export function NoteEditor({
|
||||
// new vector strokes so migrating a note never loses what was already drawn.
|
||||
const legacyRaster = note.drawingData && !hideDrawing;
|
||||
|
||||
/* Whether this note needs a page at all.
|
||||
/* The gap between two sheets.
|
||||
*
|
||||
* The fixed 3:4 stage exists for the canvas: a stroke has to land in the same
|
||||
* place on every screen, which needs one agreed page. Writing needs no such
|
||||
* thing, and paid for it with a bordered box and an empty page below two lines
|
||||
* of text. So the page appears exactly when something is on it — or about to
|
||||
* be — and plain text is simply text. */
|
||||
const needsPage = mode === "draw" || hasStrokes || !!legacyRaster;
|
||||
* 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;
|
||||
/* 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]
|
||||
);
|
||||
|
||||
const commitSheets = (next) => {
|
||||
sheetsRef.current = next;
|
||||
@@ -120,6 +127,7 @@ export function NoteEditor({
|
||||
content: note.content,
|
||||
editable: mode === "text",
|
||||
searchLinkTargets,
|
||||
pageGeometry,
|
||||
onChange: ({ content, links }) => onUpdate({ content, links }),
|
||||
onError: (msg) => {
|
||||
setEditorNotice(msg);
|
||||
@@ -227,21 +235,22 @@ export function NoteEditor({
|
||||
const surface = () => scrollerRef.current?.querySelector(".ProseMirror");
|
||||
const recount = () => {
|
||||
const el = surface();
|
||||
const pageH = stageSize.h;
|
||||
if (!el || !pageH) return;
|
||||
if (!el || !stageSize.h) return;
|
||||
const height = el.scrollHeight;
|
||||
const scroller = scrollerRef.current;
|
||||
/* Drawn pages count too. Ink put on page four of a two-page note is on the
|
||||
* note, and a count that ignored it would report two pages while showing
|
||||
* four — and the fourth would have no canvas to be redrawn on. */
|
||||
const written = Math.max(1, Math.ceil(height / pageH));
|
||||
/* Counted in whole pitches — a page and the gap after it — because that is
|
||||
* what the text is broken against. Drawn pages count too: ink put on page
|
||||
* 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 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 / pageH) + 1));
|
||||
const current = Math.max(1, Math.min(count, Math.floor(middle / pitch) + 1));
|
||||
setPages((p) => (p.count === count && p.current === current ? p : { count, current }));
|
||||
};
|
||||
|
||||
@@ -256,7 +265,7 @@ export function NoteEditor({
|
||||
scroller?.removeEventListener("scroll", recount);
|
||||
textEditor.off("transaction", recount);
|
||||
};
|
||||
}, [textEditor, stageSize.h, mode, drawnPages]);
|
||||
}, [textEditor, stageSize.h, pitch, mode, drawnPages]);
|
||||
|
||||
/* ── DRAWING: painting ──
|
||||
* Only the page under the pointer is repainted while a stroke is being drawn;
|
||||
@@ -776,38 +785,44 @@ export function NoteEditor({
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
// Without a page the column is as wide as it needs to be to read
|
||||
// comfortably, and centred; with one it is the canvas's page.
|
||||
// The text caps its own width in the stylesheet, so this is only
|
||||
// the page when there is one.
|
||||
width: needsPage ? stageSize.w : "100%",
|
||||
minHeight: needsPage ? canvasPages * stageSize.h : undefined,
|
||||
width: stageSize.w,
|
||||
maxWidth: "100%",
|
||||
// Every sheet plus the gaps between them, but not a gap trailing
|
||||
// off the last one.
|
||||
minHeight: canvasPages * pitch - PAGE_GAP,
|
||||
margin: "0 auto",
|
||||
background: needsPage ? T.bg : "transparent",
|
||||
border: needsPage && !isMobileViewport ? `1px solid ${T.border}` : "none",
|
||||
borderRadius: needsPage && !isMobileViewport ? 14 : 0,
|
||||
boxShadow: needsPage && !isMobileViewport ? "0 16px 36px rgba(0,0,0,0.14)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* A hairline where each page ends, so the count in the toolbar
|
||||
corresponds to something you can see. Drawn behind the text and
|
||||
deaf to the pointer — it marks the paper, it is not part of it. */}
|
||||
{canvasPages > 1 && (
|
||||
{/* The paper.
|
||||
One sheet per page with a gap between them, the way the PDF
|
||||
reader draws its pages — and the reason the writing sits on
|
||||
something instead of floating in the middle of an empty pane.
|
||||
Behind the text and deaf to the pointer: it is what is written
|
||||
on, not part of what is written. */}
|
||||
{Array.from({ length: canvasPages }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
aria-hidden
|
||||
data-page-sheet={i + 1}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: i * pitch,
|
||||
height: stageSize.h,
|
||||
pointerEvents: "none",
|
||||
backgroundImage: `linear-gradient(to bottom, transparent calc(100% - 1px), ${T.border} 100%)`,
|
||||
backgroundSize: `100% ${stageSize.h}px`,
|
||||
opacity: 0.55,
|
||||
background: T.bg,
|
||||
border: isMobileViewport ? "none" : `1px solid ${T.border}`,
|
||||
borderRadius: isMobileViewport ? 0 : 4,
|
||||
boxShadow: isMobileViewport ? "none" : "0 2px 10px rgba(0,0,0,0.22)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
minHeight: needsPage ? stageSize.h : undefined,
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
minHeight: stageSize.h,
|
||||
width: "100%",
|
||||
pointerEvents: mode === "text" ? "auto" : "none",
|
||||
}}
|
||||
@@ -816,7 +831,7 @@ export function NoteEditor({
|
||||
editor={textEditor}
|
||||
dark={dark}
|
||||
font={noteFont}
|
||||
minHeight={needsPage ? stageSize.h : 0}
|
||||
minHeight={stageSize.h}
|
||||
onOpenNote={onOpenNote}
|
||||
resolveLinkLabel={resolveLinkLabel}
|
||||
/>
|
||||
@@ -853,7 +868,7 @@ export function NoteEditor({
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: (page - 1) * stageSize.h,
|
||||
top: (page - 1) * pitch,
|
||||
height: stageSize.h,
|
||||
/* Deaf to the pointer, or this box lies over the writing and
|
||||
swallows every click meant for the text — which is exactly
|
||||
@@ -887,7 +902,7 @@ export function NoteEditor({
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: (page - 1) * stageSize.h,
|
||||
top: (page - 1) * pitch,
|
||||
width: "100%",
|
||||
height: stageSize.h,
|
||||
touchAction: "none",
|
||||
|
||||
@@ -391,13 +391,51 @@ own notes are set should follow you to the phone rather than be made again on
|
||||
every device. The server accepts those three values and drops anything else —
|
||||
that column is written by a browser, so it is an input like any other.
|
||||
|
||||
**You write on paper.** Each page is a sheet of its own, centred, with a gap
|
||||
after it — the same way the PDF reader draws its pages, because a page in a note
|
||||
and a page in a document should read as the same kind of thing. It also answers
|
||||
what a bare column of text could not: the space either side of the writing is the
|
||||
sheet's margin, rather than an unexplained void.
|
||||
|
||||
The sheet used to appear only once you started drawing, which made the writing
|
||||
mode look like text floating in an empty pane.
|
||||
|
||||
**Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same
|
||||
words the PDF reader uses, and a dashed rule marks where each one ends. A page is
|
||||
the same 3:4 the canvas uses, so "page 2" means the same thing writing or
|
||||
drawing. The note itself stays one continuous document — Markdown has no page,
|
||||
and a test asserts none ever reaches the stored body. The current page is the one
|
||||
under the *middle* of the window: measuring from the top said "page 1" while you
|
||||
stood at the foot of a two-page note, because a window is shorter than a page.
|
||||
words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2"
|
||||
means the same thing writing or drawing. The note itself stays one continuous
|
||||
document — Markdown has no page, and a test asserts none ever reaches the stored
|
||||
body. The current page is the one under the *middle* of the window: measuring
|
||||
from the top said "page 1" while you stood at the foot of a two-page note,
|
||||
because a window is shorter than a page.
|
||||
|
||||
### Holding the gap open
|
||||
|
||||
The browser knows nothing about the sheets. Left alone it lays the text out as
|
||||
one continuous flow and puts a line straight across the gap, cut in half by it.
|
||||
|
||||
So [`editor/pagination.js`](frontend/src/editor/pagination.js) measures the flow
|
||||
and pushes any block that would straddle a gap onto the next page, by inserting a
|
||||
spacer of exactly the right height in front of it. The spacers are **decorations,
|
||||
not content**: they never enter the document, never reach the server and never
|
||||
appear in an export. Two tests hold that down — one asks whether any block sits
|
||||
in a gap, the other reads the stored body back and looks for them.
|
||||
|
||||
The unit is a whole block. A paragraph that would be split moves down entire,
|
||||
which is what a page break means on paper. A single block taller than a page — a
|
||||
long table, a tall image — is left where it is and runs over the gap: splitting
|
||||
one would mean laying out its insides ourselves.
|
||||
|
||||
Two details that are easy to get wrong and were:
|
||||
|
||||
- **The spacer's height is not the distance the text moves.** Margins that used
|
||||
to collapse into each other no longer do once something sits between them, so
|
||||
the layout gains `min(marginBottom, marginTop)` for free and the spacer is made
|
||||
exactly that much shorter. The spacer carries the intended shift in a data
|
||||
attribute, because the next measurement has to subtract it again to recover
|
||||
where the text would sit with no spacers at all.
|
||||
- **Heights change without a transaction to announce it** — a webfont finishing
|
||||
loading, an image arriving, the window resizing. A `ResizeObserver` on the
|
||||
editor element catches all three.
|
||||
|
||||
Each page can be drawn on — see [One drawing, as many pages as the
|
||||
note](#one-drawing-as-many-pages-as-the-note).
|
||||
|
||||
Reference in new issue
Block a user