From 544e912de455a80cdb3368efc3ae598a1aca808c Mon Sep 17 00:00:00 2001 From: bxff <51504045+bxff@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:46:37 +0530 Subject: [PATCH 1/2] feat: transform stale cursor positions to current text version --- client/cursor-sync.js | 33 +++++++-- client/simpleton-sync.js | 7 ++ server.js | 45 ++++++++++- test/cursor-tests.js | 156 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 10 deletions(-) diff --git a/client/cursor-sync.js b/client/cursor-sync.js index 0746697..15f05ff 100644 --- a/client/cursor-sync.js +++ b/client/cursor-sync.js @@ -15,7 +15,7 @@ // cursors.changed(patches) // cursors.destroy() // -async function cursor_client(url, { peer, get_text, on_change, headers: custom_headers }) { +async function cursor_client(url, { peer, get_text, get_version, on_change, headers: custom_headers }) { // --- feature detection: HEAD probe --- try { var head_res = await braid_fetch(url, { @@ -121,14 +121,24 @@ async function cursor_client(url, { peer, get_text, on_change, headers: custom_h to: js_index_to_code_point(text, r.to), } }) + // Tag the PUT with the text version the cursor position refers to. + // The server uses this to transform positions from the client's + // version to the current version via DT's version DAG, preventing + // the cursor from jumping when the server is ahead of the client. + var put_headers = { + ...custom_headers, + 'Content-Type': 'application/text-cursors+json', + Peer: peer, + 'Content-Range': 'json [' + JSON.stringify(peer) + ']', + } + if (get_version) { + var v = get_version() + if (v && v.length) + put_headers.Version = v.map(function(s) { return JSON.stringify(s) }).join(', ') + } braid_fetch(url, { method: 'PUT', - headers: { - ...custom_headers, - 'Content-Type': 'application/text-cursors+json', - Peer: peer, - 'Content-Range': 'json [' + JSON.stringify(peer) + ']', - }, + headers: put_headers, body: JSON.stringify(cp_ranges), retry: function(res) { return res.status === 425 }, signal: put_ac.signal, @@ -326,6 +336,7 @@ function cursor_highlights(textarea, url, options) { var hl = textarea_highlights(textarea) var applying_remote = false var client = null + var sc = null // simpleton client, set by caller via .attach() var online = false var destroyed = false @@ -333,6 +344,9 @@ function cursor_highlights(textarea, url, options) { peer, headers: options?.headers, get_text: () => textarea.value, + // Pass the simpleton client's current version so the server can + // transform cursor positions when it is ahead of this client. + get_version: () => sc?.version, on_change: function(sels) { for (var [id, ranges] of Object.entries(sels)) { // Skip own cursor when textarea is focused (browser draws it) @@ -397,6 +411,11 @@ function cursor_highlights(textarea, url, options) { } }, + // attach(simpleton_client) — wire a simpleton client so cursor PUTs + // are tagged with the text version this client is currently at. + // Call this after creating the simpleton client for the same URL. + attach: function(simpleton) { sc = simpleton }, + destroy: function() { destroyed = true document.removeEventListener('selectionchange', on_selectionchange) diff --git a/client/simpleton-sync.js b/client/simpleton-sync.js index 98cb14b..04a2c05 100644 --- a/client/simpleton-sync.js +++ b/client/simpleton-sync.js @@ -248,6 +248,13 @@ function simpleton_client(url, { // ── abort() — cancel the subscription ───────────────────────── abort: () => ac.abort(), + // ── version — the text version the client is currently at ───── + // Returns the sorted version strings the client has seen, the + // same value the server uses for Version/Parents headers. + // Useful for tagging cursor PUTs so the server knows which text + // state the cursor position refers to. + get version() { return client_version }, + // ── changed() — call when local edits occur ─────────────────── // This is the entry point for sending local edits. It: // 1. Diffs client_state vs current state diff --git a/server.js b/server.js index 7b708ee..a91afd5 100644 --- a/server.js +++ b/server.js @@ -383,7 +383,39 @@ function create_braid_text() { let peer = req.headers["peer"] // Implement Multiplayer Text Cursors - if (await handle_cursors(resource, req, res)) + // Build a version-transform callback for cursor PUTs. + // This closure captures get_xf_patches and OpLog_remote_to_local + // which are scoped inside create_braid_text. + var cursor_version_xf = null + if (resource.dt) { + cursor_version_xf = function(cursor_data, version) { + var local_v = OpLog_remote_to_local(resource.dt.doc, version) + if (!local_v) return cursor_data + var cur_local_v = resource.dt.doc.getLocalVersion() + if (local_v.length === cur_local_v.length && local_v.every((x, i) => x === cur_local_v[i])) + return cursor_data + var xf = get_xf_patches(resource.dt.doc, local_v) + if (!xf.length) return cursor_data + // Parse string ranges into arrays and compute codepoint lengths + var parsed = xf.map(function(p) { + var m = p.range.match(/\d+/g).map(Number) + return {start: m[0], end: m[1], ins_len: [...(p.content || '')].length} + }) + return cursor_data.map(function(sel) { + var from = sel.from, to = sel.to + var offset = 0 + for (var p of parsed) { + var del_start = p.start + offset + var del_len = p.end - p.start + from = transform_pos(from, del_start, del_len, p.ins_len) + to = transform_pos(to, del_start, del_len, p.ins_len) + offset += p.ins_len - del_len + } + return {from, to} + }) + } + } + if (await handle_cursors(resource, req, res, cursor_version_xf)) return let merge_type = req.headers["merge-type"] @@ -3789,7 +3821,7 @@ class cursor_state { // Handle cursor requests routed by content negotiation. // Returns true if the request was handled, false to fall through. -async function handle_cursors(resource, req, res) { +async function handle_cursors(resource, req, res, cursor_version_xf) { var accept = req.headers['accept'] || '' var content_type = req.headers['content-type'] || '' @@ -3837,7 +3869,14 @@ async function handle_cursors(resource, req, res) { return true } var cursor_peer = JSON.parse(range.slice(5))[0] - var accepted = cursors.put(cursor_peer, JSON.parse(raw_body)) + var cursor_data = JSON.parse(raw_body) + + // If the client sent a Version header and we have a transform + // callback, rebase cursor positions from client's version to current. + if (cursor_version_xf && req.version && req.version.length) + cursor_data = cursor_version_xf(cursor_data, req.version) + + var accepted = cursors.put(cursor_peer, cursor_data) if (accepted) { res.writeHead(200) res.end() diff --git a/test/cursor-tests.js b/test/cursor-tests.js index 021846e..fe3c96a 100644 --- a/test/cursor-tests.js +++ b/test/cursor-tests.js @@ -981,6 +981,162 @@ runTest( 'ok' ) +runTest( + "cursor: version-aware PUT transforms stale position", + async () => { + var key = 'cursor-vtest-' + Math.random().toString(36).slice(2) + var cursor_peer = 'cursor-' + Math.random().toString(36).slice(2) + var edit_peer = 'edit-' + Math.random().toString(36).slice(2) + + // 1. Set initial text: "hello world" + await braid_fetch(`/${key}`, { method: 'PUT', body: 'hello world' }) + + // 2. Get the initial version via a one-shot subscribe + var version_ac = new AbortController() + var initial_version = null + var ver_r = await braid_fetch(`/${key}`, { + subscribe: true, + signal: version_ac.signal, + headers: { 'Merge-Type': 'simpleton' } + }) + await new Promise(resolve => { + ver_r.subscribe(update => { + initial_version = update.version + version_ac.abort() + resolve() + }) + }) + if (!initial_version || !initial_version.length) + return 'failed to get initial version' + + // 3. Insert "dear " at position 6 => "hello dear world" + await braid_fetch(`/${key}`, { + method: 'PUT', + headers: { 'Peer': edit_peer }, + patches: [{unit: 'text', range: '[6:6]', content: 'dear '}] + }) + + var r2 = await braid_fetch(`/${key}`) + var text = await r2.text() + if (text !== 'hello dear world') + return 'unexpected text: ' + text + + // 4. Subscribe cursor peer + var ac = await subscribe_peer(key, cursor_peer) + + // 5. PUT cursor at position 6 tagged with the OLD version. + // In the old text "hello world", position 6 = 'w'. + // Server should transform to 11 ('w' in "hello dear world"). + await braid_fetch(`/${key}`, { + method: 'PUT', + version: initial_version, + headers: { + 'Content-Type': 'application/text-cursors+json', + 'Content-Range': 'json [' + JSON.stringify(cursor_peer) + ']', + 'Peer': cursor_peer + }, + body: JSON.stringify([{from: 6, to: 6}]) + }) + + var r3 = await braid_fetch(`/${key}`, { + headers: { 'Accept': 'application/text-cursors+json' } + }) + ac.abort() + var body = JSON.parse(await r3.text()) + if (!body[cursor_peer]) + return 'cursor missing from snapshot' + if (body[cursor_peer][0].from !== 11) + return 'expected 11 (transformed), got ' + body[cursor_peer][0].from + + return 'ok' + }, + 'ok' +) + +runTest( + "cursor: version-aware PUT with no version stores as-is", + async () => { + var key = 'cursor-noversion-' + Math.random().toString(36).slice(2) + var cursor_peer = 'cursor-' + Math.random().toString(36).slice(2) + + await braid_fetch(`/${key}`, { method: 'PUT', body: 'hello world' }) + + var ac = await subscribe_peer(key, cursor_peer) + + await braid_fetch(`/${key}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/text-cursors+json', + 'Content-Range': 'json [' + JSON.stringify(cursor_peer) + ']', + 'Peer': cursor_peer + }, + body: JSON.stringify([{from: 6, to: 6}]) + }) + + var r = await braid_fetch(`/${key}`, { + headers: { 'Accept': 'application/text-cursors+json' } + }) + ac.abort() + var body = JSON.parse(await r.text()) + if (!body[cursor_peer]) return 'cursor missing' + if (body[cursor_peer][0].from !== 6) + return 'expected 6 (as-is), got ' + body[cursor_peer][0].from + + return 'ok' + }, + 'ok' +) + +runTest( + "cursor: version-aware PUT at current version is no-op", + async () => { + var key = 'cursor-curver-' + Math.random().toString(36).slice(2) + var cursor_peer = 'cursor-' + Math.random().toString(36).slice(2) + + await braid_fetch(`/${key}`, { method: 'PUT', body: 'hello world' }) + + var version_ac = new AbortController() + var ver_r = await braid_fetch(`/${key}`, { + subscribe: true, + signal: version_ac.signal, + headers: { 'Merge-Type': 'simpleton' } + }) + var current_version = null + await new Promise(resolve => { + ver_r.subscribe(update => { + current_version = update.version + version_ac.abort() + resolve() + }) + }) + + var ac = await subscribe_peer(key, cursor_peer) + + await braid_fetch(`/${key}`, { + method: 'PUT', + version: current_version, + headers: { + 'Content-Type': 'application/text-cursors+json', + 'Content-Range': 'json [' + JSON.stringify(cursor_peer) + ']', + 'Peer': cursor_peer + }, + body: JSON.stringify([{from: 6, to: 6}]) + }) + + var r2 = await braid_fetch(`/${key}`, { + headers: { 'Accept': 'application/text-cursors+json' } + }) + ac.abort() + var body = JSON.parse(await r2.text()) + if (!body[cursor_peer]) return 'cursor missing' + if (body[cursor_peer][0].from !== 6) + return 'expected 6 (no-op), got ' + body[cursor_peer][0].from + + return 'ok' + }, + 'ok' +) + } if (typeof module !== 'undefined' && module.exports) { From 200be2729cf213dbe199c0f873827ec6e9e26739 Mon Sep 17 00:00:00 2001 From: bxff <51504045+bxff@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:42:41 +0530 Subject: [PATCH 2/2] chore: wire cursors.attach() in bundled editors and update docs --- README.md | 2 ++ client/editor.html | 1 + client/markdown-editor.html | 1 + 3 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 677fa1f..adcb265 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,7 @@ This will render each peer's cursor and selection with colored highlights. Just }, get_state: () => my_textarea.value }) + cursors.attach(simpleton) // <-- tag cursor PUTs with text version my_textarea.oninput = () => { cursors.on_edit(simpleton.changed()) // <-- send local cursor @@ -278,6 +279,7 @@ The following options are deprecated and should be replaced with the new API: `cursor_highlights(textarea, url)` returns an object with: - `cursors.on_patches(patches)` — call after applying remote patches to transform and re-render remote cursors - `cursors.on_edit(patches)` — call after local edits; pass the patches from `simpleton.changed()` to update cursor positions and broadcast your selection +- `cursors.attach(simpleton_client)` — wire a simpleton client so cursor PUTs are tagged with its current text version; the server uses this to transform stale positions before storing - `cursors.destroy()` — tear down listeners and DOM elements Colors are auto-assigned per peer ID. See `?editor` and `?markdown-editor` in the demo server for working examples. diff --git a/client/editor.html b/client/editor.html index 46fb02f..369ba91 100644 --- a/client/editor.html +++ b/client/editor.html @@ -23,6 +23,7 @@ on_error: (e) => set_error_state(the_editor), on_ack: () => set_acked_state(the_editor) }) + cursors.attach(simpleton) the_editor.oninput = (e) => { set_acked_state(the_editor, false) diff --git a/client/markdown-editor.html b/client/markdown-editor.html index 32d8442..89087e0 100644 --- a/client/markdown-editor.html +++ b/client/markdown-editor.html @@ -61,6 +61,7 @@ on_error: (e) => set_error_state(the_editor), on_ack: () => set_acked_state(the_editor) }) +cursors.attach(simpleton) the_editor.oninput = (e) => { set_acked_state(the_editor, false)