@livingdocs/editable.js 5.0.11 → 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.11",
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": "85675583caacbfd2acf2577f7e7d170992627f60",
86
- "build": "1700",
85
+ "commit": "7e5149c84554e50fe42304df6a3e2b94e5bc63f7",
86
+ "build": "1965",
87
87
  "branch": "master",
88
88
  "isPr": false,
89
89
  "slug": "livingdocsIO/editable.js",
90
- "date": "2024-07-22T15:51:53.699Z"
90
+ "date": "2025-02-20T14:00:02.426Z"
91
91
  }
92
92
  }
package/src/dispatcher.js CHANGED
@@ -6,6 +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
+ import {applySmartQuotes, shouldApplySmartQuotes} from './smartQuotes.js'
9
10
 
10
11
  /**
11
12
  * The Dispatcher module is responsible for dealing with events and their handlers.
@@ -88,6 +89,7 @@ export default class Dispatcher {
88
89
  * @method setupElementListeners
89
90
  */
90
91
  setupElementListeners () {
92
+ const currentInput = {offset: undefined}
91
93
  this
92
94
  .setupDocumentListener('focus', function focusListener (evt) {
93
95
  const block = this.getEditableBlockByEvent(evt)
@@ -144,6 +146,17 @@ export default class Dispatcher {
144
146
  .setupDocumentListener('input', function inputListener (evt) {
145
147
  const block = this.getEditableBlockByEvent(evt)
146
148
  if (!block) return
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
+ )
158
+ }
159
+
147
160
  this.notify('change', block)
148
161
  })
149
162
 
@@ -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
+