@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/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.10",
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": "^10.0.0",
32
+ "mocha": "^11.0.0",
33
33
  "normalize.css": "^8.0.1",
34
34
  "prismjs": "^1.26.0",
35
- "react": "^18.0.0",
36
- "react-dom": "^18.0.0",
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": "^18.0.0",
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": "^5.0.0",
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": "56c68f84db59b7727b4aaf057fe350921862db70",
86
- "build": "1673",
85
+ "commit": "7e5149c84554e50fe42304df6a3e2b94e5bc63f7",
86
+ "build": "1965",
87
87
  "branch": "master",
88
88
  "isPr": false,
89
89
  "slug": "livingdocsIO/editable.js",
90
- "date": "2024-07-10T15:24:28.500Z"
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
- if (isInputEventSupported) {
155
- this.notify('change', block)
156
- } else {
157
- // Most likely the event was already handled manually by
158
- // triggerChangeEvent so the first time we just switch the
159
- // isInputEventSupported flag without notifying the change event.
160
- isInputEventSupported = true
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
- const notifyCharacterEvent = !isInputEventSupported
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 self.triggerChangeEvent(this)
234
+ if (!rangeContainer.isCursor) return
257
235
 
258
236
  const cursor = rangeContainer.getCursor()
259
- if (!cursor.isAtBeginning()) return self.triggerChangeEvent(this)
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 self.triggerChangeEvent(this)
246
+ if (!rangeContainer.isCursor) return
269
247
 
270
248
  const cursor = rangeContainer.getCursor()
271
- if (!cursor.isAtTextEnd()) return self.triggerChangeEvent(this)
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
+