diff --git a/CHANGELOG.md b/CHANGELOG.md index 22aa6f7..7071ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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…` diff --git a/e2e/editor.mjs b/e2e/editor.mjs index 4bc1029..aca1e36 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -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 diff --git a/e2e/probe.mjs b/e2e/probe.mjs new file mode 100644 index 0000000..8499b4a --- /dev/null +++ b/e2e/probe.mjs @@ -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(); diff --git a/frontend/src/editor/editor.css b/frontend/src/editor/editor.css index 48842c2..c35605c 100644 --- a/frontend/src/editor/editor.css +++ b/frontend/src/editor/editor.css @@ -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; } diff --git a/frontend/src/editor/extensions.js b/frontend/src/editor/extensions.js index fc22e20..f4b1a83 100644 --- a/frontend/src/editor/extensions.js +++ b/frontend/src/editor/extensions.js @@ -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 }), ]; } diff --git a/frontend/src/editor/pagination.js b/frontend/src/editor/pagination.js new file mode 100644 index 0000000..4381a09 --- /dev/null +++ b/frontend/src/editor/pagination.js @@ -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; diff --git a/frontend/src/editor/useNoteEditor.jsx b/frontend/src/editor/useNoteEditor.jsx index 03cedb0..07c9b04 100644 --- a/frontend/src/editor/useNoteEditor.jsx +++ b/frontend/src/editor/useNoteEditor.jsx @@ -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, + }), [] ); diff --git a/frontend/src/features/notes/NoteEditor.jsx b/frontend/src/features/notes/NoteEditor.jsx index 18557aa..1437619 100644 --- a/frontend/src/features/notes/NoteEditor.jsx +++ b/frontend/src/features/notes/NoteEditor.jsx @@ -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({