diff --git a/core/emitter.js b/core/emitter.js index 5c20eda1a8..8f745b6514 100644 --- a/core/emitter.js +++ b/core/emitter.js @@ -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); diff --git a/core/selection.js b/core/selection.js index c74d04a010..3147cb0b44 100644 --- a/core/selection.js +++ b/core/selection.js @@ -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'); @@ -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); } @@ -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); @@ -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(); diff --git a/core/shadow-selection-polyfill.js b/core/shadow-selection-polyfill.js new file mode 100644 index 0000000000..d94a86df0a --- /dev/null +++ b/core/shadow-selection-polyfill.js @@ -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 (hasSelection || useDocument) { + // getSelection exists or document API can be used + 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, + }; +}