@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.
@@ -1,91 +1,129 @@
1
- const isValidQuotePairConfig = (quotePair) => Array.isArray(quotePair) && quotePair.length === 2
1
+ import * as nodeType from './node-type.js'
2
+ import NodeIterator from './node-iterator.js'
2
3
 
3
- export const shouldApplySmartQuotes = (config, target) => {
4
- const {smartQuotes, quotes, singleQuotes} = config
5
- return !!smartQuotes && isValidQuotePairConfig(quotes) && isValidQuotePairConfig(singleQuotes) && target.isContentEditable
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
- export const isDoubleQuote = (char) => /^[«»"“”„]$/.test(char)
9
- export const isSingleQuote = (char) => /^[‘’‹›‚']$/.test(char)
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 shouldBeOpeningQuote = (text, indexCharBefore) => indexCharBefore < 0 || isSeparatorOrWhitespace(text[indexCharBefore])
15
- const shouldBeClosingQuote = (text, indexCharBefore) => !!text[indexCharBefore] && !isSeparatorOrWhitespace(text[indexCharBefore])
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
- export const replaceQuote = (range, index, quoteType) => {
20
- const startContainer = range?.startContainer
21
- const nodeValue = startContainer?.nodeValue
22
- if (!nodeValue) {
23
- return null
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
- const newText = `${nodeValue.substring(0, index)}${quoteType}${nodeValue.substring(index + 1)}`
26
- const newTextNode = document.createTextNode(newText)
27
- startContainer.replaceWith(newTextNode)
28
- return newTextNode
29
- }
56
+ ]
30
57
 
31
- const hasSingleOpeningQuote = (textArr, offset, singleOpeningQuote) => {
32
- if (offset <= 0) {
33
- return false
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
- for (let i = offset - 1; i >= 0; i--) {
36
- if (isSingleQuote(textArr[i]) && (!isApostrophe(singleOpeningQuote) && !isApostrophe(textArr[i]))) {
37
- return textArr[i] === singleOpeningQuote
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
- export const applySmartQuotes = (range, config, char, target, cursorOffset) => {
44
- const isCharSingleQuote = isSingleQuote(char)
45
- const isCharDoubleQuote = isDoubleQuote(char)
46
-
47
- if (!isCharDoubleQuote && !isCharSingleQuote) {
48
- return
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
- const {quotes, singleQuotes} = config
52
- if (char === quotes[0] || char === quotes[1] || char === singleQuotes[0] || char === singleQuotes[1]) {
53
- return
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
- const offset = range.startOffset
57
- const textArr = [...range.startContainer.textContent]
58
- let newTextNode
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
- // Special case for a single quote following a double quote,
61
- // which should be transformed into a single opening quote
62
- if (isCharSingleQuote && shouldBeSingleOpeningQuote(textArr, offset - 2)) {
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)
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
- if (!newTextNode) {
83
- return
84
- }
114
+ const {text, nodes} = readText(host)
115
+ const offset = getCursorOffset(nodes, range)
85
116
 
86
- // Resets the cursor to the currentPosition after applying the smart-quote
87
- const window = target.ownerDocument.defaultView
88
- const selection = window.getSelection()
89
- selection.collapse(newTextNode, cursorOffset ?? offset)
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
+ }