import { isDefined } from "./utilities"; export const isTextNode = (node) => node && node.nodeType === Node.TEXT_NODE; const isText = (text) => text && /[\w']/i.test(text); const isSpace = (text) => text && /[\s\t]/i.test(text); const destructSelection = (selection) => { const range = selection.getRangeAt(0); const { startOffset, startContainer, endOffset, endContainer } = range; const firstSymbol = startContainer.textContent[startOffset]; const prevSymbol = startContainer.textContent[startOffset - 1]; const lastSymbol = endContainer.textContent[endOffset - 1]; const nextSymbol = endContainer.textContent[endOffset]; return { selection, range, startOffset, startContainer, endOffset, endContainer, firstSymbol, prevSymbol, lastSymbol, nextSymbol, }; }; const trimSelectionLeft = (selection) => { const resultRange = selection.getRangeAt(0); selection.removeAllRanges(); selection.collapse(resultRange.startContainer, resultRange.startOffset); let currentRange = selection.getRangeAt(0); do { selection.collapse(currentRange.endContainer, currentRange.endOffset); selection.modify("extend", "forward", "character"); currentRange = selection.getRangeAt(0); } while ( !isTextNode(currentRange.startContainer) || isSpace(currentRange.startContainer.textContent[currentRange.startOffset]) ); resultRange.setStart(currentRange.startContainer, currentRange.startOffset); selection.removeAllRanges(); selection.addRange(resultRange); }; const trimSelectionRight = (selection) => { const resultRange = selection.getRangeAt(0); selection.removeAllRanges(); selection.collapse(resultRange.endContainer, resultRange.endOffset); let currentRange = selection.getRangeAt(0); do { selection.collapse(currentRange.startContainer, currentRange.startOffset); selection.modify("extend", "backward", "character"); currentRange = selection.getRangeAt(0); } while ( !isTextNode(currentRange.startContainer) || isSpace(currentRange.startContainer.textContent[currentRange.startOffset]) ); resultRange.setEnd(currentRange.endContainer, currentRange.endOffset); selection.removeAllRanges(); selection.addRange(resultRange); }; /** * Trims selection until both start and end are text nodes. We need this to make selection.move() * work properly. With non-text nodes it jumps into inner/outer blocks instead of actually moving. * Also removes leading and trailing spaces from selection. * @param {Selection} selection */ export const trimSelection = (selection) => { trimSelectionLeft(selection); trimSelectionRight(selection); }; /** * * @param {Selection} selection */ const findBoundarySelection = (selection, boundary) => { const { range: originalRange, startOffset, startContainer, endOffset, endContainer } = destructSelection(selection); const resultRange = {}; let currentRange; // It's easier to operate the selection when it's collapsed selection.collapse(endContainer, endOffset); // Looking for maximum displacement while (selection.getRangeAt(0).compareBoundaryPoints(Range.START_TO_START, originalRange) === 1) { selection.modify("move", "backward", boundary); } // Going back to find minimum displacement while (selection.getRangeAt(0).compareBoundaryPoints(Range.START_TO_START, originalRange) < 1) { currentRange = selection.getRangeAt(0); Object.assign(resultRange, { startContainer: currentRange.startContainer, startOffset: currentRange.startOffset, }); selection.modify("move", "forward", boundary); } selection.collapse(startContainer, startOffset); while (selection.getRangeAt(0).compareBoundaryPoints(Range.END_TO_END, originalRange) === -1) { selection.modify("move", "forward", boundary); } while (selection.getRangeAt(0).compareBoundaryPoints(Range.END_TO_END, originalRange) > -1) { currentRange = selection.getRangeAt(0); Object.assign(resultRange, { endContainer: currentRange.endContainer, endOffset: currentRange.endOffset, }); selection.modify("move", "backward", boundary); } selection.removeAllRanges(); const range = new Range(); range.setStart(resultRange.startContainer, resultRange.startOffset); range.setEnd(resultRange.endContainer, resultRange.endOffset); selection.addRange(range); trimSelection(selection); return selection; }; const closestBoundarySelection = (selection, boundary) => { const { range: originalRange, startOffset, startContainer, endOffset, endContainer } = destructSelection(selection); const resultRange = {}; let currentRange; // It's easier to operate the selection when it's collapsed selection.collapse(startContainer, startOffset); selection.modify("move", "forward", "character"); selection.modify("move", "backward", boundary); if (selection.getRangeAt(0).compareBoundaryPoints(Range.START_TO_START, originalRange) === 1) { selection.collapse(startContainer, startOffset); selection.modify("move", "backward", boundary); } currentRange = selection.getRangeAt(0); Object.assign(resultRange, { startContainer: currentRange.startContainer, startOffset: currentRange.startOffset, }); selection.collapse(endContainer, endOffset); selection.modify("move", "backward", "character"); selection.modify("move", "forward", boundary); if (selection.getRangeAt(0).compareBoundaryPoints(Range.START_TO_START, originalRange) === -1) { selection.collapse(endContainer, endOffset); selection.modify("move", "forward", boundary); } currentRange = selection.getRangeAt(0); Object.assign(resultRange, { endContainer: currentRange.endContainer, endOffset: currentRange.endOffset, }); selection.removeAllRanges(); const range = new Range(); range.setStart(resultRange.startContainer, resultRange.startOffset); range.setEnd(resultRange.endContainer, resultRange.endOffset); selection.addRange(range); return selection; }; const boundarySelection = (selection, boundary) => { const wordBoundary = boundary !== "symbol"; const { startOffset, startContainer, endOffset, endContainer, firstSymbol, prevSymbol, lastSymbol, nextSymbol } = destructSelection(selection); if (wordBoundary) { if (boundary.endsWith("boundary")) { closestBoundarySelection(selection, boundary); } else { findBoundarySelection(selection, boundary); } } else { if (!isText(firstSymbol) || isText(prevSymbol)) { const newRange = selection.getRangeAt(0); newRange.setEnd(startContainer, startOffset); selection.modify("move", "backward", boundary); } if (!isText(lastSymbol) || isText(nextSymbol)) { const newRange = selection.getRangeAt(0); newRange.setEnd(endContainer, endOffset); selection.modify("extend", "forward", boundary); } } }; /** * Captures current selection * @param {(response: {selectionText: string, range: Range}) => void} callback */ export const captureSelection = ( callback, { granularity, beforeCleanup, window } = { granularity: "symbol", }, ) => { const selection = window.getSelection(); if (selection.isCollapsed) return; if (granularity !== "symbol") { trimSelection(selection); } if (selection.isCollapsed) return; applyTextGranularity(selection, granularity); const selectionText = selection.toString().replace(/[\n\r]/g, "\\n"); for (let i = 0; i < selection.rangeCount; i++) { const range = fixRange(selection.getRangeAt(i)); callback({ selectionText, range }); } // eslint-disable-next-line no-unused-expressions beforeCleanup?.(); selection.removeAllRanges(); }; /** * *Experimental feature. Might nor work in Gecko browsers.* * * Updates selection's granularity. * @param {Selection} selection * @param {string} granularity */ export const applyTextGranularity = (selection, granularity) => { if (!selection.modify || !granularity || granularity === "symbol") return; try { switch (granularity) { case "word": boundarySelection(selection, "word"); break; case "sentence": boundarySelection(selection, "sentenceboundary"); break; case "paragraph": boundarySelection(selection, "paragraphboundary"); break; default: // Handles "charater", "symbol", and any other unspecified granularities break; } } catch { console.warn("Probably, you're using browser that doesn't support granularity."); } }; /** * Lookup closest text node * @param {HTMLElement} commonContainer * @param {HTMLElement} node * @param {number} offset * @param {string} direction forward, backward, forward-next, backward-next * "-next" when we need to skip node if it's a text node */ const textNodeLookup = (commonContainer, node, offset, direction = "forward") => { const startNode = node === commonContainer ? node.childNodes[offset] : node; if (isTextNode(startNode) && !direction.endsWith("next")) return startNode; const walker = commonContainer.ownerDocument.createTreeWalker(commonContainer, NodeFilter.SHOW_ALL); let currentNode = walker.nextNode(); // tree walker can't go backward, so we go forward to startNode and record every text node // to find the last one before startNode let lastTextNode; while (currentNode && currentNode !== startNode) { if (isTextNode(currentNode)) lastTextNode = currentNode; currentNode = walker.nextNode(); } if (currentNode && direction.startsWith("backward")) return lastTextNode; if (direction === "forward-next") currentNode = walker.nextNode(); while (currentNode) { if (isTextNode(currentNode)) return currentNode; currentNode = walker.nextNode(); } }; /** * Fix range if it contains non-text nodes and shrink it down to the better fit. * The main goal here is to get the most relevant xpath+offset combination. * i.e. `start` should point to the element, containing first char, not parent, * not root, not some previous element with `startOffset` on the last char. * @param {Range} range */ export const fixRange = (range) => { const { endOffset, commonAncestorContainer: commonContainer } = range; let { startOffset, startContainer, endContainer } = range; if (!isTextNode(startContainer)) { startContainer = textNodeLookup(commonContainer, startContainer, startOffset, "forward"); if (!startContainer) return null; range.setStart(startContainer, 0); startOffset = 0; } // if user started selection from the end of the tag, start could be this tag, // so we should move it to more relevant one const selectionFromTheEnd = startContainer.wholeText.length === startOffset; // we skip ephemeral whitespace-only text nodes, like \n between tags in original html const isBasicallyEmpty = (textNode) => /^\s*$/.test(textNode.wholeText); if (selectionFromTheEnd || isBasicallyEmpty(startContainer)) { do { startContainer = textNodeLookup(commonContainer, startContainer, startOffset, "forward-next"); if (!startContainer) return null; } while (isBasicallyEmpty(startContainer)); range.setStart(startContainer, 0); startOffset = 0; } if (!isTextNode(endContainer)) { endContainer = textNodeLookup(commonContainer, endContainer, endOffset, "backward"); if (!endContainer) return null; while (/^\s*$/.test(endContainer.wholeText)) { endContainer = textNodeLookup(commonContainer, endContainer, endOffset, "backward-next"); if (!endContainer) return null; } // we skip empty whitespace-only text nodes, so we need the found one to be included range.setEnd(endContainer, endContainer.length); } return range; }; /** * Highlight given Range * @param {Range} range * @param {{label: string, index?: number, classNames: string[]}} param1 */ export const highlightRange = (range, { index, label, classNames }) => { const { startContainer, endContainer, commonAncestorContainer } = range; const { startOffset, endOffset } = range; const highlights = []; /** * Wrapper with predefined classNames and cssStyles * @param {[Node, number, number]} args */ const applyStyledHighlight = (...args) => highlightRangePart(...args, classNames); // If start and end nodes are equal, we don't need // to perform any additional work, just highlighting as is if (startContainer === endContainer) { highlights.push(applyStyledHighlight(startContainer, startOffset, endOffset)); } else { // When start and end are different we need to find all // nodes between as they could contain text nodes const nodesToHighlight = findNodesBetween(startContainer, endContainer, commonAncestorContainer); // All nodes between start and end should be fully highlighted nodesToHighlight.forEach((node) => { let start = startOffset; let end = endOffset; if (node !== startContainer) start = 0; if (node !== endContainer) end = node.length; highlights.push(applyStyledHighlight(node, start, end)); }); } const lastLabel = highlights[highlights.length - 1]; if (lastLabel) { lastLabel.setAttribute("data-label", label ?? ""); lastLabel.setAttribute("data-index", index ? String(index) : ""); } return highlights; }; /** * Takes original range and splits it into multiple text * nodes highlighting a part of the text, then replaces * original text node with highlighted one * @param {Node} container * @param {number} startOffset * @param {number} endOffset * @param {object} cssStyles * @param {string[]} classNames */ export const highlightRangePart = (container, startOffset, endOffset, classNames) => { let spanHighlight; const text = container.textContent; const parent = container.parentNode; /** * In case we're inside another region, move the selection outside * to maintain proper nesting of highlight nodes */ if ( startOffset === 0 && container.length === endOffset && parent.classList.contains(classNames[0]) && parent.innerText === text ) { const placeholder = container.ownerDocument.createElement("span"); const parentNode = parent.parentNode; parentNode.replaceChild(placeholder, parent); spanHighlight = wrapWithSpan(parent, classNames); parentNode.replaceChild(spanHighlight, placeholder); } else { // Extract text content that matches offsets const content = text.substring(startOffset, endOffset); // Create text node that will be highlighted const highlitedNode = container.ownerDocument.createTextNode(content); // Split the container in three parts const noseNode = container.cloneNode(); const tailNode = container.cloneNode(); // Add all the text BEFORE selection noseNode.textContent = text.substring(0, startOffset); tailNode.textContent = text.substring(endOffset, text.length); // To avoid weird dom mutation we assemble replacement // beforehands, it allows to replace original node // directly without extra work const textFragment = container.ownerDocument.createDocumentFragment(); spanHighlight = wrapWithSpan(highlitedNode, classNames); if (noseNode.length) textFragment.appendChild(noseNode); textFragment.appendChild(spanHighlight); if (tailNode.length) textFragment.appendChild(tailNode); // At this point we have three nodes in the tree // one of them is our selected range parent.replaceChild(textFragment, container); } return spanHighlight; }; /** * Wrap text node with stylized span * @param {Text} node * @param {string[]} classNames * @param {object} cssStyles * @param {string} [label] * @todo all 2 usages of this method don't even get the label */ export const wrapWithSpan = (node, classNames, label) => { const highlight = node.ownerDocument.createElement("span"); highlight.appendChild(node); applySpanStyles(highlight, { classNames, label }); return highlight; }; /** * Apply classes and styles to a span. Optionally add or remove label * @param {HTMLSpanElement} spanNode * @param {{classNames?: string[], index?: number, label?: string}} param1 */ export const applySpanStyles = (spanNode, { classNames, index, label }) => { if (classNames) { spanNode.className = ""; spanNode.classList.add(...classNames); } // label is array, string or null, so check for length if (!label?.length) spanNode.removeAttribute("data-label"); else spanNode.setAttribute("data-label", label); spanNode.setAttribute("data-index", index ? String(index) : ""); }; /** * Look up all nodes between given `startNode` and `endNode` including ends * @param {Node} startNode * @param {Node} endNode * @param {Node} root */ export const findNodesBetween = (startNode, endNode, root) => { // Tree walker creates flat representation of DOM // it allows to iterate over nodes more efficiently // as we don't need to go up and down on a tree // Also we iterate over Text nodes only natively. That's // the only type of nodes we need to highlight. // No additional checks, long live TreeWalker :) const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_ALL); // Flag indicates that we're somwhere between `startNode` and `endNode` let inRange = false; // Here we collect all nodes between start and end // including ends const nodes = []; let { currentNode } = walker; while (currentNode) { if (currentNode === startNode) inRange = true; if (inRange && currentNode.nodeType === Node.TEXT_NODE) nodes.push(currentNode); if (inRange && currentNode === endNode) break; currentNode = walker.nextNode(); } return nodes; }; /** * Removes given range and restores DOM structure. * @param {HTMLSpanElement[]} spans */ export const removeRange = (spans) => { if (!spans) return; spans.forEach((hl) => { const fragment = hl.ownerDocument.createDocumentFragment(); const parent = hl.parentNode; // Fill replacement fragment // We need to copy childNodes because otherwise // It will be changed during the loop Array.from(hl.childNodes).forEach((node) => { node.remove(); fragment.appendChild(node); }); // Put back all text without spans parent.replaceChild(fragment, hl); // Join back all text nodes Array.from(parent.childNodes).forEach((node) => { const prev = node.previousSibling; if (!isTextNode(prev) || !isTextNode(node)) return; prev.data += node.data; node.remove(); }); }); }; /** * Fix position in node from chars count to code points count * In python and other modern tools complex unicode symbols handled as code points, not UTF chars * So for external usage js length should be converted to code points count * string to array conversion splits string into code points array, that's the easiest way * @param {{ node: Node, position: number }} container * @return {{ node: Node, position: number }} */ export const charsToCodePoints = ({ node, position }) => { const chars = node.textContent.substr(0, position); const codePoints = [...chars].length; return { node, position: codePoints }; }; /** * Fix Range start/end offsets to code points count instead of chars count * Alters given range * @param {Range} range * @return {Range} the same range */ export const fixCodePointsInRange = (range) => { const start = charsToCodePoints({ node: range.startContainer, position: range.startOffset }); const end = charsToCodePoints({ node: range.endContainer, position: range.endOffset }); range.setStart(range.startContainer, start.position); range.setEnd(range.endContainer, end.position); return range; }; /** * Convert Range to global offsets relative to a root * @param {Range} range * @param {Node} root */ export const rangeToGlobalOffset = (range, root) => { const globalOffsets = [ findGlobalOffset(range.startContainer, range.startOffset, root), findGlobalOffset(range.endContainer, range.endOffset, root), ]; return globalOffsets; }; /** * Find text offset for given node and position relative to a root * @param {Node} node * @param {Number} position * @param {Node} root */ const findGlobalOffset = (node, position, root) => { const walker = (root.contentDocument ?? root.ownerDocument).createTreeWalker(root, NodeFilter.SHOW_ALL); let globalPosition = 0; let nodeReached = false; let currentNode = walker.nextNode(); while (currentNode) { // Indicates that we at or below desired node nodeReached = nodeReached || node === currentNode; const atTargetNode = node === currentNode || currentNode.contains(node); const isText = currentNode.nodeType === Node.TEXT_NODE; const isBR = currentNode.nodeName === "BR"; // Stop iteration // Break if we passed target node and current node // is not target, nor child of a target if (nodeReached && atTargetNode === false) { break; } if (isText || isBR) { let length = isDefined(currentNode.length) ? [...currentNode.textContent].length : 1; if (atTargetNode) { length = Math.min(position, length); } globalPosition += length; } currentNode = walker.nextNode(); } return globalPosition; }; export const isSelectionContainsSpan = (spanNode) => { const selection = window.getSelection(); const spanRange = document.createRange(); const textNode = spanNode.childNodes[0]; spanRange.setStart(textNode, 0); spanRange.setEnd(textNode, textNode.length); for (let i = selection.rangeCount; i--; ) { const selRange = selection.getRangeAt(i); if ( selRange.compareBoundaryPoints(Range.START_TO_START, spanRange) < 1 && selRange.compareBoundaryPoints(Range.END_TO_END, spanRange) > -1 ) return true; } return false; };