@livingdocs/editable.js 5.0.11 → 5.1.1

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.1",
5
5
  "dependencies": {
6
6
  "@babel/runtime": "^7.16.7",
7
7
  "lodash-es": "^4.17.21"
@@ -13,14 +13,14 @@
13
13
  "@babel/preset-env": "^7.16.11",
14
14
  "@babel/preset-react": "^7.16.7",
15
15
  "@eslint/compat": "^1.1.0",
16
- "babel-loader": "^9.0.0",
16
+ "babel-loader": "^10.0.0",
17
17
  "babel-plugin-add-module-exports": "^1.0.4",
18
18
  "babel-plugin-istanbul": "^7.0.0",
19
19
  "chai": "^5.0.0",
20
20
  "css-loader": "^7.0.0",
21
21
  "eslint": "^9.6.0",
22
22
  "eslint-plugin-react": "^7.34.3",
23
- "globals": "^15.8.0",
23
+ "globals": "^16.0.0",
24
24
  "karma": "^6.3.13",
25
25
  "karma-chrome-launcher": "^3.1.0",
26
26
  "karma-coverage": "^2.1.0",
@@ -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": "6470c8240664aa6fc4967266ad8225fb86d39a10",
86
+ "build": "1992",
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-03-10T08:41:27.841Z"
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
+ }, 300
157
+ )
158
+ }
159
+
147
160
  this.notify('change', block)
148
161
  })
149
162
 
@@ -0,0 +1,91 @@
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 null
24
+ }
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
+ }
30
+
31
+ const hasSingleOpeningQuote = (textArr, offset, singleOpeningQuote) => {
32
+ if (offset <= 0) {
33
+ return false
34
+ }
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
41
+ }
42
+
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
49
+ }
50
+
51
+ const {quotes, singleQuotes} = config
52
+ if (char === quotes[0] || char === quotes[1] || char === singleQuotes[0] || char === singleQuotes[1]) {
53
+ return
54
+ }
55
+
56
+ const offset = range.startOffset
57
+ const textArr = [...range.startContainer.textContent]
58
+ let newTextNode
59
+
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
+ }
81
+
82
+ if (!newTextNode) {
83
+ return
84
+ }
85
+
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)
90
+ }
91
+