@livingdocs/editable.js 5.1.12 → 5.2.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/dist/editable.js +1 -1
- package/dist/editable.js.map +1 -1
- package/package.json +4 -4
- package/src/content.js +9 -2
- package/src/core.js +83 -0
- package/src/highlight-support.js +6 -0
- package/src/plugins/highlighting/css-highlights.js +359 -0
- package/src/smartQuotes.js +39 -28
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.
|
|
4
|
+
"version": "5.2.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": "
|
|
89
|
-
"build": "
|
|
88
|
+
"commit": "eaa44590d0a4d805673d01538fe45aaa69662513",
|
|
89
|
+
"build": "3076",
|
|
90
90
|
"branch": "master",
|
|
91
91
|
"isPr": false,
|
|
92
92
|
"slug": "livingdocsIO/editable.js",
|
|
93
|
-
"date": "2026-09-
|
|
93
|
+
"date": "2026-09-21T14:33:07.329Z"
|
|
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
|
-
|
|
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
|
-
|
|
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/highlight-support.js
CHANGED
|
@@ -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
|
+
}
|
package/src/smartQuotes.js
CHANGED
|
@@ -20,12 +20,12 @@ export const replaceQuote = (range, index, quoteType) => {
|
|
|
20
20
|
const startContainer = range?.startContainer
|
|
21
21
|
const nodeValue = startContainer?.nodeValue
|
|
22
22
|
if (!nodeValue) {
|
|
23
|
-
return
|
|
23
|
+
return false
|
|
24
24
|
}
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
startContainer.
|
|
28
|
-
return
|
|
25
|
+
const at = Math.min(index, nodeValue.length)
|
|
26
|
+
startContainer.insertData(at, quoteType)
|
|
27
|
+
startContainer.deleteData(at + quoteType.length, 1)
|
|
28
|
+
return true
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const hasSingleOpeningQuote = (textArr, offset, singleOpeningQuote) => {
|
|
@@ -40,6 +40,35 @@ const hasSingleOpeningQuote = (textArr, offset, singleOpeningQuote) => {
|
|
|
40
40
|
return false
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Returns the quote to write in place of the typed one, or undefined if the
|
|
44
|
+
// typed character should be left alone.
|
|
45
|
+
const getQuote = (textArr, offset, isCharSingleQuote, {quotes, singleQuotes}) => {
|
|
46
|
+
// Special case for a single quote following a double quote,
|
|
47
|
+
// which should be transformed into a single opening quote
|
|
48
|
+
if (isCharSingleQuote && shouldBeSingleOpeningQuote(textArr, offset - 2)) {
|
|
49
|
+
return singleQuotes[0]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (shouldBeClosingQuote(textArr, offset - 2)) {
|
|
53
|
+
if (isCharSingleQuote) {
|
|
54
|
+
// Don't transform apostrophes
|
|
55
|
+
if (hasCharAfter(textArr, offset)) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
// Don't transform single-quote if there is no respective single-opening-quote
|
|
59
|
+
if (!hasSingleOpeningQuote(textArr, offset, singleQuotes[0])) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
return singleQuotes[1]
|
|
63
|
+
}
|
|
64
|
+
return quotes[1]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (shouldBeOpeningQuote(textArr, offset - 2)) {
|
|
68
|
+
return isCharSingleQuote ? singleQuotes[0] : quotes[0]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
export const applySmartQuotes = (range, config, char, target, cursorOffset) => {
|
|
44
73
|
const isCharSingleQuote = isSingleQuote(char)
|
|
45
74
|
const isCharDoubleQuote = isDoubleQuote(char)
|
|
@@ -55,37 +84,19 @@ export const applySmartQuotes = (range, config, char, target, cursorOffset) => {
|
|
|
55
84
|
|
|
56
85
|
const offset = range.startOffset
|
|
57
86
|
const textArr = [...range.startContainer.textContent]
|
|
58
|
-
let newTextNode
|
|
59
87
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
newTextNode = replaceQuote(range, offset - 1, singleQuotes[0])
|
|
64
|
-
} else if (shouldBeClosingQuote(textArr, offset - 2)) {
|
|
65
|
-
if (isCharSingleQuote) {
|
|
66
|
-
// Don't transform apostrophes
|
|
67
|
-
if (hasCharAfter(textArr, offset)) {
|
|
68
|
-
return
|
|
69
|
-
}
|
|
70
|
-
// Don't transform single-quote if there is no respective single-opening-quote
|
|
71
|
-
if (!hasSingleOpeningQuote(textArr, offset, singleQuotes[0])) {
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const closingQuote = isCharSingleQuote ? singleQuotes[1] : quotes[1]
|
|
76
|
-
newTextNode = replaceQuote(range, offset - 1, closingQuote)
|
|
77
|
-
} else if (shouldBeOpeningQuote(textArr, offset - 2)) {
|
|
78
|
-
const openingQuote = isCharSingleQuote ? singleQuotes[0] : quotes[0]
|
|
79
|
-
newTextNode = replaceQuote(range, offset - 1, openingQuote)
|
|
88
|
+
const quote = getQuote(textArr, offset, isCharSingleQuote, config)
|
|
89
|
+
if (!quote) {
|
|
90
|
+
return
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
if (!
|
|
93
|
+
if (!replaceQuote(range, offset - 1, quote)) {
|
|
83
94
|
return
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
// Resets the cursor to the currentPosition after applying the smart-quote
|
|
87
98
|
const window = target.ownerDocument.defaultView
|
|
88
99
|
const selection = window.getSelection()
|
|
89
|
-
selection.collapse(
|
|
100
|
+
selection.collapse(range.startContainer, cursorOffset ?? offset)
|
|
90
101
|
}
|
|
91
102
|
|