@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@livingdocs/editable.js",
3
3
  "description": "Friendly contenteditable API",
4
- "version": "5.2.0",
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": "eaa44590d0a4d805673d01538fe45aaa69662513",
89
- "build": "3076",
88
+ "commit": "79d31dce526384091371e123e491eb774564e446",
89
+ "build": "3111",
90
90
  "branch": "master",
91
91
  "isPr": false,
92
92
  "slug": "livingdocsIO/editable.js",
93
- "date": "2026-09-21T14:33:07.329Z"
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, evt.target, currentInput.offset)
152
+ applySmartQuotes(block, selection.range, config, evt.data)
156
153
  }, 300
157
154
  )
158
155
  }
@@ -1,102 +1,129 @@
1
- const isValidQuotePairConfig = (quotePair) => Array.isArray(quotePair) && quotePair.length === 2
2
-
3
- export const shouldApplySmartQuotes = (config, target) => {
4
- const {smartQuotes, quotes, singleQuotes} = config
5
- return !!smartQuotes && isValidQuotePairConfig(quotes) && isValidQuotePairConfig(singleQuotes) && target.isContentEditable
6
- }
7
-
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)
13
-
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])
18
-
19
- export const replaceQuote = (range, index, quoteType) => {
20
- const startContainer = range?.startContainer
21
- const nodeValue = startContainer?.nodeValue
22
- if (!nodeValue) {
23
- return false
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
- const at = Math.min(index, nodeValue.length)
26
- startContainer.insertData(at, quoteType)
27
- startContainer.deleteData(at + quoteType.length, 1)
28
- return true
29
- }
30
-
31
- const hasSingleOpeningQuote = (textArr, offset, singleOpeningQuote) => {
32
- if (offset <= 0) {
33
- return false
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
- 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
- // 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]
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
- if (shouldBeOpeningQuote(textArr, offset - 2)) {
68
- return isCharSingleQuote ? singleQuotes[0] : quotes[0]
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
- export const applySmartQuotes = (range, config, char, target, cursorOffset) => {
73
- const isCharSingleQuote = isSingleQuote(char)
74
- const isCharDoubleQuote = isDoubleQuote(char)
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
- if (!isCharDoubleQuote && !isCharSingleQuote) {
77
- return
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 {quotes, singleQuotes} = config
81
- if (char === quotes[0] || char === quotes[1] || char === singleQuotes[0] || char === singleQuotes[1]) {
82
- return
83
- }
114
+ const {text, nodes} = readText(host)
115
+ const offset = getCursorOffset(nodes, range)
84
116
 
85
- const offset = range.startOffset
86
- const textArr = [...range.startContainer.textContent]
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 quote = getQuote(textArr, offset, isCharSingleQuote, config)
89
- if (!quote) {
90
- return
91
- }
120
+ const replacement = getReplacement(text, offset, config)
121
+ if (replacement) replaceChar(nodes, replacement.index, replacement.char)
122
+ }
92
123
 
93
- if (!replaceQuote(range, offset - 1, quote)) {
94
- return
95
- }
124
+ const isValidQuotePairConfig = (quotePair) => Array.isArray(quotePair) && quotePair.length === 2
96
125
 
97
- // Resets the cursor to the currentPosition after applying the smart-quote
98
- const window = target.ownerDocument.defaultView
99
- const selection = window.getSelection()
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
-