diff --git a/core/emitter.js b/core/emitter.js index 5814f127c1..8f745b6514 100644 --- a/core/emitter.js +++ b/core/emitter.js @@ -1,17 +1,17 @@ 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); EVENTS.forEach(function(eventName) { document.addEventListener(eventName, (...args) => { - [].slice.call(document.querySelectorAll('.ql-container')).forEach((node) => { - // TODO use WeakMap - if (node.__quill && node.__quill.emitter) { - node.__quill.emitter.handleDOM(...args); - } + EMITTERS.forEach((em) => { + em.handleDOM(...args); }); }); }); @@ -21,6 +21,7 @@ class Emitter extends EventEmitter { constructor() { super(); this.listeners = {}; + EMITTERS.push(this); this.on('error', debug.error); } @@ -30,8 +31,25 @@ class Emitter extends EventEmitter { } handleDOM(event, ...args) { + const target = (event.composedPath ? event.composedPath()[0] : event.target); + const containsNode = (node, target) => { + if (!supportsRootNode || target.getRootNode() === document) { + return node.contains(target); + } + + while (!node.contains(target)) { + const root = target.getRootNode(); + if (!root || !root.host) { + return false; + } + target = root.host; + } + + return true; + }; + (this.listeners[event.type] || []).forEach(function({ node, handler }) { - if (event.target === node || node.contains(event.target)) { + if (target === node || containsNode(node, target)) { handler(event, ...args); } }); diff --git a/core/selection.js b/core/selection.js index 92131a74bf..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'); @@ -22,12 +23,13 @@ class Selection { this.composing = false; this.mouseDown = false; this.root = this.scroll.domNode; + this.rootDocument = (this.root.getRootNode ? this.root.getRootNode() : document); this.cursor = Parchment.create('cursor', this); // savedRange is last non-null range this.lastRange = this.savedRange = new Range(0, 0); this.handleComposition(); this.handleDragging(); - this.emitter.listenDOM('selectionchange', document, () => { + this.emitter.listenDOM(SHADOW_SELECTIONCHANGE, document, () => { if (!this.mouseDown) { setTimeout(this.update.bind(this, Emitter.sources.USER), 1); } @@ -157,9 +159,7 @@ class Selection { } getNativeRange() { - let selection = document.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); @@ -174,7 +174,7 @@ class Selection { } hasFocus() { - return document.activeElement === this.root; + return this.rootDocument.activeElement === this.root; } normalizedToRange(range) { @@ -268,7 +268,7 @@ class Selection { if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) { return; } - let selection = document.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, + }; +} diff --git a/modules/toolbar.js b/modules/toolbar.js index 8cbae0809f..2b9d3ad3a0 100644 --- a/modules/toolbar.js +++ b/modules/toolbar.js @@ -4,9 +4,9 @@ import Quill from '../core/quill'; import logger from '../core/logger'; import Module from '../core/module'; +const supportsRootNode = ('getRootNode' in document); let debug = logger('quill:toolbar'); - class Toolbar extends Module { constructor(quill, options) { super(quill, options); @@ -16,7 +16,8 @@ class Toolbar extends Module { quill.container.parentNode.insertBefore(container, quill.container); this.container = container; } else if (typeof this.options.container === 'string') { - this.container = document.querySelector(this.options.container); + const rootDocument = (supportsRootNode ? quill.container.getRootNode() : document); + this.container = rootDocument.querySelector(this.options.container); } else { this.container = this.options.container; } diff --git a/test/unit/core/selection.js b/test/unit/core/selection.js index a9a157b3d0..1a7a8efb71 100644 --- a/test/unit/core/selection.js +++ b/test/unit/core/selection.js @@ -37,6 +37,47 @@ describe('Selection', function() { }); }); + describe('shadow root', function() { + // Some browsers don't support shadow DOM + if (!document.head.attachShadow) { + return; + } + + let container; + let root; + + beforeEach(function() { + root = document.createElement('div'); + root.attachShadow({ mode: 'open' }); + root.shadowRoot.innerHTML = '
'; + + document.body.appendChild(root); + + container = root.shadowRoot.firstChild; + }); + + afterEach(function() { + document.body.removeChild(root); + }); + + it('getRange()', function() { + let selection = this.initialize(Selection, '0123
', container); + selection.setNativeRange(container.firstChild.firstChild, 1); + let [range, ] = selection.getRange(); + expect(range.index).toEqual(1); + expect(range.length).toEqual(0); + }); + + it('setRange()', function() { + let selection = this.initialize(Selection, '', container); + let expected = new Range(0); + selection.setRange(expected); + let [range, ] = selection.getRange(); + expect(range).toEqual(expected); + expect(selection.hasFocus()).toBe(true); + }); + }); + describe('getRange()', function() { it('empty document', function() { let selection = this.initialize(Selection, ''); diff --git a/test/unit/modules/toolbar.js b/test/unit/modules/toolbar.js index 83cb1215e0..bbefc56814 100644 --- a/test/unit/modules/toolbar.js +++ b/test/unit/modules/toolbar.js @@ -97,6 +97,38 @@ describe('Toolbar', function() { }); }); + describe('shadow dom', function() { + // Some browsers don't support shadow DOM + if (!document.head.attachShadow) { + return; + } + + let container; + let editor; + + beforeEach(function() { + container = document.createElement('div'); + container.attachShadow({ mode: 'open' }); + container.shadowRoot.innerHTML = ` + + `; + + editor = new Quill(container.shadowRoot.querySelector('.editor'), { + modules: { + toolbar: '.toolbar' + } + }); + }); + + it('should initialise', function() { + const editorDiv = container.shadowRoot.querySelector('.editor'); + const toolbarDiv = container.shadowRoot.querySelector('.toolbar'); + expect(editorDiv.className).toBe('editor ql-container'); + expect(toolbarDiv.className).toBe('toolbar ql-toolbar'); + expect(editor.container).toBe(editorDiv); + }); + }); + describe('active', function() { beforeEach(function() { let container = this.initialize(HTMLElement, `