@livingdocs/editable.js 5.1.12 → 5.3.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@livingdocs/editable.js",
3
3
  "description": "Friendly contenteditable API",
4
- "version": "5.1.12",
4
+ "version": "5.3.0",
5
5
  "dependencies": {
6
6
  "lodash-es": "^4.17.21"
7
7
  },
@@ -85,11 +85,11 @@
85
85
  "isCi": true,
86
86
  "name": "Drone",
87
87
  "service": "drone",
88
- "commit": "f7175e19d3bfe844d093c6dff8474e25dd64605c",
89
- "build": "2997",
88
+ "commit": "79d31dce526384091371e123e491eb774564e446",
89
+ "build": "3111",
90
90
  "branch": "master",
91
91
  "isPr": false,
92
92
  "slug": "livingdocsIO/editable.js",
93
- "date": "2026-09-01T07:26:36.322Z"
93
+ "date": "2026-10-02T08:18:47.893Z"
94
94
  }
95
95
  }
package/src/content.js CHANGED
@@ -2,6 +2,7 @@ import * as nodeType from './node-type.js'
2
2
  import * as rangeSaveRestore from './range-save-restore.js'
3
3
  import * as parser from './parser.js'
4
4
  import * as string from './util/string.js'
5
+ import {refreshCssHighlights} from './plugins/highlighting/css-highlights.js'
5
6
  import {createElement, createRange, getNodes, normalizeBoundaries, splitBoundaries, containsNodeText} from './util/dom.js'
6
7
  import config from './config.js'
7
8
 
@@ -78,6 +79,7 @@ export function cleanInternals (element) {
78
79
  // Uses extract content for simplicity. A custom method
79
80
  // that does not clone the element could be faster if needed.
80
81
  element.innerHTML = extractContent(element, true)
82
+ refreshCssHighlights({editableHost: element})
81
83
  }
82
84
 
83
85
  // Extracts the content from a host element.
@@ -343,6 +345,7 @@ export function forceWrap (host, range, elem) {
343
345
  }
344
346
 
345
347
  wrap(restoredRange, elem)
348
+ refreshCssHighlights({editableHost: host})
346
349
  return restoredRange
347
350
  }
348
351
 
@@ -364,15 +367,19 @@ export function unwrap (elem) {
364
367
  }
365
368
 
366
369
  export function removeFormattingElem (host, range, elem) {
367
- return restoreRange(host, range, () => {
370
+ const restoredRange = restoreRange(host, range, () => {
368
371
  nukeElem(host, range, elem)
369
372
  })
373
+ refreshCssHighlights({editableHost: host})
374
+ return restoredRange
370
375
  }
371
376
 
372
377
  export function removeFormatting (host, range, selector) {
373
- return restoreRange(host, range, () => {
378
+ const restoredRange = restoreRange(host, range, () => {
374
379
  nuke(host, range, selector)
375
380
  })
381
+ refreshCssHighlights({editableHost: host})
382
+ return restoredRange
376
383
  }
377
384
 
378
385
  // Unwrap all tags this range is affected by.
package/src/core.js CHANGED
@@ -8,6 +8,15 @@ import Dispatcher from './dispatcher.js'
8
8
  import Cursor from './cursor.js'
9
9
  import highlightSupport from './highlight-support.js'
10
10
  import MonitoredHighlighting from './monitored-highlighting.js'
11
+ import Selection from './selection.js'
12
+ import {
13
+ getCssHighlightText,
14
+ setCssHighlight,
15
+ deleteCssHighlight,
16
+ createCssHighlightRange,
17
+ getCssHighlightRects,
18
+ getCssHighlightTextOffset
19
+ } from './plugins/highlighting/css-highlights.js'
11
20
  import createDefaultEvents from './create-default-events.js'
12
21
  import {textNodesUnder, getTextNodeAndRelativeOffset} from './util/element.js'
13
22
  import {binaryCursorSearch} from './util/binary_search.js'
@@ -428,6 +437,80 @@ export class Editable {
428
437
  highlightSupport.updateHighlight(editableHost, highlightId, addCssClass, removeCssClass)
429
438
  }
430
439
 
440
+ /**
441
+ * @param {Object} options
442
+ * @param {DOMNode} options.editableHost
443
+ * @return {String}
444
+ */
445
+ getCssHighlightText ({editableHost}) {
446
+ return getCssHighlightText({editableHost})
447
+ }
448
+
449
+ /**
450
+ * @param {Object} options
451
+ * @param {String} options.name
452
+ * @param {Array} [options.ranges]
453
+ * @param {DOMNode} options.ranges[].editableHost
454
+ * @param {Number} options.ranges[].start
455
+ * @param {Number} options.ranges[].end
456
+ */
457
+ setCssHighlight ({name, ranges = []}) {
458
+ setCssHighlight({name, ranges, win: this.win})
459
+ }
460
+
461
+ /**
462
+ * @param {Object} options
463
+ * @param {String} options.name
464
+ */
465
+ deleteCssHighlight ({name}) {
466
+ deleteCssHighlight({name, win: this.win})
467
+ }
468
+
469
+ /**
470
+ * @param {Object} options
471
+ * @param {DOMNode} options.editableHost
472
+ * @param {Number} options.start
473
+ * @param {Number} options.end
474
+ * @param {String} options.text
475
+ * @return {Boolean}
476
+ */
477
+ replaceCssHighlight ({editableHost, start, end, text}) {
478
+ const range = createCssHighlightRange({editableHost, start, end, win: this.win})
479
+ if (!range) return false
480
+
481
+ const cursor = new Selection(editableHost, range).insertCharacter(text)
482
+ cursor.triggerChange()
483
+ return true
484
+ }
485
+
486
+ /**
487
+ * @param {Object} options
488
+ * @param {DOMNode} options.editableHost
489
+ * @param {Number} options.start
490
+ * @param {Number} options.end
491
+ * @return {Object|undefined}
492
+ */
493
+ getCssHighlightRects ({editableHost, start, end}) {
494
+ return getCssHighlightRects({editableHost, start, end, win: this.win})
495
+ }
496
+
497
+ /**
498
+ * @param {Object} options
499
+ * @param {DOMNode} options.editableHost
500
+ * @return {Number|undefined}
501
+ */
502
+ getCssHighlightCursorOffset ({editableHost}) {
503
+ const cursor = this.getSelection(editableHost)
504
+ if (!cursor?.isCursor) return
505
+
506
+ const {startContainer, startOffset} = cursor.range
507
+ return getCssHighlightTextOffset({
508
+ editableHost,
509
+ container: startContainer,
510
+ containerOffset: startOffset
511
+ })
512
+ }
513
+
431
514
  /**
432
515
  * Subscribe a callback function to a custom event fired by the API.
433
516
  *
package/src/dispatcher.js CHANGED
@@ -89,7 +89,6 @@ export default class Dispatcher {
89
89
  * @method setupElementListeners
90
90
  */
91
91
  setupElementListeners () {
92
- const currentInput = {offset: undefined}
93
92
  this
94
93
  .setupDocumentListener('focus', function focusListener (evt) {
95
94
  const block = this.getEditableBlockByEvent(evt)
@@ -149,10 +148,8 @@ export default class Dispatcher {
149
148
 
150
149
  if (shouldApplySmartQuotes(config, evt.target)) {
151
150
  const selection = this.selectionWatcher.getFreshSelection()
152
- // Save offset of new input, to reset cursor correctly after timeout delay
153
- currentInput.offset = selection.range?.startOffset
154
151
  setTimeout(() => {
155
- applySmartQuotes(selection.range, config, evt.data, evt.target, currentInput.offset)
152
+ applySmartQuotes(block, selection.range, config, evt.data)
156
153
  }, 300
157
154
  )
158
155
  }
@@ -1,5 +1,6 @@
1
1
  import * as content from './content.js'
2
2
  import highlightText from './highlight-text.js'
3
+ import {refreshCssHighlights} from './plugins/highlighting/css-highlights.js'
3
4
  import {searchText} from './plugins/highlighting/text-search.js'
4
5
  import {createElement, createRange, toCharacterRange} from './util/dom.js'
5
6
 
@@ -19,6 +20,7 @@ const highlightSupport = {
19
20
  if (matches && matches.length) {
20
21
  if (highlightId) matches[0].id = highlightId
21
22
  highlightText.highlightMatches(editableHost, matches)
23
+ refreshCssHighlights({editableHost})
22
24
  if (dispatcher) dispatcher.notify('change', editableHost)
23
25
  return matches[0].startIndex
24
26
  }
@@ -61,6 +63,8 @@ const highlightSupport = {
61
63
  marker
62
64
  }], false)
63
65
 
66
+ refreshCssHighlights({editableHost})
67
+
64
68
  if (dispatcher) dispatcher.notify('change', editableHost)
65
69
 
66
70
  return startIndex
@@ -85,6 +89,8 @@ const highlightSupport = {
85
89
  // remove empty text nodes, combine adjacent text nodes
86
90
  editableHost.normalize()
87
91
 
92
+ refreshCssHighlights({editableHost})
93
+
88
94
  if (dispatcher) dispatcher.notify('change', editableHost)
89
95
  },
90
96
 
@@ -0,0 +1,359 @@
1
+ import NodeIterator from '../../node-iterator.js'
2
+ import * as nodeType from '../../node-type.js'
3
+ import {createRange} from '../../util/dom.js'
4
+ import {getScrollPosition} from '../../util/viewport.js'
5
+
6
+ // The DOM ranges in CSS.highlights do not survive markup changes, so keep the
7
+ // character offsets and redraw from those. One registry per window, because
8
+ // this module is shared by every editable on the page while CSS.highlights is
9
+ // not.
10
+ //
11
+ // win -> Map(name -> Map(editableHost -> [{start, end}]))
12
+ const registries = new WeakMap()
13
+
14
+ // Characters the editable inserts and strips again by itself, so an offset
15
+ // counted with one of them in it would move as soon as it goes.
16
+ const internalCharacters = /[\uFEFF\u200B]/g
17
+
18
+ const isInternal = (character) => character === '\uFEFF' || character === '\u200B'
19
+
20
+ const hasInternal = (data) => data.indexOf('\uFEFF') >= 0 || data.indexOf('\u200B') >= 0
21
+
22
+ const stripInternal = (data) => (hasInternal(data) ? data.replace(internalCharacters, '') : data)
23
+
24
+ /**
25
+ * Read an editable as plain text. Callers work in character offsets, so they
26
+ * need the text those offsets count against.
27
+ *
28
+ * @param {Object} options
29
+ * @param {DOMNode} options.editableHost
30
+ * @return {String}
31
+ */
32
+ export function getCssHighlightText ({editableHost}) {
33
+ return readEditable(editableHost).text
34
+ }
35
+
36
+ /**
37
+ * Highlight parts of an editable without touching its content. Spell errors
38
+ * are not part of the document, so they must not end up in the DOM.
39
+ *
40
+ * Each call replaces everything held under that name. The offsets are kept so
41
+ * the highlight can be drawn again after someone else changes the markup.
42
+ * Passing no ranges removes the highlight.
43
+ *
44
+ * @param {Object} options
45
+ * @param {String} options.name
46
+ * @param {Array} [options.ranges]
47
+ * @param {DOMNode} options.ranges[].editableHost
48
+ * @param {Number} options.ranges[].start
49
+ * @param {Number} options.ranges[].end
50
+ * @param {Window} [options.win]
51
+ */
52
+ export function setCssHighlight ({name, ranges = [], win = window}) {
53
+ const hosts = new Map()
54
+ for (const {editableHost, start, end} of ranges) {
55
+ const offsets = hosts.get(editableHost)
56
+ if (offsets) offsets.push({start, end})
57
+ else hosts.set(editableHost, [{start, end}])
58
+ }
59
+
60
+ if (!hosts.size) return deleteCssHighlight({name, win})
61
+
62
+ let registry = registries.get(win)
63
+ if (!registry) registries.set(win, registry = new Map())
64
+ registry.set(name, hosts)
65
+
66
+ drawCssHighlight({name, hosts, win})
67
+ }
68
+
69
+ /**
70
+ * Draw the highlights of an editable again after its markup changed.
71
+ *
72
+ * Comments and formats wrap text in marker nodes, which breaks the ranges the
73
+ * browser is holding. The markers carry no text themselves, so the offsets
74
+ * still point at the right words and the highlight can be rebuilt from them.
75
+ *
76
+ * @param {Object} options
77
+ * @param {DOMNode} options.editableHost
78
+ * @param {Window} [options.win]
79
+ */
80
+ export function refreshCssHighlights ({
81
+ editableHost,
82
+ win = editableHost?.ownerDocument?.defaultView
83
+ }) {
84
+ const registry = registries.get(win)
85
+ if (!registry?.size) return
86
+
87
+ for (const [name, hosts] of registry) {
88
+ if (!hosts.has(editableHost)) continue
89
+
90
+ for (const host of hosts.keys()) {
91
+ if (!host?.isConnected) hosts.delete(host)
92
+ }
93
+
94
+ if (!hosts.size) deleteCssHighlight({name, win})
95
+ else drawCssHighlight({name, hosts, win})
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Turn a character range into a DOM range, for callers that need to select or
101
+ * measure the text rather than highlight it.
102
+ *
103
+ * @param {Object} options
104
+ * @param {DOMNode} options.editableHost
105
+ * @param {Number} options.start
106
+ * @param {Number} options.end
107
+ * @param {Window} [options.win]
108
+ * @return {Range|undefined}
109
+ */
110
+ export function createCssHighlightRange ({editableHost, start, end, win = window}) {
111
+ const {segments} = readEditable(editableHost)
112
+ return createRangeFromSegments({segments, start, end, win})
113
+ }
114
+
115
+ /**
116
+ * Remove the highlights of one name and forget where they were. Nothing does
117
+ * this on its own, so whoever set them has to say when they are done.
118
+ *
119
+ * @param {Object} options
120
+ * @param {String} options.name
121
+ * @param {Window} [options.win]
122
+ */
123
+ export function deleteCssHighlight ({name, win = window}) {
124
+ registries.get(win)?.delete(name)
125
+ win.CSS.highlights.delete(name)
126
+ }
127
+
128
+ /**
129
+ * Where a range sits on screen, so a caller can place something next to it.
130
+ * The coordinates include the scroll offset.
131
+ *
132
+ * @param {Object} options
133
+ * @param {DOMNode} options.editableHost
134
+ * @param {Number} options.start
135
+ * @param {Number} options.end
136
+ * @param {Window} [options.win]
137
+ * @return {Object|undefined}
138
+ */
139
+ export function getCssHighlightRects ({editableHost, start, end, win = window}) {
140
+ const range = createCssHighlightRange({editableHost, start, end, win})
141
+ if (!range) return
142
+
143
+ const bounding = range.getBoundingClientRect()
144
+ const rects = Array.from(range.getClientRects())
145
+ const {x, y} = getScrollPosition(win)
146
+ return {
147
+ bounding: translate(bounding, x, y),
148
+ rects: rects.map((rect) => translate(rect, x, y))
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Turn a DOM position into a character offset, so a caller can tell which
154
+ * highlight a position falls in. Counts the same characters as
155
+ * getCssHighlightText(), so the two always agree on where an offset falls.
156
+ *
157
+ * @param {Object} options
158
+ * @param {DOMNode} options.editableHost
159
+ * @param {DOMNode} options.container
160
+ * @param {Number} options.containerOffset
161
+ * @return {Number|undefined}
162
+ */
163
+ export function getCssHighlightTextOffset ({editableHost, container, containerOffset}) {
164
+ // A position in an element points before one of its children instead of
165
+ // into text. Resolve it to that child so the walk can spot it.
166
+ const beforeNode = container.nodeType === nodeType.elementNode
167
+ ? container.childNodes[containerOffset]
168
+ : undefined
169
+
170
+ const {text, leading} = readEditable(editableHost)
171
+ const counted = (raw) => Math.min(Math.max(raw - leading, 0), text.length)
172
+
173
+ const iterator = new NodeIterator(editableHost)
174
+
175
+ let offset = 0
176
+ let next
177
+
178
+ while ((next = iterator.getNext())) {
179
+ if (next === beforeNode) return counted(offset)
180
+
181
+ if (next.nodeType === nodeType.elementNode && next.nodeName === 'BR') {
182
+ offset += 1
183
+ continue
184
+ }
185
+ if (next.nodeType !== nodeType.textNode || next.data === '') continue
186
+
187
+ if (next === container) {
188
+ return counted(offset + stripInternal(next.data.slice(0, containerOffset)).length)
189
+ }
190
+ offset = offset + stripInternal(next.data).length
191
+ }
192
+
193
+ // The walk never met the container. For an element that means the end of the
194
+ // block. Anything else is a node we do not count, so there is no offset.
195
+ if (!beforeNode && container.nodeType === nodeType.elementNode) return counted(offset)
196
+ }
197
+
198
+ /**
199
+ * Render one named highlight from character offsets.
200
+ *
201
+ * @param {Object} options
202
+ * @param {String} options.name
203
+ * @param {Map} options.hosts editableHost -> [{start, end}]
204
+ * @param {Window} options.win
205
+ */
206
+ function drawCssHighlight ({name, hosts, win}) {
207
+ const domRanges = []
208
+
209
+ for (const [editableHost, offsets] of hosts) {
210
+ const {segments} = readEditable(editableHost)
211
+ if (!segments.length) continue
212
+
213
+ for (const {start, end} of offsets) {
214
+ const range = createRangeFromSegments({segments, start, end, win})
215
+ if (range) domRanges.push(range)
216
+ }
217
+ }
218
+
219
+ // Keep the offsets. An editable can be empty for a moment while it renders,
220
+ // which does not mean the highlight has gone away.
221
+ if (!domRanges.length) return win.CSS.highlights.delete(name)
222
+
223
+ win.CSS.highlights.set(name, new win.Highlight(...domRanges))
224
+ }
225
+
226
+ /**
227
+ * Read an editable once: the text callers count their offsets against, and the
228
+ * segments those offsets resolve back through, out of the same walk.
229
+ *
230
+ * A <br> counts as a newline. Internal characters and the whitespace at either
231
+ * end of the block do not, because the clean on blur takes them out; an internal
232
+ * character splits its text node in two, which is what nodeOffset is for.
233
+ *
234
+ * @param {DOMNode} element
235
+ * @return {Object} {text, leading, segments} with segments ordered and non
236
+ * overlapping as [{node, nodeOffset, start, end}]
237
+ */
238
+ function readEditable (element) {
239
+ const runs = []
240
+ let raw = ''
241
+
242
+ const push = (node, nodeOffset, data) => {
243
+ runs.push({node, nodeOffset, start: raw.length, end: raw.length + data.length})
244
+ raw += data
245
+ }
246
+
247
+ for (const node of new NodeIterator(element)) {
248
+ if (node.nodeType === nodeType.elementNode && node.nodeName === 'BR') {
249
+ raw += '\n'
250
+ continue
251
+ }
252
+ if (node.nodeType !== nodeType.textNode || node.data === '') continue
253
+
254
+ const data = node.data
255
+ if (!hasInternal(data)) {
256
+ push(node, 0, data)
257
+ continue
258
+ }
259
+
260
+ let runStart = 0
261
+ let runLength = 0
262
+ for (let index = 0; index < data.length; index++) {
263
+ if (!isInternal(data[index])) {
264
+ if (runLength === 0) runStart = index
265
+ runLength += 1
266
+ continue
267
+ }
268
+ if (runLength) push(node, runStart, data.slice(runStart, runStart + runLength))
269
+ runLength = 0
270
+ }
271
+ if (runLength) push(node, runStart, data.slice(runStart, runStart + runLength))
272
+ }
273
+
274
+ const leading = raw.length - raw.trimStart().length
275
+ const kept = raw.trimEnd().length
276
+
277
+ const segments = []
278
+ for (const run of runs) {
279
+ const start = Math.max(run.start, leading)
280
+ const end = Math.min(run.end, kept)
281
+ if (start >= end) continue
282
+
283
+ segments.push({
284
+ node: run.node,
285
+ nodeOffset: run.nodeOffset + (start - run.start),
286
+ start: start - leading,
287
+ end: end - leading
288
+ })
289
+ }
290
+
291
+ return {text: raw.slice(leading, Math.max(leading, kept)), leading, segments}
292
+ }
293
+
294
+ /**
295
+ * @param {Object} options
296
+ * @param {Array} options.segments
297
+ * @param {Number} options.start
298
+ * @param {Number} options.end
299
+ * @param {Window} options.win
300
+ * @return {Range|undefined}
301
+ */
302
+ function createRangeFromSegments ({segments, start, end, win}) {
303
+ const startPoint = findRangeBoundary(segments, start, false)
304
+ const endPoint = findRangeBoundary(segments, end, true)
305
+ if (!startPoint || !endPoint) return
306
+
307
+ const range = createRange(win)
308
+ range.setStart(startPoint.node, startPoint.offset)
309
+ range.setEnd(endPoint.node, endPoint.offset)
310
+ return range
311
+ }
312
+
313
+ /**
314
+ * Find the place in the DOM that a character offset points at. Callers count
315
+ * characters, the browser wants nodes.
316
+ *
317
+ * Segments only ever move forward, so the right one can be found without
318
+ * reading them all.
319
+ *
320
+ * @param {Array} segments
321
+ * @param {Number} target
322
+ * @param {Boolean} inclusive True for a range end, false for a range start.
323
+ * @return {Object|undefined}
324
+ */
325
+ function findRangeBoundary (segments, target, inclusive) {
326
+ let low = 0
327
+ let high = segments.length
328
+
329
+ while (low < high) {
330
+ const middle = (low + high) >> 1
331
+ const {end} = segments[middle]
332
+ if (inclusive ? end >= target : end > target) high = middle
333
+ else low = middle + 1
334
+ }
335
+
336
+ const segment = segments[low]
337
+ if (!segment) return
338
+
339
+ return {node: segment.node, offset: segment.nodeOffset + Math.max(0, target - segment.start)}
340
+ }
341
+
342
+ /**
343
+ * Shift a rect by a scroll offset.
344
+ *
345
+ * @param {DOMRect} rect
346
+ * @param {Number} x
347
+ * @param {Number} y
348
+ * @return {Object}
349
+ */
350
+ function translate (rect, x, y) {
351
+ return {
352
+ top: rect.top + y,
353
+ bottom: rect.bottom + y,
354
+ left: rect.left + x,
355
+ right: rect.right + x,
356
+ width: rect.width,
357
+ height: rect.height
358
+ }
359
+ }