oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/stage.mjs — the stage: the server's HTML for the current
2// slide, with a ProseMirror view bound to every text block in it
3// (spec §3.1; plan: "the browser never becomes a second renderer").
4//
5// The stage fetches a slide's section from the server, puts it in the
6// DOM as-is, and then mounts an EditorView INTO each text-bearing
7// block element — the block's own element (or the blockquote / code
8// inside it) becomes the editable node, so what a person sees is the
9// renderer's block chrome with a live text inside it. The view's
10// paragraphs are drawn with the tag the renderer would have used (p,
11// li, or a plain div for a heading's lines) so the block's stylesheet
12// rules keep applying. A table's cells are bound the same way, one
13// view per cell over the row-major cells array. Everything else on the
14// slide — images, shapes, the layout, the theme — is the server's and
15// stays inert. A shape's label is an SVG <title>, which cannot host a
16// caret; the inspector edits it.
17//
18// A structural change (a block added, moved, its props changed) is
19// not something this file redraws: refresh() fetches the section
20// again and re-binds. Text changes, local or remote, flow through
21// y-prosemirror and never touch the section.
22//
23// The stage renders one viewport at a time (desktop, or the phone
24// rendering at ?vp=phone for the switcher, spec §3.2), and a
25// read-only stage — the "both" view's phone preview — fetches and
26// shows the section with nothing bound.
27//
28// The pure parts — which elements bind, where in them the view mounts,
29// and how a block on the stage is found in the Y.Doc — take a DOM
30// element only for its attributes, so stage.test.mjs drives them with
31// a stub.
32import * as Y from 'yjs'
33import { EditorState } from 'prosemirror-state'
34import { EditorView } from 'prosemirror-view'
35import { keymap } from 'prosemirror-keymap'
36import { baseKeymap } from 'prosemirror-commands'
37import { ySyncPlugin } from 'y-prosemirror'
38import { schema, readText } from './schema.mjs'
39import { roots, findById, getArray, getFragment, getString, orderedMaps } from './ydoc.mjs'
40import { sanitiseFragment } from './check.mjs'
41
42/** The block types this stage binds a text editor to. */
43export const BOUND_TYPES = ['heading', 'text', 'list', 'quote', 'code']
44
45/** The text-bearing blocks under root, in document order: [{el, id, type}]. */
46export function textBlocks (root) {
47 const out = []
48 for (const el of root.querySelectorAll('[slides-block][data-id]')) {
49 const id = el.getAttribute('data-id')
50 const type = el.getAttribute('data-block')
51 if (id && BOUND_TYPES.includes(type)) out.push({ el, id, type })
52 }
53 return out
54}
55
56/** The table blocks under root: [{el, id}], each bound cell by cell. */
57export function tableBlocks (root) {
58 const out = []
59 for (const el of root.querySelectorAll('[slides-block][data-block="table"][data-id]')) {
60 const id = el.getAttribute('data-id')
61 if (id) out.push({ el, id })
62 }
63 return out
64}
65
66/**
67 * The element the view mounts into: the block itself, except where the
68 * renderer wraps the paragraphs in an inner element that must stay —
69 * a quote's blockquote (its figcaption is the citation, not text) and
70 * a code block's <code>.
71 */
72export function mountTarget (el, type) {
73 if (type === 'quote') return el.querySelector('blockquote') ?? el
74 if (type === 'code') return el.querySelector('code') ?? el
75 return el
76}
77
78/** The tag a paragraph is drawn with, per block type — the renderer's own. */
79export function paragraphTag (type) {
80 if (type === 'list') return 'li'
81 if (type === 'heading' || type === 'code' || type === 'cell') return 'div'
82 return 'p'
83}
84
85function slideMap (ydoc, slideId) {
86 const found = findById(roots(ydoc).slides, slideId)
87 return found ? found[1] : null
88}
89
90function blockMap (ydoc, slideId, blockId) {
91 const s = slideMap(ydoc, slideId)
92 if (!s) return null
93 const blocks = getArray(s, 'blocks')
94 if (!blocks) return null
95 const found = findById(blocks, blockId)
96 return found ? found[1] : null
97}
98
99/** The block's text fragment, or null when there is no such block or it carries no text. */
100export function blockFragment (ydoc, slideId, blockId) {
101 const m = blockMap(ydoc, slideId, blockId)
102 return m ? getFragment(m, 'text') : null
103}
104
105/** A table block's cell fragments, row-major, or [] when it has none. */
106export function cellFragments (ydoc, slideId, blockId) {
107 const m = blockMap(ydoc, slideId, blockId)
108 const cells = m && getArray(m, 'cells')
109 if (!cells) return []
110 return cells.toArray().filter(c => c instanceof Y.XmlFragment && !(c instanceof Y.XmlElement))
111}
112
113/** The slide's notes fragment, or null when there is no such slide. */
114export function notesFragment (ydoc, slideId) {
115 const s = slideMap(ydoc, slideId)
116 return s ? getFragment(s, 'notes') : null
117}
118
119/** The slide's block ids in reading order — what a refresh compares against and what move_block's `after` is computed from. */
120export function blockIDs (ydoc, slideId) {
121 const s = slideMap(ydoc, slideId)
122 const blocks = s && getArray(s, 'blocks')
123 if (!blocks) return []
124 return orderedMaps(blocks).map(([, m]) => getString(m, 'id')).filter(id => id)
125}
126
127/**
128 * The words of a slide's first heading, or '' — what the strip labels
129 * an item with (slideLabel's "Slide {n}" is the fallback).
130 *
131 * Read from the Y.Doc rather than from the thumbnail, which is what
132 * this used to do: a thumbnail is an <img> of an SVG now, and an image
133 * has no text a script can reach. The document is the better source
134 * anyway — the label changes as the heading is typed, not a fetch
135 * later.
136 */
137export function slideHeading (ydoc, slideId) {
138 const s = slideMap(ydoc, slideId)
139 const blocks = s && getArray(s, 'blocks')
140 if (!blocks) return ''
141 for (const [, m] of orderedMaps(blocks)) {
142 if (getString(m, 'type') !== 'heading') continue
143 const f = getFragment(m, 'text')
144 if (!f) return ''
145 return readText(f).map(p => p.map(r => r.t).join('')).join(' ').trim()
146 }
147 return ''
148}
149
150/** The deck's slide ids in order — the strip's order. */
151export function slideIDs (ydoc) {
152 return orderedMaps(roots(ydoc).slides).map(([, m]) => getString(m, 'id')).filter(id => id)
153}
154
155/**
156 * The slide to show after the order changed from before to after,
157 * given the one on stage: the same one while it exists; otherwise the
158 * one that took its place (the next, or the last when it was last);
159 * empty when the deck is empty.
160 */
161export function slideAfter (before, after, current) {
162 if (after.includes(current)) return current
163 if (after.length === 0) return ''
164 const was = before.indexOf(current)
165 if (was < 0) return after[0]
166 return after[Math.min(was, after.length - 1)]
167}
168
169/**
170 * Binds a ProseMirror view to fragment inside target. The target's
171 * rendered content is cleared first: ProseMirror draws the same
172 * paragraphs back from the fragment, live. keys is the app's keymap
173 * (text-keys.mjs), ahead of baseKeymap so its bindings win.
174 *
175 * The fragment is sanitised first (check.mjs): a formatting attribute
176 * the schema does not know is stripped from the shared text before
177 * y-prosemirror sees it, because y-prosemirror 1.3.7 answers an unknown
178 * mark by deleting the whole XmlText — the words, not the mark (codex
179 * 3). The server refuses such an update now; this is for a document
180 * that already carries one.
181 */
182export function bindView (target, fragment, type, keys) {
183 sanitiseFragment(fragment)
184 target.textContent = ''
185 const tag = paragraphTag(type)
186 return new EditorView({ mount: target }, {
187 state: EditorState.create({
188 schema,
189 plugins: [ySyncPlugin(fragment), keymap(keys), keymap(baseKeymap)]
190 }),
191 nodeViews: {
192 paragraph: () => {
193 const dom = document.createElement(tag)
194 return { dom, contentDOM: dom }
195 }
196 }
197 })
198}
199
200/**
201 * Mounts the stage. root holds [slides-stage] and [slides-notes], or
202 * el names the stage element itself (the read-only phone preview has
203 * no notes); fetchSlide(id, vp) resolves
204 * to the section's HTML; keys is the keymap every view gets; vp is the
205 * viewport fetched ('desktop' or 'phone'). Returns the stage: load(id)
206 * shows a slide, refresh() re-fetches the current one, setViewport(vp)
207 * re-fetches in the other rendering, views() is what is bound.
208 *
209 * load() resolves when the slide is on the DOM, and the stage's
210 * slideId moves only then: a fetch that fails leaves the stage, and
211 * whatever follows it (the hash, the strip's "current"), on the slide
212 * it was showing, and onError hears about it.
213 */
214export function mountStage ({ root, el, ydoc, slideId, fetchSlide, keys, vp = 'desktop', readOnly = false, onLoaded = () => {}, onError = () => {} }) {
215 const stageEl = el ?? root.querySelector('[slides-stage]')
216 const notesEl = readOnly ? null : root.querySelector('[slides-notes]')
217 let current = slideId || ''
218 let viewport = vp
219 let views = []
220 let seq = 0
221
222 function unbind () {
223 for (const v of views) v.destroy()
224 views = []
225 }
226
227 function bind () {
228 unbind()
229 if (!current || readOnly) return
230 for (const { el, id, type } of textBlocks(stageEl)) {
231 const fragment = blockFragment(ydoc, current, id)
232 if (fragment) views.push(bindView(mountTarget(el, type), fragment, type, keys))
233 }
234 for (const { el, id } of tableBlocks(stageEl)) {
235 const cells = cellFragments(ydoc, current, id)
236 el.querySelectorAll('th, td').forEach((cell, i) => {
237 if (cells[i]) views.push(bindView(cell, cells[i], 'cell', keys))
238 })
239 }
240 const notes = notesEl && notesFragment(ydoc, current)
241 if (notes) views.push(bindView(notesEl, notes, 'text', keys))
242 }
243
244 async function load (id) {
245 const wanted = id || ''
246 const mine = ++seq
247 if (!wanted) {
248 current = ''
249 unbind()
250 if (stageEl) stageEl.textContent = ''
251 if (notesEl) notesEl.textContent = ''
252 onLoaded('')
253 return
254 }
255 let html
256 try {
257 html = await fetchSlide(wanted, viewport)
258 } catch (err) {
259 if (mine === seq) onError(err, wanted)
260 throw err
261 }
262 // A later load overtook this one: its section is the one to show.
263 if (mine !== seq) return
264 current = wanted
265 unbind()
266 stageEl.innerHTML = html
267 if (notesEl) notesEl.textContent = ''
268 bind()
269 onLoaded(current)
270 }
271
272 // The first paint is the server's: bind to what is already there
273 // rather than fetching it again.
274 if (stageEl && current) bind()
275
276 return {
277 load,
278 refresh: () => load(current),
279 setViewport (next) {
280 if (next === viewport) return Promise.resolve()
281 viewport = next
282 // Nothing to re-fetch on an empty deck; the empty state stays.
283 return current ? load(current) : Promise.resolve()
284 },
285 get viewport () { return viewport },
286 bind,
287 views: () => views,
288 get slideId () { return current },
289 get element () { return stageEl },
290 destroy () {
291 seq++
292 unbind()
293 }
294 }
295}
296
297/** Whether a Y event's target is text (flows through y-prosemirror) or structure (needs a refresh). */
298export function isTextTarget (target) {
299 return target instanceof Y.XmlFragment || target instanceof Y.XmlText
300}
301