@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/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/dispatcher.js +1 -4
- package/src/highlight-support.js +6 -0
- package/src/plugins/highlighting/css-highlights.js +359 -0
- package/src/smartQuotes.js +112 -74
package/src/smartQuotes.js
CHANGED
|
@@ -1,91 +1,129 @@
|
|
|
1
|
-
|
|
1
|
+
import * as nodeType from './node-type.js'
|
|
2
|
+
import NodeIterator from './node-iterator.js'
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
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)
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export const isApostrophe = (char) => /^[’']$/.test(char)
|
|
11
|
-
export const isWhitespace = (char) => /^\s$/.test(char)
|
|
12
|
-
export const isSeparatorOrWhitespace = (char) => /\s|[>\-–—]/.test(char)
|
|
9
|
+
const isAtWordStart = (charBefore) => !charBefore || /^[\s([{/\-–—]$/.test(charBefore)
|
|
10
|
+
const isAtWordEnd = (charAfter) => !isLetter(charAfter) && !isDigit(charAfter)
|
|
13
11
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const hasCharAfter = (textArr, indexCharAfter) => !!textArr[indexCharAfter] && !isWhitespace(textArr[indexCharAfter])
|
|
17
|
-
const shouldBeSingleOpeningQuote = (text, indexCharBefore) => !!text[indexCharBefore] && isDoubleQuote(text[indexCharBefore])
|
|
12
|
+
const isSingleQuoteOpen = (text, index, [openingQuote, closingQuote]) =>
|
|
13
|
+
[...text.slice(0, index)].findLast((char) => char === openingQuote || char === closingQuote) === openingQuote
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
const newTextNode = document.createTextNode(newText)
|
|
27
|
-
startContainer.replaceWith(newTextNode)
|
|
28
|
-
return newTextNode
|
|
29
|
-
}
|
|
56
|
+
]
|
|
30
57
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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'
|
|
49
88
|
}
|
|
89
|
+
return {text, nodes}
|
|
90
|
+
}
|
|
50
91
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
98
|
+
}
|
|
55
99
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
+
}
|
|
59
108
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
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)
|
|
80
|
-
}
|
|
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
|
|
81
113
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
114
|
+
const {text, nodes} = readText(host)
|
|
115
|
+
const offset = getCursorOffset(nodes, range)
|
|
85
116
|
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
119
|
+
|
|
120
|
+
const replacement = getReplacement(text, offset, config)
|
|
121
|
+
if (replacement) replaceChar(nodes, replacement.index, replacement.char)
|
|
90
122
|
}
|
|
91
123
|
|
|
124
|
+
const isValidQuotePairConfig = (quotePair) => Array.isArray(quotePair) && quotePair.length === 2
|
|
125
|
+
|
|
126
|
+
export const shouldApplySmartQuotes = (config, target) => {
|
|
127
|
+
const {smartQuotes, quotes, singleQuotes} = config
|
|
128
|
+
return !!smartQuotes && isValidQuotePairConfig(quotes) && isValidQuotePairConfig(singleQuotes) && target.isContentEditable
|
|
129
|
+
}
|