summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLuca Milanesio <luca.milanesio@gmail.com>2021-01-06 22:46:27 +0000
committerLuca Milanesio <luca.milanesio@gmail.com>2021-01-11 14:08:05 +0000
commit96ccc2388a4c6038e3c49e52c6031fda959ecd15 (patch)
tree6fce2ba39e917644900118008036992a63b1668d
parentd7748bd81f2bb86c7a445eda089bf8dac15e1aab (diff)
Add shadow-selection-polyfill
The selection on shadow-DOM is not implemented in Safari and the document.getSelection() fallback is not suitable for use. The GoogleChromeLabs provided a library to solve the problem: shadow-selection-polyfill. What the library does is to manage the selection change events and calculate and cache the results for the shadow root elements, creating a custom event '-shadow-selectionchange' once the selection has been completed. There is one gotcha that makes things slightly more complicated: what the library provides is a Range and not a Selection object. There are a couple of adjustments needed in gr-diff and gr-diff-highlight to use the range directly instead of getting it from the selection. NOTE: Even though the shadow-selection-polyfill could also manage Chrome and Firefox, keep the existing logic to avoid any possible regression, which is not desireable on a stable branch. On stable-3.1, because of the problems related to the inclusion of the dependency via Bower, shadow.js has been removed from its exports and used as pure JS file included in the gr-diff.html. This change needs would not be applied to stable-3.2 where, thanks to the npm package mangement, it would be consumed from the NPM registry directly. Bug: Issue 11811 Change-Id: I41c4e94343010972c8a9f0f1ba3a059ca7af5292
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js14
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html2
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js7
-rw-r--r--polygerrit-ui/app/scripts/shadow.js421
4 files changed, 440 insertions, 4 deletions
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 0c6f4a3507..b159b51bd6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -163,6 +163,11 @@
* })|null|!Object}
*/
_getNormalizedRange(selection) {
+ /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+ we can get is a single Range */
+ if (selection instanceof Range) {
+ return this._normalizeRange(selection);
+ }
const rangeCount = selection.rangeCount;
if (rangeCount === 0) {
return null;
@@ -323,12 +328,19 @@
},
_handleSelection(selection, isMouseUp) {
+ /* On Safari, the selection events may return a null range that should
+ be ignored */
+ if (!selection) {
+ return;
+ }
const normalizedRange = this._getNormalizedRange(selection);
if (!this._isRangeValid(normalizedRange)) {
this._removeActionBox();
return;
}
- const domRange = selection.getRangeAt(0);
+ /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+ we can get is a single Range */
+ const domRange = selection instanceof Range ? selection:selection.getRangeAt(0);
const start = normalizedRange.start;
const end = normalizedRange.end;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 1c36745b3f..f6abb14211 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -411,5 +411,7 @@ limitations under the License.
</template>
<script src="gr-diff-line.js"></script>
<script src="gr-diff-group.js"></script>
+ <!-- gr-diff.js contains an 'import' statement, which is allowed only in modules -->
+ <script src="../../../scripts/shadow.js"></script>
<script src="gr-diff.js"></script>
</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 4f07664154..c6b2e3a014 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -296,10 +296,10 @@
}
if (loggedIn && isAttached) {
- this.listen(document, 'selectionchange', '_handleSelectionChange');
+ this.listen(document, '-shadow-selectionchange', '_handleSelectionChange');
this.listen(document, 'mouseup', '_handleMouseUp');
} else {
- this.unlisten(document, 'selectionchange', '_handleSelectionChange');
+ this.unlisten(document, '-shadow-selectionchange', '_handleSelectionChange');
this.unlisten(document, 'mouseup', '_handleMouseUp');
}
},
@@ -328,7 +328,8 @@
// This takes the shadow DOM selection if one exists.
return this.root.getSelection ?
this.root.getSelection() :
- document.getSelection();
+ // This is coming from shadow.js
+ getRange(this.root);
},
_observeNodes() {
diff --git a/polygerrit-ui/app/scripts/shadow.js b/polygerrit-ui/app/scripts/shadow.js
new file mode 100644
index 0000000000..df4376652a
--- /dev/null
+++ b/polygerrit-ui/app/scripts/shadow.js
@@ -0,0 +1,421 @@
+/**
+ * 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'};
+}