Skip to content

Instantly share code, notes, and snippets.

@hausdorff
Created March 9, 2026 20:25
Show Gist options
  • Select an option

  • Save hausdorff/0ef41b1280c3ab3a6fe31018dd235632 to your computer and use it in GitHub Desktop.

Select an option

Save hausdorff/0ef41b1280c3ab3a6fe31018dd235632 to your computer and use it in GitHub Desktop.
function CollabDemo() {
const {
EditorState, EditorView, collab, sendableSteps,
receiveTransaction, getVersion, schema,
keymap, baseKeymap, history, undo, redo,
} = pmDeps;
const refA = React.useRef(null);
const refB = React.useRef(null);
const views = React.useRef({});
const latRef = React.useRef(500);
const timers = React.useRef([]);
const [latency, setLatency] = React.useState(500);
const [log, setLog] = React.useState([]);
const [serverText, setServerText] = React.useState("");
const [paused, setPaused] = React.useState(false);
const pausedRef = React.useRef(false);
const logRef = React.useRef(null);
React.useEffect(() => { latRef.current = latency; }, [latency]);
const addLog = React.useCallback((e) => {
setLog(p => {
const next = [...p, { ...e, id: Math.random() }];
return next.length > 600 ? next.slice(-500) : next;
});
}, []);
React.useEffect(() => {
const doc = schema.node("doc", null, [
schema.node("paragraph", null, [
schema.text("Type here to try collaborative editing..."),
]),
]);
const authority = {
doc,
steps: [],
stepClientIDs: [],
version: 0,
listeners: new Map(),
receiveSteps(version, steps, clientID) {
if (version !== this.version) return false;
for (const step of steps) {
const result = step.apply(this.doc);
if (result.failed) return false;
this.doc = result.doc;
this.steps.push(step);
this.stepClientIDs.push(clientID);
this.version++;
}
for (const [id, fn] of this.listeners) {
if (id !== clientID) {
const t = setTimeout(fn, latRef.current);
timers.current.push(t);
}
}
return true;
},
getSteps(version) {
return {
steps: this.steps.slice(version),
clientIDs: this.stepClientIDs.slice(version),
};
},
};
function makeEditor(container, cid, name) {
let sending = false;
const state = EditorState.create({
doc,
plugins: [
history(),
keymap({ "Mod-z": undo, "Mod-y": redo, "Mod-Shift-z": redo }),
keymap(baseKeymap),
collab({ version: 0, clientID: cid }),
],
});
function pull() {
const v = views.current[cid];
if (!v) return;
const ver = getVersion(v.state);
const { steps, clientIDs } = authority.getSteps(ver);
if (!steps.length) return;
const hadLocal = !!sendableSteps(v.state);
v.dispatch(receiveTransaction(v.state, steps, clientIDs));
const hasLocal = !!sendableSteps(v.state);
addLog({
type: "receive",
client: name,
from: ver,
to: ver + steps.length,
rebased: hadLocal && hasLocal,
});
}
function trySend() {
const v = views.current[cid];
if (!v || sending || pausedRef.current) return;
const s = sendableSteps(v.state);
if (!s) return;
sending = true;
addLog({
type: "send",
client: name,
version: s.version,
n: s.steps.length,
desc: s.steps.map(step => {
const j = step.toJSON();
const txt = (function getText(c) {
if (!c) return "";
return c.map(n => n.text || getText(n.content) || "").join("");
})(j.slice?.content);
if (j.from === j.to && txt) return "insert \u201c" + txt + "\u201d";
if (j.from !== j.to && !txt) return "delete " + j.from + ".." + j.to;
if (j.from !== j.to && txt) return "replace " + j.from + ".." + j.to + " with \u201c" + txt + "\u201d";
return j.stepType;
}).join(", "),
});
const t = setTimeout(() => {
const ok = authority.receiveSteps(s.version, s.steps, s.clientID);
if (ok) {
addLog({ type: "accept", version: authority.version });
setServerText(authority.doc.textContent);
pull();
} else {
addLog({
type: "reject",
client: name,
clientV: s.version,
serverV: authority.version,
});
pull();
}
sending = false;
trySend();
}, latRef.current);
timers.current.push(t);
}
const view = new EditorView(container, {
state,
attributes: { class: "outline-none border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 p-3 focus:ring-2 focus:ring-palette-brand-9 focus:border-transparent" },
dispatchTransaction(tr) {
view.updateState(view.state.apply(tr));
trySend();
},
});
authority.listeners.set(cid, () => {
if (pausedRef.current) return;
pull();
trySend();
});
view._pull = pull;
view._trySend = trySend;
return view;
}
views.current.a = makeEditor(refA.current, "a", "A");
views.current.b = makeEditor(refB.current, "b", "B");
return () => {
views.current.a?.destroy();
views.current.b?.destroy();
timers.current.forEach(clearTimeout);
};
}, []);
const togglePaused = React.useCallback(() => {
setPaused(p => {
const next = !p;
pausedRef.current = next;
if (!next) {
// Flush: pull + send for both editors
for (const v of Object.values(views.current)) {
if (v) { v._pull(); v._trySend(); }
}
}
return next;
});
}, []);
function fmt(e) {
switch (e.type) {
case "send":
return "\u2192 " + e.client + " sends " + e.n + " step" + (e.n !== 1 ? "s" : "") + " (v" + e.version + "): " + e.desc;
case "accept":
return "\u2713 Server accepted \u2192 v" + e.version;
case "reject":
return "\u2717 Server rejected " + e.client + " (v" + e.clientV + " behind v" + e.serverV + ")";
case "receive":
return "\u2190 " + e.client + " receives v" + e.from + "\u2192v" + e.to + (e.rebased ? " [rebased]" : "");
}
}
function color(e) {
if (e.type === "accept") return "text-emerald-600 dark:text-emerald-400";
if (e.type === "reject") return "text-red-500 dark:text-red-400";
if (e.client === "A") return "text-blue-600 dark:text-blue-400";
return "text-green-600 dark:text-green-400";
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 shrink-0">
Simulated latency
</span>
<input
type="range" min={0} max={3000} step={50}
value={latency}
onChange={e => setLatency(+e.target.value)}
className="flex-1"
/>
<span className="text-sm font-mono text-gray-500 dark:text-gray-400 w-20 text-right">
{latency}ms
</span>
<button
onClick={togglePaused}
className="rounded bg-gray-200 dark:bg-gray-700 px-2.5 py-1 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600"
>
{paused ? "\u25B6 Play" : "\u23F8 Pause"}
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-semibold text-blue-600 dark:text-blue-400 mb-1">
Client A
</div>
<div ref={refA} />
</div>
<div>
<div className="text-sm font-semibold text-green-600 dark:text-green-400 mb-1">
Client B
</div>
<div ref={refB} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1">
Server Document
</div>
<div
className="border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 p-3 overflow-y-auto text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap"
style={{ maxHeight: "calc(10 * 1.625em + 1.5rem)" }}
>
{serverText || <span className="text-gray-400 dark:text-gray-500 italic">Empty</span>}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Protocol Log
</div>
<button
onClick={() => setLog([])}
className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
Clear
</button>
</div>
<div
ref={logRef}
className="border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-900 p-3 overflow-y-auto font-mono text-xs leading-relaxed"
style={{ maxHeight: "calc(10 * 1.625em + 1.5rem)" }}
>
{!log.length && (
<span className="text-gray-400 dark:text-gray-500 italic">
Start typing in either editor...
</span>
)}
{[...log].reverse().map(e => (
<div key={e.id} className={color(e)}>{fmt(e)}</div>
))}
</div>
</div>
</div>
</div>
);
}
return CollabDemo;
@dmonad

dmonad commented Mar 14, 2026

Copy link
Copy Markdown

Ah yes, the collaborative editing rite of passage. "I just want X, how hard can it be?" Then you discover the 10,000 edge cases that make your users not lose their work. Welcome to the rabbit hole 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment