braid-text 0.3.24 → 0.3.26

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
@@ -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/cursor-highlights.js"></script>
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 -->
@@ -15,12 +15,12 @@
15
15
  // cursors.changed(patches)
16
16
  // cursors.destroy()
17
17
  //
18
- async function cursor_client(url, { peer, get_text, on_change, headers: user_headers }) {
18
+ async function cursor_client(url, { peer, get_text, on_change, headers: custom_headers }) {
19
19
  // --- feature detection: HEAD probe ---
20
20
  try {
21
21
  var head_res = await braid_fetch(url, {
22
22
  method: 'HEAD',
23
- headers: { ...user_headers, 'Accept': 'application/text-cursors+json' }
23
+ headers: { ...custom_headers, 'Accept': 'application/text-cursors+json' }
24
24
  })
25
25
  var ct = head_res.headers.get('content-type') || ''
26
26
  if (!ct.includes('application/text-cursors+json')) return null
@@ -124,7 +124,7 @@ async function cursor_client(url, { peer, get_text, on_change, headers: user_hea
124
124
  braid_fetch(url, {
125
125
  method: 'PUT',
126
126
  headers: {
127
- ...user_headers,
127
+ ...custom_headers,
128
128
  'Content-Type': 'application/text-cursors+json',
129
129
  Peer: peer,
130
130
  'Content-Range': 'json [' + JSON.stringify(peer) + ']',
@@ -156,7 +156,7 @@ async function cursor_client(url, { peer, get_text, on_change, headers: user_hea
156
156
  }},
157
157
  peer,
158
158
  headers: {
159
- ...user_headers,
159
+ ...custom_headers,
160
160
  Accept: 'application/text-cursors+json',
161
161
  Heartbeats: '10',
162
162
  },
@@ -229,6 +229,13 @@ async function cursor_client(url, { peer, get_text, on_change, headers: user_hea
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: user_hea
236
243
  send_timer = setTimeout(function() { do_put(ranges) }, 50)
237
244
  },
238
245
 
239
- // Transform all stored remote cursor positions through text edits.
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
- var any_changed = false
243
- for (var id of Object.keys(selections)) {
244
- selections[id] = selections[id].map(function(sel) {
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 del_len = p.range[1] - p.range[0]
249
- var ins_len = p.content.length
250
- from = transform_pos(from, p.range[0], del_len, ins_len)
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
- if (any_changed && on_change) {
258
- // Report all current selections
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: user_hea
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
+ }
@@ -106,7 +106,7 @@ function simpleton_client(url, {
106
106
  get_patches,
107
107
  get_state,
108
108
  content_type,
109
- headers: user_headers, // The user can pass in custom headers
109
+ headers: custom_headers, // The user can pass in custom headers
110
110
  // that are forwared into the fetch
111
111
  on_error,
112
112
  on_res,
@@ -144,7 +144,7 @@ function simpleton_client(url, {
144
144
  retry: () => true,
145
145
  parents: () => client_version.length ? client_version : null,
146
146
  onSubscriptionStatus: status => on_online && on_online(status.online),
147
- headers: { ...user_headers,
147
+ headers: { ...custom_headers,
148
148
  "Merge-Type": "simpleton",
149
149
  ...content_type && {Accept: content_type} },
150
150
  }).then(res => {
@@ -332,7 +332,7 @@ function simpleton_client(url, {
332
332
  peer, version, parents, patches,
333
333
  retry: (res) => res.status !== 550,
334
334
  headers: {
335
- ...user_headers,
335
+ ...custom_headers,
336
336
  "Merge-Type": "simpleton",
337
337
  ...send_digests && {
338
338
  "Repr-Digest": await get_digest(client_state) },
@@ -1,7 +1,11 @@
1
- // cursor-highlights.js — Render colored cursors and selections behind a <textarea>
1
+ // textarea-highlights.js — Render colored highlights behind a <textarea>
2
2
  //
3
3
  // No dependencies. Pure DOM/CSS.
4
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
+ //
5
9
  // Usage:
6
10
  // var hl = textarea_highlights(textarea)
7
11
  // hl.set('peer-1', [{ from: 5, to: 10, color: 'rgba(97,175,239,0.25)' }])
@@ -17,13 +21,13 @@ function textarea_highlights(textarea) {
17
21
  style.textContent = `
18
22
  .textarea-hl-backdrop {
19
23
  position: absolute;
20
- top: 0; left: 0; right: 0; bottom: 0;
21
24
  white-space: pre-wrap;
22
25
  word-wrap: break-word;
23
26
  overflow-y: auto;
24
27
  pointer-events: none;
25
28
  color: transparent;
26
29
  z-index: 1;
30
+ box-sizing: border-box;
27
31
  scrollbar-width: none;
28
32
  -ms-overflow-style: none;
29
33
  }
@@ -35,45 +39,34 @@ function textarea_highlights(textarea) {
35
39
  box-decoration-break: clone;
36
40
  }
37
41
  .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;
42
+ border-left: 2px solid var(--cursor-color, #ff5722);
43
+ margin-left: -1px;
44
+ margin-right: -1px;
53
45
  }
54
46
  `
55
47
  document.head.appendChild(style)
56
48
  }
57
49
 
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'
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
62
54
 
63
- // Move textarea's background to the wrapper so backdrops show through
55
+ // Read the textarea's background color before we make it transparent.
56
+ // Walk up the DOM if the textarea itself is transparent.
64
57
  var bg = getComputedStyle(textarea).backgroundColor
65
- if (!wrap.style.backgroundColor) {
66
- if (!bg || bg === 'rgba(0, 0, 0, 0)') {
67
- // Walk up the DOM to find the effective background
68
- var el = wrap
69
- while (el) {
70
- var elBg = getComputedStyle(el).backgroundColor
71
- if (elBg && elBg !== 'rgba(0, 0, 0, 0)') { bg = elBg; break }
72
- el = el.parentElement
73
- }
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
74
64
  }
75
- wrap.style.backgroundColor = bg || 'white'
76
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.
77
70
  textarea.style.backgroundColor = 'transparent'
78
71
  textarea.style.position = 'relative'
79
72
  textarea.style.zIndex = '2'
@@ -95,7 +88,6 @@ function textarea_highlights(textarea) {
95
88
  var bg_height = test_span.getBoundingClientRect().height
96
89
  document.body.removeChild(test_div)
97
90
  var sel_pad = (line_height - bg_height) / 2
98
- wrap.style.setProperty('--sel-pad', sel_pad + 'px')
99
91
 
100
92
  // State
101
93
  var layer_data = {} // layer_id -> [{ from, to, color }]
@@ -110,16 +102,29 @@ function textarea_highlights(textarea) {
110
102
  }
111
103
  textarea.addEventListener('scroll', sync_scroll)
112
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
+
113
109
  // Build a backdrop style string matching the textarea's font/padding/border
114
110
  function backdrop_style() {
115
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.
116
117
  return 'font-family:' + cs.fontFamily + ';' +
117
118
  'font-size:' + cs.fontSize + ';' +
118
119
  'line-height:' + cs.lineHeight + ';' +
119
120
  'padding:' + cs.paddingTop + ' ' + cs.paddingRight + ' ' +
120
121
  cs.paddingBottom + ' ' + cs.paddingLeft + ';' +
121
122
  'border:' + cs.borderTopWidth + ' solid transparent;' +
122
- 'border-radius:' + cs.borderRadius + ';'
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 + ';'
123
128
  }
124
129
 
125
130
  function escape_html(text) {
@@ -192,13 +197,17 @@ function textarea_highlights(textarea) {
192
197
  }))
193
198
 
194
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.
195
204
  var div = document.createElement('div')
196
205
  div.className = 'textarea-hl-backdrop'
197
- wrap.insertBefore(div, textarea)
206
+ textarea.parentElement.insertBefore(div, textarea)
198
207
  layer_divs[id] = div
199
208
  }
200
209
 
201
- // Font/padding/border are set inline to match textarea;
210
+ // Font/padding/border/size are set inline to match textarea;
202
211
  // positioning/pointer-events/etc come from the CSS class.
203
212
  layer_divs[id].style.cssText = style_str
204
213
 
@@ -208,37 +217,6 @@ function textarea_highlights(textarea) {
208
217
  }
209
218
  }
210
219
 
211
- // --- show local cursor/selection when textarea is not focused ---
212
-
213
- var local_id = '__local__'
214
-
215
- function on_blur() {
216
- var from = textarea.selectionStart
217
- var to = textarea.selectionEnd
218
- var color = getComputedStyle(textarea).caretColor
219
- if (!color || color === 'auto') color = getComputedStyle(textarea).color
220
- if (from === to) {
221
- layer_data[local_id] = [{ from, to, color: color }]
222
- } else {
223
- var match = color.match(/(\d+),\s*(\d+),\s*(\d+)/)
224
- var sel_color = match ? 'rgba(' + match[1] + ', ' + match[2] + ', ' + match[3] + ', 0.3)' : color
225
- layer_data[local_id] = [{ from, to, color: sel_color }]
226
- }
227
- do_render()
228
- }
229
-
230
- function on_focus() {
231
- delete layer_data[local_id]
232
- if (layer_divs[local_id]) {
233
- layer_divs[local_id].remove()
234
- delete layer_divs[local_id]
235
- }
236
- do_render()
237
- }
238
-
239
- textarea.addEventListener('blur', on_blur)
240
- textarea.addEventListener('focus', on_focus)
241
-
242
220
  return {
243
221
  set: function(layer_id, highlights) {
244
222
  layer_data[layer_id] = highlights
@@ -254,111 +232,15 @@ function textarea_highlights(textarea) {
254
232
 
255
233
  render: do_render,
256
234
 
257
- layers: function() {
258
- return Object.keys(layer_data)
259
- },
260
-
261
235
  destroy: function() {
262
236
  textarea.removeEventListener('scroll', sync_scroll)
263
- textarea.removeEventListener('blur', on_blur)
264
- textarea.removeEventListener('focus', on_focus)
237
+ resize_observer.disconnect()
265
238
  for (var div of Object.values(layer_divs)) div.remove()
266
239
  layer_data = {}
267
240
  layer_divs = {}
268
- }
269
- }
270
- }
271
-
272
- // --- Color helpers ---
273
-
274
- var _cursor_colors = ["#e06c75", "#61afef", "#98c379", "#c678dd", "#e5c07b", "#56b6c2"]
275
-
276
- function peer_color(peer_id) {
277
- var hash = 0
278
- for (var i = 0; i < peer_id.length; i++)
279
- hash = ((hash << 5) - hash + peer_id.charCodeAt(i)) | 0
280
- return _cursor_colors[Math.abs(hash) % _cursor_colors.length]
281
- }
282
-
283
- function peer_bg_color(peer_id) {
284
- var c = peer_color(peer_id)
285
- var r = parseInt(c.slice(1, 3), 16)
286
- var g = parseInt(c.slice(3, 5), 16)
287
- var b = parseInt(c.slice(5, 7), 16)
288
- var dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
289
- return `rgba(${r}, ${g}, ${b}, ${dark ? 0.4 : 0.25})`
290
- }
291
-
292
- // --- High-level wrapper ---
293
- //
294
- // Usage:
295
- // var cursors = cursor_highlights(textarea, url)
296
- // cursors.on_patches(patches) // call after applying remote patches
297
- // cursors.on_edit(patches) // call after local edit; patches optional
298
- // cursors.destroy()
299
- //
300
- function cursor_highlights(textarea, url, options) {
301
- var peer = Math.random().toString(36).slice(2)
302
- var hl = textarea_highlights(textarea)
303
- var applying_remote = false
304
- var client = null
305
- var online = false
306
- var destroyed = false
307
-
308
- cursor_client(url, {
309
- peer,
310
- headers: options?.headers,
311
- get_text: () => textarea.value,
312
- on_change: (sels) => {
313
- for (var [id, ranges] of Object.entries(sels)) {
314
- if (!ranges.length) { hl.remove(id); continue }
315
- hl.set(id, ranges.map(r => ({
316
- from: r.from, to: r.to,
317
- color: r.from === r.to ? peer_color(id) : peer_bg_color(id)
318
- })))
319
- }
320
- hl.render()
321
- }
322
- }).then(function(c) {
323
- client = c
324
- if (online) client.online()
325
- if (destroyed) client.destroy()
326
- })
327
-
328
- document.addEventListener('selectionchange', function() {
329
- if (applying_remote) return
330
- if (document.activeElement !== textarea) return
331
- if (client) client.set(textarea.selectionStart, textarea.selectionEnd)
332
- })
333
-
334
- return {
335
- online: function() {
336
- online = true
337
- if (client) client.online()
338
- },
339
- offline: function() {
340
- online = false
341
- if (client) client.offline()
342
- },
343
-
344
- on_patches: function(patches) {
345
- applying_remote = true
346
- if (client) client.changed(patches)
347
- hl.render()
348
- setTimeout(() => { applying_remote = false }, 0)
349
- },
350
-
351
- on_edit: function(patches) {
352
- if (client) {
353
- if (patches) client.changed(patches)
354
- client.set(textarea.selectionStart, textarea.selectionEnd)
355
- }
356
- },
357
-
358
- destroy: function() {
359
- destroyed = true
360
- if (client) client.destroy()
361
- hl.destroy()
241
+ textarea.style.backgroundColor = original_bg
242
+ textarea.style.position = original_position
243
+ textarea.style.zIndex = original_zIndex
362
244
  }
363
245
  }
364
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.3.24",
3
+ "version": "0.3.26",
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",