diff --git a/spec/content.spec.js b/spec/content.spec.js index 7965ea1a..1d1e2fa0 100644 --- a/spec/content.spec.js +++ b/spec/content.spec.js @@ -7,60 +7,129 @@ import * as rangeSaveRestore from '../src/range-save-restore.js' describe('Content', function () { describe('normalizeTags()', function () { - const plain = createElement('
Plain textblock example snippet
') - const plainWithSpace = createElement('
Plain text block example snippet
') - const nested = createElement('
Nested textblock example snippet
') - const nestedMixed = createElement('
Nested and mixed textblock examples snippet
') - const consecutiveNewLines = createElement('
Consecutive

new lines
') - const emptyTags = createElement('
Example with empty nested
tags
') + it('should sort tags lexically', function () { + const tests = [ + { + input: '
text
', + output: '
text
' + }, + { + input: '
text
', + output: '
text
' + }, + { + input: '
text1text2
', + output: '
text1text2
' + }, + { + input: '
text1text2
', + output: '
text1text2
' + }, + { + input: '
text
', + output: '
text
' + }, + { + input: '
text1
text2
', + output: '
text1
text2
' + } + ] + + for (const test of tests) { + const host = createElement(test.input) + content.normalizeTags(host) + expect(host.outerHTML).to.equal(test.output) + } + }) + + it('should merge identical tags', function () { + const tests = [ + { + input: '
text1text2
', + output: '
text1text2
' + }, + { + input: '
text1text2
', + output: '
text1text2
' + } + ] + + for (const test of tests) { + const host = createElement(test.input) + content.normalizeTags(host) + expect(host.outerHTML).to.equal(test.output) + } + }) + + it('should remove empty tags', function () { + const tests = [ + { + input: '
', + output: '
' + }, + { + input: '
', + output: '
' + }, + { + input: '
', + output: '
' + }, + { + input: '
text1
', + output: '
text1
' + } + ] + + for (const test of tests) { + const host = createElement(test.input) + content.normalizeTags(host) + expect(host.outerHTML).to.equal(test.output) + } + }) it('works with plain block', function () { - const expected = createElement('
Plain textblock example snippet
') - const actual = plain.cloneNode(true) - content.normalizeTags(actual) - expect(actual.innerHTML).to.equal(expected.innerHTML) + const host = createElement('
Plain textblock example snippet
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Plain textblock example snippet
') }) it('does not merge tags if not consecutives', function () { - const expected = plainWithSpace.cloneNode(true) - const actual = plainWithSpace.cloneNode(true) - content.normalizeTags(actual) - expect(actual.innerHTML).to.equal(expected.innerHTML) + const host = createElement('
Plain text block example snippet
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Plain text block example snippet
') }) it('works with nested blocks', function () { - const expected = createElement('
Nested textblock example snippet
') - const actual = nested.cloneNode(true) - content.normalizeTags(actual) - expect(actual.innerHTML).to.equal(expected.innerHTML) + const host = createElement('
Nested textblock example snippet
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Nested textblock example snippet
') }) it('works with nested blocks that mix other tags', function () { - const expected = createElement('
Nested and mixed textblock examples snippet
') - const actual = nestedMixed.cloneNode(true) - content.normalizeTags(actual) - expect(actual.innerHTML).to.equal(expected.innerHTML) + const host = createElement('
Nested and mixed textblock examples snippet
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Nested and mixed textblock examples snippet
') }) it('does not merge consecutive new lines', function () { - const expected = consecutiveNewLines.cloneNode(true) - const actual = consecutiveNewLines.cloneNode(true) - content.normalizeTags(actual) - expect(actual.innerHTML).to.equal(expected.innerHTML) + const host = createElement('
Consecutive

new lines
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Consecutive

new lines
') }) it('should remove empty tags and preserve new lines', function () { - const expected = createElement('
Example with empty nested
tags
') - const actual = emptyTags.cloneNode(true) - content.normalizeTags(actual) - expect(actual.innerHTML).to.equal(expected.innerHTML) + const host = createElement('
Example with empty nested
tags
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Example with empty nested
tags
') }) it('removes whitespaces at the start and end', function () { - const elem = createElement('
Hello world   
') - content.normalizeTags(elem) - expect(elem.innerHTML).to.equal('Hello world') + const host = createElement('
Hello world   
') + content.normalizeTags(host) + expect(host.outerHTML).to.equal('
Hello world
') }) + }) describe('normalizeWhitespace()', function () { diff --git a/src/content.js b/src/content.js index 9c8ed1e8..968edb2a 100644 --- a/src/content.js +++ b/src/content.js @@ -1,8 +1,7 @@ import * as nodeType from './node-type.js' import * as rangeSaveRestore from './range-save-restore.js' -import * as parser from './parser.js' import * as string from './util/string.js' -import {createElement, createRange, getNodes, normalizeBoundaries, splitBoundaries, containsNodeText} from './util/dom.js' +import {createRange, getNodes, normalizeBoundaries, splitBoundaries, containsNodeText} from './util/dom.js' import config from './config.js' function restoreRange (host, range, func) { @@ -21,45 +20,16 @@ export function tidyHtml (element) { normalizeTags(element) } -// Remove empty tags and merge consecutive tags (they must have the same -// attributes). -// -// @method normalizeTags -// @param {HTMLElement} element The element to process. -export function normalizeTags (element) { - const fragment = document.createDocumentFragment() - - // Remove line breaks at the beginning of a content block - removeWhitespaces(element, 'firstChild') - - // Remove line breaks at the end of a content block - removeWhitespaces(element, 'lastChild') - - for (const node of element.childNodes) { - // skip empty tags, so they'll get removed - if (node.nodeName !== 'BR' && !node.textContent) continue - - if (node.nodeType === nodeType.elementNode && node.nodeName !== 'BR') { - let sibling = node - while ((sibling = sibling.nextSibling) !== null) { - if (!parser.isSameNode(sibling, node)) break - - for (const siblingChild of sibling.childNodes) { - node.appendChild(siblingChild.cloneNode(true)) - } - - sibling.remove() - } - - normalizeTags(node) - } - - fragment.appendChild(node.cloneNode(true)) - } - - while (element.firstChild) element.removeChild(element.firstChild) - - element.appendChild(fragment) +/** + * Normalize a provided HTML node by lexically sorting the DOM tree under it, + * removing empty tags, and merging identical consecutive tags. + * + * @param {HTMLElement} node + */ +export function normalizeTags (node) { + sort(node) + merge(node) + node.normalize() } export function normalizeWhitespace (text) { @@ -300,85 +270,135 @@ export function expandTo (host, range, elem) { return range } +/** + * Toggles a formatting element within a range. + * + * @param {HTMLElement} host + * @param {Range} range + * @param {HTMLElement} elem + * @returns {Range} + */ export function toggleTag (host, range, elem) { - const elems = getTagsByNameAndAttributes(host, range, elem) + const treeWalker = document.createTreeWalker( + host, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + (node) => { + if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT + if (node.cloneNode().isEqualNode(elem)) return NodeFilter.FILTER_REJECT + if (node.nodeType === Node.ELEMENT_NODE) return NodeFilter.FILTER_SKIP + return NodeFilter.FILTER_ACCEPT + } + ) - if (elems.length === 1 && - isExactSelection(range, elems[0], 'visible')) { - return removeFormattingElem(host, range, elem) + // Check if there exists a node that is not wrapped in the element + if (treeWalker.nextNode()) { + return wrap(host, range, elem) } - return forceWrap(host, range, elem) + return unwrap(host, range, elem) } -export function isWrappable (range) { - return canSurroundContents(range) -} - -export function forceWrap (host, range, elem) { - let restoredRange = restoreRange(host, range, () => { - nukeElem(host, range, elem) - }) - - // remove all tags if the range is not wrappable - if (!isWrappable(restoredRange)) { - restoredRange = restoreRange(host, restoredRange, () => { - nuke(host, restoredRange) - }) - } +/** + * Wraps a range within the specified element. If the range is already wrapped + * by the element, no action is taken. + * + * @param {HTMLElement} host + * @param {Range} range + * @param {HTMLElement} elem + * @returns {Range} + */ +export function wrap (host, range, elem) { + elem = elem.jquery ? elem[0] : elem - wrap(restoredRange, elem) - return restoredRange -} + const treeWalker = document.createTreeWalker( + host, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + (node) => { + if (!range.intersectsNode(node) || node.cloneNode().isEqualNode(elem)) return NodeFilter.FILTER_REJECT + if (node.nodeType === Node.ELEMENT_NODE) return NodeFilter.FILTER_SKIP + return NodeFilter.FILTER_ACCEPT + } + ) -export function wrap (range, elem) { - if (!isWrappable(range)) { - console.log('content.wrap(): can not surround range') - return + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode + const isFirstNode = node === range.startContainer + const isLastNode = node === range.endContainer + + // Create sub-range within the current node + const nodeRange = document.createRange() + isFirstNode + ? nodeRange.setStart(node, range.startOffset) + : nodeRange.setStartBefore(node) + isLastNode + ? nodeRange.setEnd(node, range.endOffset) + : nodeRange.setEndAfter(node) + + // Wrap sub-range + const elemClone = elem.cloneNode() + nodeRange.surroundContents(elemClone) + + // Adjust range where necessary + if (isFirstNode) range.setStartBefore(elemClone) + if (isLastNode) range.setEndAfter(elemClone) } - if (typeof elem === 'string') elem = createElement(elem) - range.surroundContents(elem) -} - -export function unwrap (elem) { - elem = elem.jquery ? elem[0] : elem - const parent = elem.parentNode - while (elem.firstChild) parent.insertBefore(elem.firstChild, elem) - parent.removeChild(elem) -} - -export function removeFormattingElem (host, range, elem) { - return restoreRange(host, range, () => { - nukeElem(host, range, elem) - }) -} - -export function removeFormatting (host, range, selector) { + // TODO: Preserve range without obstructing normalization return restoreRange(host, range, () => { - nuke(host, range, selector) + normalizeTags(host) }) } -// Unwrap all tags this range is affected by. -// Can also affect content outside of the range. -export function nuke (host, range, selector) { - getTags(host, range).forEach((elem) => { - if (elem.nodeName.toUpperCase() !== 'BR' && (!selector || elem.matches(selector))) { - unwrap(elem) +/** + * Removes a specified element from a range. If the range does not contain the + * element, no action is taken. + * + * @param {HTMLElement} host + * @param {Range} range + * @param {HTMLElement|undefined} elem + * @returns {Range} + */ +export function unwrap (host, range, elem) { + elem = elem?.jquery ? elem[0] : elem + + const treeWalker = document.createTreeWalker( + host, + NodeFilter.SHOW_ELEMENT, + (node) => { + if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT + if (!elem || node.cloneNode().isEqualNode(elem)) return NodeFilter.FILTER_ACCEPT + return NodeFilter.FILTER_SKIP } - }) -} + ) + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode + const isFirstNode = !range.isPointInRange(node, 0) + const isLastNode = !range.isPointInRange(node, node.childNodes.length) -// Unwrap all tags this range is affected by. -// Can also affect content outside of the range. -export function nukeElem (host, range, node) { - getTags(host, range).forEach((elem) => { - if (elem.nodeName.toUpperCase() !== 'BR' && (!node || - (elem.nodeName.toUpperCase() === node.nodeName.toUpperCase() && - areSameAttributes(elem.attributes, node.attributes)))) { - unwrap(elem) + let selectedNode = node + if (isFirstNode) { + const [, rightNode] = split(selectedNode, range.startContainer, range.startOffset) + selectedNode = rightNode } + if (isLastNode) { + const [leftNode] = split(selectedNode, range.endContainer, range.endOffset) + selectedNode = leftNode + } + + // Adjust range where necessary + if (isFirstNode) range.setStartBefore(selectedNode) + if (isLastNode) range.setEndAfter(selectedNode) + + // Unwrap node + const parent = selectedNode.parentNode + while (selectedNode.firstChild) parent.insertBefore(selectedNode.firstChild, selectedNode) + parent.removeChild(selectedNode) + } + + // TODO: Preserve range without obstructing normalization + return restoreRange(host, range, () => { + normalizeTags(host) }) } @@ -427,81 +447,158 @@ export function containsString (range, str) { return range.toString().indexOf(str) >= 0 } -// Unwrap all tags this range is affected by. -// Can also affect content outside of the range. -export function nukeTag (host, range, tagName) { - getTags(host, range).forEach((elem) => { - if (elem.nodeName.toUpperCase() === tagName.toUpperCase()) unwrap(elem) - }) -} - -function createNodeIterator (root, filter) { - let currentNode = root - let previousNode = null +/** + * Splits a node at the specified offset, including all ancestors up to a given + * ancestor node, and returns both the left and right halves of the split. + * + * @param {HTMLElement} ancestorNode + * @param {HTMLElement} node + * @param {number} offset + */ +function split (ancestorNode, node, offset) { + const parent = ancestorNode.parentNode + const parentOffset = Array.from(parent.childNodes).indexOf(ancestorNode) + + const leftRange = document.createRange() + leftRange.setStart(parent, parentOffset) + leftRange.setEnd(node, offset) + + parent.insertBefore(leftRange.extractContents(), ancestorNode) + + return [ancestorNode.previousSibling, ancestorNode] +} + +/** + * Merge identical consecutive tags and remove empty ones. + * + * @param {HTMLElement} node + */ +function merge (node) { + for (const child of node.childNodes) { + // Remove empty tags + if (child.nodeName !== 'BR' && !child.textContent) { + node.removeChild(child) + continue + } - function nextNode () { - if (!currentNode) { - return null + // Skip non-mergable nodes + if (child.nodeType !== Node.ELEMENT_NODE || child.nodeName === 'BR') { + continue } - if (currentNode.firstChild && previousNode !== currentNode.firstChild) { - previousNode = currentNode - currentNode = currentNode.firstChild - } else if (currentNode.nextSibling) { - previousNode = currentNode - currentNode = currentNode.nextSibling - } else { - let parent = currentNode.parentNode - while (parent && parent !== root) { - if (parent.nextSibling) { - previousNode = currentNode = parent.nextSibling - break - } - parent = parent.parentNode - } - if (!parent || parent === root) { - previousNode = currentNode = null + // Merge identical adjecent nodes + while (child.nextSibling) { + const sibling = child.nextSibling + + if (!child.cloneNode().isEqualNode(sibling.cloneNode())) { + break } - } - return currentNode - } + while (sibling.firstChild) child.appendChild(sibling.firstChild) + sibling.remove() + } - return { - next: nextNode + merge(child) } } -function isNodeFullyContained (node, range) { - const nodeRange = document.createRange() - nodeRange.selectNodeContents(node) - return range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 && - range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0 +/** + * Sort the nodes under a given host node lexically in place. + * + * @param {HTMLElement} host + */ +export function sort (host) { + if (!host.childNodes.length) return + + // Perform h-1 - 1 (host) - 1 (text node) = h-3 sort passes to ensure that the + // DOM tree is fully sorted + const requiredPasses = getTreeHeight(host) - 3 + for (let pass = 0; pass < requiredPasses; pass++) { + const sortedChildren = [] + while (host.childNodes.length) { + sortedChildren.push(...sortPass(host.childNodes[0])) + host.removeChild(host.childNodes[0]) + } + + for (const sortedChild of sortedChildren) { + host.appendChild(sortedChild) + } + } } -function canSurroundContents (range) { - if (!range || !range.startContainer || !range.endContainer) { - return false +/** + * Traverse the DOM tree under the given node in post-order and sort the tree + * lexically. To ensure the DOM tree is fully sorted, this function needs to be + * called h-1 times, where h represents the height of the tree. + * + * @param {HTMLElement} node + * @returns {HTMLElement[]} + */ +function sortPass (node) { + const children = node.childNodes + if (!children.length) return [node] + + // Traverse + const sortedChildren = [] + for (const child of children) { + sortedChildren.push(...sortPass(child)) } - if (range.startContainer === range.endContainer) return true + // Sort + const sortedNodes = [] + for (const sortedChild of sortedChildren) { + // No swap + if ( + sortedChild.nodeType === Node.TEXT_NODE || + getNodeString(node) <= getNodeString(sortedChild) + ) { + const currentNode = node.cloneNode() + currentNode.appendChild(sortedChild) + sortedNodes.push(currentNode) + continue + } - // Create a custom node iterator for the common ancestor container - const iterator = createNodeIterator(range.commonAncestorContainer, function (node) { - return range.isPointInRange(node, 0) - }) + // Swap + const currentNode = sortedChild.cloneNode() + currentNode.appendChild(node.cloneNode()) + while (sortedChild.childNodes.length) { + currentNode.children[0].appendChild(sortedChild.childNodes[0]) + } + sortedNodes.push(currentNode) + } - let currentNode - let boundariesInvalid = false + return sortedNodes +} - while ((currentNode = iterator.next())) { - if (currentNode.nodeType === Node.ELEMENT_NODE) { - if (!isNodeFullyContained(currentNode, range)) { - boundariesInvalid = true - break - } - } +/** + * Compute the height of the DOM tree under the given node. + * + * @param {HTMLElement} node + * @returns {number} + */ +function getTreeHeight (node) { + if (!node) return 0 + + let maxHeight = 0 + for (const child of node.childNodes) { + maxHeight = Math.max(maxHeight, getTreeHeight(child)) } - return !boundariesInvalid + return maxHeight + 1 +} + +/** + * Convert a DOM node into its string representation. + * + * @param {HTMLElement} node + * @returns {string} + * + * @example + * + * + */ +function getNodeString (node) { + const clone = node.cloneNode() + clone.innerHTML = '' + return clone.outerHTML } diff --git a/src/highlight-support.js b/src/highlight-support.js index 0183b148..e3c429f2 100644 --- a/src/highlight-support.js +++ b/src/highlight-support.js @@ -72,7 +72,9 @@ const highlightSupport = { removeHighlight (editableHost, highlightId, dispatcher) { const elems = editableHost.querySelectorAll(`[data-word-id="${highlightId}"]`) for (const elem of elems) { - content.unwrap(elem) + const range = document.createRange() + range.selectNode(elem) + content.unwrap(editableHost, range, elem) } // remove empty text nodes, combine adjacent text nodes diff --git a/src/monitored-highlighting.js b/src/monitored-highlighting.js index fa5ebdd4..6b2e251b 100644 --- a/src/monitored-highlighting.js +++ b/src/monitored-highlighting.js @@ -138,6 +138,7 @@ export default class MonitoredHighlighting { matches = this.whitespace.findMatches(text) matchCollection.addMatches(matches) + // TODO: Fix: Do not expand range in some cases this.safeHighlightMatches(editableHost, matchCollection.matches) }) } @@ -172,7 +173,9 @@ export default class MonitoredHighlighting { removeHighlights (editableHost) { editableHost = domSelector(editableHost, this.win.document) for (const elem of domArray('[data-highlight="spellcheck"], [data-highlight="whitespace"]', editableHost)) { - content.unwrap(elem) + const range = document.createRange() + range.selectNode(elem) + content.unwrap(editableHost, range, elem) } } @@ -198,7 +201,9 @@ export default class MonitoredHighlighting { if (wordId) { selection.retainVisibleSelection(() => { for (const elem of domArray(`[data-word-id="${wordId}"]`, editableHost)) { - content.unwrap(elem) + const range = document.createRange() + range.selectNode(elem) + content.unwrap(editableHost, range, elem) } }) } diff --git a/src/selection.js b/src/selection.js index 75710b43..8227ecd4 100644 --- a/src/selection.js +++ b/src/selection.js @@ -65,12 +65,6 @@ export default class Selection extends Cursor { return this.range.toString() } - // Return true if the selection can be wrapped, i.e. all open nodes - // are closed within this selection. - isWrappable () { - return content.isWrappable(this.range) - } - // Get the ClientRects of this selection. // Use this if you want more precision than getBoundingClientRect can give. getRects () { @@ -93,7 +87,7 @@ export default class Selection extends Cursor { } if (config.linkMarkup.trim) this.trimRange() - this.forceWrap(link) + this.wrap(link) } // trims whitespaces on the left and right of a selection, i.e. what you want in case of links @@ -192,13 +186,13 @@ export default class Selection extends Cursor { makeCustom ({tagName, attributes, trim = false}) { const customElem = this.createElement(tagName, attributes) if (trim) this.trimRange() - this.forceWrap(customElem) + this.wrap(customElem) } makeBold () { const bold = this.createElement(config.boldMarkup.name, config.boldMarkup.attribs) if (config.boldMarkup.trim) this.trimRange() - this.forceWrap(bold) + this.wrap(bold) } toggleBold () { @@ -210,7 +204,7 @@ export default class Selection extends Cursor { giveEmphasis () { const em = this.createElement(config.italicMarkup.name, config.italicMarkup.attribs) if (config.italicMarkup.trim) this.trimRange() - this.forceWrap(em) + this.wrap(em) } toggleEmphasis () { @@ -222,7 +216,7 @@ export default class Selection extends Cursor { makeUnderline () { const u = this.createElement(config.underlineMarkup.name, config.underlineMarkup.attribs) if (config.underlineMarkup.trim) this.trimRange() - this.forceWrap(u) + this.wrap(u) } toggleUnderline () { @@ -275,7 +269,15 @@ export default class Selection extends Cursor { // that represents elements to be removed; if undefined, // remove all. removeFormatting (selector) { - this.range = content.removeFormatting(this.host, this.range, selector) + const elems = document.querySelectorAll(selector) + if (elems.length) { + for (const elem of elems) { + this.range = content.unwrap(this.host, this.range, elem) + } + } else { + this.range = content.unwrap(this.host, this.range) + } + this.setVisibleSelection() } @@ -337,14 +339,12 @@ export default class Selection extends Cursor { return new Cursor(this.host, this.range) } - // Wrap the selection with the specified tag. If any other tag with - // the same tagName is affecting the selection this tag will be - // remove first. - forceWrap (elem) { + // Wrap the selection with the specified tag. + wrap (elem) { if (block.isPlainTextBlock(this.host)) return if (this.range.collapsed) return elem = this.adoptElement(elem) - this.range = content.forceWrap(this.host, this.range, elem) + this.range = content.wrap(this.host, this.range, elem) this.setVisibleSelection() }