diff --git a/src/utils/normalize-node-and-offset.js b/src/utils/normalize-node-and-offset.js new file mode 100644 index 000000000..0427a6ea6 --- /dev/null +++ b/src/utils/normalize-node-and-offset.js @@ -0,0 +1,81 @@ + +/** + * From a DOM selection's `node` and `offset`, normalize so that it always + * refers to a text node. + * + * @param {Element} node + * @param {Number} offset + * @return {Object} + */ + +function normalizeNodeAndOffset(node, offset) { + // If it's an element node, its offset refers to the index of its children + // including comment nodes, so convert it to a text equivalent. + if (node.nodeType == 1) { + const isLast = offset == node.childNodes.length + const direction = isLast ? 'backward' : 'forward' + const index = isLast ? offset - 1 : offset + node = getNonComment(node, index, direction) + + // If the node is not a text node, traverse until we have one. + while (node.nodeType != 3) { + const i = isLast ? node.childNodes.length - 1 : 0 + node = getNonComment(node, i, direction) + } + + // Determine the new offset inside the text node. + offset = isLast ? node.textContent.length : 0 + } + + // Return the node and offset. + return { node, offset } +} + +/** + * Get the nearest non-comment to `index` in a `parent`, preferring `direction`. + * + * @param {Element} parent + * @param {Number} index + * @param {String} direction ('forward' or 'backward') + * @return {Element|Null} + */ + +function getNonComment(parent, index, direction) { + const { childNodes } = parent + let child = childNodes[index] + let i = index + let triedForward = false + let triedBackward = false + + while (child.nodeType == 8) { + if (triedForward && triedBackward) break + + if (i >= childNodes.length) { + triedForward = true + i = index - 1 + direction = 'backward' + continue + } + + if (i < 0) { + triedBackward = true + i = index + 1 + direction = 'forward' + continue + } + + child = childNodes[i] + if (direction == 'forward') i++ + if (direction == 'backward') i-- + } + + return child || null +} + +/** + * Export. + * + * @type {Function} + */ + +export default normalizeNodeAndOffset diff --git a/src/utils/offset-key.js b/src/utils/offset-key.js index 3ead33865..39fc9957c 100644 --- a/src/utils/offset-key.js +++ b/src/utils/offset-key.js @@ -1,4 +1,6 @@ +import normalizeNodeAndOffset from './normalize-node-and-offset' + /** * Offset key parser regex. * @@ -46,44 +48,30 @@ function findBounds(index, ranges) { } /** - * From a `element`, find the closest parent's offset key. + * From a DOM node, find the closest parent's offset key. * - * @param {Element} element - * @param {Number} offset + * @param {Element} rawNode + * @param {Number} rawOffset * @return {Object} */ -function findKey(element, offset) { - if (element.nodeType == 3) element = element.parentNode +function findKey(rawNode, rawOffset) { + let { node, offset } = normalizeNodeAndOffset(rawNode, rawOffset) - const parent = element.closest(SELECTOR) - const children = element.querySelectorAll(SELECTOR) + // Find the closest parent with an offset key attribute. + const closest = node.parentNode.closest(SELECTOR) let offsetKey - // Get the key from a parent if one exists. - if (parent) { - offsetKey = parent.getAttribute(ATTRIBUTE) + // Get the key from the closest matching node if one exists. + if (closest) { + offsetKey = closest.getAttribute(ATTRIBUTE) } - // COMPAT: In Firefox, and potentially other browsers, when performing a - // "select all" action, a parent element is selected instead of the text. In - // this case, we need to select the proper inner text nodes. (2016/07/26) - else if (children.length) { - let child = children[0] - - if (offset != 0) { - child = children[children.length - 1] - offset = child.textContent.length - } - - offsetKey = child.getAttribute(ATTRIBUTE) - } - - // Otherwise, for void node scenarios, a cousin element will be selected, and + // Otherwise, for void node scenarios, a cousin node will be selected, and // we need to select the first text node cousin we can find. else { - while (element = element.parentNode) { - const cousin = element.querySelector(SELECTOR) + while (node = node.parentNode) { + const cousin = node.querySelector(SELECTOR) if (!cousin) continue offsetKey = cousin.getAttribute(ATTRIBUTE) offset = cousin.textContent.length @@ -93,12 +81,11 @@ function findKey(element, offset) { // If we still didn't find an offset key, error. This is a bug. if (!offsetKey) { - throw new Error(`Unable to find offset key for ${element} with offset "${offset}".`) + throw new Error(`Unable to find offset key for ${node} with offset "${offset}".`) } - // Parse the offset key. + // Return the parsed the offset key. const parsed = parse(offsetKey) - return { key: parsed.key, index: parsed.index,