braid-text 0.3.25 → 0.3.27
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 +1 -1
- package/client/cursor-highlights.js +7 -135
- package/client/cursor-sync.js +142 -13
- package/client/textarea-highlights.js +246 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ This will render each peer's cursor and selection with colored highlights. Just
|
|
|
188
188
|
|
|
189
189
|
```html
|
|
190
190
|
<!-- 1. Include these additional script tags -->
|
|
191
|
-
<script src="https://unpkg.com/braid-text@~0.3/client/
|
|
191
|
+
<script src="https://unpkg.com/braid-text@~0.3/client/textarea-highlights.js"></script>
|
|
192
192
|
<script src="https://unpkg.com/braid-text@~0.3/client/cursor-sync.js"></script>
|
|
193
193
|
|
|
194
194
|
<!-- 2. Add two lines to your simpleton_client() call -->
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
//
|
|
1
|
+
// textarea-highlights.js — Render colored highlights behind a <textarea>
|
|
2
|
+
//
|
|
3
|
+
// No dependencies. Pure DOM/CSS.
|
|
4
|
+
//
|
|
5
|
+
// Renders colored ranges (selections) and zero-width points (cursors) as
|
|
6
|
+
// overlays behind a textarea's text. Works with any textarea regardless
|
|
7
|
+
// of its parent's CSS — backdrops are positioned as absolute siblings.
|
|
2
8
|
//
|
|
3
9
|
// Usage:
|
|
4
10
|
// var hl = textarea_highlights(textarea)
|
|
@@ -211,37 +217,6 @@ function textarea_highlights(textarea) {
|
|
|
211
217
|
}
|
|
212
218
|
}
|
|
213
219
|
|
|
214
|
-
// --- show local cursor/selection when textarea is not focused ---
|
|
215
|
-
|
|
216
|
-
var local_id = '__local__'
|
|
217
|
-
|
|
218
|
-
function on_blur() {
|
|
219
|
-
var from = textarea.selectionStart
|
|
220
|
-
var to = textarea.selectionEnd
|
|
221
|
-
var color = getComputedStyle(textarea).caretColor
|
|
222
|
-
if (!color || color === 'auto') color = getComputedStyle(textarea).color
|
|
223
|
-
if (from === to) {
|
|
224
|
-
layer_data[local_id] = [{ from, to, color: color }]
|
|
225
|
-
} else {
|
|
226
|
-
var match = color.match(/(\d+),\s*(\d+),\s*(\d+)/)
|
|
227
|
-
var sel_color = match ? 'rgba(' + match[1] + ', ' + match[2] + ', ' + match[3] + ', 0.3)' : color
|
|
228
|
-
layer_data[local_id] = [{ from, to, color: sel_color }]
|
|
229
|
-
}
|
|
230
|
-
do_render()
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function on_focus() {
|
|
234
|
-
delete layer_data[local_id]
|
|
235
|
-
if (layer_divs[local_id]) {
|
|
236
|
-
layer_divs[local_id].remove()
|
|
237
|
-
delete layer_divs[local_id]
|
|
238
|
-
}
|
|
239
|
-
do_render()
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
textarea.addEventListener('blur', on_blur)
|
|
243
|
-
textarea.addEventListener('focus', on_focus)
|
|
244
|
-
|
|
245
220
|
return {
|
|
246
221
|
set: function(layer_id, highlights) {
|
|
247
222
|
layer_data[layer_id] = highlights
|
|
@@ -257,118 +232,15 @@ function textarea_highlights(textarea) {
|
|
|
257
232
|
|
|
258
233
|
render: do_render,
|
|
259
234
|
|
|
260
|
-
layers: function() {
|
|
261
|
-
return Object.keys(layer_data)
|
|
262
|
-
},
|
|
263
|
-
|
|
264
235
|
destroy: function() {
|
|
265
236
|
textarea.removeEventListener('scroll', sync_scroll)
|
|
266
|
-
textarea.removeEventListener('blur', on_blur)
|
|
267
|
-
textarea.removeEventListener('focus', on_focus)
|
|
268
237
|
resize_observer.disconnect()
|
|
269
238
|
for (var div of Object.values(layer_divs)) div.remove()
|
|
270
239
|
layer_data = {}
|
|
271
240
|
layer_divs = {}
|
|
272
|
-
// Restore textarea styles
|
|
273
241
|
textarea.style.backgroundColor = original_bg
|
|
274
242
|
textarea.style.position = original_position
|
|
275
243
|
textarea.style.zIndex = original_zIndex
|
|
276
244
|
}
|
|
277
245
|
}
|
|
278
246
|
}
|
|
279
|
-
|
|
280
|
-
// --- Color helpers ---
|
|
281
|
-
|
|
282
|
-
var _cursor_colors = ["#e06c75", "#61afef", "#98c379", "#c678dd", "#e5c07b", "#56b6c2"]
|
|
283
|
-
|
|
284
|
-
function peer_color(peer_id) {
|
|
285
|
-
var hash = 0
|
|
286
|
-
for (var i = 0; i < peer_id.length; i++)
|
|
287
|
-
hash = ((hash << 5) - hash + peer_id.charCodeAt(i)) | 0
|
|
288
|
-
return _cursor_colors[Math.abs(hash) % _cursor_colors.length]
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function peer_bg_color(peer_id) {
|
|
292
|
-
var c = peer_color(peer_id)
|
|
293
|
-
var r = parseInt(c.slice(1, 3), 16)
|
|
294
|
-
var g = parseInt(c.slice(3, 5), 16)
|
|
295
|
-
var b = parseInt(c.slice(5, 7), 16)
|
|
296
|
-
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
297
|
-
return `rgba(${r}, ${g}, ${b}, ${dark ? 0.4 : 0.25})`
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// --- High-level wrapper ---
|
|
301
|
-
//
|
|
302
|
-
// Usage:
|
|
303
|
-
// var cursors = cursor_highlights(textarea, url)
|
|
304
|
-
// cursors.on_patches(patches) // call after applying remote patches
|
|
305
|
-
// cursors.on_edit(patches) // call after local edit; patches optional
|
|
306
|
-
// cursors.destroy()
|
|
307
|
-
//
|
|
308
|
-
function cursor_highlights(textarea, url, options) {
|
|
309
|
-
var peer = Math.random().toString(36).slice(2)
|
|
310
|
-
var hl = textarea_highlights(textarea)
|
|
311
|
-
var applying_remote = false
|
|
312
|
-
var client = null
|
|
313
|
-
var online = false
|
|
314
|
-
var destroyed = false
|
|
315
|
-
|
|
316
|
-
cursor_client(url, {
|
|
317
|
-
peer,
|
|
318
|
-
headers: options?.headers,
|
|
319
|
-
get_text: () => textarea.value,
|
|
320
|
-
on_change: (sels) => {
|
|
321
|
-
for (var [id, ranges] of Object.entries(sels)) {
|
|
322
|
-
if (!ranges.length) { hl.remove(id); continue }
|
|
323
|
-
hl.set(id, ranges.map(r => ({
|
|
324
|
-
from: r.from, to: r.to,
|
|
325
|
-
color: r.from === r.to ? peer_color(id) : peer_bg_color(id)
|
|
326
|
-
})))
|
|
327
|
-
}
|
|
328
|
-
hl.render()
|
|
329
|
-
}
|
|
330
|
-
}).then(function(c) {
|
|
331
|
-
client = c
|
|
332
|
-
if (online) client.online()
|
|
333
|
-
if (destroyed) client.destroy()
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
function on_selectionchange() {
|
|
337
|
-
if (applying_remote) return
|
|
338
|
-
if (document.activeElement !== textarea) return
|
|
339
|
-
if (client) client.set(textarea.selectionStart, textarea.selectionEnd)
|
|
340
|
-
}
|
|
341
|
-
document.addEventListener('selectionchange', on_selectionchange)
|
|
342
|
-
|
|
343
|
-
return {
|
|
344
|
-
online: function() {
|
|
345
|
-
online = true
|
|
346
|
-
if (client) client.online()
|
|
347
|
-
},
|
|
348
|
-
offline: function() {
|
|
349
|
-
online = false
|
|
350
|
-
if (client) client.offline()
|
|
351
|
-
},
|
|
352
|
-
|
|
353
|
-
on_patches: function(patches) {
|
|
354
|
-
applying_remote = true
|
|
355
|
-
if (client) client.changed(patches)
|
|
356
|
-
hl.render()
|
|
357
|
-
setTimeout(() => { applying_remote = false }, 0)
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
on_edit: function(patches) {
|
|
361
|
-
if (client) {
|
|
362
|
-
if (patches) client.changed(patches)
|
|
363
|
-
client.set(textarea.selectionStart, textarea.selectionEnd)
|
|
364
|
-
}
|
|
365
|
-
},
|
|
366
|
-
|
|
367
|
-
destroy: function() {
|
|
368
|
-
destroyed = true
|
|
369
|
-
document.removeEventListener('selectionchange', on_selectionchange)
|
|
370
|
-
if (client) client.destroy()
|
|
371
|
-
hl.destroy()
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
package/client/cursor-sync.js
CHANGED
|
@@ -229,6 +229,13 @@ async function cursor_client(url, { peer, get_text, on_change, headers: custom_h
|
|
|
229
229
|
last_sent = key
|
|
230
230
|
last_ranges = ranges
|
|
231
231
|
|
|
232
|
+
// Report local cursor immediately for rendering
|
|
233
|
+
if (on_change) {
|
|
234
|
+
var update = {}
|
|
235
|
+
update[peer] = last_ranges
|
|
236
|
+
on_change(update)
|
|
237
|
+
}
|
|
238
|
+
|
|
232
239
|
if (!online) return
|
|
233
240
|
|
|
234
241
|
// Debounce 50ms
|
|
@@ -236,29 +243,34 @@ async function cursor_client(url, { peer, get_text, on_change, headers: custom_h
|
|
|
236
243
|
send_timer = setTimeout(function() { do_put(ranges) }, 50)
|
|
237
244
|
},
|
|
238
245
|
|
|
239
|
-
// Transform all
|
|
246
|
+
// Transform all cursor positions (remote and local) through text edits.
|
|
240
247
|
// patches: [{ range: [start, end], content: string }] in JS string indices.
|
|
241
248
|
changed: function(patches) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
var from = sel.from
|
|
246
|
-
var to = sel.to
|
|
249
|
+
function transform_ranges(ranges) {
|
|
250
|
+
return ranges.map(function(sel) {
|
|
251
|
+
var from = sel.from, to = sel.to
|
|
247
252
|
for (var p of patches) {
|
|
248
|
-
var
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
to = transform_pos(to, p.range[0], del_len, ins_len)
|
|
253
|
+
var dl = p.range[1] - p.range[0], il = p.content.length
|
|
254
|
+
from = transform_pos(from, p.range[0], dl, il)
|
|
255
|
+
to = transform_pos(to, p.range[0], dl, il)
|
|
252
256
|
}
|
|
253
257
|
return { from, to }
|
|
254
258
|
})
|
|
255
|
-
any_changed = true
|
|
256
259
|
}
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
|
|
261
|
+
for (var id of Object.keys(selections))
|
|
262
|
+
selections[id] = transform_ranges(selections[id])
|
|
263
|
+
|
|
264
|
+
if (last_ranges)
|
|
265
|
+
last_ranges = transform_ranges(last_ranges)
|
|
266
|
+
|
|
267
|
+
// Report all cursors including local
|
|
268
|
+
if (on_change) {
|
|
259
269
|
var all = {}
|
|
260
270
|
for (var id of Object.keys(selections))
|
|
261
271
|
all[id] = selections[id]
|
|
272
|
+
if (last_ranges)
|
|
273
|
+
all[peer] = last_ranges
|
|
262
274
|
on_change(all)
|
|
263
275
|
}
|
|
264
276
|
},
|
|
@@ -278,3 +290,120 @@ async function cursor_client(url, { peer, get_text, on_change, headers: custom_h
|
|
|
278
290
|
},
|
|
279
291
|
}
|
|
280
292
|
}
|
|
293
|
+
|
|
294
|
+
// --- Color helpers ---
|
|
295
|
+
|
|
296
|
+
var _cursor_colors = ["#e06c75", "#61afef", "#98c379", "#c678dd", "#e5c07b", "#56b6c2"]
|
|
297
|
+
|
|
298
|
+
function peer_color(peer_id) {
|
|
299
|
+
var hash = 0
|
|
300
|
+
for (var i = 0; i < peer_id.length; i++)
|
|
301
|
+
hash = ((hash << 5) - hash + peer_id.charCodeAt(i)) | 0
|
|
302
|
+
return _cursor_colors[Math.abs(hash) % _cursor_colors.length]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function peer_bg_color(peer_id) {
|
|
306
|
+
var c = peer_color(peer_id)
|
|
307
|
+
var r = parseInt(c.slice(1, 3), 16)
|
|
308
|
+
var g = parseInt(c.slice(3, 5), 16)
|
|
309
|
+
var b = parseInt(c.slice(5, 7), 16)
|
|
310
|
+
var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
311
|
+
return `rgba(${r}, ${g}, ${b}, ${dark ? 0.4 : 0.25})`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- High-level wrapper: textarea + cursor sync ---
|
|
315
|
+
//
|
|
316
|
+
// Requires textarea_highlights (from textarea-highlights.js)
|
|
317
|
+
//
|
|
318
|
+
// Usage:
|
|
319
|
+
// var cursors = cursor_highlights(textarea, url, { headers: {...} })
|
|
320
|
+
// cursors.on_patches(patches) // call after applying remote patches
|
|
321
|
+
// cursors.on_edit(patches) // call after local edit; patches optional
|
|
322
|
+
// cursors.destroy()
|
|
323
|
+
//
|
|
324
|
+
function cursor_highlights(textarea, url, options) {
|
|
325
|
+
var peer = Math.random().toString(36).slice(2)
|
|
326
|
+
var hl = textarea_highlights(textarea)
|
|
327
|
+
var applying_remote = false
|
|
328
|
+
var client = null
|
|
329
|
+
var online = false
|
|
330
|
+
var destroyed = false
|
|
331
|
+
|
|
332
|
+
cursor_client(url, {
|
|
333
|
+
peer,
|
|
334
|
+
headers: options?.headers,
|
|
335
|
+
get_text: () => textarea.value,
|
|
336
|
+
on_change: function(sels) {
|
|
337
|
+
for (var [id, ranges] of Object.entries(sels)) {
|
|
338
|
+
// Skip own cursor when textarea is focused (browser draws it)
|
|
339
|
+
if (id === peer && document.activeElement === textarea) {
|
|
340
|
+
hl.remove(id)
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (!ranges.length) { hl.remove(id); continue }
|
|
344
|
+
hl.set(id, ranges.map(r => ({
|
|
345
|
+
from: r.from, to: r.to,
|
|
346
|
+
color: r.from === r.to ? peer_color(id) : peer_bg_color(id)
|
|
347
|
+
})))
|
|
348
|
+
}
|
|
349
|
+
hl.render()
|
|
350
|
+
}
|
|
351
|
+
}).then(function(c) {
|
|
352
|
+
client = c
|
|
353
|
+
if (online) client.online()
|
|
354
|
+
if (destroyed) client.destroy()
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
function on_selectionchange() {
|
|
358
|
+
if (applying_remote) return
|
|
359
|
+
if (document.activeElement !== textarea) return
|
|
360
|
+
if (client) client.set(textarea.selectionStart, textarea.selectionEnd)
|
|
361
|
+
}
|
|
362
|
+
document.addEventListener('selectionchange', on_selectionchange)
|
|
363
|
+
|
|
364
|
+
// Show own cursor when blurred, hide when focused
|
|
365
|
+
function on_blur() {
|
|
366
|
+
if (client) client.set(textarea.selectionStart, textarea.selectionEnd)
|
|
367
|
+
}
|
|
368
|
+
function on_focus() {
|
|
369
|
+
hl.remove(peer)
|
|
370
|
+
hl.render()
|
|
371
|
+
}
|
|
372
|
+
textarea.addEventListener('blur', on_blur)
|
|
373
|
+
textarea.addEventListener('focus', on_focus)
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
online: function() {
|
|
377
|
+
online = true
|
|
378
|
+
if (client) client.online()
|
|
379
|
+
},
|
|
380
|
+
offline: function() {
|
|
381
|
+
online = false
|
|
382
|
+
if (client) client.offline()
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
on_patches: function(patches) {
|
|
386
|
+
applying_remote = true
|
|
387
|
+
// cursor-sync transforms all cursors (remote + local) uniformly
|
|
388
|
+
if (client) client.changed(patches)
|
|
389
|
+
hl.render()
|
|
390
|
+
setTimeout(() => { applying_remote = false }, 0)
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
on_edit: function(patches) {
|
|
394
|
+
if (client) {
|
|
395
|
+
if (patches) client.changed(patches)
|
|
396
|
+
client.set(textarea.selectionStart, textarea.selectionEnd)
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
destroy: function() {
|
|
401
|
+
destroyed = true
|
|
402
|
+
document.removeEventListener('selectionchange', on_selectionchange)
|
|
403
|
+
textarea.removeEventListener('blur', on_blur)
|
|
404
|
+
textarea.removeEventListener('focus', on_focus)
|
|
405
|
+
if (client) client.destroy()
|
|
406
|
+
hl.destroy()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// textarea-highlights.js — Render colored highlights behind a <textarea>
|
|
2
|
+
//
|
|
3
|
+
// No dependencies. Pure DOM/CSS.
|
|
4
|
+
//
|
|
5
|
+
// Renders colored ranges (selections) and zero-width points (cursors) as
|
|
6
|
+
// overlays behind a textarea's text. Works with any textarea regardless
|
|
7
|
+
// of its parent's CSS — backdrops are positioned as absolute siblings.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// var hl = textarea_highlights(textarea)
|
|
11
|
+
// hl.set('peer-1', [{ from: 5, to: 10, color: 'rgba(97,175,239,0.25)' }])
|
|
12
|
+
// hl.render()
|
|
13
|
+
// hl.remove('peer-1')
|
|
14
|
+
// hl.destroy()
|
|
15
|
+
//
|
|
16
|
+
function textarea_highlights(textarea) {
|
|
17
|
+
// Inject CSS once per page
|
|
18
|
+
if (!document.getElementById('textarea-highlights-css')) {
|
|
19
|
+
var style = document.createElement('style')
|
|
20
|
+
style.id = 'textarea-highlights-css'
|
|
21
|
+
style.textContent = `
|
|
22
|
+
.textarea-hl-backdrop {
|
|
23
|
+
position: absolute;
|
|
24
|
+
white-space: pre-wrap;
|
|
25
|
+
word-wrap: break-word;
|
|
26
|
+
overflow-y: auto;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
color: transparent;
|
|
29
|
+
z-index: 1;
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
scrollbar-width: none;
|
|
32
|
+
-ms-overflow-style: none;
|
|
33
|
+
}
|
|
34
|
+
.textarea-hl-backdrop::-webkit-scrollbar { display: none; }
|
|
35
|
+
.textarea-hl-backdrop span { color: transparent; border-radius: 2px; }
|
|
36
|
+
.textarea-hl-backdrop span.sel {
|
|
37
|
+
padding: var(--sel-pad, 3px) 0;
|
|
38
|
+
-webkit-box-decoration-break: clone;
|
|
39
|
+
box-decoration-break: clone;
|
|
40
|
+
}
|
|
41
|
+
.textarea-hl-backdrop .cursor {
|
|
42
|
+
border-left: 2px solid var(--cursor-color, #ff5722);
|
|
43
|
+
margin-left: -1px;
|
|
44
|
+
margin-right: -1px;
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
document.head.appendChild(style)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Save original styles so we can restore on destroy
|
|
51
|
+
var original_bg = textarea.style.backgroundColor
|
|
52
|
+
var original_position = textarea.style.position
|
|
53
|
+
var original_zIndex = textarea.style.zIndex
|
|
54
|
+
|
|
55
|
+
// Read the textarea's background color before we make it transparent.
|
|
56
|
+
// Walk up the DOM if the textarea itself is transparent.
|
|
57
|
+
var bg = getComputedStyle(textarea).backgroundColor
|
|
58
|
+
if (!bg || bg === 'rgba(0, 0, 0, 0)') {
|
|
59
|
+
var el = textarea.parentElement
|
|
60
|
+
while (el) {
|
|
61
|
+
var elBg = getComputedStyle(el).backgroundColor
|
|
62
|
+
if (elBg && elBg !== 'rgba(0, 0, 0, 0)') { bg = elBg; break }
|
|
63
|
+
el = el.parentElement
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
bg = bg || 'white'
|
|
67
|
+
|
|
68
|
+
// Make textarea transparent so backdrops show through.
|
|
69
|
+
// position:relative + z-index puts the textarea text above the backdrops.
|
|
70
|
+
textarea.style.backgroundColor = 'transparent'
|
|
71
|
+
textarea.style.position = 'relative'
|
|
72
|
+
textarea.style.zIndex = '2'
|
|
73
|
+
|
|
74
|
+
// Measure font metrics for gap-free selection highlights
|
|
75
|
+
var cs = getComputedStyle(textarea)
|
|
76
|
+
var test_div = document.createElement('div')
|
|
77
|
+
test_div.style.cssText =
|
|
78
|
+
'font-family:' + cs.fontFamily + ';' +
|
|
79
|
+
'font-size:' + cs.fontSize + ';' +
|
|
80
|
+
'line-height:' + cs.lineHeight + ';' +
|
|
81
|
+
'position:absolute;top:-9999px;'
|
|
82
|
+
var test_span = document.createElement('span')
|
|
83
|
+
test_span.style.backgroundColor = 'red'
|
|
84
|
+
test_span.textContent = 'Xg'
|
|
85
|
+
test_div.appendChild(test_span)
|
|
86
|
+
document.body.appendChild(test_div)
|
|
87
|
+
var line_height = parseFloat(getComputedStyle(test_div).lineHeight)
|
|
88
|
+
var bg_height = test_span.getBoundingClientRect().height
|
|
89
|
+
document.body.removeChild(test_div)
|
|
90
|
+
var sel_pad = (line_height - bg_height) / 2
|
|
91
|
+
|
|
92
|
+
// State
|
|
93
|
+
var layer_data = {} // layer_id -> [{ from, to, color }]
|
|
94
|
+
var layer_divs = {} // layer_id -> DOM div
|
|
95
|
+
|
|
96
|
+
// Scroll sync
|
|
97
|
+
function sync_scroll() {
|
|
98
|
+
for (var div of Object.values(layer_divs)) {
|
|
99
|
+
div.scrollTop = textarea.scrollTop
|
|
100
|
+
div.scrollLeft = textarea.scrollLeft
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
textarea.addEventListener('scroll', sync_scroll)
|
|
104
|
+
|
|
105
|
+
// Re-render when textarea resizes (user drag, window resize, CSS change)
|
|
106
|
+
var resize_observer = new ResizeObserver(do_render)
|
|
107
|
+
resize_observer.observe(textarea)
|
|
108
|
+
|
|
109
|
+
// Build a backdrop style string matching the textarea's font/padding/border
|
|
110
|
+
function backdrop_style() {
|
|
111
|
+
var cs = getComputedStyle(textarea)
|
|
112
|
+
var bw = parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)
|
|
113
|
+
var bh = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)
|
|
114
|
+
// Use clientWidth/clientHeight (content + padding, excludes scrollbar)
|
|
115
|
+
// plus border, so the backdrop's content area matches the textarea's
|
|
116
|
+
// even when the textarea reserves space for a scrollbar.
|
|
117
|
+
return 'font-family:' + cs.fontFamily + ';' +
|
|
118
|
+
'font-size:' + cs.fontSize + ';' +
|
|
119
|
+
'line-height:' + cs.lineHeight + ';' +
|
|
120
|
+
'padding:' + cs.paddingTop + ' ' + cs.paddingRight + ' ' +
|
|
121
|
+
cs.paddingBottom + ' ' + cs.paddingLeft + ';' +
|
|
122
|
+
'border:' + cs.borderTopWidth + ' solid transparent;' +
|
|
123
|
+
'border-radius:' + cs.borderRadius + ';' +
|
|
124
|
+
'width:' + (textarea.clientWidth + bw) + 'px;' +
|
|
125
|
+
'height:' + (textarea.clientHeight + bh) + 'px;' +
|
|
126
|
+
'--sel-pad:' + sel_pad + 'px;' +
|
|
127
|
+
'background-color:' + bg + ';'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function escape_html(text) {
|
|
131
|
+
var div = document.createElement('div')
|
|
132
|
+
div.textContent = text
|
|
133
|
+
return div.innerHTML
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function build_html(text, highlights) {
|
|
137
|
+
var cursors = highlights.filter(h => h.from === h.to)
|
|
138
|
+
var sels = highlights.filter(h => h.from !== h.to)
|
|
139
|
+
|
|
140
|
+
var items = []
|
|
141
|
+
for (var s of sels)
|
|
142
|
+
items.push({ type: 'selection', start: s.from, end: s.to, color: s.color })
|
|
143
|
+
for (var c of cursors)
|
|
144
|
+
items.push({ type: 'cursor', pos: c.from, color: c.color })
|
|
145
|
+
|
|
146
|
+
items.sort((a, b) => {
|
|
147
|
+
var pa = a.type === 'cursor' ? a.pos : a.start
|
|
148
|
+
var pb = b.type === 'cursor' ? b.pos : b.start
|
|
149
|
+
return pa - pb
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
var result = ''
|
|
153
|
+
var last = 0
|
|
154
|
+
|
|
155
|
+
for (var item of items) {
|
|
156
|
+
if (item.type === 'selection') {
|
|
157
|
+
if (item.start < last) continue
|
|
158
|
+
result += escape_html(text.substring(last, item.start))
|
|
159
|
+
var sel_text = text.substring(item.start, item.end)
|
|
160
|
+
var sel_html = escape_html(sel_text).replace(/\n/g, ' \n')
|
|
161
|
+
result += '<span class="sel" style="background-color:' + item.color + ';">' + sel_html + '</span>'
|
|
162
|
+
last = item.end
|
|
163
|
+
} else {
|
|
164
|
+
if (item.pos < last) continue
|
|
165
|
+
result += escape_html(text.substring(last, item.pos))
|
|
166
|
+
result += '<span class="cursor" style="--cursor-color:' + item.color + ';"></span>'
|
|
167
|
+
last = item.pos
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
result += escape_html(text.substring(last))
|
|
172
|
+
if (text.endsWith('\n')) result += '\u200B\n'
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- render implementation ---
|
|
177
|
+
|
|
178
|
+
function do_render() {
|
|
179
|
+
var text = textarea.value
|
|
180
|
+
var len = text.length
|
|
181
|
+
var style_str = backdrop_style()
|
|
182
|
+
|
|
183
|
+
// Remove divs for layers that no longer exist
|
|
184
|
+
for (var id of Object.keys(layer_divs)) {
|
|
185
|
+
if (!layer_data[id]) {
|
|
186
|
+
layer_divs[id].remove()
|
|
187
|
+
delete layer_divs[id]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Render each layer
|
|
192
|
+
for (var id of Object.keys(layer_data)) {
|
|
193
|
+
var highlights = layer_data[id].map(h => ({
|
|
194
|
+
from: Math.min(h.from, len),
|
|
195
|
+
to: Math.min(h.to, len),
|
|
196
|
+
color: h.color
|
|
197
|
+
}))
|
|
198
|
+
|
|
199
|
+
if (!layer_divs[id]) {
|
|
200
|
+
// Insert backdrop as previous sibling of textarea.
|
|
201
|
+
// position:absolute takes it out of flow so it doesn't
|
|
202
|
+
// affect layout. Without top/left set, it naturally sits
|
|
203
|
+
// at the textarea's position.
|
|
204
|
+
var div = document.createElement('div')
|
|
205
|
+
div.className = 'textarea-hl-backdrop'
|
|
206
|
+
textarea.parentElement.insertBefore(div, textarea)
|
|
207
|
+
layer_divs[id] = div
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Font/padding/border/size are set inline to match textarea;
|
|
211
|
+
// positioning/pointer-events/etc come from the CSS class.
|
|
212
|
+
layer_divs[id].style.cssText = style_str
|
|
213
|
+
|
|
214
|
+
layer_divs[id].innerHTML = build_html(text, highlights)
|
|
215
|
+
layer_divs[id].scrollTop = textarea.scrollTop
|
|
216
|
+
layer_divs[id].scrollLeft = textarea.scrollLeft
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
set: function(layer_id, highlights) {
|
|
222
|
+
layer_data[layer_id] = highlights
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
remove: function(layer_id) {
|
|
226
|
+
delete layer_data[layer_id]
|
|
227
|
+
if (layer_divs[layer_id]) {
|
|
228
|
+
layer_divs[layer_id].remove()
|
|
229
|
+
delete layer_divs[layer_id]
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
render: do_render,
|
|
234
|
+
|
|
235
|
+
destroy: function() {
|
|
236
|
+
textarea.removeEventListener('scroll', sync_scroll)
|
|
237
|
+
resize_observer.disconnect()
|
|
238
|
+
for (var div of Object.values(layer_divs)) div.remove()
|
|
239
|
+
layer_data = {}
|
|
240
|
+
layer_divs = {}
|
|
241
|
+
textarea.style.backgroundColor = original_bg
|
|
242
|
+
textarea.style.position = original_position
|
|
243
|
+
textarea.style.zIndex = original_zIndex
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "braid-text",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.27",
|
|
4
4
|
"description": "Library for collaborative text over http using braid.",
|
|
5
5
|
"author": "Braid Working Group",
|
|
6
6
|
"repository": "braid-org/braid-text",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"client/simpleton-sync.js",
|
|
18
18
|
"client/cursor-sync.js",
|
|
19
19
|
"client/cursor-highlights.js",
|
|
20
|
+
"client/textarea-highlights.js",
|
|
20
21
|
"client/web-utils.js",
|
|
21
22
|
"README.md",
|
|
22
23
|
"client/editor.html",
|