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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user