UNPKG

@jhbrown94/selectionrange

Version:

Determine selection range inside of ShadowRoots with uniform API across browsers

431 lines (340 loc) 16.9 kB
/** * Copyright 2020 Jeremy H. Brown * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ // This is an attempt at determining the complete selection range, down to the // individual HTML node, down to the individual characters, with // directionality, inside a shadowRoot on Safari. It is heavily informed by // https://github.com/GoogleChromeLabs/shadow-selection-polyfill which is has // many useful techniques. // Since I have the memory of a sieve, I will attempt to include a lot of // comments in this file. // To begin with, why are we here? // In Safari, there is no getSelection method on shadowRoot. If the selection // is inside a shadowRoot, and you get a Selection from document.getSelection, // selection.getRangeAt(0) returns a range which is a single point right // before the shadow dom. This is enforced at the C++ level. So we need to // derive an actual selection range ourselves. // The good news is that most other Seletion methods still work. In // particular, to figure out which nodes you are in, Selection.containsNode // still works -- you get real answers if you pass it nodes from inside the // shadow DOM. So you can interrogate it piecemeal to determine the start // and end nodes of the real selection range. // Similarly, Selection.collapse, Selection.extend, and Selection.toString all // work with nodes from the Shadow DOM. So by playing various games with text // nodes, you can ultimately derive the offsets within text nodes as well. // This line is taken verbatim from // https://github.com/GoogleChromeLabs/shadow-selection-polyfill // which is Copyright 2018 Google LLC under the terms of the Apache License, Version 2.0 const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) || /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; let squelchCount = 0; const cachedRanges = new Map(); // Callers can use this to opt not to react to selectionchange or mutation // events that were probably just caused by getSelectionRange figuring out the // range. This will only ever return true on Safari. If you use this, there's // a risk of missing user-generated events due to our inability to precisely // bracket events we generate -- trying to use setTimeout to bracket isn't // guaranteed because Safari seems to handle that on a separate task queue // from the selectionchange events, which all go first. export function isSquelchingEvents() { return squelchCount > 0; } // Call this with a shadowRoot or a document. export function getSelectionRange(root) { // Only Chrome has getSelection for a shadow root -- but everyone does for a document. if (root.getSelection) { const s = root.getSelection(); return s.anchorNode ? { anchorNode: s.anchorNode, anchorOffset: s.anchorOffset, focusNode: s.focusNode, focusOffset: s.focusOffset } : null; } // Firefox selections look right through shadow doms if (!isSafari) { const s = document.getSelection(); if (!s.containsNode(root, true)) { return null; } return s.anchorNode ? { anchorNode: s.anchorNode, anchorOffset: s.anchorOffset, focusNode: s.focusNode, focusOffset: s.focusOffset } : null; } // Our best guess is that this is a shadowRoot in Safari. Here we go! // Caching the result briefly means that even if something responds to a // shower of selectionchange and/or mutations generated by this routine, // we shouldn't cascade even more such events as a result. let cachedResult = cachedRanges.get(root); if (cachedResult) { return cachedResult; } const selection = document.getSelection(); function getLeftNode(node) { let children = Array.from(node.childNodes); for (let [index, child] of children.entries()) { if (selection.containsNode(child, false)) { // This child is fully contained. It is the node. // If it's text, we have to find the text location. // NOTE: if the text location is 0, we need to return the parent -- we deal with that later in the flow. if (typeof child.length !== 'undefined') { return [child, null]; } // Otherwise, the start point precedes it, so we can refer to the parent. return [node, index]; } if (selection.containsNode(child, true)) { // This child is partially contained. // Is one of its kids the node? const result = getLeftNode(child); if (result) { return result; } // This child was partially contained, but none of its // children were contained at all. The caret is probably to // the right, but there are special cases for the zeroth // element. if (index == 0) { if (children.length == 1) { // this is the only node. The caret is to the left. // NOTE: there is probably a special case that can be created with splitText -- we'll flail on that here. return [node, 0]; } if (children.length > 1 && (!selection.containsNode(children[1], true))) { // Neighbor to the right isn't partially selected. Caret is to the left. return [node, 0] } } // In all other cases, the caret is to the right. return [node, index + 1]; } } // This node's children are not even a little bit contained. return null; } function getRightNode(node) { let children = Array.from(node.childNodes); for (let index = children.length - 1; index >= 0; index--) { let child = children[index]; if (selection.containsNode(child, false)) { // This child is fully contained. It is the node. // If it's text, we have to find the text location. if (typeof child.length !== 'undefined') { return [child, null]; } // Otherwise, the start point precedes it, so we can refer to the parent. return [node, index]; } if (selection.containsNode(child, true)) { // This chld is partially contained. if (typeof child.length !== 'undefined') { } // Is one of its kids the node? const result = getRightNode(child); if (result) { return result; } // This child was partially contained, but none of its children were contained at all. // The caret is always to the left. return [node, index]; } } // This node's children are not even a little bit contained. return null; } let leftResult = getLeftNode(root); let rightResult = getRightNode(root); if (!leftResult || !rightResult) { return null; } let [leftNode, leftOffset] = leftResult; let [rightNode, rightOffset] = rightResult; let direction = null; if (leftOffset === null || rightOffset === null) { // We're going to generate a shower of selectionchange and mutation events. // This will let interested parties squelch those. squelchCount++; // Safari seems to run all selection & mutation event handlers before // user-queued tasks. So the following timeout will run after those // queued events. However, it's hacky -- it's at least theoretically // possible that some other event handler could modify the selection or mutate the DOM // before this timeout runs. window.setTimeout(() => { squelchCount-- ; }, 0); } if ((leftOffset === null) && (rightOffset === null)) { if (leftNode !== rightNode) { // Working across multiple nodes const initialLength = selection.toString().length; // Try going left selection.extend(leftNode, 0); let [newRightNode, newRightOffset] = getRightNode(root); if (newRightNode === rightNode && newRightOffset === rightOffset) { // Left node was focus. So we just added a leftOffset's worth of text to the selection leftOffset = selection.toString().length - initialLength; // Now, shrink selection to just be rightOffset's worth of text selection.extend(rightNode, 0); rightOffset = selection.toString().length; direction = "LeftIsFocus"; } else { // Turns out the right node was focus. Now selection is from start of text to left offset leftOffset = selection.toString().length; // Now, move selection back to rightNode selection.extend(rightNode, 0); rightOffset = initialLength - selection.toString().length; direction = "RightIsFocus"; } } else { // Selection is within one text node. let initialLength = selection.toString().length; // First special case: a caret (zero-length selection) if (initialLength === 0) { selection.extend(leftNode, 0); leftOffset = selection.toString().length; rightOffset = leftOffset; direction = "None"; } else { let initialNext = leftNode.nextSibling; let initialData = leftNode.data; let dataLength = initialData.length; for (let dataLength = initialData.length; dataLength > 0; dataLength--) { // With apologies to all MutationObservers, we find the selection by mutating things until the // selection itself changes. Basically, we split the last character off the node over and over. leftNode.splitText(dataLength - 1); // For what it's worth, Safari doesn't generate a selectionchange event on a text node split, even though it changes // the selection. // If the removed character was outside the selection, selection length doesn't change if (selection.toString().length === initialLength) { continue; } // Aha, the selection got shorter. Here are two things we know now. rightOffset = dataLength; leftOffset = rightOffset - initialLength; // Let's add one character back. In Safari, the selection's anchor or focus will expand // to include that. leftNode.appendData("*"); // I believe this DOES generate a selectionchange event // Now we know there's at least one character in the selection. // So let's send Focus to leftOffset. If it was already there, length doesn't change. // If it wasn't already there, length goes to zero. selection.extend(leftNode, leftOffset); if (selection.toString().length === 0) { direction = "RightIsFocus"; } else { direction = "LeftIsFocus"; } break; } // Clean up the mess we made splitting the node -- put back the data and get rid of the // newly-created nodes. // I believe this DOES generate a selectionchange event leftNode.data = initialData; while (leftNode.nextSibling !== initialNext) { leftNode.nextSibling.remove(); } } } } else if (leftOffset === null) { // Right is solid. It should be a non-text node, i.e. not this one. const initialLength = selection.toString().length; selection.extend(leftNode, 0); // Depending on selection direction, we may have moved our former "left" or "right" sides... let [newRightNode, newRightOffset] = getRightNode(root); // we have to confirm that rightNode and rightOffset are unchanged -- // it's possible to bring the focus to leftNode, 0, but then have // that get promoted to some ancestor of leftnode -- which could // be rightNode at a different offset if (newRightNode === rightNode && rightOffset == newRightOffset) { // The left side was the focus. We just added an offset's worth of text. direction = "LeftIsFocus"; leftOffset = selection.toString().length - initialLength; } else { direction = "RightIsFocus"; // Looks like the right side was the focus. We'll put it back in a minute. But first, math. // We've selected from the offset point to the beginning of the text node. leftOffset = selection.toString().length; } } else if (rightOffset === null) { // Left is solid. It should be a non-text node, i.e. not this one. const initialLength = selection.toString().length; selection.extend(rightNode, 0); // Depending on selection direction, we may have moved our former "left" or "right" sides... let [newLeftNode, newLeftOffset] = getLeftNode(root); if (newLeftNode === leftNode && newLeftOffset == leftOffset) { // The right side was the focus. We just shrunk the selection by an offset's worth of text. direction = "RightIsFocus"; const selLength = selection.toString().length; rightOffset = initialLength - selLength; } else { // Looks like the left side was the focus. We'll put it back in a minute. But first, math. direction = "LeftIsFocus"; // We've selected from the offset point to the beginning of the text node. rightOffset = selection.toString().length; } } else { // we just need direction if (leftNode !== rightNode || leftOffset !== rightOffset) { selection.extend(rightNode, rightOffset); let [newLeftNode, newLeftOffset] = getLeftNode(root); if (newLeftNode === leftNode && newLeftOffset === leftOffset) { // if left didn't move, then it was the anchor direction = "RightIsFocus"; } else { direction = "LeftIsFocus"; } } else { // caret direction = "None"; } } let result; if (!direction) { console.log("FAIL: direction should not be null by this point."); } if (direction === "LeftIsFocus") { result = { anchorNode: rightNode, anchorOffset: rightOffset, focusNode: leftNode, focusOffset: leftOffset }; } else { result = { anchorNode: leftNode, anchorOffset: leftOffset, focusNode: rightNode, focusOffset: rightOffset }; } selection.collapse(result.anchorNode, result.anchorOffset); selection.extend(result.focusNode, result.focusOffset); cachedRanges.set(root, result); window.setTimeout(() => cachedRanges.delete(root), 0); return result; } export function setSelectionRange(root, range) { let selection; if (root.getSelection) { selection = root.getSelection(); } else { selection = document.getSelection(); } if (!range) { selection.removeAllRanges(); return; } selection.collapse(range.anchorNode, range.anchorOffset); selection.extend(range.focusNode, range.focusOffset); }