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 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 -->
@@ -1,4 +1,10 @@
1
- // cursor-highlights.js — Render colored cursors and selections behind a <textarea>
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
- }
@@ -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 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: 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.25",
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",