Skip to content

Commit

Permalink
Add getSelection() polyfill for Safari shadow DOM
Browse files Browse the repository at this point in the history
  • Loading branch information
web-padawan committed Oct 5, 2018
1 parent 2186896 commit e3b0eb6
Show file tree
Hide file tree
Showing 3 changed files with 351 additions and 6 deletions.
3 changes: 2 additions & 1 deletion core/emitter.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import EventEmitter from 'eventemitter3';
import logger from './logger';
import { SHADOW_SELECTIONCHANGE } from './shadow-selection-polyfill';

let debug = logger('quill:events');

const EVENTS = ['selectionchange', 'mousedown', 'mouseup', 'click'];
const EVENTS = [SHADOW_SELECTIONCHANGE, 'mousedown', 'mouseup', 'click'];
const EMITTERS = [];
const supportsRootNode = ('getRootNode' in document);

Expand Down
9 changes: 4 additions & 5 deletions core/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import clone from 'clone';
import equal from 'deep-equal';
import Emitter from './emitter';
import logger from './logger';
import { SHADOW_SELECTIONCHANGE, getRange } from './shadow-selection-polyfill';

let debug = logger('quill:selection');

Expand All @@ -28,7 +29,7 @@ class Selection {
this.lastRange = this.savedRange = new Range(0, 0);
this.handleComposition();
this.handleDragging();
this.emitter.listenDOM('selectionchange', this.rootDocument, () => {
this.emitter.listenDOM(SHADOW_SELECTIONCHANGE, document, () => {
if (!this.mouseDown) {
setTimeout(this.update.bind(this, Emitter.sources.USER), 1);
}
Expand Down Expand Up @@ -158,9 +159,7 @@ class Selection {
}

getNativeRange() {
let selection = this.rootDocument.getSelection();
if (selection == null || selection.rangeCount <= 0) return null;
let nativeRange = selection.getRangeAt(0);
let nativeRange = getRange(this.rootDocument);
if (nativeRange == null) return null;
let range = this.normalizeNative(nativeRange);
debug.info('getNativeRange', range);
Expand Down Expand Up @@ -269,7 +268,7 @@ class Selection {
if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) {
return;
}
let selection = this.rootDocument.getSelection();
let selection = typeof this.rootDocument.getSelection === 'function' ? this.rootDocument.getSelection() : document.getSelection();
if (selection == null) return;
if (startNode != null) {
if (!this.hasFocus()) this.root.focus();
Expand Down
345 changes: 345 additions & 0 deletions core/shadow-selection-polyfill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
/**
* 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.
*/

// NOTE: copied from https://github.com/GoogleChromeLabs/shadow-selection-polyfill

export const SHADOW_SELECTIONCHANGE = '-shadow-selectionchange';

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 validNodeTypes = [Node.ELEMENT_NODE, Node.TEXT_NODE, Node.DOCUMENT_FRAGMENT_NODE];
function isValidNode(node) {
return validNodeTypes.includes(node.nodeType);
}

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; // eslint-disable-line no-continue
}

if (s.containsNode(childNode, true)) {
if (s.containsNode(childNode, false)) {
return childNode;
}
return findNode(s, childNode, isLeft);
}
}
return parentNode;
}

/**
* @param {function(!Event)} fn to add to selectionchange internals
*/
const addInternalListener = (() => {
if (useDocument || hasSelection) {
// getSelection really exists, why are you using us?
document.addEventListener('selectionchange', () => {
document.dispatchEvent(new CustomEvent(SHADOW_SELECTIONCHANGE));
});
return () => {};
}

let withinInternals = false;
const handlers = [];

document.addEventListener('selectionchange', (ev) => {
if (withinInternals) {
return;
}
document.dispatchEvent(new CustomEvent(SHADOW_SELECTIONCHANGE));
withinInternals = true;
window.setTimeout(() => {
withinInternals = false;
}, 2); // FIXME: should be > 1 to prevent infinite Selection.update() loop
handlers.forEach((fn) => fn(ev));
});

return (fn) => handlers.push(fn);
})();

let wasCaret = false;
let resolveTask = null;
addInternalListener(() => {
const s = window.getSelection();
if (s.type === 'Caret') {
wasCaret = true;
} else if (wasCaret && !resolveTask) {
resolveTask = Promise.resolve(true).then(() => {
wasCaret = false;
resolveTask = null;
});
}
});


/**
* @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)) { // eslint-disable-line no-cond-assign
// 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();

if (initialSize === 1 && wasCaret && leftNode === rightNode) {
// nb. We need to reset a single selection as Safari _always_ tells us the cursor was dragged
// left to right (maybe RTL on those devices).
// To be fair, Chrome has the same bug.
s.extend(leftNode, 0);
s.collapseToEnd();
return undefined;
}

let updatedSize;

// Try extending forward and seeing what happens.
s.modify('extend', 'forward', 'character');
updatedSize = measure();

if (updatedSize > initialSize || containsNextElement(s, rightNode, true)) {
s.modify('extend', 'backward', 'character');
return true;
} else if (updatedSize < initialSize || !s.containsNode(leftNode)) {
s.modify('extend', 'backward', 'character');
return false;
}

// Maybe we were at the end of something. Extend backwards.
// TODO(samthor): We seem to be able to get away without the 'backwards' case.
s.modify('extend', 'backward', 'character');
updatedSize = measure();

if (updatedSize > initialSize || containsNextElement(s, leftNode, false)) {
s.modify('extend', 'forward', 'character');
return false;
} else if (updatedSize < initialSize || !s.containsNode(rightNode)) {
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;
}

/**
* @param {!Node} node to check for initial space
* @return {number} count of initial space
*/
function initialSpace(node) {
if (node.nodeType !== Node.TEXT_NODE) {
return 0;
}
return /^\s*/.exec(node.textContent)[0].length;
}

/**
* @param {!Node} node to check for trailing space
* @return {number} count of ignored trailing space
*/
function ignoredTrailingSpace(node) {
if (node.nodeType !== Node.TEXT_NODE) {
return 0;
}
const trailingSpaceCount = /\s*$/.exec(node.textContent)[0].length;
if (!trailingSpaceCount) {
return 0;
}
return trailingSpaceCount - 1; // always allow single last
}

const cachedRange = new Map();
export function getRange(root) {
if (hasSelection || useDocument) {
const s = (useDocument ? document : 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);
return result.range;
}

const fakeSelectionNode = document.createTextNode('');
export function internalGetShadowSelection(root) {
const range = document.createRange();

const s = window.getSelection();
if (!s.containsNode(root.host, true)) {
return {range: null, mode: 'none'};
}

// TODO: inserting fake nodes isn't ideal, but containsNode doesn't work on nearby adjacent
// text nodes (in fact it returns true for all text nodes on the page?!).

// insert a fake 'before' node to see if it's selected
root.insertBefore(fakeSelectionNode, root.childNodes[0]);
const includesBeforeRoot = s.containsNode(fakeSelectionNode);
fakeSelectionNode.remove();
if (includesBeforeRoot) {
return {range: null, mode: 'outside-before'};
}

// insert a fake 'after' node to see if it's selected
root.appendChild(fakeSelectionNode);
const includesAfterRoot = s.containsNode(fakeSelectionNode);
fakeSelectionNode.remove();
if (includesAfterRoot) {
return {range: null, mode: 'outside-after'};
}

const measure = () => s.toString().length;
if (!(s.type === 'Caret' || s.type === 'Range')) {
throw new TypeError('unexpected type: ' + s.type);
}

const leftNode = findNode(s, root, true);
let rightNode;
let isNaturalDirection;
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 (s.type === 'Caret') {
// we might transition to being a caret, so don't check initial value
s.extend(leftNode, 0);
const at = measure();
s.collapseToEnd();

range.setStart(leftNode, at);
range.setEnd(leftNode, at);
return {range, mode: 'caret'};
} else if (isNaturalDirection === undefined) {
if (s.type !== 'Range') {
throw new TypeError('unexpected type: ' + s.type);
}
// This occurs when we can't move because we can't extend left or right to measure the
// direction we're moving in. Good news though: we don't need to _change_ the selection
// to measure it, so just return immediately.
range.setStart(leftNode, 0);
range.setEnd(rightNode, rightNode.length);
return {range, mode: 'all'};
}

const size = measure();
let offsetLeft, offsetRight;

// only one newline/space char is cared about
const validRightLength = rightNode.length - ignoredTrailingSpace(rightNode);

if (isNaturalDirection) {
// walk in the opposite direction first
s.extend(leftNode, 0);
offsetLeft = measure() + initialSpace(leftNode); // measure doesn't include initial space

// then in our actual direction
s.extend(rightNode, validRightLength);
offsetRight = validRightLength - (measure() - size);

// then revert to the original position
s.extend(rightNode, offsetRight);
} else {
// walk in the opposite direction first
s.extend(rightNode, validRightLength);
offsetRight = validRightLength - measure();

// then in our actual direction
s.extend(leftNode, 0);
offsetLeft = measure() - size + initialSpace(leftNode); // doesn't include initial space

// then revert to the original position
s.extend(leftNode, offsetLeft);
}

range.setStart(leftNode, offsetLeft);
range.setEnd(rightNode, offsetRight);
return {
mode: isNaturalDirection ? 'right' : 'left',
range,
};
}

0 comments on commit e3b0eb6

Please sign in to comment.