Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

fix(frontend): CollabClient reads sendableSteps at flush time instead of queuing PM steps per-transaction

+57 -34
+42 -25
static/collab-client.js
··· 4 4 // Works with both source (text-patch steps) and rich (PM steps) modes. 5 5 6 6 export class CollabClient { 7 - /** 8 - * @param {string} rkey - Document rkey 9 - * @param {number} initialVersion - Version the client started at 10 - * @param {function(steps: object[]): void} applyRemoteSteps - Called when server confirms steps from others 11 - */ 12 7 constructor(rkey, initialVersion, applyRemoteSteps) { 13 8 this.rkey = rkey; 14 9 this.version = initialVersion; 15 10 this.applyRemoteSteps = applyRemoteSteps; 16 11 this._inflight = false; 17 - this._queue = []; 12 + this._queue = []; // used by source mode (text-patch steps) 13 + this._pmGetSendable = null; // set by rich mode; returns {steps, clientID} or null 18 14 } 19 15 20 - /** 21 - * Queue local steps and attempt to flush to the server. 22 - * @param {object[]} steps - Array of step objects (text-patch or PM step JSON) 23 - */ 16 + // Source mode: queue explicit step objects and flush. 24 17 sendSteps(steps) { 25 18 this._queue.push(...steps); 26 19 this._flush(); 27 20 } 28 21 22 + // Rich mode: register a callback that returns sendable PM steps on demand. 23 + // The callback should call sendableSteps(pmView.state) and return the result, 24 + // or null if nothing to send. 25 + setPMSendable(fn) { 26 + this._pmGetSendable = fn; 27 + } 28 + 29 + // Signal that a PM transaction occurred; try to flush if not already in-flight. 30 + notifyPMChange() { 31 + this._flush(); 32 + } 33 + 29 34 async _flush() { 30 - if (this._inflight || this._queue.length === 0) return; 35 + if (this._inflight) return; 36 + 37 + let toSend; 38 + let isRichMode = false; 39 + 40 + if (this._pmGetSendable) { 41 + // Rich mode: ask prosemirror-collab what needs sending. 42 + const sendable = this._pmGetSendable(); 43 + if (!sendable || sendable.steps.length === 0) return; 44 + toSend = sendable.steps.map(s => ({type: 'pm-step', json: JSON.stringify(s.toJSON())})); 45 + isRichMode = true; 46 + } else { 47 + // Source mode: drain the queue. 48 + if (this._queue.length === 0) return; 49 + toSend = this._queue.slice(); 50 + } 51 + 31 52 this._inflight = true; 32 - const toSend = this._queue.slice(); 33 53 try { 34 54 const resp = await fetch(`/api/docs/${this.rkey}/steps`, { 35 55 method: 'POST', ··· 44 64 if (resp.ok) { 45 65 const {version} = await resp.json(); 46 66 this.version = version; 47 - // Remove the steps we just confirmed. 48 - this._queue = this._queue.slice(toSend.length); 67 + if (!isRichMode) { 68 + this._queue = this._queue.slice(toSend.length); 69 + } 70 + // Rich mode: prosemirror-collab tracks confirmation via receiveTransaction; 71 + // no queue to drain here. 49 72 } else if (resp.status === 409) { 50 - // Server has steps we haven't seen yet. 51 73 const {version, steps: missedJSON} = await resp.json(); 52 74 const missed = missedJSON.map(s => JSON.parse(s)); 53 - // Let the editor rebase local steps on top of missed ones. 54 75 this.applyRemoteSteps(missed); 55 76 this.version = version; 56 - // Don't clear _queue — resubmit after rebase. 57 77 } else { 58 78 console.error('CollabClient: unexpected status', resp.status); 59 79 } ··· 61 81 console.error('CollabClient: fetch error', e); 62 82 } finally { 63 83 this._inflight = false; 64 - if (this._queue.length > 0) { 84 + // Retry if there's more to send. 85 + const hasMore = this._pmGetSendable 86 + ? (this._pmGetSendable()?.steps.length > 0) 87 + : this._queue.length > 0; 88 + if (hasMore) { 65 89 setTimeout(() => this._flush(), 50); 66 90 } 67 91 } 68 92 } 69 93 70 - /** 71 - * Call when a WebSocket "steps" message arrives from the server. 72 - * Advances local version and notifies the editor. 73 - * @param {object} msg - {type:"steps", steps:[...], version:N, clientID:string} 74 - * @param {string} myClientID 75 - */ 76 94 handleWSMessage(msg, myClientID) { 77 95 if (msg.type !== 'steps') return; 78 96 if (msg.clientID === myClientID) { 79 - // Our own steps confirmed — just advance version. 80 97 this.version = msg.version; 81 98 return; 82 99 }
+15 -9
templates/document_edit.html
··· 325 325 ...plugins, 326 326 collab({ version: collabClient.version }), 327 327 ]); 328 - // Override dispatchTransaction to detect local edits at the PM level. 329 - // After every transaction, sendableSteps is non-null only for local 330 - // (unconfirmed) edits — remote receiveTransaction steps are confirmed 331 - // immediately and never appear in sendableSteps. 328 + // Override dispatchTransaction to notify CollabClient after every 329 + // local transaction. CollabClient calls sendableSteps itself at 330 + // flush time, so it always sends exactly the current unconfirmed 331 + // set — never duplicates across keystrokes. 332 332 ctx.update(editorViewOptionsCtx, (prev) => ({ 333 333 ...prev, 334 334 dispatchTransaction: function(tr) { 335 335 const newState = this.state.apply(tr); 336 336 this.updateState(newState); 337 337 if (!applyingRemote) { 338 - const sendable = sendableSteps(newState); 339 - if (sendable) { 340 - const stepsJSON = sendable.steps.map(s => JSON.stringify(s.toJSON())); 341 - collabClient.sendSteps(stepsJSON.map(j => ({type: 'pm-step', json: j}))); 338 + collabClient.notifyPMChange(); 339 + if (tr.docChanged) { 342 340 try { 343 341 const serializer = milkdownEditor.action(c => c.get(serializerCtx)); 344 342 scheduleAutoSave(serializer(newState.doc)); ··· 359 357 }); 360 358 }) 361 359 .create(); 360 + 361 + // Register the PM sendable-steps provider so CollabClient reads directly 362 + // from prosemirror-collab's unconfirmed buffer rather than a separate queue. 363 + collabClient.setPMSendable(() => { 364 + const pmView = milkdownEditor.action(c => c.get(editorViewCtx)); 365 + return sendableSteps(pmView.state); 366 + }); 362 367 363 368 return milkdownEditor; 364 369 } ··· 405 410 const doc = cmView.state.doc; 406 411 cmView.dispatch({ changes: { from: 0, to: doc.length, insert: md } }); 407 412 updatePreview(md); 413 + collabClient.setPMSendable(null); // detach from stale Milkdown instance 408 414 } else { 409 415 // source → rich: extract markdown from CodeMirror, recreate Milkdown 410 416 const md = cmView.state.doc.toString(); 411 - await createMilkdownEditor(md); 417 + await createMilkdownEditor(md); // setPMSendable called inside 412 418 } 413 419 414 420 currentMode = nextMode;