blob: df4376652a46a525b41c55c1ad448d70f0a811a6 [file] [log] [blame]
/**
* Copyright 2018 Google LLC
*
* 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.
*/
/* Downloaded from [1] and adapted to be used as HTML-imported JavaScript
* rather than import module.
* TODO: to be removed when merging to stable-3.2, where the NPM package
* management would allow to actually consume the artifact directly without
* adaptation.
*
* [1] https://raw.githubusercontent.com/GoogleChromeLabs/shadow-selection-polyfill/master/shadow.js
*/
const debug = false;
const hasShadow = 'attachShadow' in Element.prototype && 'getRootNode' in Element.prototype;
const hasSelection = !!(hasShadow && document.createElement('div').attachShadow({ mode: 'open' }).getSelection);
const hasShady = window.ShadyDOM && window.ShadyDOM.inUse;
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const useDocument = !hasShadow || hasShady || (!hasSelection && !isSafari);
const invalidPartialElements = /^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|script|source|style|template|track|wbr)$/;
const eventName = '-shadow-selectionchange';
const validNodeTypes = [Node.ELEMENT_NODE, Node.TEXT_NODE, Node.DOCUMENT_FRAGMENT_NODE];
function isValidNode(node) {
return validNodeTypes.includes(node.nodeType);
}
/**
* @param {!Selection} s selection to use
* @param {!Node} node to find caret position within a shadow root
* @return {!Node|!ShadowRoot}
*/
function findCaretFocus(s, node) {
const pending = [];
const pushAll = (nodeList) => {
for (let i = 0; i < nodeList.length; ++i) {
if (nodeList[i].shadowRoot) {
pending.push(nodeList[i].shadowRoot);
}
}
};
// We're told by Safari that a node containing a child with a Shadow Root is selected, but check
// the node directly too (just in case they change their mind later).
if (node.shadowRoot) {
pending.push(node.shadowRoot);
}
pushAll(node.childNodes);
while (pending.length) {
const root = pending.shift();
for (let i = 0; i < root.childNodes.length; ++i) {
if (s.containsNode(root.childNodes[i], true)) {
return root;
}
}
// The selection must be inside a further Shadow Root, but there's no good way to get a list of
// them. Safari won't tell you what regular node contains the root which has a selection. So,
// unfortunately if you stack them this will be slow(-ish).
pushAll(root.querySelectorAll('*'));
}
return null;
}
function findNode(s, parentNode, isLeft) {
const nodes = parentNode.childNodes || parentNode.children;
if (!nodes) {
return parentNode; // found it, probably text
}
for (let i = 0; i < nodes.length; ++i) {
const j = isLeft ? i : (nodes.length - 1 - i);
const childNode = nodes[j];
if (!isValidNode(childNode)) {
continue;
}
debug && console.debug('checking child', childNode, 'IsLeft', isLeft);
if (s.containsNode(childNode, true)) {
if (s.containsNode(childNode, false)) {
debug && console.info('found child', childNode);
return childNode;
}
// Special-case elements that cannot have feasible children.
if (!invalidPartialElements.exec(childNode.localName || '')) {
debug && console.info('descending child', childNode);
return findNode(s, childNode, isLeft);
}
}
debug && console.info(parentNode, 'does NOT contain', childNode);
}
return parentNode;
}
let recentCaretRange = {node: null, offset: -1};
(function() {
if (hasSelection || useDocument) {
// getSelection exists or document API can be used
document.addEventListener('selectionchange', (ev) => {
document.dispatchEvent(new CustomEvent(eventName));
});
return () => {};
}
let withinInternals = false;
document.addEventListener('selectionchange', (ev) => {
if (withinInternals) {
return;
}
withinInternals = true;
const s = window.getSelection();
if (s.type === 'Caret') {
const root = findCaretFocus(s, s.anchorNode);
if (root instanceof window.ShadowRoot) {
const range = getRange(root);
if (range) {
const node = range.startContainer;
const offset = range.startOffset;
recentCaretRange = {node, offset};
}
}
}
document.dispatchEvent(new CustomEvent('-shadow-selectionchange'));
window.requestAnimationFrame(() => {
withinInternals = false;
});
});
})();
/**
* @param {!Selection} s the window selection to use
* @param {!Node} node the node to walk from
* @param {boolean} walkForward should this walk in natural direction
* @return {boolean} whether the selection contains the following node (even partially)
*/
function containsNextElement(s, node, walkForward) {
const start = node;
while (node = walkFromNode(node, walkForward)) {
// walking (left) can contain our own parent, which we don't want
if (!node.contains(start)) {
break;
}
}
if (!node) {
return false;
}
// we look for Element as .containsNode says true for _every_ text node, and we only care about
// elements themselves
return node instanceof Element && s.containsNode(node, true);
}
/**
* @param {!Selection} s the window selection to use
* @param {!Node} leftNode the left node
* @param {!Node} rightNode the right node
* @return {boolean|undefined} whether this has natural direction
*/
function getSelectionDirection(s, leftNode, rightNode) {
if (s.type !== 'Range') {
return undefined; // no direction
}
const measure = () => s.toString().length;
const initialSize = measure();
debug && console.info(`initial selection: "${s.toString()}"`)
let updatedSize;
// Try extending forward and seeing what happens.
s.modify('extend', 'forward', 'character');
updatedSize = measure();
debug && console.info(`forward selection: "${s.toString()}"`)
if (updatedSize > initialSize || containsNextElement(s, rightNode, true)) {
debug && console.info('got forward >, moving right')
s.modify('extend', 'backward', 'character');
return true;
} else if (updatedSize < initialSize || !s.containsNode(leftNode)) {
debug && console.info('got forward <, moving left')
s.modify('extend', 'backward', 'character');
return false;
}
// Maybe we were at the end of something. Extend backwards instead.
s.modify('extend', 'backward', 'character');
updatedSize = measure();
debug && console.info(`backward selection: "${s.toString()}"`)
if (updatedSize > initialSize || containsNextElement(s, leftNode, false)) {
debug && console.info('got backwards >, moving left')
s.modify('extend', 'forward', 'character');
return false;
} else if (updatedSize < initialSize || !s.containsNode(rightNode)) {
debug && console.info('got backwards <, moving right')
s.modify('extend', 'forward', 'character');
return true;
}
// This is likely a select-all.
return undefined;
}
/**
* Returns the next valid node (element or text). This is needed as Safari doesn't support
* TreeWalker inside Shadow DOM. Don't escape shadow roots.
*
* @param {!Node} node to start from
* @param {boolean} walkForward should this walk in natural direction
* @return {Node} node found, if any
*/
function walkFromNode(node, walkForward) {
if (!walkForward) {
return node.previousSibling || node.parentNode || null;
}
while (node) {
if (node.nextSibling) {
return node.nextSibling;
}
node = node.parentNode;
}
return null;
}
const cachedRange = new Map();
function getRange(root) {
if (hasShady) {
const s = document.getSelection();
return s.rangeCount ? s.getRangeAt(0) : null;
} else if (useDocument) {
// Document pierces Shadow Root for selection, so actively filter it down to the right node.
// This is only for Firefox, which does not allow selection across Shadow Root boundaries.
const s = document.getSelection();
if (s.containsNode(root, true)) {
return s.getRangeAt(0);
}
return null;
} else if (hasSelection) {
const s = root.getSelection();
return s.rangeCount ? s.getRangeAt(0) : null;
}
const thisFrame = cachedRange.get(root);
if (thisFrame) {
return thisFrame;
}
const result = internalGetShadowSelection(root);
cachedRange.set(root, result.range);
window.setTimeout(() => {
cachedRange.delete(root);
}, 0);
debug && console.debug('getRange got', result);
return result.range;
}
function internalGetShadowSelection(root) {
// nb. We used to check whether the selection contained the host, but this broke in Safari 13.
// This is "nicely formatted" whitespace as per the browser's renderer. This is fine, and we only
// provide selection information at this granularity.
const s = window.getSelection();
if (s.type === 'None') {
return {range: null, type: 'none'};
} else if (!(s.type === 'Caret' || s.type === 'Range')) {
throw new TypeError('unexpected type: ' + s.type);
}
const leftNode = findNode(s, root, true);
if (leftNode === root) {
return {range: null, mode: 'none'};
}
const range = document.createRange();
let rightNode = null;
let isNaturalDirection = undefined;
if (s.type === 'Range') {
rightNode = findNode(s, root, false); // get right node here _before_ getSelectionDirection
isNaturalDirection = getSelectionDirection(s, leftNode, rightNode);
// isNaturalDirection means "going right"
if (isNaturalDirection === undefined) {
// This occurs when we can't move because we can't extend left or right to measure the
// direction we're moving in... because it's the entire range. Hooray!
range.setStart(leftNode, 0);
range.setEnd(rightNode, rightNode.length);
return {range, mode: 'all'};
}
}
const initialSize = s.toString().length;
// Dumbest possible approach: remove characters from left side until no more selection,
// re-add.
// Try right side first, as we can trim characters until selection gets shorter.
let leftOffset = 0;
let rightOffset = 0;
if (rightNode === null) {
// This is a caret selection, do nothing.
} else if (rightNode.nodeType === Node.TEXT_NODE) {
const rightText = rightNode.textContent;
const existingNextSibling = rightNode.nextSibling;
for (let i = rightText.length - 1; i >= 0; --i) {
rightNode.splitText(i);
const updatedSize = s.toString().length;
if (updatedSize !== initialSize) {
rightOffset = i + 1;
break;
}
}
// We don't use .normalize() here, as the user might already have a weird node arrangement
// they need to maintain.
rightNode.insertData(rightNode.length, rightText.substr(rightNode.length));
while (rightNode.nextSibling !== existingNextSibling) {
rightNode.nextSibling.remove();
}
}
if (leftNode.nodeType === Node.TEXT_NODE) {
if (leftNode !== rightNode) {
// If we're at the end of a text node, it's impossible to extend the selection, so add an
// extra character to select (that we delete later).
leftNode.appendData('?');
s.collapseToStart();
s.modify('extend', 'right', 'character');
}
const leftText = leftNode.textContent;
const existingNextSibling = leftNode.nextSibling;
const start = (leftNode === rightNode ? rightOffset : leftText.length - 1);
for (let i = start; i >= 0; --i) {
leftNode.splitText(i);
if (s.toString() === '') {
leftOffset = i;
break;
}
}
// As above, we don't want to use .normalize().
leftNode.insertData(leftNode.length, leftText.substr(leftNode.length));
while (leftNode.nextSibling !== existingNextSibling) {
leftNode.nextSibling.remove();
}
if (leftNode !== rightNode) {
leftNode.deleteData(leftNode.length - 1, 1);
}
if (rightNode === null) {
rightNode = leftNode;
rightOffset = leftOffset;
}
} else if (rightNode === null) {
rightNode = leftNode;
}
// Work around common browser bug. Single character selction is always seen as 'forward'. Check
// if it's actually supposed to be backward.
if (initialSize === 1 && recentCaretRange && recentCaretRange.node === leftNode) {
if (recentCaretRange.offset > leftOffset && isNaturalDirection) {
isNaturalDirection = false;
}
}
if (isNaturalDirection === true) {
s.collapse(leftNode, leftOffset);
s.extend(rightNode, rightOffset);
} else if (isNaturalDirection === false) {
s.collapse(rightNode, rightOffset);
s.extend(leftNode, leftOffset);
} else {
s.setPosition(leftNode, leftOffset);
}
range.setStart(leftNode, leftOffset);
range.setEnd(rightNode, rightOffset);
return {range, mode: 'normal'};
}