oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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
35import { Plugin, PluginKey } from 'prosemirror-state'
36import { Decoration, DecorationSet } from 'prosemirror-view'
37
38export 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.
48export 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.
54export const PAGED = 'paged'
55export 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.
67const COOKIE = 'docs-view'
68
69export function readMode() {
70 const m = document.cookie.match(/(?:^|;\s*)docs-view=(paged|infinite)/)
71 return m ? m[1] : INFINITE
72}
73
74export 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.
89function 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.
136export 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).
228function 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