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: '',
+ output: ''
+ },
+ {
+ input: '',
+ output: ''
+ },
+ {
+ input: '',
+ output: ''
+ },
+ {
+ input: '',
+ output: ''
+ },
+ {
+ input: '',
+ output: ''
+ },
+ {
+ input: '',
+ output: ''
+ }
+ ]
+
+ 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: '',
+ output: ''
+ },
+ {
+ input: '',
+ output: ''
+ }
+ ]
+
+ 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: '',
+ output: ''
+ }
+ ]
+
+ 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()
}