From 82c54fc6701e903737e5ab5bec6b269b714a3d52 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 25 Jun 2021 16:47:46 +0200 Subject: [PATCH] code(cursor-search): fix inconsistencies, simplify logic --- spec/api.spec.js | 35 +++++----- spec/node-iterator.spec.js | 5 +- src/core.js | 137 ++++++++++++------------------------- src/cursor.js | 76 ++++++++++++++++---- src/dispatcher.js | 8 +-- src/highlight-support.js | 8 +-- src/node-iterator.js | 58 +++++++++++++--- src/util/binary_search.js | 119 +++++++++++++++++++++----------- src/util/element.js | 21 ++---- 9 files changed, 269 insertions(+), 198 deletions(-) diff --git a/spec/api.spec.js b/spec/api.spec.js index 8a0b8027..bf03bde1 100644 --- a/spec/api.spec.js +++ b/spec/api.spec.js @@ -126,11 +126,11 @@ describe('Editable', function () { describe('findClosestCursorOffset:', function () { /* -Cursor1: | (left: 130) -Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. -Comp 2: Der Spieler blieb bei fünf Champions-League-Titeln stehen. -Cursor 2: | (offset: 19 chars) - */ + * Cursor1: | (left: 130) + * Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. + * Comp 2: Der Spieler blieb bei fünf Champions-League-Titeln stehen. + * Cursor 2: | (offset: 19 chars) + */ it('finds the index in a text node', function () { $div.html('Der Spieler blieb bei fünf Champions-League-Titeln stehen.') const {wasFound, offset} = editable.findClosestCursorOffset({ @@ -142,11 +142,11 @@ Cursor 2: | (offset: 19 chars) }) /* -Cursor1: | (left: 130) -Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. -Comp 2:

Der Spieler blieb bei fünf Champions-League-Titeln stehen.

-Cursor 2: | - */ + * Cursor1: | (left: 130) + * Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. + * Comp 2:

Der Spieler blieb bei fünf Champions-League-Titeln stehen.

+ * Cursor 2: | + */ it('finds the index in a nested html tag structure', function () { $div.html('

Der Spieler blieb bei fünf Champions-League-Titeln stehen.

') const {wasFound, offset} = editable.findClosestCursorOffset({ @@ -167,18 +167,19 @@ Cursor 2: | }) /* -Cursor1: | -Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. -Comp 2: Foo -Cursor 2: not found - */ + * Cursor1: | + * Comp 1: Cristiano Ronaldo wurde 2018 mit grossem Tamtam nach Turin geholt. + * Comp 2: Foo + * Cursor 2: not found + */ it('returns not found for coordinates that are out of the text area', function () { $div.html('Foo') - const {wasFound} = editable.findClosestCursorOffset({ + const {wasFound, offset} = editable.findClosestCursorOffset({ element: $div[0], origCoordinates: {top: 0, left: 130} }) - expect(wasFound).toEqual(false) + expect(wasFound).toEqual(true) + expect(offset).toEqual(3) }) }) }) diff --git a/spec/node-iterator.spec.js b/spec/node-iterator.spec.js index a82eee61..d31b37c0 100644 --- a/spec/node-iterator.spec.js +++ b/spec/node-iterator.spec.js @@ -22,7 +22,8 @@ describe('NodeIterator', function () { it('sets its properties', function () { expect(this.iterator.root).toEqual(this.element) expect(this.iterator.current).toEqual(this.element) - expect(this.iterator.next).toEqual(this.element) + expect(this.iterator.nextNode).toEqual(this.element) + expect(this.iterator.previous).toEqual(this.element) }) }) @@ -63,7 +64,7 @@ describe('NodeIterator', function () { this.iterator.replaceCurrent(replacement) expect(this.iterator.current).toEqual(replacement) - expect(this.iterator.next).toEqual(null) + expect(this.iterator.nextNode).toEqual(null) }) it('replaces the first character of longer a text node', function () { diff --git a/src/core.js b/src/core.js index ac8144b6..6aaeb5ed 100644 --- a/src/core.js +++ b/src/core.js @@ -12,7 +12,7 @@ import highlightSupport from './highlight-support' import Highlighting from './highlighting' import createDefaultEvents from './create-default-events' import browser from 'bowser' -import {getTotalCharCount, textNodesUnder, getTextNodeAndRelativeOffset} from './util/element' +import {textNodesUnder, getTextNodeAndRelativeOffset} from './util/element' import {binaryCursorSearch} from './util/binary_search' /** @@ -195,16 +195,15 @@ export default class Editable { */ createCursor (element, position = 'beginning') { - const $host = $(element).closest(this.editableSelector) - - if (!$host.length) return undefined + const host = Cursor.findHost(element, this.editableSelector) + if (!host) return undefined const range = rangy.createRange() if (position === 'beginning' || position === 'end') { range.selectNodeContents(element) range.collapse(position === 'beginning') - } else if (element !== $host[0]) { + } else if (element !== host) { if (position === 'before') { range.setStartBefore(element) range.setEndBefore(element) @@ -216,26 +215,19 @@ export default class Editable { error('EditableJS: cannot create cursor outside of an editable block.') } - return new Cursor($host[0], range) - } - - createRangyRange () { - return rangy.createRange() - } - - createCursorWithRange ({element, range}) { - const $host = $(element).closest(this.editableSelector) - return new Cursor($host[0], range) + return new Cursor(host, range) } createCursorAtCharacterOffset ({element, offset}) { const textNodes = textNodesUnder(element) const {node, relativeOffset} = getTextNodeAndRelativeOffset({textNodes, absOffset: offset}) - const newRange = this.createRangyRange() + const newRange = rangy.createRange() newRange.setStart(node, relativeOffset) - newRange.setEnd(node, relativeOffset) - newRange.collapse() - const nextCursor = this.createCursorWithRange({element, range: newRange}) + newRange.collapse(true) + + const host = Cursor.findHost(element, this.editableSelector) + const nextCursor = new Cursor(host, newRange) + nextCursor.setVisibleSelection() return nextCursor } @@ -470,83 +462,42 @@ export default class Editable { return this } - /* - Takes coordinates and uses its left value to find out how to offset a character in a string to - closely match the coordinates.left value. - Takes conditions for the result being on the first line, used when navigating to a paragraph from - the above paragraph and being on the last line, used when navigating to a paragraph from the below - paragraph. - - Internally this sets up the methods used for a binary cursor search and calls this. - - @param {DomNode} element The DOM Node (usually editable elem) to which the cursor jumps - @param {Object} coordinates The bounding rect of the preceeding cursor to be matched - @param {Boolean} requiredOnFirstLine set to true if you want to require the cursor to be on the first line of the paragraph - @param {Boolean} requiredOnLastLine set to true if you want to require the cursor to be on the last line of the paragraph - - @return {Object} object with boolean `wasFound` indicating if the binary search found an offset and `offset` to indicate the actual character offset - */ - findClosestCursorOffset ( - {element, origCoordinates, requiredOnFirstLine = false, requiredOnLastLine = false}) { - // early terminate on empty editables - const totalCharCount = getTotalCharCount(element) - if (totalCharCount === 0) return {wasFound: false} - // move left if the coordinates are to the left - const leftCondition = ({data, coordinates}) => { - if (coordinates.left >= data.origCoordinates.left) return true - return false - } - // move up if cursor is required to be on first line but is currently not - const upCondition = ({data, cursor}) => { - if (data.requiredOnFirstLine && !cursor.isAtFirstLine()) return true - return false - } - // move down if cursor is required to be on last line but is currently not - const downCondition = ({data, cursor}) => { - if (data.requiredOnLastLine && !cursor.isAtLastLine()) return true - return false - } - // move the binary search index in between the current position and the left limit - const moveLeft = (data) => { - data.rightLimit = data.currentOffset - data.currentOffset = Math.floor((data.currentOffset - data.leftLimit) / 2) - } - // move the binary search index in between the current position and the right limit - const moveRight = (data) => { - data.leftLimit = data.currentOffset - data.currentOffset = data.currentOffset + Math.floor((data.rightLimit - data.currentOffset) / 2) - } - // consider a small bluriness since character never exactly match coordinates - const foundChecker = ({distance, bluriness}) => { - return distance <= bluriness - } - // consider converged if 2 consecutive history entries are equal - const convergenceChecker = (history) => { - const lastTwo = history.slice(-2) - if (lastTwo.length === 2 && lastTwo[0] === lastTwo[1]) return true - return false - } + /** + * Takes coordinates and uses its left value to find out how to offset a character in a string to + * closely match the coordinates.left value. + * Takes conditions for the result being on the first line, used when navigating to a paragraph from + * the above paragraph and being on the last line, used when navigating to a paragraph from the below + * paragraph. + * + * Internally this sets up the methods used for a binary cursor search and calls this. + * + * @param {DomNode} element + * - the editable hostDOM Node to which the cursor jumps + * @param {object} coordinates + * - The bounding rect of the preceeding cursor to be matched + * @param {boolean} requiredOnFirstLine + * - set to true if you want to require the cursor to be on the first line of the paragraph + * @param {boolean} requiredOnLastLine + * - set to true if you want to require the cursor to be on the last line of the paragraph + * + * @return {Object} + * - object with boolean `wasFound` indicating if the binary search found an offset and `offset` to indicate the actual character offset + */ + findClosestCursorOffset ({ + element, + origCoordinates, + requiredOnFirstLine = false, + requiredOnLastLine = false + }) { + const positionX = this.dispatcher.switchContext + ? this.dispatcher.switchContext.positionX + : origCoordinates.left - const data = { - element, - currentOffset: Math.floor(totalCharCount / 2), - rightLimit: totalCharCount, - leftLimit: 0, + return binaryCursorSearch({ + host: element, requiredOnFirstLine, requiredOnLastLine, - origCoordinates - } - - return binaryCursorSearch({ - moveLeft, - moveRight, - leftCondition, - upCondition, - downCondition, - convergenceChecker, - foundChecker, - createCursorAtCharacterOffset: this.createCursorAtCharacterOffset.bind(this), - data + positionX }) } } diff --git a/src/cursor.js b/src/cursor.js index 65fbec25..69ffa82d 100644 --- a/src/cursor.js +++ b/src/cursor.js @@ -5,9 +5,11 @@ import * as viewport from './util/viewport' import * as content from './content' import * as parser from './parser' import * as string from './util/string' -import * as nodeType from './node-type' +import {elementNode, documentFragmentNode} from './node-type' import error from './util/error' import * as rangeSaveRestore from './range-save-restore' +// import printRange from './util/print_range' +import NodeIterator from './node-iterator' /** * The Cursor module provides a cross-browser abstraction layer for cursor. @@ -17,6 +19,12 @@ import * as rangeSaveRestore from './range-save-restore' */ export default class Cursor { + + static findHost (elem, selector) { + if (!elem.closest) elem = elem.parentNode + return elem.closest(selector) + } + /** * Class for the Cursor module. * @@ -46,21 +54,61 @@ export default class Cursor { } isAtLastLine () { - const range = rangy.createRange() - range.selectNodeContents(this.host) - - const hostCoords = range.nativeRange.getBoundingClientRect() - const cursorCoords = this.range.nativeRange.getBoundingClientRect() + const hostRange = this.win.document.createRange() + hostRange.selectNodeContents(this.host) + const hostCoords = hostRange.getBoundingClientRect() + + let cursorCoords + if (this.range.nativeRange.startContainer.nodeType === elementNode) { + const container = this.range.nativeRange.startContainer + if ((container.children.length - 1) >= this.range.nativeRange.startOffset) { + const elem = container.children[this.range.nativeRange.startOffset] + const iterator = new NodeIterator(elem) + const textNode = iterator.getNextTextNode() + if (textNode) { + const cursorRange = this.win.document.createRange() + cursorRange.setStart(textNode, 0) + cursorRange.collapse(true) + cursorCoords = cursorRange.getBoundingClientRect() + } else { + cursorCoords = hostCoords + } + } else { + cursorCoords = hostCoords + } + } else { + cursorCoords = this.getBoundingClientRect() + } return hostCoords.bottom === cursorCoords.bottom } isAtFirstLine () { - const range = rangy.createRange() - range.selectNodeContents(this.host) - - const hostCoords = range.nativeRange.getBoundingClientRect() - const cursorCoords = this.range.nativeRange.getBoundingClientRect() + const hostRange = this.win.document.createRange() + hostRange.selectNodeContents(this.host) + const hostCoords = hostRange.getBoundingClientRect() + + let cursorCoords + if (this.range.nativeRange.startContainer.nodeType === elementNode) { + const container = this.range.nativeRange.startContainer + if ((container.children.length - 1) >= this.range.nativeRange.startOffset) { + const elem = container.children[this.range.nativeRange.startOffset] + const iterator = new NodeIterator(elem) + const textNode = iterator.getPreviousTextNode() + if (textNode) { + const cursorRange = this.win.document.createRange() + cursorRange.setStart(textNode, 0) + cursorRange.collapse(true) + cursorCoords = cursorRange.getBoundingClientRect() + } else { + cursorCoords = hostCoords + } + } else { + cursorCoords = hostCoords + } + } else { + cursorCoords = this.range.nativeRange.getBoundingClientRect() + } return hostCoords.top === cursorCoords.top } @@ -83,7 +131,7 @@ export default class Cursor { element = this.adoptElement(element) let preceedingElement = element - if (element.nodeType === nodeType.documentFragmentNode) { + if (element.nodeType === documentFragmentNode) { const lastIndex = element.childNodes.length - 1 preceedingElement = element.childNodes[lastIndex] } @@ -169,6 +217,10 @@ export default class Cursor { return content.getInnerHtmlOfFragment(this.after()) } + getBoundingClientRect () { + return this.range.nativeRange.getBoundingClientRect() + } + // Get the BoundingClientRect of the cursor. // The returned values are transformed to be absolute // (relative to the document). diff --git a/src/dispatcher.js b/src/dispatcher.js index 57950d97..d5d28a63 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -6,7 +6,6 @@ import eventable from './eventable' import SelectionWatcher from './selection-watcher' import config from './config' import Keyboard from './keyboard' -import {getTotalCharCount} from './util/element' // This will be set to true once we detect the input event is working. // Input event description on MDN: @@ -166,17 +165,16 @@ export default class Dispatcher { dispatchSwitchEvent (event, element, direction) { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return - const cursor = this.selectionWatcher.getSelection() + const cursor = this.selectionWatcher.getFreshSelection() if (!cursor || cursor.isSelection) return - const totalCharCount = getTotalCharCount(element) - if (direction === 'up' && (cursor.isAtFirstLine() || totalCharCount === 0)) { + if (direction === 'up' && cursor.isAtFirstLine()) { event.preventDefault() event.stopPropagation() this.notify('switch', element, direction, cursor) } - if (direction === 'down' && (cursor.isAtLastLine() || totalCharCount === 0)) { + if (direction === 'down' && cursor.isAtLastLine()) { event.preventDefault() event.stopPropagation() this.notify('switch', element, direction, cursor) diff --git a/src/highlight-support.js b/src/highlight-support.js index aa38c96d..84eacba0 100644 --- a/src/highlight-support.js +++ b/src/highlight-support.js @@ -4,11 +4,9 @@ import * as content from './content' import highlightText from './highlight-text' import TextHighlighting from './plugins/highlighting/text-highlighting' -function isInHost (el, host) { - if (!el.closest) { - el = el.parentNode - } - return el.closest('[data-editable]:not([data-word-id])') === host +function isInHost (elem, host) { + if (!elem.closest) elem = elem.parentNode + return elem.closest('[data-editable]:not([data-word-id])') === host } const highlightSupport = { diff --git a/src/node-iterator.js b/src/node-iterator.js index f5ef22ce..46ccee42 100644 --- a/src/node-iterator.js +++ b/src/node-iterator.js @@ -1,33 +1,70 @@ -import * as nodeType from './node-type' +import {textNode} from './node-type' // A DOM node iterator. // // Has the ability to replace nodes on the fly and continue // the iteration. export default class NodeIterator { - constructor (root) { - this.current = this.next = this.root = root + + constructor (root, method) { + this.current = this.previous = this.nextNode = this.root = root + this.iteratorFunc = this[method || 'getNext'] + } + + [Symbol.iterator] () { + return this } getNextTextNode () { let next while ((next = this.getNext())) { - if (next.nodeType === nodeType.textNode && next.data !== '') return next + if (next.nodeType === textNode && next.data !== '') return next + } + } + + getPreviousTextNode () { + let prev + while ((prev = this.getPrevious())) { + if (prev.nodeType === textNode && prev.data !== '') return prev } } + next () { + const value = this.iteratorFunc() + return value ? {value} : {done: true} + } + getNext () { - let n = this.current = this.next - let child = this.next = undefined + let n = this.current = this.nextNode + let child = this.nextNode = undefined if (this.current) { child = n.firstChild // Skip the children of elements with the attribute data-editable="remove" // This prevents text nodes that are not part of the content to be included. if (child && n.getAttribute('data-editable') !== 'remove') { - this.next = child + this.nextNode = child + } else { + while ((n !== this.root) && !(this.nextNode = n.nextSibling)) { + n = n.parentNode + } + } + } + return this.current + } + + getPrevious () { + let n = this.current = this.previous + let child = this.previous = undefined + if (this.current) { + child = n.lastChild + + // Skip the children of elements with the attribute data-editable="remove" + // This prevents text nodes that are not part of the content to be included. + if (child && n.getAttribute('data-editable') !== 'remove') { + this.previous = child } else { - while ((n !== this.root) && !(this.next = n.nextSibling)) { + while ((n !== this.root) && !(this.previous = n.previousSibling)) { n = n.parentNode } } @@ -37,9 +74,10 @@ export default class NodeIterator { replaceCurrent (replacement) { this.current = replacement - this.next = undefined + this.nextNode = undefined + this.previous = undefined let n = this.current - while ((n !== this.root) && !(this.next = n.nextSibling)) { + while ((n !== this.root) && !(this.nextNode = n.nextSibling)) { n = n.parentNode } } diff --git a/src/util/binary_search.js b/src/util/binary_search.js index 9435f880..8d6901c2 100644 --- a/src/util/binary_search.js +++ b/src/util/binary_search.js @@ -1,56 +1,95 @@ -/* - This is a binary search algorithm implementation aimed at finding a character offset position - in a consecutive strings of characters over several lines. - Refer to this page in order to learn more about binary search: https://en.wikipedia.org/wiki/Binary_search_algorithm - - The method takes a setup of methods to perform the partioning and returns an offset if one was found. - Due to maintain good performance in the Browser we limited the binary search partitions to 30. This should be enough for most cases - of paragraph content. - - @param {Function} moveLeft control how a left movement in binary search works - @param {Function} leftCondition control when a left movement in binary search should be performed - @param {Function} moveRight control how a right movement in binary search works - @param {Function} upCondition control when the binary searc should move up one line (internally left movement) - @param {Function} downCondition control when the binary search should move down one line (internally right movement) - @param {Function} convergenceChecker returns true if the binary search converged on a value (considered a break condition) - @param {Function} foundChecker returns true if the target position has been found with binary search (considered a break condition) - @param {Funciton} createCursorAtCharacterOffset method that can apply the binary search offset to a real cursor (returns coordinates) - @param {Function} data data that is passed with the different methods - - @return {Object} object with boolean `wasFound` indicating if the binary search found an offset and `offset` to indicate the actual character offset -*/ -function binaryCursorSearch ({moveLeft, leftCondition, moveRight, upCondition, downCondition, convergenceChecker, foundChecker, createCursorAtCharacterOffset, data}) { - const history = [] - const bluriness = 5 - let found = false - for (let i = 0; i < 30; i++) { - history.push(data.currentOffset) - if (convergenceChecker(history)) break - const cursor = createCursorAtCharacterOffset({element: data.element, offset: data.currentOffset}) +'use strict' +import {getTotalCharCount, textNodesUnder, getTextNodeAndRelativeOffset} from './element' + +/** + * This is a binary search algorithm implementation aimed at finding + * a character offset position in a consecutive strings of characters + * over several lines. + * + * Refer to this page in order to learn more about binary search: + * https://en.wikipedia.org/wiki/Binary_search_algorithm + * + * @returns {object} + * - object with boolean `wasFound` indicating if the binary search found an offset and `offset` to indicate the actual character offset + */ +export function binaryCursorSearch ({ + host, + requiredOnFirstLine, + requiredOnLastLine, + positionX // coordinates relative to viewport (e.g. from getBoundingClientRect()) +}) { + const hostRange = host.ownerDocument.createRange() + hostRange.selectNodeContents(host) + const hostCoords = hostRange.getBoundingClientRect() + const totalCharCount = getTotalCharCount(host) + const textNodes = textNodesUnder(host) + + // early terminate on empty editables + if (totalCharCount === 0) return {wasFound: false} + + const data = { + currentOffset: Math.floor(totalCharCount / 2), + leftLimit: 0, + rightLimit: totalCharCount + } + + let offset = data.currentOffset + let distance + let safety = 20 + while (data.leftLimit < data.rightLimit && safety > 0) { + safety = safety -= 1 + offset = data.currentOffset + const range = createRangeAtCharacterOffset({textNodes, offset: data.currentOffset}) + const coords = range.getBoundingClientRect() + distance = Math.abs(coords.left - positionX) + // up / down axis - if (upCondition({data, cursor})) { + if (requiredOnFirstLine && hostCoords.top !== coords.top) { moveLeft(data) continue - } else if (downCondition({data, cursor})) { + } else if (requiredOnLastLine && hostCoords.bottom !== coords.bottom) { moveRight(data) continue } - const coordinates = cursor.getCoordinates() - const distance = Math.abs(coordinates.left - data.origCoordinates.left) - if (foundChecker({distance, bluriness})) { - found = true - break - } // left / right axis - if (leftCondition({data, coordinates})) { + if (positionX < coords.left) { moveLeft(data) } else { moveRight(data) } } - return {wasFound: found, offset: data.currentOffset} + const range = createRangeAtCharacterOffset({textNodes, offset: data.currentOffset}) + const coords = range.getBoundingClientRect() + const finalDistance = Math.abs(coords.left - positionX) + + // Decide if last or second last offset is closest + if (finalDistance < distance) { + distance = finalDistance + offset = data.currentOffset + } + + return {distance, offset, wasFound: true} +} + +// move the binary search index in between the current position and the left limit +function moveLeft (data) { + data.rightLimit = data.currentOffset + data.currentOffset = Math.floor((data.currentOffset + data.leftLimit) / 2) } +// move the binary search index in between the current position and the right limit +function moveRight (data) { + data.leftLimit = data.currentOffset + data.currentOffset = Math.ceil((data.currentOffset + data.rightLimit) / 2) +} + +function createRangeAtCharacterOffset ({textNodes, offset}) { + const {node, relativeOffset} = getTextNodeAndRelativeOffset({textNodes, absOffset: offset}) -module.exports = {binaryCursorSearch} + const newRange = node.ownerDocument.createRange() + newRange.setStart(node, relativeOffset) + newRange.collapse(true) + + return newRange +} diff --git a/src/util/element.js b/src/util/element.js index 41334f17..4b472544 100644 --- a/src/util/element.js +++ b/src/util/element.js @@ -1,19 +1,14 @@ 'use strict' -function textNodesUnder (node) { - let all = [] - for (node = node.firstChild; node; node = node.nextSibling) { - if (node.nodeType === 3) { - all.push(node) - } else { - all = all.concat(textNodesUnder(node)) - } - } - return all +import NodeIterator from '../node-iterator' + +export function textNodesUnder (node) { + const iterator = new NodeIterator(node, 'getNextTextNode') + return [...iterator] } // NOTE: if there is only one text node, then just that node and // the abs offset are returned -function getTextNodeAndRelativeOffset ({textNodes, absOffset}) { +export function getTextNodeAndRelativeOffset ({textNodes, absOffset}) { let cumulativeOffset = 0 let relativeOffset = 0 let targetNode @@ -29,10 +24,8 @@ function getTextNodeAndRelativeOffset ({textNodes, absOffset}) { return {node: targetNode, relativeOffset} } -function getTotalCharCount (element) { +export function getTotalCharCount (element) { const textNodes = textNodesUnder(element) const reducer = (acc, node) => acc + node.textContent.length return textNodes.reduce(reducer, 0) } - -module.exports = {getTotalCharCount, textNodesUnder, getTextNodeAndRelativeOffset}