@livingdocs/editable.js 3.1.7 → 3.2.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": "3.1.7",
4
+ "version": "3.2.1",
5
5
  "dependencies": {
6
6
  "@babel/runtime": "^7.16.7",
7
7
  "bowser": "2.11.0",
@@ -21,8 +21,6 @@
21
21
  "css-loader": "^6.5.1",
22
22
  "eslint": "^8.8.0",
23
23
  "eslint-plugin-react": "^7.28.0",
24
- "font-awesome": "^4.7.0",
25
- "gh-pages": "^5.0.0",
26
24
  "karma": "^6.3.13",
27
25
  "karma-chrome-launcher": "^3.1.0",
28
26
  "karma-coverage": "^2.1.0",
@@ -37,7 +35,7 @@
37
35
  "react": "^18.0.0",
38
36
  "react-dom": "^18.0.0",
39
37
  "react-transition-group": "^4.4.2",
40
- "rimraf": "^4.0.0",
38
+ "rimraf": "^5.0.0",
41
39
  "sinon": "^15.0.0",
42
40
  "style-loader": "^3.3.1",
43
41
  "url-loader": "^4.1.1",
@@ -46,7 +44,8 @@
46
44
  "webpack-dev-server": "^4.7.3"
47
45
  },
48
46
  "engines": {
49
- "node": ">=12"
47
+ "node": ">=12",
48
+ "npm": ">=6"
50
49
  },
51
50
  "files": [
52
51
  "src",
@@ -76,18 +75,22 @@
76
75
  "test:watch": "npm run test:karma -s -- --single-run false",
77
76
  "lint": "eslint --ignore-path .gitignore ."
78
77
  },
78
+ "publishConfig": {
79
+ "tag": "3.x"
80
+ },
79
81
  "release": {
80
- "extends": "@livingdocs/semantic-release-presets/npm-github-verify"
82
+ "extends": "@livingdocs/semantic-release-presets/npm-github-verify",
83
+ "branch": "3.x"
81
84
  },
82
85
  "ci": {
83
86
  "isCi": true,
84
87
  "name": "Drone",
85
88
  "service": "drone",
86
- "commit": "faeb13de973b3fcbaca1c38da4d8efc7b138565d",
87
- "build": "900",
88
- "branch": "master",
89
+ "commit": "ca878f0f32cf4eaf0ebd53f2467973e5c59e8b97",
90
+ "build": "1407",
91
+ "branch": "3.x",
89
92
  "isPr": false,
90
93
  "slug": "livingdocsIO/editable.js",
91
- "date": "2023-03-10T10:06:35.613Z"
94
+ "date": "2024-01-10T22:49:33.014Z"
92
95
  }
93
96
  }
package/src/block.js CHANGED
@@ -7,11 +7,12 @@ const state = {}
7
7
  export const next = getSibling('nextElementSibling')
8
8
  export const previous = getSibling('previousElementSibling')
9
9
 
10
- export function init (elem, {normalize, shouldSpellcheck}) {
10
+ export function init (elem, {normalize, plainText, shouldSpellcheck}) {
11
11
  setBlockId(elem)
12
12
 
13
13
  elem.setAttribute('contenteditable', true)
14
14
  elem.setAttribute('spellcheck', Boolean(shouldSpellcheck))
15
+ elem.setAttribute('data-plaintext', Boolean(plainText))
15
16
 
16
17
  elem.classList.remove(config.editableDisabledClass)
17
18
  elem.classList.add(config.editableClass)
@@ -23,6 +24,7 @@ export function init (elem, {normalize, shouldSpellcheck}) {
23
24
  export function disable (elem) {
24
25
  elem.removeAttribute('contenteditable')
25
26
  elem.removeAttribute('spellcheck')
27
+ elem.removeAttribute('data-plaintext')
26
28
 
27
29
  setState(elem, undefined)
28
30
 
@@ -30,6 +32,9 @@ export function disable (elem) {
30
32
  elem.classList.add(config.editableDisabledClass)
31
33
  }
32
34
 
35
+ export function isPlainTextBlock (elem) {
36
+ return elem.getAttribute('data-plaintext') === 'true'
37
+ }
33
38
 
34
39
  export function setBlockId (elem) {
35
40
  if (!elem.hasAttribute('data-editable')) {
package/src/clipboard.js CHANGED
@@ -2,8 +2,9 @@ import config from './config'
2
2
  import * as string from './util/string'
3
3
  import * as nodeType from './node-type'
4
4
  import * as quotes from './quotes'
5
+ import {isPlainTextBlock} from './block'
5
6
 
6
- let allowedElements, requiredAttributes, transformElements, blockLevelElements, replaceQuotes
7
+ let allowedElements, allowedPlainTextElements, requiredAttributes, transformElements, blockLevelElements, replaceQuotes
7
8
  let splitIntoBlocks, blacklistedElements
8
9
  const whitespaceOnly = /^\s*$/
9
10
  const blockPlaceholder = '<!-- BLOCK -->'
@@ -13,6 +14,7 @@ updateConfig(config)
13
14
  export function updateConfig (conf) {
14
15
  const rules = conf.pastedHtmlRules
15
16
  allowedElements = rules.allowedElements || {}
17
+ allowedPlainTextElements = rules.allowedPlainTextElements || {}
16
18
  requiredAttributes = rules.requiredAttributes || {}
17
19
  transformElements = rules.transformElements || {}
18
20
  blacklistedElements = rules.blacklistedElements || []
@@ -25,9 +27,9 @@ export function updateConfig (conf) {
25
27
  rules.splitIntoBlocks.forEach((name) => { splitIntoBlocks[name] = true })
26
28
  }
27
29
 
28
- export function paste (element, cursor, clipboardContent) {
29
- const document = element.ownerDocument
30
- element.setAttribute(config.pastingAttribute, true)
30
+ export function paste (block, cursor, clipboardContent) {
31
+ const document = block.ownerDocument
32
+ block.setAttribute(config.pastingAttribute, true)
31
33
 
32
34
  if (cursor.isSelection) {
33
35
  cursor = cursor.deleteExactSurroundingTags()
@@ -39,9 +41,10 @@ export function paste (element, cursor, clipboardContent) {
39
41
  const pasteHolder = document.createElement('div')
40
42
  pasteHolder.innerHTML = clipboardContent
41
43
 
42
- const blocks = parseContent(pasteHolder)
44
+ const isPlainText = isPlainTextBlock(block)
45
+ const blocks = parseContent(pasteHolder, {plainText: isPlainText})
43
46
 
44
- element.removeAttribute(config.pastingAttribute)
47
+ block.removeAttribute(config.pastingAttribute)
45
48
  return {blocks, cursor}
46
49
  }
47
50
 
@@ -55,30 +58,35 @@ export function paste (element, cursor, clipboardContent) {
55
58
  * @param {DOM node} A container where the pasted content is located.
56
59
  * @returns {Array of Strings} An array of cleaned innerHTML like strings.
57
60
  */
58
- export function parseContent (element) {
61
+ export function parseContent (element, {plainText = false} = {}) {
62
+ const options = {
63
+ allowedElements: plainText ? allowedPlainTextElements : allowedElements,
64
+ keepInternalRelativeLinks: plainText ? false : keepInternalRelativeLinks
65
+ }
66
+
59
67
  // Filter pasted content
60
- return filterHtmlElements(element)
68
+ return filterHtmlElements(element, options)
61
69
  // Handle Blocks
62
70
  .split(blockPlaceholder)
63
71
  .map((entry) => string.trim(cleanWhitespace(replaceAllQuotes(entry))))
64
72
  .filter((entry) => !whitespaceOnly.test(entry))
65
73
  }
66
74
 
67
- export function filterHtmlElements (elem) {
75
+ function filterHtmlElements (elem, options) {
68
76
  return Array.from(elem.childNodes).reduce((content, child) => {
69
77
  if (blacklistedElements.indexOf(child.nodeName.toLowerCase()) !== -1) {
70
78
  return ''
71
79
  }
72
80
 
73
81
  // Keep internal relative links relative (on paste).
74
- if (keepInternalRelativeLinks && child.nodeName === 'A' && child.href) {
82
+ if (options.keepInternalRelativeLinks && child.nodeName === 'A' && child.href) {
75
83
  const stripInternalHost = child.getAttribute('href').replace(window.location.origin, '')
76
84
  child.setAttribute('href', stripInternalHost)
77
85
  }
78
86
 
79
87
  if (child.nodeType === nodeType.elementNode) {
80
- const childContent = filterHtmlElements(child)
81
- return content + conditionalNodeWrap(child, childContent)
88
+ const childContent = filterHtmlElements(child, options)
89
+ return content + conditionalNodeWrap(child, childContent, options)
82
90
  }
83
91
 
84
92
  // Escape HTML characters <, > and &
@@ -87,11 +95,11 @@ export function filterHtmlElements (elem) {
87
95
  }, '')
88
96
  }
89
97
 
90
- export function conditionalNodeWrap (child, content) {
98
+ function conditionalNodeWrap (child, content, options) {
91
99
  let nodeName = child.nodeName.toLowerCase()
92
100
  nodeName = transformNodeName(nodeName)
93
101
 
94
- if (shouldKeepNode(nodeName, child)) {
102
+ if (shouldKeepNode(nodeName, child, options)) {
95
103
  const attributes = filterAttributes(nodeName, child)
96
104
 
97
105
  if (nodeName === 'br') return `<${nodeName + attributes}>`
@@ -115,7 +123,7 @@ export function conditionalNodeWrap (child, content) {
115
123
  }
116
124
 
117
125
  // returns string of concatenated attributes e.g. 'target="_blank" rel="nofollow" href="/test.com"'
118
- export function filterAttributes (nodeName, node) {
126
+ function filterAttributes (nodeName, node) {
119
127
  return Array.from(node.attributes).reduce((attributes, {name, value}) => {
120
128
  if (allowedElements[nodeName][name] && value) {
121
129
  return `${attributes} ${name}="${value}"`
@@ -124,22 +132,22 @@ export function filterAttributes (nodeName, node) {
124
132
  }, '')
125
133
  }
126
134
 
127
- export function transformNodeName (nodeName) {
135
+ function transformNodeName (nodeName) {
128
136
  return transformElements[nodeName] || nodeName
129
137
  }
130
138
 
131
- export function hasRequiredAttributes (nodeName, node) {
139
+ function hasRequiredAttributes (nodeName, node) {
132
140
  const requiredAttrs = requiredAttributes[nodeName]
133
141
  if (!requiredAttrs) return true
134
142
 
135
143
  return !requiredAttrs.some((name) => !node.getAttribute(name))
136
144
  }
137
145
 
138
- export function shouldKeepNode (nodeName, node) {
139
- return allowedElements[nodeName] && hasRequiredAttributes(nodeName, node)
146
+ function shouldKeepNode (nodeName, node, options) {
147
+ return options.allowedElements[nodeName] && hasRequiredAttributes(nodeName, node)
140
148
  }
141
149
 
142
- export function cleanWhitespace (str) {
150
+ function cleanWhitespace (str) {
143
151
  return str
144
152
  .replace(/\n/g, ' ')
145
153
  .replace(/ {2,}/g, ' ')
@@ -149,7 +157,7 @@ export function cleanWhitespace (str) {
149
157
  ))
150
158
  }
151
159
 
152
- export function replaceAllQuotes (str) {
160
+ function replaceAllQuotes (str) {
153
161
  if (replaceQuotes.quotes || replaceQuotes.singleQuotes || replaceQuotes.apostrophe) {
154
162
  return quotes.replaceAllQuotes(str, replaceQuotes)
155
163
  }
package/src/config.js CHANGED
@@ -52,6 +52,9 @@ export default {
52
52
  'em': {},
53
53
  'br': {}
54
54
  },
55
+ allowedPlainTextElements: {
56
+ 'br': {}
57
+ },
55
58
 
56
59
  // Elements that have required attributes.
57
60
  // If these are not present the elements are filtered out.
package/src/core.js CHANGED
@@ -93,10 +93,11 @@ export class Editable {
93
93
  * @param {HTMLElement|Array(HTMLElement)|String} target A HTMLElement, an
94
94
  * array of HTMLElement or a query selector representing the target where
95
95
  * the API should be added on.
96
+ * @param {normalize, plainText} options Block specific configuration
96
97
  * @chainable
97
98
  */
98
- add (target) {
99
- this.enable(target)
99
+ add (target, options) {
100
+ this.enable(target, options)
100
101
  // TODO check css whitespace settings
101
102
  return this
102
103
  }
@@ -143,19 +144,26 @@ export class Editable {
143
144
  }
144
145
 
145
146
  /**
146
- * Adds the Editable.JS API to the given target elements.
147
- *
148
- * @method enable
149
- * @param { HTMLElement | undefined } target editable root element(s)
150
- * If no param is specified all editables marked as disabled are enabled.
151
- * @chainable
152
- */
153
- enable (target, normalize) {
147
+ * Adds the Editable.JS API to the given target elements.
148
+ *
149
+ * @method enable
150
+ * @param { HTMLElement | undefined } target editable root element(s)
151
+ * If no param is specified all editables marked as disabled are enabled.
152
+ * @param {boolean} normalize normalizes target content (legacy param)
153
+ * @param {boolean} options.normalize normalizes target content
154
+ * @param {boolean} options.plainText prevents text formatting for block
155
+ * @chainable
156
+ */
157
+ enable (target, options) {
158
+ const {
159
+ normalize = typeof options === 'boolean' ? options : false,
160
+ plainText = false
161
+ } = options ?? {}
154
162
  const shouldSpellcheck = this.config.browserSpellcheck
155
163
  const targets = domArray(target || `.${config.editableDisabledClass}`, this.win.document)
156
164
 
157
165
  for (const element of targets) {
158
- block.init(element, {normalize, shouldSpellcheck})
166
+ block.init(element, {normalize, plainText, shouldSpellcheck})
159
167
  this.dispatcher.notify('init', element)
160
168
  }
161
169
 
package/src/selection.js CHANGED
@@ -3,9 +3,14 @@ import 'rangy/lib/rangy-textrange'
3
3
  import Cursor from './cursor'
4
4
  import * as content from './content'
5
5
  import * as parser from './parser'
6
+ import * as block from './block'
6
7
  import config from './config'
7
8
  import highlightSupport from './highlight-support'
8
9
  import highlightText from './highlight-text'
10
+ import {
11
+ findStartExcludingWhitespace,
12
+ findEndExcludingWhitespace
13
+ } from './util/dom'
9
14
 
10
15
  /**
11
16
  * The Selection module provides a cross-browser abstraction layer for range
@@ -97,9 +102,25 @@ export default class Selection extends Cursor {
97
102
  const textToTrim = this.range.toString()
98
103
  const whitespacesOnTheLeft = textToTrim.search(/\S|$/)
99
104
  const lastNonWhitespace = textToTrim.search(/\S[\s]+$/)
100
- const whitespacesOnTheRight = lastNonWhitespace === -1 ? 0 : textToTrim.length - (lastNonWhitespace + 1)
101
- this.range.setStart(this.range.startContainer, this.range.startOffset + whitespacesOnTheLeft)
102
- this.range.setEnd(this.range.endContainer, this.range.endOffset - whitespacesOnTheRight)
105
+ const whitespacesOnTheRight = lastNonWhitespace === -1
106
+ ? 0
107
+ : textToTrim.length - (lastNonWhitespace + 1)
108
+
109
+ const [startContainer, startOffset] = findStartExcludingWhitespace({
110
+ root: this.range.commonAncestorContainer,
111
+ startContainer: this.range.startContainer,
112
+ startOffset: this.range.startOffset,
113
+ whitespacesOnTheLeft
114
+ })
115
+ this.range.setStart(startContainer, startOffset)
116
+
117
+ const [endContainer, endOffset] = findEndExcludingWhitespace({
118
+ root: this.range.commonAncestorContainer,
119
+ endContainer: this.range.endContainer,
120
+ endOffset: this.range.endOffset,
121
+ whitespacesOnTheRight
122
+ })
123
+ this.range.setEnd(endContainer, endOffset)
103
124
  }
104
125
 
105
126
  unlink () {
@@ -144,6 +165,8 @@ export default class Selection extends Cursor {
144
165
 
145
166
  // toggle('<em>')
146
167
  toggle (elem) {
168
+ if (block.isPlainTextBlock(this.host)) return
169
+ if (this.range.collapsed) return
147
170
  elem = this.adoptElement(elem)
148
171
  this.range = content.toggleTag(this.host, this.range, elem)
149
172
  this.setSelection()
@@ -307,6 +330,8 @@ export default class Selection extends Cursor {
307
330
  // the same tagName is affecting the selection this tag will be
308
331
  // remove first.
309
332
  forceWrap (elem) {
333
+ if (block.isPlainTextBlock(this.host)) return
334
+ if (this.range.collapsed) return
310
335
  elem = this.adoptElement(elem)
311
336
  this.range = content.forceWrap(this.host, this.range, elem)
312
337
  this.setSelection()
package/src/util/dom.js CHANGED
@@ -1,3 +1,6 @@
1
+ import NodeIterator from '../node-iterator'
2
+ import {textNode} from '../node-type'
3
+
1
4
  /**
2
5
  * @param {HTMLElement | Array | String} target
3
6
  * @param {Document} document
@@ -42,3 +45,88 @@ export const closest = (elem, selector) => {
42
45
  if (!elem.closest) elem = elem.parentNode
43
46
  if (elem && elem.closest) return elem.closest(selector)
44
47
  }
48
+
49
+ export function findStartExcludingWhitespace ({root, startContainer, startOffset, whitespacesOnTheLeft}) {
50
+ const isTextNode = startContainer.nodeType === textNode
51
+ if (!isTextNode) {
52
+ return findStartExcludingWhitespace({
53
+ root,
54
+ startContainer: startContainer.childNodes[startOffset],
55
+ startOffset: 0,
56
+ whitespacesOnTheLeft
57
+ })
58
+ }
59
+
60
+ const offsetAfterWhitespace = startOffset + whitespacesOnTheLeft
61
+ if (startContainer.length > offsetAfterWhitespace) {
62
+ return [startContainer, offsetAfterWhitespace]
63
+ }
64
+
65
+ // Pass the root so that the iterator can traverse to siblings
66
+ const iterator = new NodeIterator(root)
67
+ // Set the position to the node which is selected
68
+ iterator.nextNode = startContainer
69
+ // Iterate once to avoid returning self
70
+ iterator.getNextTextNode()
71
+
72
+ const container = iterator.getNextTextNode()
73
+ if (!container) {
74
+ // No more text nodes - use the end of the last text node
75
+ const previousTextNode = iterator.getPreviousTextNode()
76
+ return [previousTextNode, previousTextNode.length]
77
+ }
78
+
79
+ return findStartExcludingWhitespace({
80
+ root,
81
+ startContainer: container,
82
+ startOffset: 0,
83
+ whitespacesOnTheLeft: offsetAfterWhitespace - startContainer.length
84
+ })
85
+ }
86
+
87
+ export function findEndExcludingWhitespace ({root, endContainer, endOffset, whitespacesOnTheRight}) {
88
+ const isTextNode = endContainer.nodeType === textNode
89
+ if (!isTextNode) {
90
+ const isFirstNode = !endContainer.childNodes[endOffset - 1]
91
+ const container = isFirstNode
92
+ ? endContainer.childNodes[endOffset]
93
+ : endContainer.childNodes[endOffset - 1]
94
+ let offset = 0
95
+ if (!isFirstNode) {
96
+ offset = container.nodeType === textNode
97
+ ? container.length
98
+ : container.childNodes.length
99
+ }
100
+ return findEndExcludingWhitespace({
101
+ root,
102
+ endContainer: container,
103
+ endOffset: offset,
104
+ whitespacesOnTheRight
105
+ })
106
+ }
107
+
108
+ const offsetBeforeWhitespace = endOffset - whitespacesOnTheRight
109
+ if (offsetBeforeWhitespace > 0) {
110
+ return [endContainer, offsetBeforeWhitespace]
111
+ }
112
+
113
+ // Pass the root so that the iterator can traverse to siblings
114
+ const iterator = new NodeIterator(root)
115
+ // Set the position to the node which is selected
116
+ iterator.previous = endContainer
117
+ // Iterate once to avoid returning self
118
+ iterator.getPreviousTextNode()
119
+
120
+ const container = iterator.getPreviousTextNode()
121
+ if (!container) {
122
+ // No more text nodes - use the start of the first text node
123
+ return [iterator.getNextTextNode(), 0]
124
+ }
125
+
126
+ return findEndExcludingWhitespace({
127
+ root,
128
+ endContainer: container,
129
+ endOffset: container.length,
130
+ whitespacesOnTheRight: whitespacesOnTheRight - endOffset
131
+ })
132
+ }