From ab83735298b9b51f8de1b8a184ff7ab8965cdc3e Mon Sep 17 00:00:00 2001 From: Sam Clarke Date: Tue, 29 Jun 2021 14:37:12 +0100 Subject: [PATCH 1/2] Use innerText to convert HTML to text Normalises node first as innerText will add extra line breaks otherwise. Fixes #839 --- src/lib/SCEditor.js | 23 +++++++++++++++++++++-- src/plugins/plaintext.js | 23 +++++++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/lib/SCEditor.js b/src/lib/SCEditor.js index ddc854b63..17ec01a24 100644 --- a/src/lib/SCEditor.js +++ b/src/lib/SCEditor.js @@ -1511,9 +1511,29 @@ export default function SCEditor(original, userOptions) { } dom.appendChild(firstParent || container, range.cloneContents()); + dom.removeWhiteSpace(container); e.clipboardData.setData('text/html', container.innerHTML); - e.clipboardData.setData('text/plain', range.toString()); + + // TODO: Refactor into private shared module with plaintext plugin + // innerText adds two newlines after

tags so convert them to + //

tags + utils.each(dom.find(container, 'p'), function (_, elm) { + dom.convertElement(elm, 'div'); + }); + // Remove collapsed
tags as innerText converts them to newlines + utils.each(dom.find(container, 'br'), function (_, elm) { + if (!elm.nextSibling || !dom.isInline(elm.nextSibling, true)) { + dom.remove(elm); + } + }); + + // range.toString() doesn't include newlines so can't use that. + // selection.toString() seems to use the same method as innerText + // but needs to be normalised first so using container.innerText + dom.appendChild(wysiwygBody, container); + e.clipboardData.setData('text/plain', container.innerText); + dom.remove(container); if (e.type === 'cut') { range.deleteContents(); @@ -1639,7 +1659,6 @@ export default function SCEditor(original, userOptions) { var parent = rangeHelper.getFirstBlockParent(); base.wysiwygEditorInsertHtml(paste.val, null, true); - dom.fixNesting(parent); dom.merge(parent); }; diff --git a/src/plugins/plaintext.js b/src/plugins/plaintext.js index 7b2bfc61c..12d0ba58c 100644 --- a/src/plugins/plaintext.js +++ b/src/plugins/plaintext.js @@ -12,7 +12,8 @@ (function (sceditor) { 'use strict'; - var extend = sceditor.utils.extend; + var utils = sceditor.utils; + var dom = sceditor.dom; /** * Options: @@ -33,7 +34,7 @@ if (opts && opts.plaintext && opts.plaintext.addButton) { plainTextEnabled = opts.plaintext.enabled; - commands.pastetext = extend(commands.pastetext || {}, { + commands.pastetext = utils.extend(commands.pastetext || {}, { state: function () { return plainTextEnabled ? 1 : 0; }, @@ -49,7 +50,25 @@ if (data.html && !data.text) { var div = document.createElement('div'); div.innerHTML = data.html; + + // TODO: Refactor into private shared module with editor + // innerText adds two newlines after

tags so convert + // them to

tags + utils.each(div.querySelectorAll('p'), function (_, elm) { + dom.convertElement(elm, 'div'); + }); + // Remove collapsed
tags as innerText converts them to + // newlines + utils.each(div.querySelectorAll('br'), function (_, elm) { + if (!elm.nextSibling || + !dom.isInline(elm.nextSibling, true)) { + elm.parentNode.removeChild(elm); + } + }); + + document.body.appendChild(div); data.text = div.innerText; + document.body.removeChild(div); } data.html = null; From 52140d571cb8e9f45aa33cf36ce20266ecaba164 Mon Sep 17 00:00:00 2001 From: Sam Clarke Date: Wed, 30 Jun 2021 14:52:57 +0100 Subject: [PATCH 2/2] Update insertNode to move inlines to containers Will move inserted inline nodes at the start of end or the input into the start/end containers. For example, Inserting: "foo" into: "|texttext|" will become: "foo" and not: "foo" --- src/lib/RangeHelper.js | 94 ++++++++++++++++------- src/lib/dom.js | 4 +- tests/unit/htmlAssert.js | 24 ++++-- tests/unit/lib/RangeHelper.js | 136 +++++++++++++++++++++++++++++++++- 4 files changed, 224 insertions(+), 34 deletions(-) diff --git a/src/lib/RangeHelper.js b/src/lib/RangeHelper.js index 83c8aa901..160ad47b8 100644 --- a/src/lib/RangeHelper.js +++ b/src/lib/RangeHelper.js @@ -125,7 +125,7 @@ export default function RangeHelper(win, d, sanitize) { * @param {Node|string} node * @param {Node|string} [endNode] * @param {boolean} [returnHtml] - * @return {Node|string} + * @return {DocumentFragment|string} * @private */ _prepareInput = function (node, endNode, returnHtml) { @@ -191,61 +191,103 @@ export default function RangeHelper(win, d, sanitize) { * * Returns boolean false on fail * - * @param {Node} node + * @param {Node} startNode * @param {Node} endNode * @return {false|undefined} * @function * @name insertNode * @memberOf RangeHelper.prototype */ - base.insertNode = function (node, endNode) { - var first, last, - input = _prepareInput(node, endNode), - range = base.selectedRange(), + base.insertNode = function (startNode, endNode) { + var selStartNode, selEndNode, node, + input = _prepareInput(startNode, endNode), + range = base.selectedRange(), parent = range.commonAncestorContainer, - emptyNodes = []; + checkNodes = [], + startInlines = [], + endInlines = []; if (!input) { return false; } - function removeIfEmpty(node) { - // Only remove empty node if it wasn't already empty - if (node && dom.isEmpty(node) && emptyNodes.indexOf(node) < 0) { - dom.remove(node); - } - } - - if (range.startContainer !== range.endContainer) { - utils.each(parent.childNodes, function (_, node) { - if (dom.isEmpty(node)) { - emptyNodes.push(node); + function addCheckNodes(container) { + while (parent.contains(container)) { + if (!dom.isEmpty(container)) { + checkNodes.push(container); } - }); - - first = input.firstChild; - last = input.lastChild; + container = container.parentNode; + } } - range.deleteContents(); - // FF allows
to be selected but inserting a node // into
will cause it not to be displayed so must // insert before the
in FF. // 3 = TextNode if (parent && parent.nodeType !== 3 && !dom.canHaveChildren(parent)) { + range.deleteContents(); dom.insertBefore(input, parent); } else { + if (range.startContainer !== range.endContainer) { + // Store non-empty nodes at start and end up to the parent to + // check if deleteContents() has emptied them. + // If it has, they should be removed. + addCheckNodes(range.startContainer); + addCheckNodes(range.endContainer); + + // Store inlines at the start or end of the input + node = input.firstChild; + while (node && dom.isInline(node, true)) { + startInlines.unshift(node); + node = node.nextSibling; + } + + node = input.lastChild; + while (node && dom.isInline(node, true)) { + endInlines.push(node); + node = node.previousSibling; + } + } + + base.saveRange(); + selStartNode = base.getMarker(startMarker); + selEndNode = base.getMarker(endMarker); + range.setStartAfter(selStartNode); + range.setEndBefore(selEndNode); + + range.deleteContents(); range.insertNode(input); + // Move any inlines from start / end of input into start or end of + // of the selection, for example, inserting "a
b
c": + //

x|

y

|z

+ // After deleteContents will become: + //

x

|

z

+ // After insert becomes + //

x

a
b
c|

z

+ // And after moving them below, will become: + //

xa

b

c|z

+ while (endInlines.length) { + dom.insertBefore(endInlines.pop(), selEndNode); + } + while (startInlines.length) { + dom.insertBefore(startInlines.pop(), selStartNode); + } + + dom.remove(selStartNode); + dom.remove(selEndNode); + // If a node was split or its contents deleted, remove any resulting // empty tags. For example: //

|test

test|
// When deleteContents could become: //

|
// So remove the empty ones - removeIfEmpty(first && first.previousSibling); - removeIfEmpty(last && last.nextSibling); + utils.each(checkNodes, function (_, node) { + if (dom.isEmpty(node)) { + dom.remove(node); + } + }); } base.restoreRange(); diff --git a/src/lib/dom.js b/src/lib/dom.js index 800e8198a..539d61129 100644 --- a/src/lib/dom.js +++ b/src/lib/dom.js @@ -773,11 +773,11 @@ export function copyCSS(from, to) { */ export function isEmpty(node) { if (node.lastChild && isEmpty(node.lastChild)) { - remove(node.lastChild); + return node.firstChild === node.lastChild; } return node.nodeType === 3 ? !node.nodeValue : - (canHaveChildren(node) && !node.childNodes.length); + (canHaveChildren(node) && !node.firstChild); } /** diff --git a/tests/unit/htmlAssert.js b/tests/unit/htmlAssert.js index ca18e9cad..e21e6dde0 100644 --- a/tests/unit/htmlAssert.js +++ b/tests/unit/htmlAssert.js @@ -1,5 +1,16 @@ import * as utils from 'tests/unit/utils.js'; +function nodeHtml(node) { + if (node.parentNode) { + return node.parentNode.innerHTML; + } + + var div = node.ownerDocument.createElement('div'); + div.appendChild(node); + + return div.innerHTML; +} + var normalize = function (parentNode) { var nextSibling, node = parentNode.firstChild; @@ -29,6 +40,9 @@ var compareNodes = function (nodeA, nodeB) { return false; } + nodeA.normalize(); + nodeB.normalize(); + if (nodeA.nodeType === 1) { if (nodeA.attributes.length !== nodeB.attributes.length || nodeA.childNodes.length !== nodeB.childNodes.length) { @@ -102,20 +116,20 @@ QUnit.assert.nodesEqual = function (actual, expected, message) { this.pushResult({ result: compareNodes(actual, expected), - actual: actual, - expected: expected, + actual: nodeHtml(actual), + expected: nodeHtml(expected), message: message || 'Expected nodes to be equal' }); }; -QUnit.assert.nodesNodeEqual = function (actual, expected, message) { +QUnit.assert.nodesNoteEqual = function (actual, expected, message) { normalize(actual); normalize(expected); this.pushResult({ result: !compareNodes(actual, expected), - actual: actual, - expected: expected, + actual: nodeHtml(actual), + expected: nodeHtml(expected), message: message || 'Expected nodes to not be equal' }); }; diff --git a/tests/unit/lib/RangeHelper.js b/tests/unit/lib/RangeHelper.js index e14f1c189..3a4698a5b 100644 --- a/tests/unit/lib/RangeHelper.js +++ b/tests/unit/lib/RangeHelper.js @@ -167,11 +167,145 @@ QUnit.test('insertNode() - Remove created empty tags', function (assert) { sel.setSingleRange(range); + rangeHelper.insertNode(utils.htmlToFragment('
foo
')); + + assert.nodesEqual(editableDiv.firstChild, utils.htmlToNode( + '
foo
' + )); + assert.strictEqual(editableDiv.childNodes.length, 1); +}); + +QUnit.test('insertNode() - Remove created empty inline tags', function (assert) { + var range = rangy.createRangyRange(); + var sel = rangy.getSelection(); + + editableDiv.innerHTML = 'texttext'; + + var p1 = editableDiv.firstChild; + var p2 = editableDiv.lastChild; + range.setStart(p1.firstChild, 0); + range.setEnd(p2.firstChild, 4); + + sel.setSingleRange(range); + + rangeHelper.insertNode(utils.htmlToFragment('foo')); + + assert.nodesEqual(editableDiv.firstChild, utils.htmlToNode( + 'foo' + )); + assert.strictEqual(editableDiv.childNodes.length, 1); +}); + +QUnit.test('insertNode() - Remove created empty tags nested right', function (assert) { + var range = rangy.createRangyRange(); + var sel = rangy.getSelection(); + + editableDiv.innerHTML = + '

outer

' + + '
' + + '
' + + 'test' + + '
' + + 'inner' + + '
' + + '
' + + '
'; + + var outerText = editableDiv.firstChild; + var innerText = editableDiv.ownerDocument.getElementById('inner').firstChild; + + range.setStart(outerText, 0); + range.setEnd(innerText, innerText.nodeValue.length); + + sel.setSingleRange(range); + + rangeHelper.insertNode(utils.htmlToFragment('foo')); + + assert.nodesEqual(editableDiv.firstChild, utils.htmlToNode( + '

foo

' + )); + assert.strictEqual(editableDiv.childNodes.length, 1); +}); + +QUnit.test('insertNode() - Remove created empty tags nested left', function (assert) { + var range = rangy.createRangyRange(); + var sel = rangy.getSelection(); + + editableDiv.innerHTML = + '
' + + '
' + + 'keep' + + '
' + + 'inner' + + '
' + + '
' + + '
' + + '

outer

'; + + var outerText = editableDiv.lastChild.firstChild; + var innerText = editableDiv.ownerDocument.getElementById('inner').firstChild; + + range.setStart(innerText, 0); + range.setEnd(outerText, outerText.nodeValue.length); + + sel.setSingleRange(range); + rangeHelper.insertNode(utils.htmlToFragment('foo')); assert.nodesEqual(editableDiv.firstChild, utils.htmlToNode( - 'foo' + '
' + + '
' + + 'keep' + + '
' + + 'foo' + + '
' + + '
' + + '
' )); + assert.strictEqual(editableDiv.childNodes.length, 1); +}); + +QUnit.test('insertNode() - Remove created empty tags nested both', function (assert) { + var range = rangy.createRangyRange(); + var sel = rangy.getSelection(); + + editableDiv.innerHTML = + '
' + + 'keep' + + '
' + + 'start' + + '
' + + '
' + + '
' + + 'remove' + + '
' + + 'end' + + '
' + + 'keep' + + '
'; + + var startText = editableDiv.ownerDocument.getElementById('start').firstChild; + var endText = editableDiv.ownerDocument.getElementById('end').firstChild; + + range.setStart(startText, 0); + range.setEnd(endText, endText.nodeValue.length); + + sel.setSingleRange(range); + + rangeHelper.insertNode(utils.htmlToFragment('foo')); + + assert.htmlEqual(editableDiv.innerHTML, + '
' + + 'keep' + + '
' + + 'foo' + + '
' + + '
' + + '
' + + 'keep' + + '
' + ); + assert.strictEqual(editableDiv.childNodes.length, 2); }); QUnit.test('insertNode() - Do not remove existing empty tags', function (assert) {