Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 26 additions & 7 deletions client/cursor-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -326,13 +336,17 @@ 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

cursor_client(url, {
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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions client/editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions client/markdown-editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions client/simpleton-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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'] || ''

Expand Down Expand Up @@ -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()
Expand Down
156 changes: 156 additions & 0 deletions test/cursor-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down