braid-text 0.2.117 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,7 +104,7 @@ Here's a basic running example to start:
104
104
 
105
105
  <!-- 2. Include the libraries -->
106
106
  <script src="https://unpkg.com/braid-http@~1.3/braid-http-client.js"></script>
107
- <script src="https://unpkg.com/braid-text/simpleton-client.js"></script>
107
+ <script src="https://unpkg.com/braid-text@~0.3/client/simpleton-sync.js"></script>
108
108
 
109
109
  <!-- 3. Wire it up -->
110
110
  <script>
@@ -173,7 +173,35 @@ var simpleton = simpleton_client(url, {
173
173
  })
174
174
  ```
175
175
 
176
- See [editor.html](https://github.com/braid-org/braid-text/blob/master/editor.html) for a complete example.
176
+ See [editor.html](https://github.com/braid-org/braid-text/blob/master/client/editor.html) for a complete example.
177
+
178
+ ### Adding Multiplayer Cursor + Selections
179
+
180
+ This will render each peer's cursor and selection with colored highlights. Just add three lines to your simpleton client:
181
+
182
+ ```html
183
+ <!-- 1. Include these additional script tags -->
184
+ <script src="https://unpkg.com/braid-text@~0.3/client/cursor-highlights.js"></script>
185
+ <script src="https://unpkg.com/braid-text@~0.3/client/cursor-sync.js"></script>
186
+
187
+ <!-- 2. Add two lines to your simpleton_client() call -->
188
+ <script>
189
+ var cursors = cursor_highlights(my_textarea, location.pathname)
190
+
191
+ var simpleton = simpleton_client(location.pathname, {
192
+ on_patches: (patches) => {
193
+ apply_patches_and_update_selection(my_textarea, patches)
194
+ cursors.on_patches(patches) // <-- update remote cursors
195
+ },
196
+ get_state: () => my_textarea.value
197
+ })
198
+
199
+ my_textarea.oninput = () => {
200
+ cursors.on_edit(simpleton.changed()) // <-- send local cursor
201
+ }
202
+ </script>
203
+ ```
204
+
177
205
 
178
206
  ## Client API
179
207
 
@@ -229,7 +257,7 @@ Creates a new Simpleton client that synchronizes with a Braid-Text server.
229
257
 
230
258
  ### Methods
231
259
 
232
- - `simpleton.changed()`: Notify the client that local changes have occurred. Call this in your editor's change event handler. The client will call `get_patches` and `get_state` when it's ready to send updates.
260
+ - `simpleton.changed()`: Notify the client that local changes have occurred. Call this in your editor's change event handler. The client will call `get_patches` and `get_state` when it's ready to send updates. Returns the array of JS-index patches (or `undefined` if there was no change), which can be passed to `cursors.on_edit()`.
233
261
 
234
262
  ### Deprecated Options
235
263
 
@@ -238,6 +266,17 @@ The following options are deprecated and should be replaced with the new API:
238
266
  - ~~`apply_remote_update`~~ → Use `on_patches` or `on_state` instead
239
267
  - ~~`generate_local_diff_update`~~ → Use `get_patches` and `get_state` instead
240
268
 
269
+ ### Multiplayer Cursor API
270
+
271
+ `cursor_highlights(textarea, url)` returns an object with:
272
+ - `cursors.on_patches(patches)` — call after applying remote patches to transform and re-render remote cursors
273
+ - `cursors.on_edit(patches)` — call after local edits; pass the patches from `simpleton.changed()` to update cursor positions and broadcast your selection
274
+ - `cursors.destroy()` — tear down listeners and DOM elements
275
+
276
+ Colors are auto-assigned per peer ID. See `?editor` and `?markdown-editor` in the demo server for working examples.
277
+
278
+
279
+
241
280
  ## Testing
242
281
 
243
282
  ### to run unit tests:
@@ -0,0 +1,302 @@
1
+ // cursor-highlights.js — Render colored cursors and selections behind a <textarea>
2
+ //
3
+ // No dependencies. Pure DOM/CSS.
4
+ //
5
+ // Usage:
6
+ // var hl = textarea_highlights(textarea)
7
+ // hl.set('peer-1', [{ from: 5, to: 10, color: 'rgba(97,175,239,0.25)' }])
8
+ // hl.render()
9
+ // hl.remove('peer-1')
10
+ // hl.destroy()
11
+ //
12
+ function textarea_highlights(textarea) {
13
+ // Inject CSS once per page
14
+ if (!document.getElementById('textarea-highlights-css')) {
15
+ var style = document.createElement('style')
16
+ style.id = 'textarea-highlights-css'
17
+ style.textContent = `
18
+ .textarea-hl-backdrop {
19
+ position: absolute;
20
+ top: 0; left: 0; right: 0; bottom: 0;
21
+ white-space: pre-wrap;
22
+ word-wrap: break-word;
23
+ overflow-y: auto;
24
+ pointer-events: none;
25
+ color: transparent;
26
+ z-index: 1;
27
+ scrollbar-width: none;
28
+ -ms-overflow-style: none;
29
+ }
30
+ .textarea-hl-backdrop::-webkit-scrollbar { display: none; }
31
+ .textarea-hl-backdrop span { color: transparent; border-radius: 2px; }
32
+ .textarea-hl-backdrop span.sel {
33
+ padding: var(--sel-pad, 3px) 0;
34
+ -webkit-box-decoration-break: clone;
35
+ box-decoration-break: clone;
36
+ }
37
+ .textarea-hl-backdrop .cursor {
38
+ position: relative;
39
+ display: inline-block;
40
+ width: 0;
41
+ height: 1em;
42
+ z-index: 10;
43
+ }
44
+ .textarea-hl-backdrop .cursor::before {
45
+ content: '';
46
+ position: absolute;
47
+ left: -1px;
48
+ top: 0;
49
+ bottom: 0;
50
+ width: 2px;
51
+ background-color: var(--cursor-color, #ff5722);
52
+ z-index: 10;
53
+ }
54
+ `
55
+ document.head.appendChild(style)
56
+ }
57
+
58
+ // Ensure textarea is wrapped in a position:relative container
59
+ var wrap = textarea.parentElement
60
+ if (getComputedStyle(wrap).position === 'static')
61
+ wrap.style.position = 'relative'
62
+
63
+ // Move textarea's background to the wrapper so backdrops show through
64
+ var bg = getComputedStyle(textarea).backgroundColor
65
+ if (!wrap.style.backgroundColor)
66
+ wrap.style.backgroundColor = (!bg || bg === 'rgba(0, 0, 0, 0)') ? 'white' : bg
67
+ textarea.style.backgroundColor = 'transparent'
68
+ textarea.style.position = 'relative'
69
+ textarea.style.zIndex = '2'
70
+
71
+ // Measure font metrics for gap-free selection highlights
72
+ var cs = getComputedStyle(textarea)
73
+ var test_div = document.createElement('div')
74
+ test_div.style.cssText =
75
+ 'font-family:' + cs.fontFamily + ';' +
76
+ 'font-size:' + cs.fontSize + ';' +
77
+ 'line-height:' + cs.lineHeight + ';' +
78
+ 'position:absolute;top:-9999px;'
79
+ var test_span = document.createElement('span')
80
+ test_span.style.backgroundColor = 'red'
81
+ test_span.textContent = 'Xg'
82
+ test_div.appendChild(test_span)
83
+ document.body.appendChild(test_div)
84
+ var line_height = parseFloat(getComputedStyle(test_div).lineHeight)
85
+ var bg_height = test_span.getBoundingClientRect().height
86
+ document.body.removeChild(test_div)
87
+ var sel_pad = (line_height - bg_height) / 2
88
+ wrap.style.setProperty('--sel-pad', sel_pad + 'px')
89
+
90
+ // State
91
+ var layer_data = {} // layer_id -> [{ from, to, color }]
92
+ var layer_divs = {} // layer_id -> DOM div
93
+
94
+ // Scroll sync
95
+ function sync_scroll() {
96
+ for (var div of Object.values(layer_divs)) {
97
+ div.scrollTop = textarea.scrollTop
98
+ div.scrollLeft = textarea.scrollLeft
99
+ }
100
+ }
101
+ textarea.addEventListener('scroll', sync_scroll)
102
+
103
+ // Build a backdrop style string matching the textarea's font/padding/border
104
+ function backdrop_style() {
105
+ var cs = getComputedStyle(textarea)
106
+ return 'font-family:' + cs.fontFamily + ';' +
107
+ 'font-size:' + cs.fontSize + ';' +
108
+ 'line-height:' + cs.lineHeight + ';' +
109
+ 'padding:' + cs.paddingTop + ' ' + cs.paddingRight + ' ' +
110
+ cs.paddingBottom + ' ' + cs.paddingLeft + ';' +
111
+ 'border:' + cs.borderTopWidth + ' solid transparent;' +
112
+ 'border-radius:' + cs.borderRadius + ';'
113
+ }
114
+
115
+ function escape_html(text) {
116
+ var div = document.createElement('div')
117
+ div.textContent = text
118
+ return div.innerHTML
119
+ }
120
+
121
+ function build_html(text, highlights) {
122
+ var cursors = highlights.filter(h => h.from === h.to)
123
+ var sels = highlights.filter(h => h.from !== h.to)
124
+
125
+ var items = []
126
+ for (var s of sels)
127
+ items.push({ type: 'selection', start: s.from, end: s.to, color: s.color })
128
+ for (var c of cursors)
129
+ items.push({ type: 'cursor', pos: c.from, color: c.color })
130
+
131
+ items.sort((a, b) => {
132
+ var pa = a.type === 'cursor' ? a.pos : a.start
133
+ var pb = b.type === 'cursor' ? b.pos : b.start
134
+ return pa - pb
135
+ })
136
+
137
+ var result = ''
138
+ var last = 0
139
+
140
+ for (var item of items) {
141
+ if (item.type === 'selection') {
142
+ if (item.start < last) continue
143
+ result += escape_html(text.substring(last, item.start))
144
+ var sel_text = text.substring(item.start, item.end)
145
+ var sel_html = escape_html(sel_text).replace(/\n/g, ' \n')
146
+ result += '<span class="sel" style="background-color:' + item.color + ';">' + sel_html + '</span>'
147
+ last = item.end
148
+ } else {
149
+ if (item.pos < last) continue
150
+ result += escape_html(text.substring(last, item.pos))
151
+ result += '<span class="cursor" style="--cursor-color:' + item.color + ';"></span>'
152
+ last = item.pos
153
+ }
154
+ }
155
+
156
+ result += escape_html(text.substring(last))
157
+ if (text.endsWith('\n')) result += '\u200B\n'
158
+ return result
159
+ }
160
+
161
+ return {
162
+ set: function(layer_id, highlights) {
163
+ layer_data[layer_id] = highlights
164
+ },
165
+
166
+ remove: function(layer_id) {
167
+ delete layer_data[layer_id]
168
+ if (layer_divs[layer_id]) {
169
+ layer_divs[layer_id].remove()
170
+ delete layer_divs[layer_id]
171
+ }
172
+ },
173
+
174
+ render: function() {
175
+ var text = textarea.value
176
+ var len = text.length
177
+ var style_str = backdrop_style()
178
+
179
+ // Remove divs for layers that no longer exist
180
+ for (var id of Object.keys(layer_divs)) {
181
+ if (!layer_data[id]) {
182
+ layer_divs[id].remove()
183
+ delete layer_divs[id]
184
+ }
185
+ }
186
+
187
+ // Render each layer
188
+ for (var id of Object.keys(layer_data)) {
189
+ var highlights = layer_data[id].map(h => ({
190
+ from: Math.min(h.from, len),
191
+ to: Math.min(h.to, len),
192
+ color: h.color
193
+ }))
194
+
195
+ if (!layer_divs[id]) {
196
+ var div = document.createElement('div')
197
+ div.className = 'textarea-hl-backdrop'
198
+ wrap.insertBefore(div, textarea)
199
+ layer_divs[id] = div
200
+ }
201
+
202
+ // Font/padding/border are set inline to match textarea;
203
+ // positioning/pointer-events/etc come from the CSS class.
204
+ layer_divs[id].style.cssText = style_str
205
+
206
+ layer_divs[id].innerHTML = build_html(text, highlights)
207
+ layer_divs[id].scrollTop = textarea.scrollTop
208
+ layer_divs[id].scrollLeft = textarea.scrollLeft
209
+ }
210
+ },
211
+
212
+ layers: function() {
213
+ return Object.keys(layer_data)
214
+ },
215
+
216
+ destroy: function() {
217
+ textarea.removeEventListener('scroll', sync_scroll)
218
+ for (var div of Object.values(layer_divs)) div.remove()
219
+ layer_data = {}
220
+ layer_divs = {}
221
+ }
222
+ }
223
+ }
224
+
225
+ // --- Color helpers ---
226
+
227
+ var _cursor_colors = ["#e06c75", "#61afef", "#98c379", "#c678dd", "#e5c07b", "#56b6c2"]
228
+
229
+ function peer_color(peer_id) {
230
+ var hash = 0
231
+ for (var i = 0; i < peer_id.length; i++)
232
+ hash = ((hash << 5) - hash + peer_id.charCodeAt(i)) | 0
233
+ return _cursor_colors[Math.abs(hash) % _cursor_colors.length]
234
+ }
235
+
236
+ function peer_bg_color(peer_id) {
237
+ var c = peer_color(peer_id)
238
+ var r = parseInt(c.slice(1, 3), 16)
239
+ var g = parseInt(c.slice(3, 5), 16)
240
+ var b = parseInt(c.slice(5, 7), 16)
241
+ return `rgba(${r}, ${g}, ${b}, 0.25)`
242
+ }
243
+
244
+ // --- High-level wrapper ---
245
+ //
246
+ // Usage:
247
+ // var cursors = cursor_highlights(textarea, url)
248
+ // cursors.on_patches(patches) // call after applying remote patches
249
+ // cursors.on_edit(patches) // call after local edit; patches optional
250
+ // cursors.destroy()
251
+ //
252
+ function cursor_highlights(textarea, url) {
253
+ var peer = Math.random().toString(36).slice(2)
254
+ var hl = textarea_highlights(textarea)
255
+ var applying_remote = false
256
+ var client = null
257
+
258
+ cursor_client(url, {
259
+ peer,
260
+ get_text: () => textarea.value,
261
+ on_change: (sels) => {
262
+ for (var [id, ranges] of Object.entries(sels)) {
263
+ if (!ranges.length) { hl.remove(id); continue }
264
+ hl.set(id, ranges.map(r => ({
265
+ from: r.from, to: r.to,
266
+ color: r.from === r.to ? peer_color(id) : peer_bg_color(id)
267
+ })))
268
+ }
269
+ hl.render()
270
+ }
271
+ }).then(function(c) { client = c })
272
+
273
+ document.addEventListener('selectionchange', function() {
274
+ if (applying_remote) return
275
+ if (document.activeElement !== textarea) return
276
+ if (client) client.set(textarea.selectionStart, textarea.selectionEnd)
277
+ })
278
+
279
+ return {
280
+ online: function() { if (client) client.online() },
281
+ offline: function() { if (client) client.offline() },
282
+
283
+ on_patches: function(patches) {
284
+ applying_remote = true
285
+ if (client) client.changed(patches)
286
+ hl.render()
287
+ setTimeout(() => { applying_remote = false }, 0)
288
+ },
289
+
290
+ on_edit: function(patches) {
291
+ if (client) {
292
+ if (patches) client.changed(patches)
293
+ client.set(textarea.selectionStart, textarea.selectionEnd)
294
+ }
295
+ },
296
+
297
+ destroy: function() {
298
+ if (client) client.destroy()
299
+ hl.destroy()
300
+ }
301
+ }
302
+ }
@@ -0,0 +1,278 @@
1
+ // cursor-sync.js — Sync cursor/selection positions via braid-http
2
+ //
3
+ // Requires braid-http-client.js (for braid_fetch)
4
+ //
5
+ // Usage:
6
+ // var cursors = await cursor_client(url, {
7
+ // peer: 'my-id',
8
+ // get_text: () => textarea.value,
9
+ // on_change: (selections) => { ... },
10
+ // })
11
+ // if (!cursors) return // server doesn't support cursors
12
+ // cursors.online() // call when text subscription is online
13
+ // cursors.offline() // call when text subscription goes offline
14
+ // cursors.set(selectionStart, selectionEnd)
15
+ // cursors.changed(patches)
16
+ // cursors.destroy()
17
+ //
18
+ async function cursor_client(url, { peer, get_text, on_change }) {
19
+ // --- feature detection: HEAD probe ---
20
+ try {
21
+ var head_res = await fetch(url, {
22
+ method: 'HEAD',
23
+ headers: { 'Accept': 'application/text-cursors+json' }
24
+ })
25
+ var ct = head_res.headers.get('content-type') || ''
26
+ if (!ct.includes('application/text-cursors+json')) return null
27
+ } catch (e) { return null }
28
+
29
+ var selections = {} // peer_id -> [{ from, to }] in JS string indices
30
+ var online = false // true after online(), false after offline()
31
+ var pending = null // buffered cursor snapshot while offline
32
+ var last_sent = null
33
+ var last_ranges = null // last cursor ranges (JS indices) for re-PUT on reconnect
34
+ var send_timer = null
35
+ var ac = new AbortController()
36
+ var put_ac = null // AbortController for in-flight PUT retry
37
+
38
+ // --- code-point <-> JS index helpers ---
39
+
40
+ function code_point_to_index_map(s) {
41
+ var m = []
42
+ var c = 0
43
+ for (var i = 0; i < s.length; i++) {
44
+ m[c] = i
45
+ var code = s.charCodeAt(i)
46
+ if (code >= 0xd800 && code <= 0xdbff) i++
47
+ c++
48
+ }
49
+ m[c] = i
50
+ return m
51
+ }
52
+
53
+ function js_index_to_code_point(s, idx) {
54
+ var c = 0
55
+ for (var i = 0; i < idx; i++) {
56
+ var code = s.charCodeAt(i)
57
+ if (code >= 0xd800 && code <= 0xdbff) i++
58
+ c++
59
+ }
60
+ return c
61
+ }
62
+
63
+ // --- position transform through edits ---
64
+
65
+ function transform_pos(pos, del_start, del_len, ins_len) {
66
+ if (del_len === 0) {
67
+ if (pos < del_start) return pos
68
+ return pos + ins_len
69
+ }
70
+ if (pos <= del_start) return pos
71
+ if (pos <= del_start + del_len) return del_start + ins_len
72
+ return pos - del_len + ins_len
73
+ }
74
+
75
+ // --- process cursor data (code-points → JS indices) ---
76
+
77
+ function process_data(data, is_snapshot) {
78
+ var text = get_text()
79
+ var m = code_point_to_index_map(text)
80
+ var changed = {}
81
+
82
+ // Full snapshot: remove peers no longer present
83
+ if (is_snapshot) {
84
+ for (var id of Object.keys(selections)) {
85
+ if (!(id in data)) {
86
+ delete selections[id]
87
+ changed[id] = []
88
+ }
89
+ }
90
+ }
91
+
92
+ for (var id of Object.keys(data)) {
93
+ if (id === peer) continue
94
+ var ranges = data[id]
95
+ if (!ranges) {
96
+ delete selections[id]
97
+ changed[id] = []
98
+ } else {
99
+ selections[id] = ranges.map(function(r) {
100
+ return {
101
+ from: m[r.from] !== undefined ? m[r.from] : text.length,
102
+ to: m[r.to] !== undefined ? m[r.to] : text.length,
103
+ }
104
+ })
105
+ changed[id] = selections[id]
106
+ }
107
+ }
108
+
109
+ if (on_change) on_change(changed)
110
+ }
111
+
112
+ // --- PUT helper ---
113
+
114
+ function do_put(ranges) {
115
+ if (put_ac) put_ac.abort()
116
+ put_ac = new AbortController()
117
+ var text = get_text()
118
+ var cp_ranges = ranges.map(function(r) {
119
+ return {
120
+ from: js_index_to_code_point(text, r.from),
121
+ to: js_index_to_code_point(text, r.to),
122
+ }
123
+ })
124
+ braid_fetch(url, {
125
+ method: 'PUT',
126
+ headers: {
127
+ 'Content-Type': 'application/text-cursors+json',
128
+ Peer: peer,
129
+ 'Content-Range': 'json [' + JSON.stringify(peer) + ']',
130
+ },
131
+ body: JSON.stringify(cp_ranges),
132
+ retry: function(res) { return res.status === 425 },
133
+ signal: put_ac.signal,
134
+ }).catch(function() {})
135
+ }
136
+
137
+ // --- subscribe for remote cursors ---
138
+ // The subscription stays alive always. Data is only processed when online.
139
+
140
+ var connected_before = false
141
+ braid_fetch(url, {
142
+ subscribe: true,
143
+ retry: { onRes: function() {
144
+ if (connected_before && online) {
145
+ // Reconnecting — go offline to clear stale cursors.
146
+ // The application will call online() again when text is ready.
147
+ var changed = {}
148
+ for (var id of Object.keys(selections)) changed[id] = []
149
+ selections = {}
150
+ online = false
151
+ pending = null
152
+ if (on_change && Object.keys(changed).length) on_change(changed)
153
+ }
154
+ connected_before = true
155
+ }},
156
+ peer,
157
+ headers: {
158
+ Accept: 'application/text-cursors+json',
159
+ Heartbeats: '10',
160
+ },
161
+ signal: ac.signal,
162
+ }).then(function(r) {
163
+ r.subscribe(function(update) {
164
+ var data
165
+ var is_snapshot = false
166
+ if (update.body_text != null) {
167
+ data = JSON.parse(update.body_text)
168
+ is_snapshot = true
169
+ } else if (update.patches && update.patches.length) {
170
+ var p = update.patches[0]
171
+ var ct = p.content_text
172
+ data = { [JSON.parse(p.range)[0]]: ct ? JSON.parse(ct) : null }
173
+ } else return
174
+
175
+ if (!online) {
176
+ // Buffer snapshot data; apply patches to buffered data
177
+ if (is_snapshot || !pending) pending = {}
178
+ if (is_snapshot) pending = data
179
+ else for (var id of Object.keys(data)) pending[id] = data[id]
180
+ return
181
+ }
182
+
183
+ process_data(data, is_snapshot)
184
+ })
185
+ })
186
+
187
+ return {
188
+ // Call when text subscription comes online.
189
+ // Processes any buffered cursor data and re-PUTs local cursor.
190
+ online: function() {
191
+ online = true
192
+ if (pending) {
193
+ process_data(pending, true)
194
+ pending = null
195
+ }
196
+ if (last_ranges) do_put(last_ranges)
197
+ },
198
+
199
+ // Call when text subscription goes offline.
200
+ // Clears all remote cursors.
201
+ offline: function() {
202
+ online = false
203
+ pending = null
204
+ if (put_ac) put_ac.abort()
205
+ if (send_timer) { clearTimeout(send_timer); send_timer = null }
206
+ var changed = {}
207
+ for (var id of Object.keys(selections)) {
208
+ changed[id] = []
209
+ }
210
+ selections = {}
211
+ if (on_change && Object.keys(changed).length) on_change(changed)
212
+ },
213
+
214
+ // Send local cursor/selection position (JS string indices).
215
+ // Supports multiple selections: set(from, to) or set([{from, to}, ...])
216
+ set: function(from_or_ranges, to) {
217
+ var ranges
218
+ if (Array.isArray(from_or_ranges)) {
219
+ ranges = from_or_ranges
220
+ } else {
221
+ ranges = [{ from: from_or_ranges, to: to }]
222
+ }
223
+
224
+ // Skip if same as last sent
225
+ var key = JSON.stringify(ranges)
226
+ if (key === last_sent) return
227
+ last_sent = key
228
+ last_ranges = ranges
229
+
230
+ if (!online) return
231
+
232
+ // Debounce 50ms
233
+ if (send_timer) clearTimeout(send_timer)
234
+ send_timer = setTimeout(function() { do_put(ranges) }, 50)
235
+ },
236
+
237
+ // Transform all stored remote cursor positions through text edits.
238
+ // patches: [{ range: [start, end], content: string }] in JS string indices.
239
+ changed: function(patches) {
240
+ var any_changed = false
241
+ for (var id of Object.keys(selections)) {
242
+ selections[id] = selections[id].map(function(sel) {
243
+ var from = sel.from
244
+ var to = sel.to
245
+ for (var p of patches) {
246
+ var del_len = p.range[1] - p.range[0]
247
+ var ins_len = p.content.length
248
+ from = transform_pos(from, p.range[0], del_len, ins_len)
249
+ to = transform_pos(to, p.range[0], del_len, ins_len)
250
+ }
251
+ return { from, to }
252
+ })
253
+ any_changed = true
254
+ }
255
+ if (any_changed && on_change) {
256
+ // Report all current selections
257
+ var all = {}
258
+ for (var id of Object.keys(selections))
259
+ all[id] = selections[id]
260
+ on_change(all)
261
+ }
262
+ },
263
+
264
+ // Get current selections (for reading state)
265
+ get_selections: function() {
266
+ var copy = {}
267
+ for (var id of Object.keys(selections))
268
+ copy[id] = selections[id]
269
+ return copy
270
+ },
271
+
272
+ destroy: function() {
273
+ ac.abort()
274
+ if (put_ac) put_ac.abort()
275
+ if (send_timer) clearTimeout(send_timer)
276
+ },
277
+ }
278
+ }
@@ -4,20 +4,28 @@
4
4
  <script src="https://braid.org/code/myers-diff1.js"></script>
5
5
  <script src="https://unpkg.com/braid-http@~1.3/braid-http-client.js"></script>
6
6
  <script src="/web-utils.js"></script>
7
- <script src="/simpleton-client.js"></script>
7
+ <script src="/simpleton-sync.js"></script>
8
+ <script src="/cursor-highlights.js"></script>
9
+ <script src="/cursor-sync.js"></script>
8
10
  <script>
9
11
 
10
- let simpleton = simpleton_client(location.pathname, {
11
- on_patches: (patches) => apply_patches_and_update_selection(the_editor, patches),
12
+ var cursors = cursor_highlights(the_editor, location.pathname)
13
+
14
+ var simpleton = simpleton_client(location.pathname, {
15
+ on_online: ({online}) => { online ? cursors.online() : cursors.offline() },
16
+ on_patches: (patches) => {
17
+ apply_patches_and_update_selection(the_editor, patches)
18
+ cursors.on_patches(patches)
19
+ },
12
20
  get_patches: (prev_state) => diff(prev_state, the_editor.value),
13
21
  get_state: () => the_editor.value,
14
22
  on_error: (e) => set_error_state(the_editor),
15
23
  on_ack: () => set_acked_state(the_editor)
16
- });
24
+ })
17
25
 
18
26
  the_editor.oninput = (e) => {
19
27
  set_acked_state(the_editor, false)
20
- simpleton.changed()
28
+ cursors.on_edit(simpleton.changed())
21
29
  }
22
30
 
23
31
  </script>
@@ -3,22 +3,29 @@
3
3
  <script src="https://braid.org/code/myers-diff1.js"></script>
4
4
  <script src="https://unpkg.com/braid-http@~1.3/braid-http-client.js"></script>
5
5
  <script src="/web-utils.js"></script>
6
- <script src="/simpleton-client.js"></script>
6
+ <script src="/simpleton-sync.js"></script>
7
+ <script src="/cursor-highlights.js"></script>
8
+ <script src="/cursor-sync.js"></script>
7
9
  <body>
8
10
  <div id="markdown_container" style="max-width: 750px; padding: 10px 60px"></div>
9
11
  <div id="bottom_spacer" style="height: 50vh; display: none;"></div>
10
- <textarea
11
- id="the_editor"
12
- style="
12
+ <div id="editor_wrap" style="
13
13
  position: fixed;
14
14
  bottom: 0;
15
15
  right: 0;
16
16
  width: 45vw;
17
17
  height: 100%;
18
- font-size: 15px;
19
- font-family: helvetica, arial, avenir, lucida grande;
20
- hyphens: none;
21
- "></textarea>
18
+ ">
19
+ <textarea
20
+ id="the_editor"
21
+ style="
22
+ width: 100%;
23
+ height: 100%;
24
+ font-size: 15px;
25
+ font-family: helvetica, arial, avenir, lucida grande;
26
+ hyphens: none;
27
+ "></textarea>
28
+ </div>
22
29
  <div
23
30
  id="edit-button"
24
31
  style="
@@ -38,9 +45,13 @@ var first_time = true
38
45
  var render_timer = null
39
46
  var render_delay = 100
40
47
 
48
+ var cursors = cursor_highlights(the_editor, location.pathname)
49
+
41
50
  var simpleton = simpleton_client(location.pathname, {
51
+ on_online: ({online}) => { online ? cursors.online() : cursors.offline() },
42
52
  on_patches: (patches) => {
43
53
  apply_patches_and_update_selection(the_editor, patches)
54
+ cursors.on_patches(patches)
44
55
  update_markdown_later()
45
56
  },
46
57
  get_patches: (prev_state) => diff(prev_state, the_editor.value),
@@ -51,7 +62,7 @@ var simpleton = simpleton_client(location.pathname, {
51
62
 
52
63
  the_editor.oninput = (e) => {
53
64
  set_acked_state(the_editor, false)
54
- simpleton.changed()
65
+ cursors.on_edit(simpleton.changed())
55
66
  update_markdown_later()
56
67
  }
57
68
 
@@ -70,9 +81,9 @@ function update_layout() {
70
81
  var vert = window.innerWidth < 1200
71
82
  markdown_container.style.width = editing && !vert ? '55vw' : ''
72
83
  bottom_spacer.style.display = !editing || !vert ? 'none' : ''
73
- the_editor.style.width = vert ? '100%' : '45vw'
74
- the_editor.style.height = vert ? '50vh' : '100%'
75
- the_editor.style.display = !editing ? 'none' : ''
84
+ editor_wrap.style.width = vert ? '100%' : '45vw'
85
+ editor_wrap.style.height = vert ? '50vh' : '100%'
86
+ editor_wrap.style.display = !editing ? 'none' : ''
76
87
  }
77
88
 
78
89
  function toggle_editor() {
@@ -47,6 +47,7 @@ function simpleton_client(url, {
47
47
 
48
48
  on_error,
49
49
  on_res,
50
+ on_online,
50
51
  on_ack,
51
52
  send_digests
52
53
  }) {
@@ -67,6 +68,7 @@ function simpleton_client(url, {
67
68
  ...(content_type ? {Accept: content_type} : {}) },
68
69
  subscribe: true,
69
70
  retry: () => true,
71
+ onSubscriptionStatus: (status) => { if (on_online) on_online(status) },
70
72
  parents: () => current_version.length ? current_version : null,
71
73
  peer,
72
74
  signal: ac.signal
@@ -138,71 +140,91 @@ function simpleton_client(url, {
138
140
  stop: async () => {
139
141
  ac.abort()
140
142
  },
141
- changed: async () => {
143
+ changed: () => {
142
144
  if (outstanding_changes >= max_outstanding_changes) return
143
- while (true) {
144
- if (generate_local_diff_update) {
145
- // DEPRECATED
146
- var update = generate_local_diff_update(prev_state)
147
- if (!update) return // Stop if there wasn't a change!
148
- var {patches, new_state} = update
149
- } else {
150
- var new_state = get_state()
151
- if (new_state === prev_state) return // Stop if there wasn't a change!
152
- var patches = get_patches ? get_patches(prev_state) :
153
- [simple_diff(prev_state, new_state)]
154
- }
155
145
 
156
- // convert from js-indicies to code-points
157
- let c = 0
158
- let i = 0
159
- for (let p of patches) {
160
- while (i < p.range[0]) {
161
- i += get_char_size(prev_state, i)
162
- c++
163
- }
164
- p.range[0] = c
146
+ if (generate_local_diff_update) {
147
+ // DEPRECATED
148
+ var update = generate_local_diff_update(prev_state)
149
+ if (!update) return // Stop if there wasn't a change!
150
+ var {patches, new_state} = update
151
+ } else {
152
+ var new_state = get_state()
153
+ if (new_state === prev_state) return // Stop if there wasn't a change!
154
+ var patches = get_patches ? get_patches(prev_state) :
155
+ [simple_diff(prev_state, new_state)]
156
+ }
157
+
158
+ // Save JS-index patches before code-point conversion mutates them
159
+ var js_patches = patches.map(p => ({range: [...p.range], content: p.content}))
160
+
161
+ ;(async () => {
162
+ while (true) {
163
+ // convert from js-indicies to code-points
164
+ let c = 0
165
+ let i = 0
166
+ for (let p of patches) {
167
+ while (i < p.range[0]) {
168
+ i += get_char_size(prev_state, i)
169
+ c++
170
+ }
171
+ p.range[0] = c
172
+
173
+ while (i < p.range[1]) {
174
+ i += get_char_size(prev_state, i)
175
+ c++
176
+ }
177
+ p.range[1] = c
165
178
 
166
- while (i < p.range[1]) {
167
- i += get_char_size(prev_state, i)
168
- c++
179
+ char_counter += p.range[1] - p.range[0]
180
+ char_counter += count_code_points(p.content)
181
+
182
+ p.unit = "text"
183
+ p.range = `[${p.range[0]}:${p.range[1]}]`
169
184
  }
170
- p.range[1] = c
171
185
 
172
- char_counter += p.range[1] - p.range[0]
173
- char_counter += count_code_points(p.content)
186
+ var version = [peer + "-" + char_counter]
174
187
 
175
- p.unit = "text"
176
- p.range = `[${p.range[0]}:${p.range[1]}]`
177
- }
188
+ var parents = current_version
189
+ current_version = version
190
+ prev_state = new_state
191
+
192
+ outstanding_changes++
193
+ try {
194
+ var r = await braid_fetch(url, {
195
+ headers: {
196
+ "Merge-Type": "simpleton",
197
+ ...send_digests && {"Repr-Digest": await get_digest(prev_state)},
198
+ ...content_type && {"Content-Type": content_type}
199
+ },
200
+ method: "PUT",
201
+ retry: (res) => res.status !== 550,
202
+ version, parents, patches,
203
+ peer
204
+ })
205
+ if (!r.ok) throw new Error(`bad http status: ${r.status}${(r.status === 401 || r.status === 403) ? ` (access denied)` : ''}`)
206
+ } catch (e) {
207
+ on_error(e)
208
+ throw e
209
+ }
210
+ outstanding_changes--
211
+ if (on_ack && !outstanding_changes) on_ack()
178
212
 
179
- var version = [peer + "-" + char_counter]
180
-
181
- var parents = current_version
182
- current_version = version
183
- prev_state = new_state
184
-
185
- outstanding_changes++
186
- try {
187
- var r = await braid_fetch(url, {
188
- headers: {
189
- "Merge-Type": "simpleton",
190
- ...send_digests && {"Repr-Digest": await get_digest(prev_state)},
191
- ...content_type && {"Content-Type": content_type}
192
- },
193
- method: "PUT",
194
- retry: (res) => res.status !== 550,
195
- version, parents, patches,
196
- peer
197
- })
198
- if (!r.ok) throw new Error(`bad http status: ${r.status}${(r.status === 401 || r.status === 403) ? ` (access denied)` : ''}`)
199
- } catch (e) {
200
- on_error(e)
201
- throw e
213
+ // Check for more changes that accumulated while we were sending
214
+ if (generate_local_diff_update) {
215
+ update = generate_local_diff_update(prev_state)
216
+ if (!update) return
217
+ ;({patches, new_state} = update)
218
+ } else {
219
+ new_state = get_state()
220
+ if (new_state === prev_state) return
221
+ patches = get_patches ? get_patches(prev_state) :
222
+ [simple_diff(prev_state, new_state)]
223
+ }
202
224
  }
203
- outstanding_changes--
204
- if (on_ack && !outstanding_changes) on_ack()
205
- }
225
+ })()
226
+
227
+ return js_patches
206
228
  }
207
229
  }
208
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.2.117",
3
+ "version": "0.3.2",
4
4
  "description": "Library for collaborative text over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-text",
@@ -8,17 +8,21 @@
8
8
  "scripts": {
9
9
  "test": "node test/test.js"
10
10
  },
11
+ "main": "server.js",
11
12
  "files": [
12
- "index.js",
13
+ "server.js",
13
14
  "simpleton-client.js",
14
- "web-utils.js",
15
+ "client/simpleton-sync.js",
16
+ "client/cursor-sync.js",
17
+ "client/cursor-highlights.js",
18
+ "client/web-utils.js",
15
19
  "README.md",
16
- "editor.html",
17
- "markdown-editor.html",
20
+ "client/editor.html",
21
+ "client/markdown-editor.html",
18
22
  "server-demo.js"
19
23
  ],
20
24
  "dependencies": {
21
25
  "@braid.org/diamond-types-node": "^2.0.1",
22
- "braid-http": "~1.3.89"
26
+ "braid-http": "^1.3.95"
23
27
  }
24
28
  }
package/server-demo.js CHANGED
@@ -1,5 +1,5 @@
1
1
  var port = process.argv[2] || 8888
2
- var braid_text = require("./index.js")
2
+ var braid_text = require("./server.js")
3
3
 
4
4
  // TODO: set a custom database folder
5
5
  // (the default is ./braid-text-db)
@@ -16,13 +16,14 @@ var server = require("http").createServer(async (req, res) => {
16
16
  var q = req.url.split('?').slice(-1)[0]
17
17
  if (q === 'editor' || q === 'markdown-editor') {
18
18
  res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-cache" })
19
- require("fs").createReadStream(`./${q}.html`).pipe(res)
19
+ require("fs").createReadStream(`./client/${q}.html`).pipe(res)
20
20
  return
21
21
  }
22
22
 
23
- if (req.url === '/simpleton-client.js' || req.url === '/web-utils.js') {
23
+ if (req.url === '/simpleton-sync.js' || req.url === '/web-utils.js'
24
+ || req.url === '/cursor-highlights.js' || req.url === '/cursor-sync.js') {
24
25
  res.writeHead(200, { "Content-Type": "text/javascript", "Cache-Control": "no-cache" })
25
- require("fs").createReadStream("." + req.url).pipe(res)
26
+ require("fs").createReadStream("./client" + req.url).pipe(res)
26
27
  return
27
28
  }
28
29
 
@@ -3,6 +3,7 @@ let { Doc, OpLog, Branch } = require("@braid.org/diamond-types-node")
3
3
  let {http_server: braidify, fetch: braid_fetch} = require("braid-http")
4
4
  let fs = require("fs")
5
5
 
6
+
6
7
  function create_braid_text() {
7
8
  let braid_text = {
8
9
  verbose: false,
@@ -371,51 +372,9 @@ function create_braid_text() {
371
372
 
372
373
  let peer = req.headers["peer"]
373
374
 
374
- // selection sharing prototype
375
- if (req.headers['selection-sharing-prototype']) {
376
- res.setHeader('Content-Type', 'application/json')
377
-
378
- if (!resource.selections) resource.selections = {}
379
- if (!resource.selection_clients) resource.selection_clients = new Set()
380
-
381
- if (req.method === "GET" || req.method === "HEAD") {
382
- if (!req.subscribe) {
383
- return my_end(200, JSON.stringify(resource.selections))
384
- } else {
385
- var client = {peer, res}
386
- resource.selection_clients.add(client)
387
- res.startSubscription({
388
- onClose: () => resource.selection_clients.delete(client)
389
- })
390
- res.sendUpdate({ body: JSON.stringify(resource.selections) })
391
- return
392
- }
393
- } else if (req.method == "PUT" || req.method == "POST" || req.method == "PATCH") {
394
- var body = (await req.patches())[0].content_text
395
- var json = JSON.parse(body)
396
-
397
- // only keep new selections if they are newer
398
- for (var [user, selection] of Object.entries(json)) {
399
- if (resource.selections[user] && resource.selections[user].time > selection.time) delete json[user]
400
- else resource.selections[user] = selection
401
- }
402
-
403
- // remove old selections that are too old
404
- var long_ago = Date.now() - 1000 * 60 * 5
405
- for (var [user, selection] of Object.entries(resource.selections))
406
- if (selection.time < long_ago) {
407
- delete resource.selections[user]
408
- delete json[user]
409
- }
410
-
411
- body = JSON.stringify(json)
412
- if (body.length > 2)
413
- for (let client of resource.selection_clients)
414
- if (client.peer !== peer) client.res.sendUpdate({ body })
415
-
416
- return my_end(200)
417
- }
418
- }
375
+ // Implement Multiplayer Text Cursors
376
+ if (await handle_cursors(resource, req, res))
377
+ return
419
378
 
420
379
  let merge_type = req.headers["merge-type"]
421
380
  if (!merge_type) merge_type = 'simpleton'
@@ -1023,6 +982,9 @@ function create_braid_text() {
1023
982
  resource.val = resource.doc.get()
1024
983
  resource.version = resource.doc.getRemoteVersion().map(x => x.join("-")).sort()
1025
984
 
985
+ // Transform stored cursor positions through the applied patches
986
+ if (resource.cursor_state) resource.cursor_state.transform(patches)
987
+
1026
988
  var post_commit_updates = []
1027
989
 
1028
990
  if (options.merge_type != "dt") {
@@ -3011,4 +2973,174 @@ function create_braid_text() {
3011
2973
  return braid_text
3012
2974
  }
3013
2975
 
2976
+
2977
+ // Cursor lifecycle state for a single resource.
2978
+ //
2979
+ // Each peer's cursor is stored as:
2980
+ // cursors[peer_id] = { data: [{from, to}, ...], last_connected: timestamp }
2981
+ //
2982
+ // A cursor is "online" if the peer has an active subscription, OR if
2983
+ // last_connected is within expiry_ms. Expired entries are lazily
2984
+ // cleaned on each snapshot.
2985
+ //
2986
+ // This is factored out so that the same logic could conceptually run
2987
+ // on a client as well (e.g. for client-side filtering of stale cursors).
2988
+ // Transform a single position through a delete+insert operation.
2989
+ // Positions before the edit are unchanged; positions inside the deleted
2990
+ // range collapse to the edit point; positions after shift by the net change.
2991
+ // Pure inserts (del_len=0) push positions at the insert point forward.
2992
+ function transform_pos(pos, del_start, del_len, ins_len) {
2993
+ if (del_len === 0) {
2994
+ // Pure insert: push positions at or after the insert point
2995
+ if (pos < del_start) return pos
2996
+ return pos + ins_len
2997
+ }
2998
+ if (pos <= del_start) return pos
2999
+ if (pos <= del_start + del_len) return del_start + ins_len
3000
+ return pos - del_len + ins_len
3001
+ }
3002
+
3003
+ class cursor_state {
3004
+ constructor() {
3005
+ this.cursors = {}
3006
+ this.subscribers = new Set()
3007
+ }
3008
+
3009
+ subscribed_peers() {
3010
+ var peers = new Set()
3011
+ for (var sub of this.subscribers)
3012
+ if (sub.peer) peers.add(sub.peer)
3013
+ return peers
3014
+ }
3015
+
3016
+ broadcast(peer_id, data, exclude_peer) {
3017
+ var content = data != null ? JSON.stringify(data) : ''
3018
+ for (var sub of this.subscribers)
3019
+ if (sub.peer !== exclude_peer)
3020
+ try { sub.res.sendUpdate({
3021
+ patches: [{
3022
+ unit: 'json',
3023
+ range: '[' + JSON.stringify(peer_id) + ']',
3024
+ content: content
3025
+ }]
3026
+ }) } catch (e) {}
3027
+ }
3028
+
3029
+ snapshot() {
3030
+ var result = {}
3031
+ for (var [peer_id, cursor] of Object.entries(this.cursors))
3032
+ result[peer_id] = cursor.data
3033
+ return result
3034
+ }
3035
+
3036
+ subscribe(subscriber) {
3037
+ this.subscribers.add(subscriber)
3038
+ }
3039
+
3040
+ unsubscribe(subscriber) {
3041
+ this.subscribers.delete(subscriber)
3042
+ var peer_id = subscriber.peer
3043
+ if (peer_id && !this.subscribed_peers().has(peer_id)) {
3044
+ delete this.cursors[peer_id]
3045
+ this.broadcast(peer_id, null)
3046
+ }
3047
+ }
3048
+
3049
+ // Transform all stored cursor positions through text patches.
3050
+ // Each patch has { range: [start, end], content_codepoints: [...] }.
3051
+ // Patches must be sorted by range[0] ascending (original coordinates).
3052
+ transform(patches) {
3053
+ if (!patches || !patches.length) return
3054
+
3055
+ for (var cursor of Object.values(this.cursors)) {
3056
+ cursor.data = cursor.data.map(sel => {
3057
+ var from = sel.from
3058
+ var to = sel.to
3059
+
3060
+ // Apply each patch's effect on positions, accumulating offset
3061
+ var offset = 0
3062
+ for (var p of patches) {
3063
+ var del_start = p.range[0] + offset
3064
+ var del_end = p.range[1] + offset
3065
+ var del_len = del_end - del_start
3066
+ var ins_len = p.content_codepoints.length
3067
+
3068
+ from = transform_pos(from, del_start, del_len, ins_len)
3069
+ to = transform_pos(to, del_start, del_len, ins_len)
3070
+
3071
+ offset += ins_len - del_len
3072
+ }
3073
+
3074
+ return {from, to}
3075
+ })
3076
+ }
3077
+ }
3078
+
3079
+ put(peer_id, cursor_data) {
3080
+ if (!peer_id || !cursor_data) return false
3081
+ if (!this.subscribed_peers().has(peer_id)) return false
3082
+ this.cursors[peer_id] = { data: cursor_data }
3083
+ this.broadcast(peer_id, cursor_data, peer_id)
3084
+ return true
3085
+ }
3086
+ }
3087
+
3088
+ // Handle cursor requests routed by content negotiation.
3089
+ // Returns true if the request was handled, false to fall through.
3090
+ async function handle_cursors(resource, req, res) {
3091
+ var accept = req.headers['accept'] || ''
3092
+ var content_type = req.headers['content-type'] || ''
3093
+
3094
+ if (!accept.includes('application/text-cursors+json')
3095
+ && !content_type.includes('application/text-cursors+json'))
3096
+ return false
3097
+
3098
+ res.setHeader('Content-Type', 'application/text-cursors+json')
3099
+
3100
+ if (!resource.cursor_state) resource.cursor_state = new cursor_state()
3101
+ var cursors = resource.cursor_state
3102
+ var peer = req.headers['peer']
3103
+
3104
+ if (req.method === 'GET' || req.method === 'HEAD') {
3105
+ if (!req.subscribe) {
3106
+ res.writeHead(200)
3107
+ if (req.method === 'HEAD')
3108
+ res.end()
3109
+ else
3110
+ res.end(JSON.stringify(cursors.snapshot()))
3111
+ } else {
3112
+ var subscriber = {peer, res}
3113
+ cursors.subscribe(subscriber)
3114
+ res.startSubscription({
3115
+ onClose: () => cursors.unsubscribe(subscriber)
3116
+ })
3117
+ res.sendUpdate({ body: JSON.stringify(cursors.snapshot()) })
3118
+ }
3119
+ } else if (req.method === 'PUT' || req.method === 'POST' || req.method === 'PATCH') {
3120
+ var raw_body = await new Promise((resolve, reject) => {
3121
+ var chunks = []
3122
+ req.on('data', chunk => chunks.push(chunk))
3123
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()))
3124
+ req.on('error', reject)
3125
+ })
3126
+ var range = req.headers['content-range']
3127
+ if (!range || !range.startsWith('json ')) {
3128
+ res.writeHead(400)
3129
+ res.end('Missing Content-Range: json [<peer-id>] header')
3130
+ return true
3131
+ }
3132
+ var cursor_peer = JSON.parse(range.slice(5))[0]
3133
+ var accepted = cursors.put(cursor_peer, JSON.parse(raw_body))
3134
+ if (accepted) {
3135
+ res.writeHead(200)
3136
+ res.end()
3137
+ } else {
3138
+ res.writeHead(425)
3139
+ res.end('Peer not subscribed')
3140
+ }
3141
+ }
3142
+
3143
+ return true
3144
+ }
3145
+
3014
3146
  module.exports = create_braid_text()
File without changes