oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// fixtures/editor-entry.js — the vendored editor bundle's entire
2// source. fixtures/build-bundles.mjs bundles this one file (and
3// everything it imports) into internal/docs/static/editor.<hash>.js;
4// this is the whole program that runs in the browser, not a library
5// surface something else consumes — the app performs no build step
6// (spec §6.5), so what's committed here is what ships.
7//
8// yjs stays pinned at the exact version fixtures/package.json pins
9// for the wire fixtures (13.6.27, see that file's own comment) —
10// this bundle and core/'s Rust CRDT must agree on the same wire
11// format, and a bundle built against a different yjs would make that
12// agreement untested.
13import * as Y from 'yjs'
14import * as decoding from 'lib0/decoding'
15import { EditorState, Selection } from 'prosemirror-state'
16import { EditorView } from 'prosemirror-view'
17import { Schema } from 'prosemirror-model'
18import { schema as basic } from 'prosemirror-schema-basic'
19import { addListNodes, sinkListItem, liftListItem, splitListItem } from 'prosemirror-schema-list'
20import { keymap } from 'prosemirror-keymap'
21import { baseKeymap, toggleMark, chainCommands } from 'prosemirror-commands'
22import { withIndent, changeIndent } from './indent.mjs'
23import { autoformatRules } from './autoformat.mjs'
24import { SIZE_MARKS } from './sizes.mjs'
25import { pagesPlugin, PAGED, INFINITE, writeMode } from './pages.mjs'
26import { mountWordCount } from './wordcount.mjs'
27import { mountShortcuts } from './shortcuts.mjs'
28import { undoInputRule } from 'prosemirror-inputrules'
29import { ySyncPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror'
30import { createSaveQueue } from './save-queue.mjs'
31import { createResyncController } from './resync-controller.mjs'
32import { alignedParagraph } from './align.mjs'
33import { blankLinesFromBreaks, hardBreakIgnoringInterchange } from './paste.mjs'
34import { createToolbar } from './toolbar.mjs'
35import { mountMenu } from './menu.mjs'
36import { createLiveUpdates, updatesURL } from './live-updates.mjs'
37
38// spanMark builds one of the font/size marks: attribute-free, rendered
39// as a span with a fixed class.
40//
41// The class names, and the mark names, are a contract with
42// core/src/fragment.rs's MARKS table — the server renders the same
43// document to the same markup for a reader with no JavaScript, and
44// internal/export reads the same mark names to lay out a PDF. All
45// three have to agree, and none of them can carry a value: see that
46// table's own comment for why a mark with an attribute would spend
47// the property that lets the read view emit its markup unescaped.
48//
49// group/excludes make each set mutually exclusive, so text cannot end
50// up both sans and mono, or both small and large. The toolbar's own
51// select controls rely on that as well as on their own clearing —
52// belt and braces, because the schema is what also governs a paste.
53function spanMark(cls, group) {
54 return {
55 group,
56 excludes: group,
57 parseDOM: [{ tag: 'span.' + cls }],
58 toDOM: () => ['span', { class: cls }, 0],
59 }
60}
61
62// addToStart, applied in reverse, leaves the font and size marks ahead
63// of prosemirror-schema-basic's own — ProseMirror nests marks in
64// schema order, so this makes the browser produce
65// <span class="docs-font-sans"><strong>…</strong></span>, which is the
66// order fragment.rs's MARKS table renders server-side.
67//
68// All three faces are marks, the default one included. The default
69// (fixtures/fonts.mjs's DEFAULT_FONT) is what the toolbar CLEARS to
70// rather than sets, so no document gains a span for the face it was
71// already in — but every one of the three still has to exist here.
72//
73// Not for the reason it is tempting to give. A document written before
74// the default moved cannot contain font_serif: serif was the default
75// then, so it was the ABSENCE of a mark, which is exactly why this mark
76// had to be invented. What those documents contain is font_sans — the
77// mark of the face that is now the default — put there by everyone who
78// chose sans while choosing it still meant something. So the argument is
79// the stronger one: the mark that most needs to go on parsing is the
80// CURRENT default's, and dropping any face's mark from this schema
81// would strip it out of every document carrying it on the next edit.
82// The size marks are added from SIZE_MARKS rather than written out,
83// which is a departure from the three faces below and needs saying.
84//
85// The usual argument for writing names out is that internal/docs's
86// tests grep this file's source for a literal, so a loop would leave
87// them nothing to find. That argument still holds for anything they
88// grep — and it is why fixtures/sizes.mjs spells the ladder out as
89// numbers, where they can be read. Here there are fourteen of them
90// (eleven points and three legacy names), the class is mechanically the
91// mark name with its prefix swapped, and fourteen hand-written lines
92// would be fourteen chances to typo a class that then renders as
93// nothing. The vocabulary lives in sizes.mjs; this is its application.
94const marks = SIZE_MARKS.reduce(
95 (spec, mark) => spec.addToStart(mark, spanMark('docs-' + mark.replace('_', '-'), 'fontSize')),
96 basic.spec.marks,
97)
98 .addToStart('font_mono', spanMark('docs-font-mono', 'fontFamily'))
99 .addToStart('font_sans', spanMark('docs-font-sans', 'fontFamily'))
100 .addToStart('font_serif', spanMark('docs-font-serif', 'fontFamily'))
101
102// The paragraph gains an `align` attribute — the first block-level
103// formatting, and the first thing in this schema that is not a mark.
104//
105// It cannot be one: a mark applies to a run and alignment applies to
106// the whole block, so it lives on the node. That means it crosses the
107// wire differently too — y-prosemirror stores a node's attributes as
108// Yjs XML attributes, so an aligned paragraph genuinely changes what
109// the document CONTAINS, where a mark only changes how a run is
110// annotated. fixtures/align.mjs has the vocabulary and the reasoning,
111// and fixtures/align.test.mjs pins it; the short version is that the
112// value is only ever a key into a closed table, in this schema, in
113// core/src/fragment.rs and in internal/export/pdf.go alike.
114//
115// Applied before addListNodes so the list nodes are built against the
116// paragraph that already has the attribute — a list item's paragraph is
117// an ordinary paragraph and gets alignment like any other.
118//
119// hard_break is patched at the same time, and only on the way in: the
120// rule ignores WebKit's Apple-interchange-newline marker, which is a
121// copy boundary rather than a line the author left. See paste.mjs.
122// Indent is the second block attribute, and it wraps the paragraph
123// AFTER alignment so a paragraph carries both. withIndent composes
124// rather than replaces — it keeps whatever parseDOM rules the spec it
125// wraps already had and merges its own getAttrs into them, which is
126// what stops a pasted paragraph that is centred AND indented from
127// losing one of the two.
128//
129// Headings get it too, and do not get alignment. Every word processor
130// indents a heading; none of the reference applications this one
131// follows offers alignment on a heading through its own toolbar, and
132// adding it would be a change to fragment.rs and pdf.go as well as to
133// this line.
134const nodes = addListNodes(
135 basic.spec.nodes
136 .update(
137 'paragraph',
138 withIndent(alignedParagraph(basic.spec.nodes.get('paragraph')), 'p'),
139 )
140 .update('heading', withIndent(basic.spec.nodes.get('heading'), 'h1'))
141 .update('hard_break', hardBreakIgnoringInterchange(basic.spec.nodes.get('hard_break'))),
142 'paragraph block*',
143 'block',
144)
145
146const schema = new Schema({ nodes, marks })
147
148// Shift+Enter: a line break inside the current block, rather than a
149// new block.
150//
151// hard_break came with prosemirror-schema-basic from the beginning —
152// the node was always in the schema above — but nothing bound a key to
153// it, so there was no way to type one. The rest of the vocabulary is a
154// contract shared with core/src/fragment.rs (which renders the node as
155// <br> for a reader with no JavaScript, and reports it to
156// internal/export so both exporters can see it); the node's NAME is
157// what crosses the wire, because y-prosemirror stores every non-text
158// node as a Y.XmlElement named after its type.
159//
160// Written out rather than imported: prosemirror-commands exports no
161// insert-a-break command (checked against 1.7.2's own export list —
162// splitBlock, exitCode and the rest, nothing for this), so every
163// ProseMirror setup writes these same three lines. dispatch is checked
164// because a command can be called without one, to ask whether it
165// applies rather than to run it, and scrollIntoView because the new
166// line can be below the fold on a long document.
167function insertHardBreak(state, dispatch) {
168 if (dispatch) {
169 dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView())
170 }
171 return true
172}
173
174// Coalesce a burst of typing into one request.
175//
176// This constant is the floor on how fast an edit can reach anyone
177// else: nothing leaves the tab that made it until the window closes,
178// so the stream, the fetch and the render all happen after it. 250ms
179// is chosen for that — under the ~300ms at which a delay stops
180// reading as "the app responded" and starts reading as "the app is
181// thinking" — rather than for any property of the server.
182//
183// It could not be chosen that way until recently, and the history is
184// worth keeping because the old value looked like a feel decision and
185// was not. Every accepted POST is one doc_updates row, and
186// core.Handle's maxRetainedUpdates (internal/core/core.go, 4096)
187// capped a document at that many, after which it refused edits
188// permanently. The value was therefore headroom against a cliff:
189// 400ms bought ~27 minutes of continuous typing, 2000ms bought about
190// 2h16m. Snapshots removed the cliff — replay now starts from a
191// snapshot plus a bounded tail — so this is free to be a number about
192// perception again.
193//
194// What it still costs, because this is not free and the next reader
195// should not have to rediscover it: eight times the row rate, roughly
196// 14,400 doc_updates rows an hour of continuous typing against the
197// old 1,800, each holding a character or two rather than a sentence.
198// Snapshots bound what that costs to REPLAY; nothing yet bounds what
199// it costs to STORE, because §4.1's compaction horizon is still
200// deferred, so the log and the WAL Litestream replicates grow eight
201// times faster than they did. The fix that would give back the rows
202// without giving back the latency is §4.1's other mechanism —
203// coalescing same-actor updates at the storage boundary — and it
204// belongs with the WebSocket transport, which needs it anyway.
205//
206// Nothing shows a "saving" indicator during an ordinary wait; only a
207// failure does, via the save queue's own status (see save-queue.mjs
208// and views/show.html's [estilo-save-status]). So this delay stays
209// invisible exactly when saving is going fine, which is the case that
210// matters for feel.
211const DEBOUNCE_MS = 250
212
213// applyServerLog replays handlers.go's updatesGet response into ydoc.
214// That response is a raw concatenation of one encoded Yjs update per
215// accepted POST, back to back with no framing between them (its own
216// doc comment: "concatenated in seq order") — one row per update,
217// exactly like Store.replay applies them server-side. A single
218// Y.applyUpdate call cannot replay that correctly: its decoder reads
219// only the client count declared in the FIRST message's own header,
220// then returns, leaving every later message's bytes on the floor —
221// confirmed empirically (a second update silently vanishes) before
222// this fix went in. Y.readUpdate, looped over one shared lib0 decoder
223// until it is exhausted, replays every message the log actually
224// holds instead of just the first one.
225function applyServerLog(ydoc, log) {
226 if (!log.length) return
227 const decoder = decoding.createDecoder(log)
228 while (decoding.hasContent(decoder)) {
229 Y.readUpdate(decoder, ydoc, 'server')
230 }
231}
232
233export async function mountEditor(el) {
234 const sqid = el.getAttribute('data-sqid')
235 const base = `/d/${sqid}/updates`
236 // The live-update stream (internal/docs/stream.go). A sibling of
237 // base rather than something derived from it: they are two routes
238 // on one document, not one route and a variation of it.
239 const streamURL = `/d/${sqid}/stream`
240
241 const statusEl = document.querySelector('[estilo-save-status]')
242 const statusURL = new URL(el.getAttribute('data-save-status-module'), location.href)
243 if (!statusEl || statusURL.origin !== location.origin) throw new Error('Invalid save-status module')
244 const { mount } = await import(statusURL.href)
245 const setSaveState = mount(statusEl)
246
247 // sessionFailed is stickier than any one save queue's own status
248 // (save-queue.mjs's own report() is only sticky within a single
249 // instance, and a resync replaces that instance): once a batch is
250 // ever dropped in this page's lifetime, the failed indicator must
251 // stay lit even after the editor is resynced and saving normally
252 // again — a person still needs to know something did not make it.
253 let sessionFailed = false
254 // currentQueue/currentView are read by beforeunload and by
255 // attemptResync below; both are reassigned wholesale on every
256 // resync, so anything that needs "whichever one is live right now"
257 // reads through these rather than closing over a value captured at
258 // mount time.
259 let currentQueue = null
260 let currentView = null
261 // currentLive is the live-update subscriber (live-updates.mjs) for
262 // whichever Y.Doc is current. It belongs to that document, not to
263 // the page: a resync builds a new Y.Doc, so the subscriber feeding
264 // the old one is stopped and replaced along with the save queue and
265 // the view, or it would apply bytes into a document that has been
266 // discarded.
267 let currentLive = null
268
269 // The toolbar (fixtures/toolbar.mjs) is built once, before the first
270 // mount, because mountView needs its plugin. It reads the live view
271 // through this closure rather than being handed one: attemptResync
272 // destroys and rebuilds the EditorView wholesale, and a toolbar
273 // holding a captured view would dispatch into a dead one after a
274 // resync — the same reason placeCaretFromPage reads currentView at
275 // event time. views/show.html renders the markup; a page without it
276 // (or a future template that drops it) gets a toolbar with no
277 // plugin and an editor that works exactly as it did before.
278 // The menu bar (fixtures/menu.mjs) is mounted first but wired second,
279 // because the two point at each other: the menu dispatches through
280 // the toolbar's command table, and the toolbar's plugin is what
281 // drives the menu's sync. Declaring it here and filling it in below
282 // is the smaller of the two awkwardnesses — the alternative is a
283 // second ProseMirror plugin watching the same editor, and two
284 // watchers of one selection is how a menu item and the toolbar button
285 // beside it end up enabled differently.
286 //
287 // The bar works before any of this runs: it is six <details>
288 // elements, and its links and forms need no editor at all. Only the
289 // command items wait on a view.
290 let menu = null
291
292 // Paged or infinite. The server has already put its answer on
293 // [docs-canvas] from the cookie (handlers.go), so the first paint is
294 // already right and this only has to agree with it — reading the
295 // cookie again here would be a second source for one fact.
296 //
297 // The canvas carries the attribute rather than the editor, because
298 // what changes between the modes is the desk the paper sits on, and
299 // the gaps are drawn inside the page's own text flow.
300 const canvasEl = document.querySelector('[docs-canvas]')
301 let viewMode = canvasEl && canvasEl.getAttribute('data-view') === PAGED ? PAGED : INFINITE
302 // The page-number template ("Page {n}"), server-rendered from the
303 // catalog into a data attribute — the bundle carries no copy of its
304 // own, the same rule the save-status indicator and the toolbar
305 // already follow.
306 const pageLabelText = canvasEl ? canvasEl.getAttribute('data-page-label') : ''
307
308 function setViewMode(mode) {
309 viewMode = mode
310 if (canvasEl) canvasEl.setAttribute('data-view', mode)
311 writeMode(mode)
312 // Nudge the plugin: the mode is not part of editor state, so
313 // nothing else would tell it to re-measure.
314 const view = currentView
315 if (view) view.dispatch(view.state.tr)
316 }
317
318 // The Tools menu's two dialogs. Both read their copy from markup
319 // views/show.html rendered from the catalog, and both are null when
320 // that markup is absent — a template without them gets a menu whose
321 // items stay disabled rather than an error.
322 const wordCount = mountWordCount(document.querySelector('[docs-wordcount]'), {
323 getView: () => currentView,
324 })
325 const shortcuts = mountShortcuts(document.querySelector('[docs-shortcuts]'), {
326 menubar: document.querySelector('[estilo-menubar]'),
327 // The bindings no menu item carries — Tab, Shift+Tab, the
328 // autoformat undo and Shift+Enter — server-rendered into a hidden
329 // block as data so their labels stay in the catalog with every
330 // other string.
331 extraRows: Array.from(
332 document.querySelectorAll('[docs-shortcuts-extra] span'),
333 ).map((el) => ({
334 group: el.closest('[docs-shortcuts-extra]').getAttribute('data-group') || '',
335 label: el.getAttribute('data-label') || '',
336 keys: el.getAttribute('data-keys') || '',
337 })),
338 })
339
340 const toolbar = createToolbar({
341 root: document.querySelector('[docs-toolbar]'),
342 schema,
343 getView: () => currentView,
344 onSync: (view) => {
345 if (menu) menu.sync(view)
346 },
347 })
348
349 menu = mountMenu(document.querySelector('[estilo-menubar]'), {
350 getView: () => currentView,
351 commands: toolbar.commands,
352 // The check column: the view mode is this file's; the paragraph
353 // style and alignment are the toolbar's, read the same way its own
354 // pressed buttons and Style select are.
355 isPressed: (name, view) => {
356 if (name === 'view_paged') return viewMode === PAGED
357 if (name === 'view_infinite') return viewMode === INFINITE
358 return Boolean(view && toolbar.isPressed && toolbar.isPressed(view.state, name))
359 },
360 // The two View-menu commands. They are not editor commands — they
361 // change how the document is drawn, not what it contains — so they
362 // are passed in here rather than living in toolbar.mjs's table
363 // alongside Bold. menu.mjs treats them the way it treats Print: not
364 // dependent on a selection, and available whenever the page is.
365 extraCommands: {
366 view_paged: () => {
367 setViewMode(PAGED)
368 return true
369 },
370 view_infinite: () => {
371 setViewMode(INFINITE)
372 return true
373 },
374 // Both are null when the template renders no dialog, and
375 // mountMenu leaves an item with no command disabled — which is
376 // the right outcome rather than a crash.
377 ...(wordCount ? { word_count: wordCount } : {}),
378 ...(shortcuts ? { shortcuts } : {}),
379 },
380 })
381
382 function updateIndicator(state) {
383 setSaveState(sessionFailed ? 'failed' : state)
384 }
385
386 // getLive is a getter for THIS lifecycle's own subscriber, never
387 // the module-level currentLive. Whole-branch review found the
388 // difference matters: attemptResync stops the old subscriber but
389 // cannot stop the old save queue (save-queue.mjs has no stop, by
390 // design), so a POST issued before a resync can resolve after it.
391 // Reading currentLive at that moment would hand the seq of a
392 // pre-resync row to the FRESH subscriber, whose cursor was seeded
393 // from a GET that predates it — and if the contiguity check
394 // happened to pass, that cursor would step over a row the fresh
395 // Y.Doc never applied. Closing over the lifecycle's own reference
396 // means a late response lands on a stopped instance, which ignores
397 // it.
398 function makeSaveQueue(getLive) {
399 // The save queue (fixtures/save-queue.mjs) owns the retry/drop
400 // decision — fix round 1's F1: a permanently-rejected batch (a
401 // 4xx) must be dropped, not retried forever and not merged into
402 // by whatever gets typed next, while a network failure or a 5xx
403 // is worth retrying. See that module's own doc comment for the
404 // bug this replaced and how fixtures/save-queue.test.mjs
405 // reproduces it.
406 return createSaveQueue({
407 // Wrapped rather than handed straight to fetch so this tab can
408 // read the seq the server assigned to its own write, which
409 // saves the round trip of hearing about it on the stream and
410 // fetching back bytes it already has. save-queue.mjs is
411 // untouched by this: it consumes only r.ok and r.status, and
412 // this returns the same response it always did.
413 //
414 // Only on a 2xx. A 4xx or 5xx carries no seq — the server
415 // broadcasts and sets the header only when ApplyUpdate returned
416 // no error — and noteOwnSeq's own contiguity check would ignore
417 // a stale number anyway.
418 post: async (bytes) => {
419 const res = await fetch(base, {
420 method: 'POST',
421 credentials: 'same-origin',
422 headers: { 'Content-Type': 'application/octet-stream' },
423 body: bytes,
424 })
425 const live = getLive()
426 if (res.ok && live) {
427 const seq = Number(res.headers.get('Docs-Seq'))
428 if (Number.isFinite(seq)) live.noteOwnSeq(seq)
429 }
430 return res
431 },
432 mergeUpdates: Y.mergeUpdates,
433 debounceMs: DEBOUNCE_MS,
434 onStatus: (state) => {
435 if (state === 'failed') {
436 sessionFailed = true
437 resyncController.reportDivergence()
438 }
439 updateIndicator(state)
440 },
441 })
442 }
443
444 // placeCaretFromPage makes the whole sheet of paper behave like the
445 // document: a click in the page's 1in margin — outside the editable
446 // node, so neither the browser nor ProseMirror sees it as a click on
447 // text at all — puts the caret on the line nearest where the pointer
448 // landed, the way every word processor does. views/show.html grows
449 // the editable node to the full height of the page's text area, so
450 // the empty space below the last paragraph is already ProseMirror's
451 // own and needs none of this; the margins around it never can be,
452 // because they are [docs-page]'s padding and the editable node has
453 // no way to reach outside itself.
454 //
455 // Clamping the click's coordinates into the editable node's own
456 // rectangle before asking posAtCoords is what turns "beside the
457 // text" into "at the nearest end of the nearest line": posAtCoords
458 // answers null for a point outside the editor, and the clamped point
459 // is the closest one inside it. A null answer even after clamping
460 // falls back to the end of the document — not because any particular
461 // document is known to produce one (an empty document still holds an
462 // empty paragraph, which resolves fine), but because posAtCoords
463 // reads the live DOM and is documented to be able to return null,
464 // and the end of the document is the one position that always
465 // exists whatever it hands back.
466 //
467 // Reads currentView, not a view captured when the listener was
468 // registered: a resync destroys the whole EditorView and builds
469 // another (attemptResync), and a click after that must reach the
470 // live one rather than dispatching into a destroyed view.
471 function placeCaretFromPage(event) {
472 const view = currentView
473 if (!view || event.button !== 0) return
474 // Inside the editable node already: ProseMirror's own handling is
475 // better than anything reconstructed from coordinates here.
476 if (view.dom.contains(event.target)) return
477
478 const rect = view.dom.getBoundingClientRect()
479 const found = view.posAtCoords({
480 left: Math.min(Math.max(event.clientX, rect.left + 1), rect.right - 1),
481 top: Math.min(Math.max(event.clientY, rect.top + 1), rect.bottom - 1),
482 })
483 const pos = found ? found.pos : view.state.doc.content.size
484 // Selection.near, spelled on the base class rather than as
485 // TextSelection.near: TextSelection defines no static near of its
486 // own (prosemirror-state 1.4.4), so the latter silently resolves
487 // to this exact same inherited function while reading like a
488 // promise that the result is a TextSelection. It usually is, but
489 // near falls back to a NodeSelection or an AllSelection when no
490 // text position is reachable, and a name that overstates that is
491 // the kind of comment-shaped lie this file's own conventions
492 // exist to keep out. Every branch here yields some valid
493 // selection, which is all the dispatch below needs.
494 const selection = Selection.near(view.state.doc.resolve(pos))
495
496 // preventDefault before focusing: the default mousedown on a
497 // non-editable element blurs the editor and collapses the
498 // selection we are about to set, so the caret would appear and
499 // vanish in the same tick.
500 event.preventDefault()
501 view.dispatch(view.state.tr.setSelection(selection).scrollIntoView())
502 view.focus()
503 }
504
505 function mountView(mountEl, fragment) {
506 // {mount: mountEl}, not plain mountEl: passed as a bare DOM node,
507 // EditorView only appends its own div as an extra child
508 // (prosemirror-view's own constructor —
509 // place.appendChild(this.dom)), leaving whatever was already
510 // there sitting beside it as inert, stale markup a click could
511 // land on instead of the real editable surface. {mount: mountEl}
512 // makes mountEl itself the editable DOM node, so ProseMirror takes
513 // over its existing children (this.mounted = true) instead of
514 // adding a sibling.
515 const plugins = [
516 ySyncPlugin(fragment),
517 yUndoPlugin(),
518 // Autoformat (fixtures/autoformat.mjs): "- " makes a bullet,
519 // "1. " a numbered list, "> " a quote, "# " a heading. Ahead of
520 // the keymaps because the rules act on typed TEXT rather than on
521 // keys, and because the Backspace binding below that reverts them
522 // has to be able to see that one just fired.
523 autoformatRules(schema),
524 // Paged/infinite (fixtures/pages.mjs). Reads the mode through a
525 // getter rather than being handed one, because the View menu can
526 // change it at any moment and a plugin is configuration reused
527 // across every state this page builds.
528 pagesPlugin({ getMode: () => viewMode, pageLabel: pageLabelText }),
529 keymap({
530 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo,
531 'Mod-b': toggleMark(schema.marks.strong),
532 'Mod-i': toggleMark(schema.marks.em),
533 // In this block rather than appended to baseKeymap below,
534 // which is a shared module-level object every editor on the
535 // page would see mutated. Nothing binds Shift-Enter today:
536 // baseKeymap has Enter and Mod-Enter only, and
537 // prosemirror-keymap looks up the full modified name for a
538 // named key like Enter (its shift-less fallback is for
539 // single-character keys), so the keystroke matched no binding,
540 // was then captured and preventDefaulted by prosemirror-view,
541 // which treats every Enter as handled (captureKeyDown returns
542 // true for keyCode 13 whether or not shift is down, and its
543 // caller preventDefaults whenever handleKeyDown fails and
544 // captureKeyDown succeeds), and so did nothing at all.
545 //
546 // Worth stating precisely, because the plausible wrong reading
547 // is that the keystroke fell through to the browser's own
548 // contenteditable handling, inserted a native <br>, and was
549 // re-parsed into this node by the schema's parseDOM — i.e.
550 // that a break sometimes worked before this binding existed.
551 // It never could: the event never reached the browser.
552 'Shift-Enter': insertHardBreak,
553 // Tab and Shift+Tab.
554 //
555 // chainCommands, so one key does the right thing in two
556 // contexts without this file testing which one it is in: in a
557 // list, Tab nests the item and Shift+Tab lifts it; anywhere
558 // else it steps the block's own indent. sinkListItem is tried
559 // FIRST and returns false when the selection is not in a list,
560 // which is what hands the key to changeIndent.
561 //
562 // Tab inside an editable surface does not move focus, and that
563 // is a deliberate exception to the usual rule rather than an
564 // oversight. WCAG 2.1.2 forbids a keyboard trap, not a
565 // repurposed Tab: Escape is not needed to leave because the
566 // surrounding chrome — the menu bar, the formatting bar, the
567 // sign-out control — is all reachable by Tab from outside, and
568 // Shift+Tab at indent 0 in the first block returns false, so
569 // the browser's own focus move takes over and a person tabs
570 // backwards out of the document exactly as before. That last
571 // part is why changeIndent reports honestly at the ends of its
572 // range instead of returning true and swallowing the key.
573 Tab: chainCommands(sinkListItem(schema.nodes.list_item), changeIndent(1)),
574 'Shift-Tab': chainCommands(liftListItem(schema.nodes.list_item), changeIndent(-1)),
575 // Backspace immediately after an input rule fired reverts the
576 // transformation and leaves the typed characters — "- " goes
577 // back to being "- " rather than becoming a list you have to
578 // fight your way out of. This is the whole reason autoformat is
579 // tolerable, and it has to be in THIS block rather than
580 // appended to baseKeymap below, because baseKeymap binds
581 // Backspace too and whichever comes first wins. Away from a
582 // rule, undoInputRule returns false and baseKeymap's own
583 // Backspace takes the key as it always did.
584 Backspace: undoInputRule,
585 // Escape leaves the document for the formatting bar.
586 //
587 // Tab is repurposed in here — it indents rather than moving
588 // focus — and although Shift+Tab still escapes backwards
589 // (changeIndent reports false at indent 0, handing the key
590 // back to the browser), there is no forward exit below indent
591 // 8. WCAG 2.1.2 permits a modified key provided the person is
592 // told, and the shortcuts sheet tells them; but "you can only
593 // leave backwards" is a poor thing to have to say, so Escape
594 // leaves forwards onto the nearest chrome.
595 //
596 // Returns whatever focusStop reports rather than always true:
597 // with no toolbar in the DOM there is nowhere to go, and
598 // swallowing Escape then would take the key away from anything
599 // else that wants it while doing nothing.
600 Escape: () => toolbar.focusStop(),
601 // Enter in a list splits the item, and on an empty item lifts
602 // out of the list — the idiom every editor has. Without this,
603 // baseKeymap's splitBlock puts a bare paragraph inside the list
604 // item and there is no way to end a list by pressing Enter
605 // twice. Missing since lists were added; found in review.
606 Enter: chainCommands(splitListItem(schema.nodes.list_item), baseKeymap.Enter),
607 }),
608 keymap(baseKeymap),
609 ]
610 // The toolbar's plugin keeps its buttons in step with the
611 // selection. One Plugin instance is reused across every state this
612 // page builds — a plugin is configuration, not state — so a resync
613 // rebuilding the view does not need a new toolbar, and the toolbar
614 // does not need to know a resync happened.
615 if (toolbar.plugin) plugins.push(toolbar.plugin)
616
617 return new EditorView({ mount: mountEl }, {
618 state: EditorState.create({ schema, plugins }),
619 // A blank line pasted out of Google Docs arrives as a paragraph
620 // holding one hard_break, which the editor draws two lines tall.
621 // paste.mjs's header has the whole chain; the short version is
622 // that this is the first point where a block-level <br> can be
623 // told apart from one the author typed.
624 transformPasted: blankLinesFromBreaks(schema),
625 })
626 }
627
628 // attemptResync fetches the server's own log fresh and rebuilds the
629 // whole editor (Y.Doc, save queue, EditorView) from it — the same
630 // thing this function also does for the very first mount below,
631 // and the same truth a manual reload already reconstructs
632 // correctly. Used both ways rather than duplicated: the initial
633 // mount awaits it directly (a failure there means the editor never
634 // mounted at all, handled by the outer .catch below), while a
635 // later resync goes through resyncController, which retries on
636 // failure instead of propagating one (fix round 2's own fix — see
637 // resync-controller.mjs's header comment for why a resync that
638 // silently gives up after one failure recreates the exact data
639 // loss F1 was supposed to close).
640 //
641 // ySyncPlugin mirrors every local ProseMirror transaction into the
642 // Y.Doc immediately, optimistically, before any network call even
643 // starts, so once a batch is permanently rejected the local doc
644 // already holds content the server never received — every update
645 // produced after that point is encoded relative to that now-
646 // fictional history, which core.Handle.Apply accepts (a real 204)
647 // but can never actually integrate. The only way back to a state
648 // new edits can build on is to discard the diverged document
649 // entirely and rebuild from the server's own log — which is also
650 // why this necessarily discards whatever was typed locally in the
651 // interim: it was built on the same poisoned history and could
652 // never have been saved either.
653 // fetchLog does the whole-log GET that both the initial mount and
654 // every later resync use (attemptResync below). raw, when true,
655 // appends the literal query string `raw=1` — handlers.go matches
656 // nothing else — which skips the snapshot lookup entirely and
657 // serves exactly the doc_updates rows the server has always been
658 // able to serve correctly. See applyServerLog's caller below for
659 // why this exists.
660 async function fetchLog(raw) {
661 const url = updatesURL(base, { raw })
662 const res = await fetch(url, { credentials: 'same-origin' })
663 if (!res.ok) throw new Error(`load ${url}: ${res.status}`)
664 return {
665 log: new Uint8Array(await res.arrayBuffer()),
666 // Docs-Seq names how far the bytes just received actually reach,
667 // and the server derives it from those bytes rather than from a
668 // separate query — so this cursor can never start out ahead of
669 // what this document holds. A missing or unparseable header
670 // (an older server, a proxy that strips it) becomes 0, which
671 // costs one redundant full fetch on the first notification and
672 // loses nothing.
673 startSeq: Number(res.headers.get('Docs-Seq')),
674 }
675 }
676
677 async function attemptResync() {
678 let { log, startSeq } = await fetchLog(false)
679
680 let ydoc = new Y.Doc()
681 try {
682 applyServerLog(ydoc, log)
683 } catch (decodeErr) {
684 // This fetch happens before any live-update subscriber exists —
685 // it IS the document, for the initial mount as much as for a
686 // later resync — so a corrupt snapshot here has no cursor to
687 // fall back through the way live-updates.mjs's drain() does; it
688 // would simply stop the editor mounting at all. Same cause as
689 // that module's own retry (its comment explains the "why" in
690 // full): the server serves a snapshot without ever calling into
691 // the CRDT core, so it cannot detect one corrupted on disk after
692 // it was written and verified — only Y.readUpdate, here, can.
693 // A fresh Y.Doc for the retry, not the one just poisoned by a
694 // partial decode: applyServerLog can throw partway through the
695 // loop, leaving ydoc in an unknown state that must not be built
696 // on. A second throw is not caught again; it propagates like any
697 // other attemptResync failure — to the outer .catch on the
698 // initial mount, or to resyncController's own retry loop on a
699 // later resync.
700 ;({ log, startSeq } = await fetchLog(true))
701 ydoc = new Y.Doc()
702 applyServerLog(ydoc, log)
703 }
704 const fragment = ydoc.getXmlFragment('prosemirror')
705
706 // lifecycleLive is this resync's own subscriber — see
707 // makeSaveQueue's own comment for why the queue must not read the
708 // module-level currentLive instead. Assigned below, once
709 // createLiveUpdates has built it; until then the getter returns
710 // null and a POST simply records no seq, which costs one fetch.
711 let lifecycleLive = null
712 const saveQueue = makeSaveQueue(() => lifecycleLive)
713 // Send only what originated here, and only once the document is
714 // known consistent with the server. The 'server' origin above
715 // marks replayed bytes (echoing those back would grow the log
716 // without adding information); resyncController.diverged marks
717 // everything produced while a previous rejection is still being
718 // recovered from — sending THAT would only earn more silently-
719 // unintegrated 204s (fix round 2's blocking finding) and spend
720 // core.go's maxRetainedUpdates cap for nothing. Once a resync
721 // actually replaces this ydoc with a fresh one, diverged is false
722 // again and this check simply passes every update through.
723 ydoc.on('update', (update, origin) => {
724 if (origin === 'server') return
725 if (resyncController.diverged) return
726 saveQueue.enqueue(update)
727 })
728
729 // Destroy the old view BEFORE creating the new one, not after:
730 // {mount: el} makes an EditorView take exclusive ownership of
731 // el's own DOM node, so two views both pointed at the same el at
732 // once corrupted that node instead of cleanly handing it off —
733 // found when a resync's own follow-up edit landed nowhere: the
734 // click after resync hit a DOM ProseMirror no longer recognised
735 // as its own.
736 if (currentView) currentView.destroy()
737 currentQueue = saveQueue
738 currentView = mountView(el, fragment)
739
740 // The live subscriber is replaced wholesale with everything else.
741 // Stopping the old one first matters for the same reason
742 // destroying the old view first does: it may have a fetch in
743 // flight, and its own stop() is what makes that response land
744 // nowhere instead of in the document that just replaced it.
745 if (currentLive) currentLive.stop()
746 lifecycleLive = createLiveUpdates({
747 initialSeq: Number.isFinite(startSeq) ? startSeq : 0,
748 openStream: (onEvent, onStreamError) => {
749 const es = new EventSource(streamURL, { withCredentials: true })
750 es.addEventListener('update', (ev) => onEvent(Number(ev.data)))
751 es.addEventListener('error', (ev) => onStreamError(ev))
752 return { close: () => es.close() }
753 },
754 // raw must reach the server as the literal query string `raw=1`
755 // — handlers.go matches nothing else, not `raw=true` or a bare
756 // `raw` — or the escape hatch fails silently and drain() ends up
757 // refetching the same corrupt snapshot forever.
758 fetchSince: async (since, { raw } = {}) => {
759 const url = updatesURL(base, { since, raw })
760 const r = await fetch(url, { credentials: 'same-origin' })
761 if (!r.ok) throw new Error(`fetch ${url}: ${r.status}`)
762 return {
763 bytes: new Uint8Array(await r.arrayBuffer()),
764 seq: Number(r.headers.get('Docs-Seq')),
765 }
766 },
767 onUpdates: (bytes) => {
768 // While diverged the local document is known poisoned and a
769 // rebuild is already being retried; applying remote bytes to
770 // it would only build more history on top of a fiction. The
771 // resync replaces this whole subscriber, and its fresh
772 // cursor picks these same rows up again.
773 if (resyncController.diverged) return
774 applyServerLog(ydoc, bytes)
775 },
776 onError: (err) => {
777 // Deliberately not updateIndicator('failed'): [estilo-save-status]
778 // means "your edit did not save", and that is still false —
779 // POST is a separate path and is unaffected by a dead stream.
780 // What is lost is freshness, which heals on the next activity.
781 console.error('live updates:', err)
782 },
783 })
784 currentLive = lifecycleLive
785 currentLive.start()
786
787 // A tab that LOADS hidden never fires visibilitychange, so the
788 // listener below cannot close its stream and only the idle timer
789 // would — leaving a background tab holding a connection for two
790 // minutes it never needed. Cheap to check once, here, where the
791 // stream has just been opened.
792 if (typeof document !== 'undefined' && document.hidden) currentLive.noteHidden()
793 }
794
795 // resyncController (fix round 2) owns "the local document has
796 // diverged and needs rebuilding" as its own retried-until-it-
797 // succeeds state machine — see resync-controller.mjs's own header
798 // comment for the bug this replaced: round 1's resync gave up
799 // silently after one failure, and nothing ever told it to try
800 // again, so a single dropped connection at the wrong moment
801 // recreated the exact silent-204 loss F1 exists to prevent.
802 // onDiverged/onRecovered keep the visible indicator in step with
803 // the state machine's own truth rather than with whatever the
804 // (possibly still-diverged) save queue's own status happens to
805 // report — sessionFailed's own stickiness (updateIndicator) means
806 // onRecovered never actually clears the indicator once something
807 // has been lost, but it does matter the first time: onDiverged is
808 // what lights the indicator up if a batch is ever dropped without
809 // save-queue.mjs's own onStatus('failed') having already done so.
810 const resyncController = createResyncController({
811 attemptResync,
812 onDiverged: () => updateIndicator('failed'),
813 onRecovered: () => updateIndicator(sessionFailed ? 'failed' : 'ok'),
814 })
815
816 // Initial mount: attemptResync's own failure here is NOT retried —
817 // it propagates to the outer .catch below, which leaves the
818 // server-rendered read view in place. Retrying belongs to a
819 // document the person is already editing (resyncController's own
820 // job); a document that never opened has nothing to retry into.
821 await attemptResync()
822
823 // The live subscriber's window on whether anybody is here. It owns
824 // no DOM of its own (live-updates.mjs is testable under plain Node
825 // precisely because of that), so every listener it needs is
826 // registered here, once, reading currentLive rather than a
827 // subscriber captured at mount time — a resync replaces that
828 // object, and these listeners outlive every one of them.
829 //
830 // A hidden tab shortens its deadline rather than closing on the
831 // spot: platform hibernation is default-on and this app holds one
832 // stream per open editor, so a document left open on Friday must not
833 // keep its instance awake all weekend — but closing the instant a
834 // tab goes to the background made every switch back paint the old
835 // document for a frame before the reconnect caught it up, which is
836 // the flash Paul kept seeing. live-updates.mjs's HIDDEN_MS carries
837 // the measurement and the arithmetic.
838 //
839 // Everything else is "activity", and the breadth is the point. The
840 // obvious three — visibility, focus, and typing — all miss the
841 // commonest case there is: someone READING a long document, on a
842 // tab that is already visible and already focused. That person
843 // fires none of them, so an idle close would never be undone and
844 // the tab would go quietly stale — which is exactly the watching
845 // half of "type in one tab, watch the other". A pointer moving or
846 // a page scrolling is what says they are still there.
847 //
848 // All passive, and none of them does work per event: noteActivity
849 // only re-arms a timer, or reopens a stream that is already closed.
850 addEventListener('visibilitychange', () => {
851 if (document.hidden) currentLive && currentLive.noteHidden()
852 else currentLive && currentLive.noteActivity()
853 })
854 for (const name of ['pointermove', 'scroll', 'keydown', 'focus']) {
855 addEventListener(name, () => currentLive && currentLive.noteActivity(), { passive: true, capture: true })
856 }
857
858 // Registered on [docs-page] rather than on the editable node (which
859 // never receives these clicks) and after the first mount rather than
860 // before it, so placeCaretFromPage always has a live view to
861 // dispatch into. Once, not once per resync: it reads currentView,
862 // and the page element outlives every EditorView mounted into it.
863 const page = el.closest('[docs-page]')
864 if (page) page.addEventListener('mousedown', placeCaretFromPage)
865
866 // The caret is this editor's focus indicator — views/show.html drops
867 // the browser's focus ring, on the argument that a text cursor says
868 // more than a box around the paper does. That argument only holds
869 // while the caret is actually on screen, and one route in breaks it:
870 // Tab in from the sign-out button, on a document scrolled away from
871 // wherever the selection was last left, and ProseMirror restores that
872 // selection without scrolling to it (its focus() does not scroll on
873 // purpose), so focus lands with no visible indicator anywhere in the
874 // viewport. The ring, whose edges would have been on screen, used to
875 // cover that case. AGENTS.md's rule is WCAG 2.2 AA with no
876 // exemptions, so the ring's replacement has to be visible on every
877 // path that reaches focus, not just on the click that scrolls itself
878 // into view. An empty transaction carries the scroll: it changes no
879 // content, so ySyncPlugin emits no Y update and the save queue never
880 // sees it (this must not cost a row against core.go's
881 // maxRetainedUpdates for merely tabbing into a document).
882 //
883 // Only for focus that did NOT arrive by pointer, which is the whole
884 // subtlety here. A focus event fires before ProseMirror has set the
885 // selection the click asked for, so scrolling on every focus would
886 // scroll to wherever the selection happened to be left instead —
887 // click into the third page of a document and the view yanks back to
888 // the first, which is a worse bug than the one this is closing.
889 // :focus-visible cannot make the distinction: a contenteditable
890 // takes keyboard input, so it matches whatever the modality was.
891 // Hence the flag: a pointer press sets it, and clearing it on the
892 // next macrotask leaves it set for exactly the focus that press
893 // causes (focus is dispatched synchronously inside the press's own
894 // default action) and clear for every later one.
895 let focusFromPointer = false
896 const notePointer = () => {
897 focusFromPointer = true
898 setTimeout(() => { focusFromPointer = false }, 0)
899 }
900 document.addEventListener('mousedown', notePointer, true)
901 document.addEventListener('touchstart', notePointer, true)
902
903 el.addEventListener('focus', () => {
904 if (focusFromPointer) return
905 const view = currentView
906 if (view) view.dispatch(view.state.tr.scrollIntoView())
907 })
908
909 // A close or reload with unsent bytes must not lose the edit.
910 // pendingBytes() covers both what is still queued AND what is
911 // already in flight (fix round 1's LOW finding: a flush's own
912 // fetch, awaited but not yet resolved, held bytes that a same-tick
913 // unload would otherwise abandon with nothing beaconed at all,
914 // since they had already been cleared out of the queue proper).
915 // Reads currentQueue, not a queue captured at mount time, so a
916 // close during/after a resync still beacons the live queue.
917 addEventListener('beforeunload', () => {
918 const bytes = currentQueue && currentQueue.pendingBytes()
919 if (!bytes) return
920 const blob = new Blob([bytes], { type: 'application/octet-stream' })
921 const sent = navigator.sendBeacon(base, blob)
922 if (!sent) {
923 // sendBeacon refuses outright once its small outstanding quota
924 // (browsers commonly cap the total around 64KB, shared across
925 // every beacon call on the page) is used up — verified against
926 // a real browser (fix round 2): a fetch with keepalive:true is
927 // NOT a bigger-payload workaround for that — it shares
928 // materially the same quota (a 100KB body was refused by both;
929 // 30KB got through both). It is still tried here because
930 // sendBeacon's refusal is not always about THIS payload's own
931 // size — the quota is shared across every beacon call a page
932 // makes, so some other beacon (an analytics call, say) could be
933 // what actually used it up, in which case a fresh fetch call
934 // can still get through. What this does NOT do, and cannot: get
935 // a paste-sized edit that has not yet cleared one debounce
936 // window (DEBOUNCE_MS above) out the door during an immediate
937 // tab close. That gap is real and unclosed — a hard platform
938 // limit, not a bug in this fallback — which is exactly why the
939 // debounce above stays short enough that most edits have
940 // already gone out long before anyone reaches for the tab's
941 // close button.
942 console.error('beforeunload: sendBeacon refused the pending edit, retrying via keepalive fetch')
943 fetch(base, {
944 method: 'POST',
945 credentials: 'same-origin',
946 keepalive: true,
947 headers: { 'Content-Type': 'application/octet-stream' },
948 body: bytes,
949 }).catch(() => {})
950 }
951 })
952
953 return currentView
954}
955
956const mount = document.querySelector('[docs-editor]')
957if (mount) mountEditor(mount).catch(err => {
958 // Leave the server-rendered read view in place rather than replacing
959 // it with a blank editor: unreadable is worse than uneditable.
960 mount.setAttribute('data-editor-failed', '1')
961 console.error(err)
962})
963