@livingdocs/editable.js 5.0.10 → 5.1.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 +9 -9
- package/src/dispatcher.js +18 -40
- package/src/smartQuotes.js +84 -0
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.0
|
|
4
|
+
"version": "5.1.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@babel/runtime": "^7.16.7",
|
|
7
7
|
"lodash-es": "^4.17.21"
|
|
@@ -29,18 +29,18 @@
|
|
|
29
29
|
"karma-safari-launcher": "^1.0.0",
|
|
30
30
|
"karma-sourcemap-loader": "^0.4.0",
|
|
31
31
|
"karma-webpack": "^5.0.0",
|
|
32
|
-
"mocha": "^
|
|
32
|
+
"mocha": "^11.0.0",
|
|
33
33
|
"normalize.css": "^8.0.1",
|
|
34
34
|
"prismjs": "^1.26.0",
|
|
35
|
-
"react": "^
|
|
36
|
-
"react-dom": "^
|
|
35
|
+
"react": "^19.0.0",
|
|
36
|
+
"react-dom": "^19.0.0",
|
|
37
37
|
"react-transition-group": "^4.4.2",
|
|
38
38
|
"rimraf": "^6.0.0",
|
|
39
|
-
"sinon": "^
|
|
39
|
+
"sinon": "^19.0.0",
|
|
40
40
|
"style-loader": "^4.0.0",
|
|
41
41
|
"url-loader": "^4.1.1",
|
|
42
42
|
"webpack": "^5.68.0",
|
|
43
|
-
"webpack-cli": "^
|
|
43
|
+
"webpack-cli": "^6.0.0",
|
|
44
44
|
"webpack-dev-server": "^5.0.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
@@ -82,11 +82,11 @@
|
|
|
82
82
|
"isCi": true,
|
|
83
83
|
"name": "Drone",
|
|
84
84
|
"service": "drone",
|
|
85
|
-
"commit": "
|
|
86
|
-
"build": "
|
|
85
|
+
"commit": "7e5149c84554e50fe42304df6a3e2b94e5bc63f7",
|
|
86
|
+
"build": "1965",
|
|
87
87
|
"branch": "master",
|
|
88
88
|
"isPr": false,
|
|
89
89
|
"slug": "livingdocsIO/editable.js",
|
|
90
|
-
"date": "
|
|
90
|
+
"date": "2025-02-20T14:00:02.426Z"
|
|
91
91
|
}
|
|
92
92
|
}
|
package/src/dispatcher.js
CHANGED
|
@@ -6,11 +6,7 @@ import config from './config.js'
|
|
|
6
6
|
import Keyboard from './keyboard.js'
|
|
7
7
|
import {closest} from './util/dom.js'
|
|
8
8
|
import {replaceLast, endsWithSingleSpace} from './util/string.js'
|
|
9
|
-
|
|
10
|
-
// This will be set to true once we detect the input event is working.
|
|
11
|
-
// Input event description on MDN:
|
|
12
|
-
// https://developer.mozilla.org/en-US/docs/Web/Reference/Events/input
|
|
13
|
-
let isInputEventSupported = false
|
|
9
|
+
import {applySmartQuotes, shouldApplySmartQuotes} from './smartQuotes.js'
|
|
14
10
|
|
|
15
11
|
/**
|
|
16
12
|
* The Dispatcher module is responsible for dealing with events and their handlers.
|
|
@@ -23,7 +19,6 @@ export default class Dispatcher {
|
|
|
23
19
|
constructor (editable) {
|
|
24
20
|
const win = editable.win
|
|
25
21
|
eventable(this, editable)
|
|
26
|
-
this.supportsInputEvent = false
|
|
27
22
|
this.document = win.document
|
|
28
23
|
this.config = editable.config
|
|
29
24
|
this.editable = editable
|
|
@@ -94,6 +89,7 @@ export default class Dispatcher {
|
|
|
94
89
|
* @method setupElementListeners
|
|
95
90
|
*/
|
|
96
91
|
setupElementListeners () {
|
|
92
|
+
const currentInput = {offset: undefined}
|
|
97
93
|
this
|
|
98
94
|
.setupDocumentListener('focus', function focusListener (evt) {
|
|
99
95
|
const block = this.getEditableBlockByEvent(evt)
|
|
@@ -122,7 +118,6 @@ export default class Dispatcher {
|
|
|
122
118
|
const selection = this.selectionWatcher.getFreshSelection()
|
|
123
119
|
if (selection && selection.isSelection) {
|
|
124
120
|
this.notify('clipboard', block, 'cut', selection)
|
|
125
|
-
this.triggerChangeEvent(block)
|
|
126
121
|
}
|
|
127
122
|
})
|
|
128
123
|
.setupDocumentListener('paste', function pasteListener (evt) {
|
|
@@ -151,14 +146,18 @@ export default class Dispatcher {
|
|
|
151
146
|
.setupDocumentListener('input', function inputListener (evt) {
|
|
152
147
|
const block = this.getEditableBlockByEvent(evt)
|
|
153
148
|
if (!block) return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
149
|
+
|
|
150
|
+
if (shouldApplySmartQuotes(config, evt.target)) {
|
|
151
|
+
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
|
+
setTimeout(() => {
|
|
155
|
+
applySmartQuotes(selection.range, config, evt.data, evt.target, currentInput.offset)
|
|
156
|
+
}, 500
|
|
157
|
+
)
|
|
161
158
|
}
|
|
159
|
+
|
|
160
|
+
this.notify('change', block)
|
|
162
161
|
})
|
|
163
162
|
|
|
164
163
|
.setupDocumentListener('formatEditable', function formatEditableListener (evt) {
|
|
@@ -168,26 +167,6 @@ export default class Dispatcher {
|
|
|
168
167
|
})
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
/**
|
|
172
|
-
* Trigger a change event
|
|
173
|
-
*
|
|
174
|
-
* This should be done in these cases:
|
|
175
|
-
* - typing a letter
|
|
176
|
-
* - delete (backspace and delete keys)
|
|
177
|
-
* - cut
|
|
178
|
-
* - paste
|
|
179
|
-
* - copy and paste (not easily possible manually as far as I know)
|
|
180
|
-
*
|
|
181
|
-
* Preferably this is done using the input event. But the input event is not
|
|
182
|
-
* supported on all browsers for contenteditable elements.
|
|
183
|
-
* To make things worse it is not detectable either. So instead of detecting
|
|
184
|
-
* we set 'isInputEventSupported' when the input event fires the first time.
|
|
185
|
-
*/
|
|
186
|
-
triggerChangeEvent (target) {
|
|
187
|
-
if (isInputEventSupported) return
|
|
188
|
-
this.notify('change', target)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
170
|
dispatchSwitchEvent (event, element, direction) {
|
|
192
171
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
|
|
193
172
|
const cursor = this.selectionWatcher.getFreshSelection()
|
|
@@ -228,8 +207,7 @@ export default class Dispatcher {
|
|
|
228
207
|
this.setupDocumentListener('keydown', function (evt) {
|
|
229
208
|
const block = this.getEditableBlockByEvent(evt)
|
|
230
209
|
if (!block) return
|
|
231
|
-
|
|
232
|
-
this.keyboard.dispatchKeyEvent(evt, block, notifyCharacterEvent)
|
|
210
|
+
this.keyboard.dispatchKeyEvent(evt, block, false)
|
|
233
211
|
}, true)
|
|
234
212
|
}
|
|
235
213
|
|
|
@@ -253,10 +231,10 @@ export default class Dispatcher {
|
|
|
253
231
|
|
|
254
232
|
.on('backspace', function (event) {
|
|
255
233
|
const rangeContainer = self.selectionWatcher.getFreshRange()
|
|
256
|
-
if (!rangeContainer.isCursor) return
|
|
234
|
+
if (!rangeContainer.isCursor) return
|
|
257
235
|
|
|
258
236
|
const cursor = rangeContainer.getCursor()
|
|
259
|
-
if (!cursor.isAtBeginning()) return
|
|
237
|
+
if (!cursor.isAtBeginning()) return
|
|
260
238
|
|
|
261
239
|
event.preventDefault()
|
|
262
240
|
event.stopPropagation()
|
|
@@ -265,10 +243,10 @@ export default class Dispatcher {
|
|
|
265
243
|
|
|
266
244
|
.on('delete', function (event) {
|
|
267
245
|
const rangeContainer = self.selectionWatcher.getFreshRange()
|
|
268
|
-
if (!rangeContainer.isCursor) return
|
|
246
|
+
if (!rangeContainer.isCursor) return
|
|
269
247
|
|
|
270
248
|
const cursor = rangeContainer.getCursor()
|
|
271
|
-
if (!cursor.isAtTextEnd()) return
|
|
249
|
+
if (!cursor.isAtTextEnd()) return
|
|
272
250
|
|
|
273
251
|
event.preventDefault()
|
|
274
252
|
event.stopPropagation()
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
const isDoubleQuote = (char) => /^[«»"“”„]$/.test(char)
|
|
9
|
+
const isSingleQuote = (char) => /^[‘’‹›‚']$/.test(char)
|
|
10
|
+
const isApostrophe = (char) => /^[’']$/.test(char)
|
|
11
|
+
const isWhitespace = (char) => /^\s$/.test(char)
|
|
12
|
+
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
|
+
|
|
18
|
+
const replaceQuote = (range, index, quoteType) => {
|
|
19
|
+
const startContainer = range.startContainer
|
|
20
|
+
if (!startContainer.nodeValue) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const textNode = document.createTextNode(`${startContainer.nodeValue.substring(0, index)}${quoteType}${startContainer.nodeValue.substring(index + 1)}`)
|
|
24
|
+
startContainer.replaceWith(textNode)
|
|
25
|
+
return textNode
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const hasSingleOpeningQuote = (textArr, offset, singleOpeningQuote) => {
|
|
29
|
+
if (offset <= 0) {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
for (let i = offset - 1; i >= 0; i--) {
|
|
33
|
+
if (isSingleQuote(textArr[i]) && (!isApostrophe(singleOpeningQuote) && !isApostrophe(textArr[i]))) {
|
|
34
|
+
return textArr[i] === singleOpeningQuote
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const applySmartQuotes = (range, config, char, target, cursorOffset) => {
|
|
41
|
+
const isCharSingleQuote = isSingleQuote(char)
|
|
42
|
+
const isCharDoubleQuote = isDoubleQuote(char)
|
|
43
|
+
|
|
44
|
+
if (!isCharDoubleQuote && !isCharSingleQuote) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const {quotes, singleQuotes} = config
|
|
49
|
+
if (char === quotes[0] || char === quotes[1] || char === singleQuotes[0] || char === singleQuotes[1]) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const offset = range.startOffset
|
|
54
|
+
const textArr = [...range.startContainer.textContent]
|
|
55
|
+
let newTextNode
|
|
56
|
+
|
|
57
|
+
if (shouldBeClosingQuote(textArr, offset - 2)) {
|
|
58
|
+
if (isCharSingleQuote) {
|
|
59
|
+
// Don't transform apostrophes
|
|
60
|
+
if (hasCharAfter(textArr, offset)) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
// Don't transform single-quote if there is no respective single-opening-quote
|
|
64
|
+
if (!hasSingleOpeningQuote(textArr, offset, singleQuotes[0])) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const closingQuote = isCharSingleQuote ? singleQuotes[1] : quotes[1]
|
|
69
|
+
newTextNode = replaceQuote(range, offset - 1, closingQuote)
|
|
70
|
+
} else if (shouldBeOpeningQuote(textArr, offset - 2)) {
|
|
71
|
+
const openingQuote = isCharSingleQuote ? singleQuotes[0] : quotes[0]
|
|
72
|
+
newTextNode = replaceQuote(range, offset - 1, openingQuote)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!newTextNode) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resets the cursor to the currentPosition after applying the smart-quote
|
|
80
|
+
const window = target.ownerDocument.defaultView
|
|
81
|
+
const selection = window.getSelection()
|
|
82
|
+
selection.collapse(newTextNode, cursorOffset ?? offset)
|
|
83
|
+
}
|
|
84
|
+
|