| 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. |
| 13 | import * as Y from 'yjs' |
| 14 | import * as decoding from 'lib0/decoding' |
| 15 | import { EditorState, Selection } from 'prosemirror-state' |
| 16 | import { EditorView } from 'prosemirror-view' |
| 17 | import { Schema } from 'prosemirror-model' |
| 18 | import { schema as basic } from 'prosemirror-schema-basic' |
| 19 | import { addListNodes, sinkListItem, liftListItem, splitListItem } from 'prosemirror-schema-list' |
| 20 | import { keymap } from 'prosemirror-keymap' |
| 21 | import { baseKeymap, toggleMark, chainCommands } from 'prosemirror-commands' |
| 22 | import { withIndent, changeIndent } from './indent.mjs' |
| 23 | import { autoformatRules } from './autoformat.mjs' |
| 24 | import { SIZE_MARKS } from './sizes.mjs' |
| 25 | import { pagesPlugin, PAGED, INFINITE, writeMode } from './pages.mjs' |
| 26 | import { mountWordCount } from './wordcount.mjs' |
| 27 | import { mountShortcuts } from './shortcuts.mjs' |
| 28 | import { undoInputRule } from 'prosemirror-inputrules' |
| 29 | import { ySyncPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror' |
| 30 | import { createSaveQueue } from './save-queue.mjs' |
| 31 | import { createResyncController } from './resync-controller.mjs' |
| 32 | import { alignedParagraph } from './align.mjs' |
| 33 | import { blankLinesFromBreaks, hardBreakIgnoringInterchange } from './paste.mjs' |
| 34 | import { createToolbar } from './toolbar.mjs' |
| 35 | import { mountMenu } from './menu.mjs' |
| 36 | import { 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. |
| 53 | function 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. |
| 94 | const 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. |
| 134 | const 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 | |
| 146 | const 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. |
| 167 | function 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. |
| 211 | const 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. |
| 225 | function 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 | |
| 233 | export 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 | |
| 956 | const mount = document.querySelector('[docs-editor]') |
| 957 | if (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 | |