@livingdocs/editable.js 5.2.0 → 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/dist/editable.js +1 -1
- package/dist/editable.js.map +1 -1
- package/package.json +4 -4
- package/src/dispatcher.js +1 -4
- package/src/smartQuotes.js +114 -87
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.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": "
|
|
89
|
-
"build": "
|
|
88
|
+
"commit": "79d31dce526384091371e123e491eb774564e446",
|
|
89
|
+
"build": "3111",
|
|
90
90
|
"branch": "master",
|
|
91
91
|
"isPr": false,
|
|
92
92
|
"slug": "livingdocsIO/editable.js",
|
|
93
|
-
"date": "2026-
|
|
93
|
+
"date": "2026-10-02T08:18:47.893Z"
|
|
94
94
|
}
|
|
95
95
|
}
|
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
|
|
152
|
+
applySmartQuotes(block, selection.range, config, evt.data)
|
|
156
153
|
}, 300
|
|
157
154
|
)
|
|
158
155
|
}
|
package/src/smartQuotes.js
CHANGED
|
@@ -1,102 +1,129 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
import * as nodeType from './node-type.js'
|
|
2
|
+
import NodeIterator from './node-iterator.js'
|
|
3
|
+
|
|
4
|
+
const isDoubleQuote = (char) => /^[«»"“”„]$/.test(char)
|
|
5
|
+
const isSingleQuote = (char) => /^[‘’‹›‚']$/.test(char)
|
|
6
|
+
const isLetter = (char) => /^\p{L}$/u.test(char)
|
|
7
|
+
const isDigit = (char) => /^\p{N}$/u.test(char)
|
|
8
|
+
|
|
9
|
+
const isAtWordStart = (charBefore) => !charBefore || /^[\s([{/\-–—]$/.test(charBefore)
|
|
10
|
+
const isAtWordEnd = (charAfter) => !isLetter(charAfter) && !isDigit(charAfter)
|
|
11
|
+
|
|
12
|
+
const isSingleQuoteOpen = (text, index, [openingQuote, closingQuote]) =>
|
|
13
|
+
[...text.slice(0, index)].findLast((char) => char === openingQuote || char === closingQuote) === openingQuote
|
|
14
|
+
|
|
15
|
+
// The rules for a typed character, in order. The first rule that matches
|
|
16
|
+
// writes its character at `writeAt`. An empty character leaves the text alone.
|
|
17
|
+
const rules = [
|
|
18
|
+
{
|
|
19
|
+
// ‹geht› + s → ‹geht’s
|
|
20
|
+
when: (c) => isLetter(c.typed) && c.before === c.singleQuotes[1] && isLetter(c.beforeBefore) && c.apostrophe !== c.singleQuotes[1],
|
|
21
|
+
write: (c) => c.apostrophe,
|
|
22
|
+
writeAt: 'before'
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
// Er sagte: ' → Er sagte: ‹
|
|
26
|
+
// «' → «‹
|
|
27
|
+
when: (c) => isSingleQuote(c.typed) && (isAtWordStart(c.before) || isDoubleQuote(c.before)),
|
|
28
|
+
write: (c) => c.singleQuotes[0],
|
|
29
|
+
writeAt: 'typed'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
// ‹geht + ' → ‹geht›
|
|
33
|
+
when: (c) => isSingleQuote(c.typed) && !isAtWordStart(c.before) && isAtWordEnd(c.after) && isSingleQuoteOpen(c.text, c.index, c.singleQuotes),
|
|
34
|
+
write: (c) => c.singleQuotes[1],
|
|
35
|
+
writeAt: 'typed'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
// Hans + ' → Hans’
|
|
39
|
+
// geht + ' + s → geht’s (e.g. when correcting an existing word)
|
|
40
|
+
when: (c) => isSingleQuote(c.typed) && !isAtWordStart(c.before),
|
|
41
|
+
write: (c) => c.apostrophe,
|
|
42
|
+
writeAt: 'typed'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// Er sagte: " → Er sagte: «
|
|
46
|
+
when: (c) => isDoubleQuote(c.typed) && isAtWordStart(c.before),
|
|
47
|
+
write: (c) => c.quotes[0],
|
|
48
|
+
writeAt: 'typed'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
// «Tor + " → «Tor»
|
|
52
|
+
when: (c) => isDoubleQuote(c.typed) && !isAtWordStart(c.before),
|
|
53
|
+
write: (c) => c.quotes[1],
|
|
54
|
+
writeAt: 'typed'
|
|
24
55
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
// Returns the index and the character to write for the character typed
|
|
59
|
+
// before `offset`, or undefined if the text should be left alone.
|
|
60
|
+
const getReplacement = (text, offset, {quotes, singleQuotes, apostrophe}) => {
|
|
61
|
+
const index = offset - 1
|
|
62
|
+
const context = {
|
|
63
|
+
quotes,
|
|
64
|
+
singleQuotes,
|
|
65
|
+
apostrophe,
|
|
66
|
+
text,
|
|
67
|
+
index,
|
|
68
|
+
typed: text[index],
|
|
69
|
+
before: text[index - 1],
|
|
70
|
+
beforeBefore: text[index - 2],
|
|
71
|
+
after: text[index + 1]
|
|
34
72
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
return false
|
|
73
|
+
const rule = rules.find((r) => r.when(context))
|
|
74
|
+
const char = rule?.write(context)
|
|
75
|
+
if (!char) return
|
|
76
|
+
return {index: rule.writeAt === 'before' ? index - 1 : index, char}
|
|
41
77
|
}
|
|
42
78
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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]
|
|
79
|
+
// Reads the text of the host, so the rules also see the text in a link
|
|
80
|
+
// or in bold text. A <br> reads as a line break.
|
|
81
|
+
const readText = (host) => {
|
|
82
|
+
let text = ''
|
|
83
|
+
const nodes = []
|
|
84
|
+
for (const node of new NodeIterator(host)) {
|
|
85
|
+
if (node.nodeType !== nodeType.textNode && node.nodeName !== 'BR') continue
|
|
86
|
+
nodes.push({node, start: text.length})
|
|
87
|
+
text += node.nodeValue ?? '\n'
|
|
65
88
|
}
|
|
89
|
+
return {text, nodes}
|
|
90
|
+
}
|
|
66
91
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
// Returns the cursor offset in the text of the host,
|
|
93
|
+
// or undefined if the cursor is not in a text node of the host
|
|
94
|
+
const getCursorOffset = (nodes, {startContainer, startOffset}) => {
|
|
95
|
+
if (startContainer.nodeType !== nodeType.textNode) return
|
|
96
|
+
const cursorNode = nodes.find(({node}) => node === startContainer)
|
|
97
|
+
if (cursorNode) return cursorNode.start + startOffset
|
|
70
98
|
}
|
|
71
99
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
// Inserts and deletes instead of replacing, so a highlight that ends
|
|
101
|
+
// right after the character keeps its end
|
|
102
|
+
const replaceChar = (nodes, index, char) => {
|
|
103
|
+
const {node, start} = nodes.findLast((n) => n.start <= index)
|
|
104
|
+
const offset = index - start
|
|
105
|
+
node.insertData(offset, char)
|
|
106
|
+
node.deleteData(offset + char.length, 1)
|
|
107
|
+
}
|
|
75
108
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
export const applySmartQuotes = (host, range, config, char) => {
|
|
110
|
+
const {quotes, singleQuotes, apostrophe} = config
|
|
111
|
+
if ([...quotes, ...singleQuotes, apostrophe].includes(char)) return
|
|
112
|
+
if (!isSingleQuote(char) && !isDoubleQuote(char) && !(apostrophe && isLetter(char))) return
|
|
79
113
|
|
|
80
|
-
const {
|
|
81
|
-
|
|
82
|
-
return
|
|
83
|
-
}
|
|
114
|
+
const {text, nodes} = readText(host)
|
|
115
|
+
const offset = getCursorOffset(nodes, range)
|
|
84
116
|
|
|
85
|
-
|
|
86
|
-
|
|
117
|
+
// The typed character can be gone by now, e.g. when it was deleted right away
|
|
118
|
+
if (offset === undefined || text[offset - 1] !== char) return
|
|
87
119
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
}
|
|
120
|
+
const replacement = getReplacement(text, offset, config)
|
|
121
|
+
if (replacement) replaceChar(nodes, replacement.index, replacement.char)
|
|
122
|
+
}
|
|
92
123
|
|
|
93
|
-
|
|
94
|
-
return
|
|
95
|
-
}
|
|
124
|
+
const isValidQuotePairConfig = (quotePair) => Array.isArray(quotePair) && quotePair.length === 2
|
|
96
125
|
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
selection.collapse(range.startContainer, cursorOffset ?? offset)
|
|
126
|
+
export const shouldApplySmartQuotes = (config, target) => {
|
|
127
|
+
const {smartQuotes, quotes, singleQuotes} = config
|
|
128
|
+
return !!smartQuotes && isValidQuotePairConfig(quotes) && isValidQuotePairConfig(singleQuotes) && target.isContentEditable
|
|
101
129
|
}
|
|
102
|
-
|