// fixtures/editor-entry.js — the vendored editor bundle's entire // source. fixtures/build-bundles.mjs bundles this one file (and // everything it imports) into internal/docs/static/editor..js; // this is the whole program that runs in the browser, not a library // surface something else consumes — the app performs no build step // (spec §6.5), so what's committed here is what ships. // // yjs stays pinned at the exact version fixtures/package.json pins // for the wire fixtures (13.6.27, see that file's own comment) — // this bundle and core/'s Rust CRDT must agree on the same wire // format, and a bundle built against a different yjs would make that // agreement untested. import * as Y from 'yjs' import * as decoding from 'lib0/decoding' import { EditorState, Selection } from 'prosemirror-state' import { EditorView } from 'prosemirror-view' import { Schema } from 'prosemirror-model' import { schema as basic } from 'prosemirror-schema-basic' import { addListNodes, sinkListItem, liftListItem, splitListItem } from 'prosemirror-schema-list' import { keymap } from 'prosemirror-keymap' import { baseKeymap, toggleMark, chainCommands } from 'prosemirror-commands' import { withIndent, changeIndent } from './indent.mjs' import { autoformatRules } from './autoformat.mjs' import { SIZE_MARKS } from './sizes.mjs' import { pagesPlugin, PAGED, INFINITE, writeMode } from './pages.mjs' import { mountWordCount } from './wordcount.mjs' import { mountShortcuts } from './shortcuts.mjs' import { undoInputRule } from 'prosemirror-inputrules' import { ySyncPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror' import { createSaveQueue } from './save-queue.mjs' import { createResyncController } from './resync-controller.mjs' import { alignedParagraph } from './align.mjs' import { blankLinesFromBreaks, hardBreakIgnoringInterchange } from './paste.mjs' import { createToolbar } from './toolbar.mjs' import { mountMenu } from './menu.mjs' import { createLiveUpdates, updatesURL } from './live-updates.mjs' // spanMark builds one of the font/size marks: attribute-free, rendered // as a span with a fixed class. // // The class names, and the mark names, are a contract with // core/src/fragment.rs's MARKS table — the server renders the same // document to the same markup for a reader with no JavaScript, and // internal/export reads the same mark names to lay out a PDF. All // three have to agree, and none of them can carry a value: see that // table's own comment for why a mark with an attribute would spend // the property that lets the read view emit its markup unescaped. // // group/excludes make each set mutually exclusive, so text cannot end // up both sans and mono, or both small and large. The toolbar's own // select controls rely on that as well as on their own clearing — // belt and braces, because the schema is what also governs a paste. function spanMark(cls, group) { return { group, excludes: group, parseDOM: [{ tag: 'span.' + cls }], toDOM: () => ['span', { class: cls }, 0], } } // addToStart, applied in reverse, leaves the font and size marks ahead // of prosemirror-schema-basic's own — ProseMirror nests marks in // schema order, so this makes the browser produce // …, which is the // order fragment.rs's MARKS table renders server-side. // // All three faces are marks, the default one included. The default // (fixtures/fonts.mjs's DEFAULT_FONT) is what the toolbar CLEARS to // rather than sets, so no document gains a span for the face it was // already in — but every one of the three still has to exist here. // // Not for the reason it is tempting to give. A document written before // the default moved cannot contain font_serif: serif was the default // then, so it was the ABSENCE of a mark, which is exactly why this mark // had to be invented. What those documents contain is font_sans — the // mark of the face that is now the default — put there by everyone who // chose sans while choosing it still meant something. So the argument is // the stronger one: the mark that most needs to go on parsing is the // CURRENT default's, and dropping any face's mark from this schema // would strip it out of every document carrying it on the next edit. // The size marks are added from SIZE_MARKS rather than written out, // which is a departure from the three faces below and needs saying. // // The usual argument for writing names out is that internal/docs's // tests grep this file's source for a literal, so a loop would leave // them nothing to find. That argument still holds for anything they // grep — and it is why fixtures/sizes.mjs spells the ladder out as // numbers, where they can be read. Here there are fourteen of them // (eleven points and three legacy names), the class is mechanically the // mark name with its prefix swapped, and fourteen hand-written lines // would be fourteen chances to typo a class that then renders as // nothing. The vocabulary lives in sizes.mjs; this is its application. const marks = SIZE_MARKS.reduce( (spec, mark) => spec.addToStart(mark, spanMark('docs-' + mark.replace('_', '-'), 'fontSize')), basic.spec.marks, ) .addToStart('font_mono', spanMark('docs-font-mono', 'fontFamily')) .addToStart('font_sans', spanMark('docs-font-sans', 'fontFamily')) .addToStart('font_serif', spanMark('docs-font-serif', 'fontFamily')) // The paragraph gains an `align` attribute — the first block-level // formatting, and the first thing in this schema that is not a mark. // // It cannot be one: a mark applies to a run and alignment applies to // the whole block, so it lives on the node. That means it crosses the // wire differently too — y-prosemirror stores a node's attributes as // Yjs XML attributes, so an aligned paragraph genuinely changes what // the document CONTAINS, where a mark only changes how a run is // annotated. fixtures/align.mjs has the vocabulary and the reasoning, // and fixtures/align.test.mjs pins it; the short version is that the // value is only ever a key into a closed table, in this schema, in // core/src/fragment.rs and in internal/export/pdf.go alike. // // Applied before addListNodes so the list nodes are built against the // paragraph that already has the attribute — a list item's paragraph is // an ordinary paragraph and gets alignment like any other. // // hard_break is patched at the same time, and only on the way in: the // rule ignores WebKit's Apple-interchange-newline marker, which is a // copy boundary rather than a line the author left. See paste.mjs. // Indent is the second block attribute, and it wraps the paragraph // AFTER alignment so a paragraph carries both. withIndent composes // rather than replaces — it keeps whatever parseDOM rules the spec it // wraps already had and merges its own getAttrs into them, which is // what stops a pasted paragraph that is centred AND indented from // losing one of the two. // // Headings get it too, and do not get alignment. Every word processor // indents a heading; none of the reference applications this one // follows offers alignment on a heading through its own toolbar, and // adding it would be a change to fragment.rs and pdf.go as well as to // this line. const nodes = addListNodes( basic.spec.nodes .update( 'paragraph', withIndent(alignedParagraph(basic.spec.nodes.get('paragraph')), 'p'), ) .update('heading', withIndent(basic.spec.nodes.get('heading'), 'h1')) .update('hard_break', hardBreakIgnoringInterchange(basic.spec.nodes.get('hard_break'))), 'paragraph block*', 'block', ) const schema = new Schema({ nodes, marks }) // Shift+Enter: a line break inside the current block, rather than a // new block. // // hard_break came with prosemirror-schema-basic from the beginning — // the node was always in the schema above — but nothing bound a key to // it, so there was no way to type one. The rest of the vocabulary is a // contract shared with core/src/fragment.rs (which renders the node as //
for a reader with no JavaScript, and reports it to // internal/export so both exporters can see it); the node's NAME is // what crosses the wire, because y-prosemirror stores every non-text // node as a Y.XmlElement named after its type. // // Written out rather than imported: prosemirror-commands exports no // insert-a-break command (checked against 1.7.2's own export list — // splitBlock, exitCode and the rest, nothing for this), so every // ProseMirror setup writes these same three lines. dispatch is checked // because a command can be called without one, to ask whether it // applies rather than to run it, and scrollIntoView because the new // line can be below the fold on a long document. function insertHardBreak(state, dispatch) { if (dispatch) { dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()) } return true } // Coalesce a burst of typing into one request. // // This constant is the floor on how fast an edit can reach anyone // else: nothing leaves the tab that made it until the window closes, // so the stream, the fetch and the render all happen after it. 250ms // is chosen for that — under the ~300ms at which a delay stops // reading as "the app responded" and starts reading as "the app is // thinking" — rather than for any property of the server. // // It could not be chosen that way until recently, and the history is // worth keeping because the old value looked like a feel decision and // was not. Every accepted POST is one doc_updates row, and // core.Handle's maxRetainedUpdates (internal/core/core.go, 4096) // capped a document at that many, after which it refused edits // permanently. The value was therefore headroom against a cliff: // 400ms bought ~27 minutes of continuous typing, 2000ms bought about // 2h16m. Snapshots removed the cliff — replay now starts from a // snapshot plus a bounded tail — so this is free to be a number about // perception again. // // What it still costs, because this is not free and the next reader // should not have to rediscover it: eight times the row rate, roughly // 14,400 doc_updates rows an hour of continuous typing against the // old 1,800, each holding a character or two rather than a sentence. // Snapshots bound what that costs to REPLAY; nothing yet bounds what // it costs to STORE, because §4.1's compaction horizon is still // deferred, so the log and the WAL Litestream replicates grow eight // times faster than they did. The fix that would give back the rows // without giving back the latency is §4.1's other mechanism — // coalescing same-actor updates at the storage boundary — and it // belongs with the WebSocket transport, which needs it anyway. // // Nothing shows a "saving" indicator during an ordinary wait; only a // failure does, via the save queue's own status (see save-queue.mjs // and views/show.html's [estilo-save-status]). So this delay stays // invisible exactly when saving is going fine, which is the case that // matters for feel. const DEBOUNCE_MS = 250 // applyServerLog replays handlers.go's updatesGet response into ydoc. // That response is a raw concatenation of one encoded Yjs update per // accepted POST, back to back with no framing between them (its own // doc comment: "concatenated in seq order") — one row per update, // exactly like Store.replay applies them server-side. A single // Y.applyUpdate call cannot replay that correctly: its decoder reads // only the client count declared in the FIRST message's own header, // then returns, leaving every later message's bytes on the floor — // confirmed empirically (a second update silently vanishes) before // this fix went in. Y.readUpdate, looped over one shared lib0 decoder // until it is exhausted, replays every message the log actually // holds instead of just the first one. function applyServerLog(ydoc, log) { if (!log.length) return const decoder = decoding.createDecoder(log) while (decoding.hasContent(decoder)) { Y.readUpdate(decoder, ydoc, 'server') } } export async function mountEditor(el) { const sqid = el.getAttribute('data-sqid') const base = `/d/${sqid}/updates` // The live-update stream (internal/docs/stream.go). A sibling of // base rather than something derived from it: they are two routes // on one document, not one route and a variation of it. const streamURL = `/d/${sqid}/stream` const statusEl = document.querySelector('[estilo-save-status]') const statusURL = new URL(el.getAttribute('data-save-status-module'), location.href) if (!statusEl || statusURL.origin !== location.origin) throw new Error('Invalid save-status module') const { mount } = await import(statusURL.href) const setSaveState = mount(statusEl) // sessionFailed is stickier than any one save queue's own status // (save-queue.mjs's own report() is only sticky within a single // instance, and a resync replaces that instance): once a batch is // ever dropped in this page's lifetime, the failed indicator must // stay lit even after the editor is resynced and saving normally // again — a person still needs to know something did not make it. let sessionFailed = false // currentQueue/currentView are read by beforeunload and by // attemptResync below; both are reassigned wholesale on every // resync, so anything that needs "whichever one is live right now" // reads through these rather than closing over a value captured at // mount time. let currentQueue = null let currentView = null // currentLive is the live-update subscriber (live-updates.mjs) for // whichever Y.Doc is current. It belongs to that document, not to // the page: a resync builds a new Y.Doc, so the subscriber feeding // the old one is stopped and replaced along with the save queue and // the view, or it would apply bytes into a document that has been // discarded. let currentLive = null // The toolbar (fixtures/toolbar.mjs) is built once, before the first // mount, because mountView needs its plugin. It reads the live view // through this closure rather than being handed one: attemptResync // destroys and rebuilds the EditorView wholesale, and a toolbar // holding a captured view would dispatch into a dead one after a // resync — the same reason placeCaretFromPage reads currentView at // event time. views/show.html renders the markup; a page without it // (or a future template that drops it) gets a toolbar with no // plugin and an editor that works exactly as it did before. // The menu bar (fixtures/menu.mjs) is mounted first but wired second, // because the two point at each other: the menu dispatches through // the toolbar's command table, and the toolbar's plugin is what // drives the menu's sync. Declaring it here and filling it in below // is the smaller of the two awkwardnesses — the alternative is a // second ProseMirror plugin watching the same editor, and two // watchers of one selection is how a menu item and the toolbar button // beside it end up enabled differently. // // The bar works before any of this runs: it is six
// elements, and its links and forms need no editor at all. Only the // command items wait on a view. let menu = null // Paged or infinite. The server has already put its answer on // [docs-canvas] from the cookie (handlers.go), so the first paint is // already right and this only has to agree with it — reading the // cookie again here would be a second source for one fact. // // The canvas carries the attribute rather than the editor, because // what changes between the modes is the desk the paper sits on, and // the gaps are drawn inside the page's own text flow. const canvasEl = document.querySelector('[docs-canvas]') let viewMode = canvasEl && canvasEl.getAttribute('data-view') === PAGED ? PAGED : INFINITE // The page-number template ("Page {n}"), server-rendered from the // catalog into a data attribute — the bundle carries no copy of its // own, the same rule the save-status indicator and the toolbar // already follow. const pageLabelText = canvasEl ? canvasEl.getAttribute('data-page-label') : '' function setViewMode(mode) { viewMode = mode if (canvasEl) canvasEl.setAttribute('data-view', mode) writeMode(mode) // Nudge the plugin: the mode is not part of editor state, so // nothing else would tell it to re-measure. const view = currentView if (view) view.dispatch(view.state.tr) } // The Tools menu's two dialogs. Both read their copy from markup // views/show.html rendered from the catalog, and both are null when // that markup is absent — a template without them gets a menu whose // items stay disabled rather than an error. const wordCount = mountWordCount(document.querySelector('[docs-wordcount]'), { getView: () => currentView, }) const shortcuts = mountShortcuts(document.querySelector('[docs-shortcuts]'), { menubar: document.querySelector('[estilo-menubar]'), // The bindings no menu item carries — Tab, Shift+Tab, the // autoformat undo and Shift+Enter — server-rendered into a hidden // block as data so their labels stay in the catalog with every // other string. extraRows: Array.from( document.querySelectorAll('[docs-shortcuts-extra] span'), ).map((el) => ({ group: el.closest('[docs-shortcuts-extra]').getAttribute('data-group') || '', label: el.getAttribute('data-label') || '', keys: el.getAttribute('data-keys') || '', })), }) const toolbar = createToolbar({ root: document.querySelector('[docs-toolbar]'), schema, getView: () => currentView, onSync: (view) => { if (menu) menu.sync(view) }, }) menu = mountMenu(document.querySelector('[estilo-menubar]'), { getView: () => currentView, commands: toolbar.commands, // The check column: the view mode is this file's; the paragraph // style and alignment are the toolbar's, read the same way its own // pressed buttons and Style select are. isPressed: (name, view) => { if (name === 'view_paged') return viewMode === PAGED if (name === 'view_infinite') return viewMode === INFINITE return Boolean(view && toolbar.isPressed && toolbar.isPressed(view.state, name)) }, // The two View-menu commands. They are not editor commands — they // change how the document is drawn, not what it contains — so they // are passed in here rather than living in toolbar.mjs's table // alongside Bold. menu.mjs treats them the way it treats Print: not // dependent on a selection, and available whenever the page is. extraCommands: { view_paged: () => { setViewMode(PAGED) return true }, view_infinite: () => { setViewMode(INFINITE) return true }, // Both are null when the template renders no dialog, and // mountMenu leaves an item with no command disabled — which is // the right outcome rather than a crash. ...(wordCount ? { word_count: wordCount } : {}), ...(shortcuts ? { shortcuts } : {}), }, }) function updateIndicator(state) { setSaveState(sessionFailed ? 'failed' : state) } // getLive is a getter for THIS lifecycle's own subscriber, never // the module-level currentLive. Whole-branch review found the // difference matters: attemptResync stops the old subscriber but // cannot stop the old save queue (save-queue.mjs has no stop, by // design), so a POST issued before a resync can resolve after it. // Reading currentLive at that moment would hand the seq of a // pre-resync row to the FRESH subscriber, whose cursor was seeded // from a GET that predates it — and if the contiguity check // happened to pass, that cursor would step over a row the fresh // Y.Doc never applied. Closing over the lifecycle's own reference // means a late response lands on a stopped instance, which ignores // it. function makeSaveQueue(getLive) { // The save queue (fixtures/save-queue.mjs) owns the retry/drop // decision — fix round 1's F1: a permanently-rejected batch (a // 4xx) must be dropped, not retried forever and not merged into // by whatever gets typed next, while a network failure or a 5xx // is worth retrying. See that module's own doc comment for the // bug this replaced and how fixtures/save-queue.test.mjs // reproduces it. return createSaveQueue({ // Wrapped rather than handed straight to fetch so this tab can // read the seq the server assigned to its own write, which // saves the round trip of hearing about it on the stream and // fetching back bytes it already has. save-queue.mjs is // untouched by this: it consumes only r.ok and r.status, and // this returns the same response it always did. // // Only on a 2xx. A 4xx or 5xx carries no seq — the server // broadcasts and sets the header only when ApplyUpdate returned // no error — and noteOwnSeq's own contiguity check would ignore // a stale number anyway. post: async (bytes) => { const res = await fetch(base, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/octet-stream' }, body: bytes, }) const live = getLive() if (res.ok && live) { const seq = Number(res.headers.get('Docs-Seq')) if (Number.isFinite(seq)) live.noteOwnSeq(seq) } return res }, mergeUpdates: Y.mergeUpdates, debounceMs: DEBOUNCE_MS, onStatus: (state) => { if (state === 'failed') { sessionFailed = true resyncController.reportDivergence() } updateIndicator(state) }, }) } // placeCaretFromPage makes the whole sheet of paper behave like the // document: a click in the page's 1in margin — outside the editable // node, so neither the browser nor ProseMirror sees it as a click on // text at all — puts the caret on the line nearest where the pointer // landed, the way every word processor does. views/show.html grows // the editable node to the full height of the page's text area, so // the empty space below the last paragraph is already ProseMirror's // own and needs none of this; the margins around it never can be, // because they are [docs-page]'s padding and the editable node has // no way to reach outside itself. // // Clamping the click's coordinates into the editable node's own // rectangle before asking posAtCoords is what turns "beside the // text" into "at the nearest end of the nearest line": posAtCoords // answers null for a point outside the editor, and the clamped point // is the closest one inside it. A null answer even after clamping // falls back to the end of the document — not because any particular // document is known to produce one (an empty document still holds an // empty paragraph, which resolves fine), but because posAtCoords // reads the live DOM and is documented to be able to return null, // and the end of the document is the one position that always // exists whatever it hands back. // // Reads currentView, not a view captured when the listener was // registered: a resync destroys the whole EditorView and builds // another (attemptResync), and a click after that must reach the // live one rather than dispatching into a destroyed view. function placeCaretFromPage(event) { const view = currentView if (!view || event.button !== 0) return // Inside the editable node already: ProseMirror's own handling is // better than anything reconstructed from coordinates here. if (view.dom.contains(event.target)) return const rect = view.dom.getBoundingClientRect() const found = view.posAtCoords({ left: Math.min(Math.max(event.clientX, rect.left + 1), rect.right - 1), top: Math.min(Math.max(event.clientY, rect.top + 1), rect.bottom - 1), }) const pos = found ? found.pos : view.state.doc.content.size // Selection.near, spelled on the base class rather than as // TextSelection.near: TextSelection defines no static near of its // own (prosemirror-state 1.4.4), so the latter silently resolves // to this exact same inherited function while reading like a // promise that the result is a TextSelection. It usually is, but // near falls back to a NodeSelection or an AllSelection when no // text position is reachable, and a name that overstates that is // the kind of comment-shaped lie this file's own conventions // exist to keep out. Every branch here yields some valid // selection, which is all the dispatch below needs. const selection = Selection.near(view.state.doc.resolve(pos)) // preventDefault before focusing: the default mousedown on a // non-editable element blurs the editor and collapses the // selection we are about to set, so the caret would appear and // vanish in the same tick. event.preventDefault() view.dispatch(view.state.tr.setSelection(selection).scrollIntoView()) view.focus() } function mountView(mountEl, fragment) { // {mount: mountEl}, not plain mountEl: passed as a bare DOM node, // EditorView only appends its own div as an extra child // (prosemirror-view's own constructor — // place.appendChild(this.dom)), leaving whatever was already // there sitting beside it as inert, stale markup a click could // land on instead of the real editable surface. {mount: mountEl} // makes mountEl itself the editable DOM node, so ProseMirror takes // over its existing children (this.mounted = true) instead of // adding a sibling. const plugins = [ ySyncPlugin(fragment), yUndoPlugin(), // Autoformat (fixtures/autoformat.mjs): "- " makes a bullet, // "1. " a numbered list, "> " a quote, "# " a heading. Ahead of // the keymaps because the rules act on typed TEXT rather than on // keys, and because the Backspace binding below that reverts them // has to be able to see that one just fired. autoformatRules(schema), // Paged/infinite (fixtures/pages.mjs). Reads the mode through a // getter rather than being handed one, because the View menu can // change it at any moment and a plugin is configuration reused // across every state this page builds. pagesPlugin({ getMode: () => viewMode, pageLabel: pageLabelText }), keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo, 'Mod-b': toggleMark(schema.marks.strong), 'Mod-i': toggleMark(schema.marks.em), // In this block rather than appended to baseKeymap below, // which is a shared module-level object every editor on the // page would see mutated. Nothing binds Shift-Enter today: // baseKeymap has Enter and Mod-Enter only, and // prosemirror-keymap looks up the full modified name for a // named key like Enter (its shift-less fallback is for // single-character keys), so the keystroke matched no binding, // was then captured and preventDefaulted by prosemirror-view, // which treats every Enter as handled (captureKeyDown returns // true for keyCode 13 whether or not shift is down, and its // caller preventDefaults whenever handleKeyDown fails and // captureKeyDown succeeds), and so did nothing at all. // // Worth stating precisely, because the plausible wrong reading // is that the keystroke fell through to the browser's own // contenteditable handling, inserted a native
, and was // re-parsed into this node by the schema's parseDOM — i.e. // that a break sometimes worked before this binding existed. // It never could: the event never reached the browser. 'Shift-Enter': insertHardBreak, // Tab and Shift+Tab. // // chainCommands, so one key does the right thing in two // contexts without this file testing which one it is in: in a // list, Tab nests the item and Shift+Tab lifts it; anywhere // else it steps the block's own indent. sinkListItem is tried // FIRST and returns false when the selection is not in a list, // which is what hands the key to changeIndent. // // Tab inside an editable surface does not move focus, and that // is a deliberate exception to the usual rule rather than an // oversight. WCAG 2.1.2 forbids a keyboard trap, not a // repurposed Tab: Escape is not needed to leave because the // surrounding chrome — the menu bar, the formatting bar, the // sign-out control — is all reachable by Tab from outside, and // Shift+Tab at indent 0 in the first block returns false, so // the browser's own focus move takes over and a person tabs // backwards out of the document exactly as before. That last // part is why changeIndent reports honestly at the ends of its // range instead of returning true and swallowing the key. Tab: chainCommands(sinkListItem(schema.nodes.list_item), changeIndent(1)), 'Shift-Tab': chainCommands(liftListItem(schema.nodes.list_item), changeIndent(-1)), // Backspace immediately after an input rule fired reverts the // transformation and leaves the typed characters — "- " goes // back to being "- " rather than becoming a list you have to // fight your way out of. This is the whole reason autoformat is // tolerable, and it has to be in THIS block rather than // appended to baseKeymap below, because baseKeymap binds // Backspace too and whichever comes first wins. Away from a // rule, undoInputRule returns false and baseKeymap's own // Backspace takes the key as it always did. Backspace: undoInputRule, // Escape leaves the document for the formatting bar. // // Tab is repurposed in here — it indents rather than moving // focus — and although Shift+Tab still escapes backwards // (changeIndent reports false at indent 0, handing the key // back to the browser), there is no forward exit below indent // 8. WCAG 2.1.2 permits a modified key provided the person is // told, and the shortcuts sheet tells them; but "you can only // leave backwards" is a poor thing to have to say, so Escape // leaves forwards onto the nearest chrome. // // Returns whatever focusStop reports rather than always true: // with no toolbar in the DOM there is nowhere to go, and // swallowing Escape then would take the key away from anything // else that wants it while doing nothing. Escape: () => toolbar.focusStop(), // Enter in a list splits the item, and on an empty item lifts // out of the list — the idiom every editor has. Without this, // baseKeymap's splitBlock puts a bare paragraph inside the list // item and there is no way to end a list by pressing Enter // twice. Missing since lists were added; found in review. Enter: chainCommands(splitListItem(schema.nodes.list_item), baseKeymap.Enter), }), keymap(baseKeymap), ] // The toolbar's plugin keeps its buttons in step with the // selection. One Plugin instance is reused across every state this // page builds — a plugin is configuration, not state — so a resync // rebuilding the view does not need a new toolbar, and the toolbar // does not need to know a resync happened. if (toolbar.plugin) plugins.push(toolbar.plugin) return new EditorView({ mount: mountEl }, { state: EditorState.create({ schema, plugins }), // A blank line pasted out of Google Docs arrives as a paragraph // holding one hard_break, which the editor draws two lines tall. // paste.mjs's header has the whole chain; the short version is // that this is the first point where a block-level
can be // told apart from one the author typed. transformPasted: blankLinesFromBreaks(schema), }) } // attemptResync fetches the server's own log fresh and rebuilds the // whole editor (Y.Doc, save queue, EditorView) from it — the same // thing this function also does for the very first mount below, // and the same truth a manual reload already reconstructs // correctly. Used both ways rather than duplicated: the initial // mount awaits it directly (a failure there means the editor never // mounted at all, handled by the outer .catch below), while a // later resync goes through resyncController, which retries on // failure instead of propagating one (fix round 2's own fix — see // resync-controller.mjs's header comment for why a resync that // silently gives up after one failure recreates the exact data // loss F1 was supposed to close). // // ySyncPlugin mirrors every local ProseMirror transaction into the // Y.Doc immediately, optimistically, before any network call even // starts, so once a batch is permanently rejected the local doc // already holds content the server never received — every update // produced after that point is encoded relative to that now- // fictional history, which core.Handle.Apply accepts (a real 204) // but can never actually integrate. The only way back to a state // new edits can build on is to discard the diverged document // entirely and rebuild from the server's own log — which is also // why this necessarily discards whatever was typed locally in the // interim: it was built on the same poisoned history and could // never have been saved either. // fetchLog does the whole-log GET that both the initial mount and // every later resync use (attemptResync below). raw, when true, // appends the literal query string `raw=1` — handlers.go matches // nothing else — which skips the snapshot lookup entirely and // serves exactly the doc_updates rows the server has always been // able to serve correctly. See applyServerLog's caller below for // why this exists. async function fetchLog(raw) { const url = updatesURL(base, { raw }) const res = await fetch(url, { credentials: 'same-origin' }) if (!res.ok) throw new Error(`load ${url}: ${res.status}`) return { log: new Uint8Array(await res.arrayBuffer()), // Docs-Seq names how far the bytes just received actually reach, // and the server derives it from those bytes rather than from a // separate query — so this cursor can never start out ahead of // what this document holds. A missing or unparseable header // (an older server, a proxy that strips it) becomes 0, which // costs one redundant full fetch on the first notification and // loses nothing. startSeq: Number(res.headers.get('Docs-Seq')), } } async function attemptResync() { let { log, startSeq } = await fetchLog(false) let ydoc = new Y.Doc() try { applyServerLog(ydoc, log) } catch (decodeErr) { // This fetch happens before any live-update subscriber exists — // it IS the document, for the initial mount as much as for a // later resync — so a corrupt snapshot here has no cursor to // fall back through the way live-updates.mjs's drain() does; it // would simply stop the editor mounting at all. Same cause as // that module's own retry (its comment explains the "why" in // full): the server serves a snapshot without ever calling into // the CRDT core, so it cannot detect one corrupted on disk after // it was written and verified — only Y.readUpdate, here, can. // A fresh Y.Doc for the retry, not the one just poisoned by a // partial decode: applyServerLog can throw partway through the // loop, leaving ydoc in an unknown state that must not be built // on. A second throw is not caught again; it propagates like any // other attemptResync failure — to the outer .catch on the // initial mount, or to resyncController's own retry loop on a // later resync. ;({ log, startSeq } = await fetchLog(true)) ydoc = new Y.Doc() applyServerLog(ydoc, log) } const fragment = ydoc.getXmlFragment('prosemirror') // lifecycleLive is this resync's own subscriber — see // makeSaveQueue's own comment for why the queue must not read the // module-level currentLive instead. Assigned below, once // createLiveUpdates has built it; until then the getter returns // null and a POST simply records no seq, which costs one fetch. let lifecycleLive = null const saveQueue = makeSaveQueue(() => lifecycleLive) // Send only what originated here, and only once the document is // known consistent with the server. The 'server' origin above // marks replayed bytes (echoing those back would grow the log // without adding information); resyncController.diverged marks // everything produced while a previous rejection is still being // recovered from — sending THAT would only earn more silently- // unintegrated 204s (fix round 2's blocking finding) and spend // core.go's maxRetainedUpdates cap for nothing. Once a resync // actually replaces this ydoc with a fresh one, diverged is false // again and this check simply passes every update through. ydoc.on('update', (update, origin) => { if (origin === 'server') return if (resyncController.diverged) return saveQueue.enqueue(update) }) // Destroy the old view BEFORE creating the new one, not after: // {mount: el} makes an EditorView take exclusive ownership of // el's own DOM node, so two views both pointed at the same el at // once corrupted that node instead of cleanly handing it off — // found when a resync's own follow-up edit landed nowhere: the // click after resync hit a DOM ProseMirror no longer recognised // as its own. if (currentView) currentView.destroy() currentQueue = saveQueue currentView = mountView(el, fragment) // The live subscriber is replaced wholesale with everything else. // Stopping the old one first matters for the same reason // destroying the old view first does: it may have a fetch in // flight, and its own stop() is what makes that response land // nowhere instead of in the document that just replaced it. if (currentLive) currentLive.stop() lifecycleLive = createLiveUpdates({ initialSeq: Number.isFinite(startSeq) ? startSeq : 0, openStream: (onEvent, onStreamError) => { const es = new EventSource(streamURL, { withCredentials: true }) es.addEventListener('update', (ev) => onEvent(Number(ev.data))) es.addEventListener('error', (ev) => onStreamError(ev)) return { close: () => es.close() } }, // raw must reach the server as the literal query string `raw=1` // — handlers.go matches nothing else, not `raw=true` or a bare // `raw` — or the escape hatch fails silently and drain() ends up // refetching the same corrupt snapshot forever. fetchSince: async (since, { raw } = {}) => { const url = updatesURL(base, { since, raw }) const r = await fetch(url, { credentials: 'same-origin' }) if (!r.ok) throw new Error(`fetch ${url}: ${r.status}`) return { bytes: new Uint8Array(await r.arrayBuffer()), seq: Number(r.headers.get('Docs-Seq')), } }, onUpdates: (bytes) => { // While diverged the local document is known poisoned and a // rebuild is already being retried; applying remote bytes to // it would only build more history on top of a fiction. The // resync replaces this whole subscriber, and its fresh // cursor picks these same rows up again. if (resyncController.diverged) return applyServerLog(ydoc, bytes) }, onError: (err) => { // Deliberately not updateIndicator('failed'): [estilo-save-status] // means "your edit did not save", and that is still false — // POST is a separate path and is unaffected by a dead stream. // What is lost is freshness, which heals on the next activity. console.error('live updates:', err) }, }) currentLive = lifecycleLive currentLive.start() // A tab that LOADS hidden never fires visibilitychange, so the // listener below cannot close its stream and only the idle timer // would — leaving a background tab holding a connection for two // minutes it never needed. Cheap to check once, here, where the // stream has just been opened. if (typeof document !== 'undefined' && document.hidden) currentLive.noteHidden() } // resyncController (fix round 2) owns "the local document has // diverged and needs rebuilding" as its own retried-until-it- // succeeds state machine — see resync-controller.mjs's own header // comment for the bug this replaced: round 1's resync gave up // silently after one failure, and nothing ever told it to try // again, so a single dropped connection at the wrong moment // recreated the exact silent-204 loss F1 exists to prevent. // onDiverged/onRecovered keep the visible indicator in step with // the state machine's own truth rather than with whatever the // (possibly still-diverged) save queue's own status happens to // report — sessionFailed's own stickiness (updateIndicator) means // onRecovered never actually clears the indicator once something // has been lost, but it does matter the first time: onDiverged is // what lights the indicator up if a batch is ever dropped without // save-queue.mjs's own onStatus('failed') having already done so. const resyncController = createResyncController({ attemptResync, onDiverged: () => updateIndicator('failed'), onRecovered: () => updateIndicator(sessionFailed ? 'failed' : 'ok'), }) // Initial mount: attemptResync's own failure here is NOT retried — // it propagates to the outer .catch below, which leaves the // server-rendered read view in place. Retrying belongs to a // document the person is already editing (resyncController's own // job); a document that never opened has nothing to retry into. await attemptResync() // The live subscriber's window on whether anybody is here. It owns // no DOM of its own (live-updates.mjs is testable under plain Node // precisely because of that), so every listener it needs is // registered here, once, reading currentLive rather than a // subscriber captured at mount time — a resync replaces that // object, and these listeners outlive every one of them. // // A hidden tab shortens its deadline rather than closing on the // spot: platform hibernation is default-on and this app holds one // stream per open editor, so a document left open on Friday must not // keep its instance awake all weekend — but closing the instant a // tab goes to the background made every switch back paint the old // document for a frame before the reconnect caught it up, which is // the flash Paul kept seeing. live-updates.mjs's HIDDEN_MS carries // the measurement and the arithmetic. // // Everything else is "activity", and the breadth is the point. The // obvious three — visibility, focus, and typing — all miss the // commonest case there is: someone READING a long document, on a // tab that is already visible and already focused. That person // fires none of them, so an idle close would never be undone and // the tab would go quietly stale — which is exactly the watching // half of "type in one tab, watch the other". A pointer moving or // a page scrolling is what says they are still there. // // All passive, and none of them does work per event: noteActivity // only re-arms a timer, or reopens a stream that is already closed. addEventListener('visibilitychange', () => { if (document.hidden) currentLive && currentLive.noteHidden() else currentLive && currentLive.noteActivity() }) for (const name of ['pointermove', 'scroll', 'keydown', 'focus']) { addEventListener(name, () => currentLive && currentLive.noteActivity(), { passive: true, capture: true }) } // Registered on [docs-page] rather than on the editable node (which // never receives these clicks) and after the first mount rather than // before it, so placeCaretFromPage always has a live view to // dispatch into. Once, not once per resync: it reads currentView, // and the page element outlives every EditorView mounted into it. const page = el.closest('[docs-page]') if (page) page.addEventListener('mousedown', placeCaretFromPage) // The caret is this editor's focus indicator — views/show.html drops // the browser's focus ring, on the argument that a text cursor says // more than a box around the paper does. That argument only holds // while the caret is actually on screen, and one route in breaks it: // Tab in from the sign-out button, on a document scrolled away from // wherever the selection was last left, and ProseMirror restores that // selection without scrolling to it (its focus() does not scroll on // purpose), so focus lands with no visible indicator anywhere in the // viewport. The ring, whose edges would have been on screen, used to // cover that case. AGENTS.md's rule is WCAG 2.2 AA with no // exemptions, so the ring's replacement has to be visible on every // path that reaches focus, not just on the click that scrolls itself // into view. An empty transaction carries the scroll: it changes no // content, so ySyncPlugin emits no Y update and the save queue never // sees it (this must not cost a row against core.go's // maxRetainedUpdates for merely tabbing into a document). // // Only for focus that did NOT arrive by pointer, which is the whole // subtlety here. A focus event fires before ProseMirror has set the // selection the click asked for, so scrolling on every focus would // scroll to wherever the selection happened to be left instead — // click into the third page of a document and the view yanks back to // the first, which is a worse bug than the one this is closing. // :focus-visible cannot make the distinction: a contenteditable // takes keyboard input, so it matches whatever the modality was. // Hence the flag: a pointer press sets it, and clearing it on the // next macrotask leaves it set for exactly the focus that press // causes (focus is dispatched synchronously inside the press's own // default action) and clear for every later one. let focusFromPointer = false const notePointer = () => { focusFromPointer = true setTimeout(() => { focusFromPointer = false }, 0) } document.addEventListener('mousedown', notePointer, true) document.addEventListener('touchstart', notePointer, true) el.addEventListener('focus', () => { if (focusFromPointer) return const view = currentView if (view) view.dispatch(view.state.tr.scrollIntoView()) }) // A close or reload with unsent bytes must not lose the edit. // pendingBytes() covers both what is still queued AND what is // already in flight (fix round 1's LOW finding: a flush's own // fetch, awaited but not yet resolved, held bytes that a same-tick // unload would otherwise abandon with nothing beaconed at all, // since they had already been cleared out of the queue proper). // Reads currentQueue, not a queue captured at mount time, so a // close during/after a resync still beacons the live queue. addEventListener('beforeunload', () => { const bytes = currentQueue && currentQueue.pendingBytes() if (!bytes) return const blob = new Blob([bytes], { type: 'application/octet-stream' }) const sent = navigator.sendBeacon(base, blob) if (!sent) { // sendBeacon refuses outright once its small outstanding quota // (browsers commonly cap the total around 64KB, shared across // every beacon call on the page) is used up — verified against // a real browser (fix round 2): a fetch with keepalive:true is // NOT a bigger-payload workaround for that — it shares // materially the same quota (a 100KB body was refused by both; // 30KB got through both). It is still tried here because // sendBeacon's refusal is not always about THIS payload's own // size — the quota is shared across every beacon call a page // makes, so some other beacon (an analytics call, say) could be // what actually used it up, in which case a fresh fetch call // can still get through. What this does NOT do, and cannot: get // a paste-sized edit that has not yet cleared one debounce // window (DEBOUNCE_MS above) out the door during an immediate // tab close. That gap is real and unclosed — a hard platform // limit, not a bug in this fallback — which is exactly why the // debounce above stays short enough that most edits have // already gone out long before anyone reaches for the tab's // close button. console.error('beforeunload: sendBeacon refused the pending edit, retrying via keepalive fetch') fetch(base, { method: 'POST', credentials: 'same-origin', keepalive: true, headers: { 'Content-Type': 'application/octet-stream' }, body: bytes, }).catch(() => {}) } }) return currentView } const mount = document.querySelector('[docs-editor]') if (mount) mountEditor(mount).catch(err => { // Leave the server-rendered read view in place rather than replacing // it with a blank editor: unreadable is worse than uneditable. mount.setAttribute('data-editor-failed', '1') console.error(err) })