braid-text 0.2.116 → 0.3.1
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 +42 -3
- package/client/cursor-highlights.js +302 -0
- package/client/cursor-sync.js +278 -0
- package/{editor.html → client/editor.html} +12 -5
- package/{markdown-editor.html → client/markdown-editor.html} +22 -12
- package/{simpleton-client.js → client/simpleton-sync.js} +79 -57
- package/package.json +10 -7
- package/server-demo.js +5 -4
- package/{index.js → server.js} +178 -46
- package/AI-README.md +0 -82
- /package/{web-utils.js → client/web-utils.js} +0 -0
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-
|
|
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,27 @@
|
|
|
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-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
+
var cursors = cursor_highlights(the_editor, location.pathname)
|
|
13
|
+
|
|
14
|
+
var simpleton = simpleton_client(location.pathname, {
|
|
15
|
+
on_patches: (patches) => {
|
|
16
|
+
apply_patches_and_update_selection(the_editor, patches)
|
|
17
|
+
cursors.on_patches(patches)
|
|
18
|
+
},
|
|
12
19
|
get_patches: (prev_state) => diff(prev_state, the_editor.value),
|
|
13
20
|
get_state: () => the_editor.value,
|
|
14
21
|
on_error: (e) => set_error_state(the_editor),
|
|
15
22
|
on_ack: () => set_acked_state(the_editor)
|
|
16
|
-
})
|
|
23
|
+
})
|
|
17
24
|
|
|
18
25
|
the_editor.oninput = (e) => {
|
|
19
26
|
set_acked_state(the_editor, false)
|
|
20
|
-
simpleton.changed()
|
|
27
|
+
cursors.on_edit(simpleton.changed())
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
</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-
|
|
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
|
-
<
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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,12 @@ 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, {
|
|
42
51
|
on_patches: (patches) => {
|
|
43
52
|
apply_patches_and_update_selection(the_editor, patches)
|
|
53
|
+
cursors.on_patches(patches)
|
|
44
54
|
update_markdown_later()
|
|
45
55
|
},
|
|
46
56
|
get_patches: (prev_state) => diff(prev_state, the_editor.value),
|
|
@@ -51,7 +61,7 @@ var simpleton = simpleton_client(location.pathname, {
|
|
|
51
61
|
|
|
52
62
|
the_editor.oninput = (e) => {
|
|
53
63
|
set_acked_state(the_editor, false)
|
|
54
|
-
simpleton.changed()
|
|
64
|
+
cursors.on_edit(simpleton.changed())
|
|
55
65
|
update_markdown_later()
|
|
56
66
|
}
|
|
57
67
|
|
|
@@ -70,9 +80,9 @@ function update_layout() {
|
|
|
70
80
|
var vert = window.innerWidth < 1200
|
|
71
81
|
markdown_container.style.width = editing && !vert ? '55vw' : ''
|
|
72
82
|
bottom_spacer.style.display = !editing || !vert ? 'none' : ''
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
editor_wrap.style.width = vert ? '100%' : '45vw'
|
|
84
|
+
editor_wrap.style.height = vert ? '50vh' : '100%'
|
|
85
|
+
editor_wrap.style.display = !editing ? 'none' : ''
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
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:
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
char_counter += count_code_points(p.content)
|
|
186
|
+
var version = [peer + "-" + char_counter]
|
|
174
187
|
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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,18 +8,21 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"test": "node test/test.js"
|
|
10
10
|
},
|
|
11
|
+
"main": "server.js",
|
|
11
12
|
"files": [
|
|
12
|
-
"
|
|
13
|
+
"server.js",
|
|
13
14
|
"simpleton-client.js",
|
|
14
|
-
"
|
|
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
|
-
"
|
|
17
|
-
"editor.html",
|
|
18
|
-
"markdown-editor.html",
|
|
20
|
+
"client/editor.html",
|
|
21
|
+
"client/markdown-editor.html",
|
|
19
22
|
"server-demo.js"
|
|
20
23
|
],
|
|
21
24
|
"dependencies": {
|
|
22
25
|
"@braid.org/diamond-types-node": "^2.0.1",
|
|
23
|
-
"braid-http": "
|
|
26
|
+
"braid-http": "^1.3.95"
|
|
24
27
|
}
|
|
25
28
|
}
|
package/server-demo.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
var port = process.argv[2] || 8888
|
|
2
|
-
var braid_text = require("./
|
|
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(
|
|
19
|
+
require("fs").createReadStream(`./client/${q}.html`).pipe(res)
|
|
20
20
|
return
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
if (req.url === '/simpleton-
|
|
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("
|
|
26
|
+
require("fs").createReadStream("./client" + req.url).pipe(res)
|
|
26
27
|
return
|
|
27
28
|
}
|
|
28
29
|
|
package/{index.js → server.js}
RENAMED
|
@@ -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
|
-
//
|
|
375
|
-
if (req
|
|
376
|
-
|
|
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") {
|
|
@@ -1144,7 +1106,7 @@ function create_braid_text() {
|
|
|
1144
1106
|
if (braid_text.db_folder) {
|
|
1145
1107
|
await db_folder_init()
|
|
1146
1108
|
var pages = new Set()
|
|
1147
|
-
for (let x of await require('fs').promises.readdir(braid_text.db_folder)) pages.add(decode_filename(x.replace(/\.\
|
|
1109
|
+
for (let x of await require('fs').promises.readdir(braid_text.db_folder)) if (/\.\d+$/.test(x)) pages.add(decode_filename(x.replace(/\.\d+$/, '')))
|
|
1148
1110
|
return [...pages.keys()]
|
|
1149
1111
|
} else return Object.keys(braid_text.cache)
|
|
1150
1112
|
} catch (e) { return [] }
|
|
@@ -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()
|
package/AI-README.md
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# AI-README for braid-text
|
|
2
|
-
|
|
3
|
-
This document provides AI assistants with key information about the braid-text project, including development procedures and architecture notes.
|
|
4
|
-
|
|
5
|
-
## Release Procedure
|
|
6
|
-
|
|
7
|
-
Follow these steps to create a new release:
|
|
8
|
-
|
|
9
|
-
1. **Version Bump**
|
|
10
|
-
- Update the version in `package.json` (use smallest version bump: patch level)
|
|
11
|
-
- Current version format: `0.2.x`
|
|
12
|
-
|
|
13
|
-
2. **Run Tests**
|
|
14
|
-
- Run `node test/test.js` - should show all 74 tests passing
|
|
15
|
-
- Run `node test/fuzz-test.js` - runs 10,000 iterations (takes ~5-10 minutes), should complete with `best_n = Infinity @ NaN` indicating success
|
|
16
|
-
- Both tests must pass before proceeding
|
|
17
|
-
|
|
18
|
-
3. **Commit Changes**
|
|
19
|
-
- Use commit message format: `VERSION - description`
|
|
20
|
-
- Example: `0.2.73 - adds automatic test cleanup`
|
|
21
|
-
- Keep description concise and descriptive
|
|
22
|
-
|
|
23
|
-
4. **Push to Remote**
|
|
24
|
-
- Run `git push` to push to the remote repository
|
|
25
|
-
|
|
26
|
-
5. **Publish to npm**
|
|
27
|
-
- Run `npm publish` to publish the new version
|
|
28
|
-
|
|
29
|
-
## Test Suite
|
|
30
|
-
|
|
31
|
-
### test/test.js
|
|
32
|
-
- Main test suite with 74 tests
|
|
33
|
-
- Tests Braid protocol, version control, syncing, and collaboration features
|
|
34
|
-
- Automatically cleans up `test_db_folder` after completion
|
|
35
|
-
- Run with: `node test/test.js`
|
|
36
|
-
- Filter tests with: `node test/test.js --filter="sync"`
|
|
37
|
-
|
|
38
|
-
### test/fuzz-test.js
|
|
39
|
-
- Fuzz testing suite that generates random edits and verifies correctness
|
|
40
|
-
- Tests diamond-types integration and merge operations
|
|
41
|
-
- Runs 10,000 iterations by default (takes approximately 5-10 minutes)
|
|
42
|
-
- Success indicated by `best_n = Infinity @ NaN` at completion (no failures found)
|
|
43
|
-
- Run with: `node test/fuzz-test.js`
|
|
44
|
-
- Let it run to completion - do not interrupt
|
|
45
|
-
|
|
46
|
-
## Project Structure
|
|
47
|
-
|
|
48
|
-
- **index.js** - Main library file implementing Braid protocol for collaborative text
|
|
49
|
-
- **package.json** - Package configuration and dependencies
|
|
50
|
-
- **test/** - Test suite directory
|
|
51
|
-
- **test.js** - Main test runner (supports both console and browser modes)
|
|
52
|
-
- **tests.js** - Test definitions (shared between console and browser)
|
|
53
|
-
- **fuzz-test.js** - Fuzz testing suite
|
|
54
|
-
- **test.html** - Browser test interface
|
|
55
|
-
|
|
56
|
-
## Key Dependencies
|
|
57
|
-
|
|
58
|
-
- **@braid.org/diamond-types-node** - CRDT implementation for conflict-free text editing
|
|
59
|
-
- **braid-http** - Braid protocol HTTP implementation
|
|
60
|
-
- **url-file-db** - File-based database for persistent storage
|
|
61
|
-
|
|
62
|
-
## Architecture Notes
|
|
63
|
-
|
|
64
|
-
- Implements the Braid protocol for synchronizing collaborative text over HTTP
|
|
65
|
-
- Uses diamond-types CRDT for conflict-free merging
|
|
66
|
-
- Supports both simpleton and dt (diamond-types) merge strategies
|
|
67
|
-
- Provides version control with parents tracking and version history
|
|
68
|
-
- File-based persistence with case-insensitive filesystem support
|
|
69
|
-
|
|
70
|
-
## Common Operations
|
|
71
|
-
|
|
72
|
-
### Running Tests Locally
|
|
73
|
-
```bash
|
|
74
|
-
node test/test.js # Run all tests
|
|
75
|
-
node test/test.js --filter="sync" # Run tests matching "sync"
|
|
76
|
-
node test/fuzz-test.js # Run fuzz tests
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
### Test Cleanup
|
|
80
|
-
The test suite automatically cleans up temporary files and databases. If manual cleanup is needed:
|
|
81
|
-
- Test database is created at `test/test_db_folder` during tests
|
|
82
|
-
- Automatically removed after test completion
|
|
File without changes
|