// fixtures/pages.mjs — paged and infinite. // // Paul asked for "multiple pages, and the ability to switch between // paged and infinite mode". This is the paged half, and the whole of it // lives in the browser. // // NOTHING ABOUT PAGINATION IS PERSISTED, AND NOTHING IS SHARED. Page // breaks are a view computed from measured block heights, not content. // That follows from concurrency rather than from taste: a persisted // break would be an edit, it would merge, and two people typing in the // same document would fight over where page three starts. It also // follows from the document model — the CRDT holds a flat sequence of // blocks, and a page is a fact about a piece of paper, not about the // sequence. // // So two people reading the same document at different window widths, // zoom levels, or with different fonts loaded see different page // breaks, and that is correct rather than a bug to fix. // // WHAT THIS DOES NOT CLAIM. views/show.html used to assert that the // screen and the PDF paginate identically because both measure with the // same font programs. That is true of LINE breaking and it is not true // of PAGE breaking: internal/export/pdf.go breaks between lines, and // this breaks between blocks, so any page whose last paragraph straddles // the boundary comes out differently. The claim was corrected in the // same commit that added this file rather than left standing and wrong. // Screen page numbers are an orientation aid; the PDF's are the ones to // quote to somebody. // // A BLOCK TALLER THAN A PAGE overflows its gap rather than being split. // Splitting inside a paragraph is line-level layout — the layout engine's // job, which this app does not have yet — and faking it here would put a // break in a place no exporter agrees with. Stated rather than hidden. import { Plugin, PluginKey } from 'prosemirror-state' import { Decoration, DecorationSet } from 'prosemirror-view' export const pagesKey = new PluginKey('docs-pages') // The printable height of one page, in CSS pixels. // // US Letter is 792pt tall with a 72pt margin top and bottom // (internal/export/pdf.go's PageHeightPt and MarginPt), leaving 648pt — // and CSS pixels are 96 to the inch against PostScript points' 72, so // 648 * 96 / 72. views/show.html's [docs-page] states the same geometry // in inches and TestPageGeometryMatchesTheStylesheet holds it to the // exporter, so this is the one number here that is not free to drift. export const PAGE_CONTENT_PX = 864 // The two modes. Values, not booleans, because they are what the View // menu's two commands are named after and what the cookie carries — and // because a boolean called "paged" reads badly at every call site that // has to say what false means. export const PAGED = 'paged' export const INFINITE = 'infinite' // COOKIE, not localStorage. // // The server reads it (internal/docs/handlers.go) and renders // [docs-canvas]'s data-view attribute accordingly, so a person who // prefers paged gets paged in the first paint rather than watching the // document reflow when this deferred bundle finally runs. localStorage // is invisible to the server and would guarantee that flash. // // SameSite=Lax and a year: it is a display preference, it is not // sensitive, and it should survive. const COOKIE = 'docs-view' export function readMode() { const m = document.cookie.match(/(?:^|;\s*)docs-view=(paged|infinite)/) return m ? m[1] : INFINITE } export function writeMode(mode) { document.cookie = `${COOKIE}=${mode}; Path=/; Max-Age=31536000; SameSite=Lax` } // breakPositions measures the rendered document and returns the // document positions at which a new page should start. // // Measured from the DOM rather than from the model, because the // question is how tall things actually are — which depends on the // fonts that loaded, the window's width, and the reader's zoom, none of // which the model knows. // // Top-level blocks only. A break inside a list or a quote would put a // page boundary between a list item and its own siblings, which no word // processor does and which the exporter would not agree with anyway. function breakPositions(view) { const out = [] const doc = view.state.doc // The top of the current page, in viewport coordinates. Everything // below is measured against it. let pageTop = null doc.forEach((node, offset) => { const dom = view.nodeDOM(offset) if (!dom || !dom.getBoundingClientRect) return const rect = dom.getBoundingClientRect() if (rect.height === 0) return if (pageTop === null) { pageTop = rect.top return } // MEASURED FROM THE PAGE'S TOP, not by summing heights. // // This summed getBoundingClientRect().height for one review cycle, // and that silently ignores margins: a paragraph carries 1em below // it, a heading 1.5em above, and none of that is in the height. On // a page of ordinary prose the sum ran roughly 15% short, so every // page took about three paragraphs more than fits and the breaks // drifted further down the document with each one. // // rect.bottom - pageTop is the distance actually occupied, // collapsed margins and all, because the browser has already done // that arithmetic to lay the blocks out. It is also simpler. if (rect.bottom - pageTop > PAGE_CONTENT_PX) { out.push(offset) pageTop = rect.top } }) return out } // pagesPlugin draws the gaps. // // The measuring happens in the plugin's view (after the DOM is up to // date), and the result reaches the decorations through a transaction // carrying meta — the standard shape for a decoration that depends on // layout. The guard against an infinite loop is that a transaction is // only dispatched when the set of positions actually CHANGES, compared // as a string; without it, every measurement would schedule another. export function pagesPlugin({ getMode, pageLabel }) { return new Plugin({ key: pagesKey, state: { init: () => ({ positions: [], deco: DecorationSet.empty }), apply(tr, value, _old, state) { const next = tr.getMeta(pagesKey) if (!next) { // Not a measurement — just keep the decorations pointing at // the right places through whatever edit this was. return { positions: value.positions, deco: value.deco.map(tr.mapping, tr.doc) } } const decos = next.positions.map((pos, i) => Decoration.widget(pos, () => gapElement(i + 2, pageLabel), { // side: -1 so the gap sits BEFORE the block at this // position rather than after the one before it — the // difference shows when a person types at the very start of // the first block on a page. side: -1, // The gap is furniture, not content: it must never be // treated as part of the document for selection or copying. ignoreSelection: true, }), ) return { positions: next.positions, deco: DecorationSet.create(state.doc, decos) } }, }, props: { decorations(state) { if (getMode() !== PAGED) return DecorationSet.empty return pagesKey.getState(state).deco }, }, view(view) { let frame = 0 let last = '' const measure = () => { frame = 0 if (getMode() !== PAGED) { if (last !== '') { last = '' view.dispatch(view.state.tr.setMeta(pagesKey, { positions: [] })) } return } const positions = breakPositions(view) const key = positions.join(',') if (key === last) return last = key view.dispatch(view.state.tr.setMeta(pagesKey, { positions })) } // Coalesced to one frame: an update can arrive per keystroke, and // measuring every block's box on each of them is the kind of // thing that makes an editor feel heavy. const schedule = () => { if (frame) return frame = requestAnimationFrame(measure) } // Re-measure when the box changes for reasons no transaction // reports: the window resizing, and — the one that actually // catches people out — a web font finishing loading and every // line reflowing. const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null if (observer) observer.observe(view.dom) schedule() return { update: schedule, destroy() { if (frame) cancelAnimationFrame(frame) if (observer) observer.disconnect() }, } }, }) } // gapElement is the desk showing between two sheets, with the number of // the page that starts below it. // // contenteditable="false" so ProseMirror does not treat it as text, and // aria-hidden because the number is orientation for the eye: a screen // reader user moving through the document by structure is not helped by // "page 3" announced between two paragraphs, and the count is not a // fact about the document anyway (see this module's head comment). function gapElement(number, pageLabel) { const el = document.createElement('div') el.setAttribute('docs-page-gap', '') el.setAttribute('contenteditable', 'false') el.setAttribute('aria-hidden', 'true') const label = document.createElement('span') label.setAttribute('docs-page-number', '') label.textContent = pageLabel ? pageLabel.replace('{n}', String(number)) : String(number) el.appendChild(label) return el }