| 1 | // fixtures/pages.mjs — paged and infinite. |
| 2 | // |
| 3 | // Paul asked for "multiple pages, and the ability to switch between |
| 4 | // paged and infinite mode". This is the paged half, and the whole of it |
| 5 | // lives in the browser. |
| 6 | // |
| 7 | // NOTHING ABOUT PAGINATION IS PERSISTED, AND NOTHING IS SHARED. Page |
| 8 | // breaks are a view computed from measured block heights, not content. |
| 9 | // That follows from concurrency rather than from taste: a persisted |
| 10 | // break would be an edit, it would merge, and two people typing in the |
| 11 | // same document would fight over where page three starts. It also |
| 12 | // follows from the document model — the CRDT holds a flat sequence of |
| 13 | // blocks, and a page is a fact about a piece of paper, not about the |
| 14 | // sequence. |
| 15 | // |
| 16 | // So two people reading the same document at different window widths, |
| 17 | // zoom levels, or with different fonts loaded see different page |
| 18 | // breaks, and that is correct rather than a bug to fix. |
| 19 | // |
| 20 | // WHAT THIS DOES NOT CLAIM. views/show.html used to assert that the |
| 21 | // screen and the PDF paginate identically because both measure with the |
| 22 | // same font programs. That is true of LINE breaking and it is not true |
| 23 | // of PAGE breaking: internal/export/pdf.go breaks between lines, and |
| 24 | // this breaks between blocks, so any page whose last paragraph straddles |
| 25 | // the boundary comes out differently. The claim was corrected in the |
| 26 | // same commit that added this file rather than left standing and wrong. |
| 27 | // Screen page numbers are an orientation aid; the PDF's are the ones to |
| 28 | // quote to somebody. |
| 29 | // |
| 30 | // A BLOCK TALLER THAN A PAGE overflows its gap rather than being split. |
| 31 | // Splitting inside a paragraph is line-level layout — the layout engine's |
| 32 | // job, which this app does not have yet — and faking it here would put a |
| 33 | // break in a place no exporter agrees with. Stated rather than hidden. |
| 34 | |
| 35 | import { Plugin, PluginKey } from 'prosemirror-state' |
| 36 | import { Decoration, DecorationSet } from 'prosemirror-view' |
| 37 | |
| 38 | export const pagesKey = new PluginKey('docs-pages') |
| 39 | |
| 40 | // The printable height of one page, in CSS pixels. |
| 41 | // |
| 42 | // US Letter is 792pt tall with a 72pt margin top and bottom |
| 43 | // (internal/export/pdf.go's PageHeightPt and MarginPt), leaving 648pt — |
| 44 | // and CSS pixels are 96 to the inch against PostScript points' 72, so |
| 45 | // 648 * 96 / 72. views/show.html's [docs-page] states the same geometry |
| 46 | // in inches and TestPageGeometryMatchesTheStylesheet holds it to the |
| 47 | // exporter, so this is the one number here that is not free to drift. |
| 48 | export const PAGE_CONTENT_PX = 864 |
| 49 | |
| 50 | // The two modes. Values, not booleans, because they are what the View |
| 51 | // menu's two commands are named after and what the cookie carries — and |
| 52 | // because a boolean called "paged" reads badly at every call site that |
| 53 | // has to say what false means. |
| 54 | export const PAGED = 'paged' |
| 55 | export const INFINITE = 'infinite' |
| 56 | |
| 57 | // COOKIE, not localStorage. |
| 58 | // |
| 59 | // The server reads it (internal/docs/handlers.go) and renders |
| 60 | // [docs-canvas]'s data-view attribute accordingly, so a person who |
| 61 | // prefers paged gets paged in the first paint rather than watching the |
| 62 | // document reflow when this deferred bundle finally runs. localStorage |
| 63 | // is invisible to the server and would guarantee that flash. |
| 64 | // |
| 65 | // SameSite=Lax and a year: it is a display preference, it is not |
| 66 | // sensitive, and it should survive. |
| 67 | const COOKIE = 'docs-view' |
| 68 | |
| 69 | export function readMode() { |
| 70 | const m = document.cookie.match(/(?:^|;\s*)docs-view=(paged|infinite)/) |
| 71 | return m ? m[1] : INFINITE |
| 72 | } |
| 73 | |
| 74 | export function writeMode(mode) { |
| 75 | document.cookie = `${COOKIE}=${mode}; Path=/; Max-Age=31536000; SameSite=Lax` |
| 76 | } |
| 77 | |
| 78 | // breakPositions measures the rendered document and returns the |
| 79 | // document positions at which a new page should start. |
| 80 | // |
| 81 | // Measured from the DOM rather than from the model, because the |
| 82 | // question is how tall things actually are — which depends on the |
| 83 | // fonts that loaded, the window's width, and the reader's zoom, none of |
| 84 | // which the model knows. |
| 85 | // |
| 86 | // Top-level blocks only. A break inside a list or a quote would put a |
| 87 | // page boundary between a list item and its own siblings, which no word |
| 88 | // processor does and which the exporter would not agree with anyway. |
| 89 | function breakPositions(view) { |
| 90 | const out = [] |
| 91 | const doc = view.state.doc |
| 92 | // The top of the current page, in viewport coordinates. Everything |
| 93 | // below is measured against it. |
| 94 | let pageTop = null |
| 95 | |
| 96 | doc.forEach((node, offset) => { |
| 97 | const dom = view.nodeDOM(offset) |
| 98 | if (!dom || !dom.getBoundingClientRect) return |
| 99 | const rect = dom.getBoundingClientRect() |
| 100 | if (rect.height === 0) return |
| 101 | |
| 102 | if (pageTop === null) { |
| 103 | pageTop = rect.top |
| 104 | return |
| 105 | } |
| 106 | |
| 107 | // MEASURED FROM THE PAGE'S TOP, not by summing heights. |
| 108 | // |
| 109 | // This summed getBoundingClientRect().height for one review cycle, |
| 110 | // and that silently ignores margins: a paragraph carries 1em below |
| 111 | // it, a heading 1.5em above, and none of that is in the height. On |
| 112 | // a page of ordinary prose the sum ran roughly 15% short, so every |
| 113 | // page took about three paragraphs more than fits and the breaks |
| 114 | // drifted further down the document with each one. |
| 115 | // |
| 116 | // rect.bottom - pageTop is the distance actually occupied, |
| 117 | // collapsed margins and all, because the browser has already done |
| 118 | // that arithmetic to lay the blocks out. It is also simpler. |
| 119 | if (rect.bottom - pageTop > PAGE_CONTENT_PX) { |
| 120 | out.push(offset) |
| 121 | pageTop = rect.top |
| 122 | } |
| 123 | }) |
| 124 | |
| 125 | return out |
| 126 | } |
| 127 | |
| 128 | // pagesPlugin draws the gaps. |
| 129 | // |
| 130 | // The measuring happens in the plugin's view (after the DOM is up to |
| 131 | // date), and the result reaches the decorations through a transaction |
| 132 | // carrying meta — the standard shape for a decoration that depends on |
| 133 | // layout. The guard against an infinite loop is that a transaction is |
| 134 | // only dispatched when the set of positions actually CHANGES, compared |
| 135 | // as a string; without it, every measurement would schedule another. |
| 136 | export function pagesPlugin({ getMode, pageLabel }) { |
| 137 | return new Plugin({ |
| 138 | key: pagesKey, |
| 139 | |
| 140 | state: { |
| 141 | init: () => ({ positions: [], deco: DecorationSet.empty }), |
| 142 | apply(tr, value, _old, state) { |
| 143 | const next = tr.getMeta(pagesKey) |
| 144 | if (!next) { |
| 145 | // Not a measurement — just keep the decorations pointing at |
| 146 | // the right places through whatever edit this was. |
| 147 | return { positions: value.positions, deco: value.deco.map(tr.mapping, tr.doc) } |
| 148 | } |
| 149 | const decos = next.positions.map((pos, i) => |
| 150 | Decoration.widget(pos, () => gapElement(i + 2, pageLabel), { |
| 151 | // side: -1 so the gap sits BEFORE the block at this |
| 152 | // position rather than after the one before it — the |
| 153 | // difference shows when a person types at the very start of |
| 154 | // the first block on a page. |
| 155 | side: -1, |
| 156 | // The gap is furniture, not content: it must never be |
| 157 | // treated as part of the document for selection or copying. |
| 158 | ignoreSelection: true, |
| 159 | }), |
| 160 | ) |
| 161 | return { positions: next.positions, deco: DecorationSet.create(state.doc, decos) } |
| 162 | }, |
| 163 | }, |
| 164 | |
| 165 | props: { |
| 166 | decorations(state) { |
| 167 | if (getMode() !== PAGED) return DecorationSet.empty |
| 168 | return pagesKey.getState(state).deco |
| 169 | }, |
| 170 | }, |
| 171 | |
| 172 | view(view) { |
| 173 | let frame = 0 |
| 174 | let last = '' |
| 175 | |
| 176 | const measure = () => { |
| 177 | frame = 0 |
| 178 | if (getMode() !== PAGED) { |
| 179 | if (last !== '') { |
| 180 | last = '' |
| 181 | view.dispatch(view.state.tr.setMeta(pagesKey, { positions: [] })) |
| 182 | } |
| 183 | return |
| 184 | } |
| 185 | const positions = breakPositions(view) |
| 186 | const key = positions.join(',') |
| 187 | if (key === last) return |
| 188 | last = key |
| 189 | view.dispatch(view.state.tr.setMeta(pagesKey, { positions })) |
| 190 | } |
| 191 | |
| 192 | // Coalesced to one frame: an update can arrive per keystroke, and |
| 193 | // measuring every block's box on each of them is the kind of |
| 194 | // thing that makes an editor feel heavy. |
| 195 | const schedule = () => { |
| 196 | if (frame) return |
| 197 | frame = requestAnimationFrame(measure) |
| 198 | } |
| 199 | |
| 200 | // Re-measure when the box changes for reasons no transaction |
| 201 | // reports: the window resizing, and — the one that actually |
| 202 | // catches people out — a web font finishing loading and every |
| 203 | // line reflowing. |
| 204 | const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null |
| 205 | if (observer) observer.observe(view.dom) |
| 206 | |
| 207 | schedule() |
| 208 | |
| 209 | return { |
| 210 | update: schedule, |
| 211 | destroy() { |
| 212 | if (frame) cancelAnimationFrame(frame) |
| 213 | if (observer) observer.disconnect() |
| 214 | }, |
| 215 | } |
| 216 | }, |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | // gapElement is the desk showing between two sheets, with the number of |
| 221 | // the page that starts below it. |
| 222 | // |
| 223 | // contenteditable="false" so ProseMirror does not treat it as text, and |
| 224 | // aria-hidden because the number is orientation for the eye: a screen |
| 225 | // reader user moving through the document by structure is not helped by |
| 226 | // "page 3" announced between two paragraphs, and the count is not a |
| 227 | // fact about the document anyway (see this module's head comment). |
| 228 | function gapElement(number, pageLabel) { |
| 229 | const el = document.createElement('div') |
| 230 | el.setAttribute('docs-page-gap', '') |
| 231 | el.setAttribute('contenteditable', 'false') |
| 232 | el.setAttribute('aria-hidden', 'true') |
| 233 | const label = document.createElement('span') |
| 234 | label.setAttribute('docs-page-number', '') |
| 235 | label.textContent = pageLabel ? pageLabel.replace('{n}', String(number)) : String(number) |
| 236 | el.appendChild(label) |
| 237 | return el |
| 238 | } |
| 239 | |