diff --git a/core/emitter.js b/core/emitter.js index 0f9ba4f06a..419fa6d453 100644 --- a/core/emitter.js +++ b/core/emitter.js @@ -1,17 +1,16 @@ import EventEmitter from 'eventemitter3'; -import instances from './instances'; +// import instances from './instances'; import logger from './logger'; const debug = logger('quill:events'); const EVENTS = ['selectionchange', 'mousedown', 'mouseup', 'click']; +const EMITTERS = []; +const supportsRootNode = 'getRootNode' in document; EVENTS.forEach(eventName => { document.addEventListener(eventName, (...args) => { - Array.from(document.querySelectorAll('.ql-container')).forEach(node => { - const quill = instances.get(node); - if (quill && quill.emitter) { - quill.emitter.handleDOM(...args); - } + EMITTERS.forEach(em => { + em.handleDOM(...args); }); }); }); @@ -20,6 +19,7 @@ class Emitter extends EventEmitter { constructor() { super(); this.listeners = {}; + EMITTERS.push(this); this.on('error', debug.error); } @@ -29,8 +29,25 @@ class Emitter extends EventEmitter { } handleDOM(event, ...args) { + const target = event.composedPath ? event.composedPath()[0] : event.target; + const containsNode = (node, targetNode) => { + if (!supportsRootNode || targetNode.getRootNode() === document) { + return node.contains(targetNode); + } + + while (!node.contains(targetNode)) { + const root = targetNode.getRootNode(); + if (!root || !root.host) { + return false; + } + targetNode = root.host; + } + + return true; + }; + (this.listeners[event.type] || []).forEach(({ 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 b099fad6e4..b724db96b0 100644 --- a/core/selection.js +++ b/core/selection.js @@ -3,6 +3,7 @@ import cloneDeep from 'lodash.clonedeep'; import isEqual from 'lodash.isequal'; import Emitter from './emitter'; import logger from './logger'; +import { ShadowSelection } from './shadow-selection-polyfill'; const debug = logger('quill:selection'); @@ -20,6 +21,9 @@ class Selection { this.composing = false; this.mouseDown = false; this.root = this.scroll.domNode; + this.rootDocument = this.root.getRootNode + ? this.root.getRootNode() + : document; this.cursor = this.scroll.create('cursor', this); // savedRange is last non-null range this.savedRange = new Range(0, 0); @@ -32,11 +36,23 @@ class Selection { setTimeout(this.update.bind(this, Emitter.sources.USER), 1); } }); - this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => { + this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, (_, mutations) => { if (!this.hasFocus()) return; const native = this.getNativeRange(); if (native == null) return; + + // We might need to hack the offset on Safari, when we are dealing with the first character of a row. + // This likely happens because of a race condition between quill's update method being called before the + // selectionchange event being fired in the selection polyfill. + const hackOffset = + native.start.offset === 0 && + native.start.offset === native.end.offset && + this.rootDocument.getSelection() instanceof ShadowSelection && + mutations.some(a => a.type === 'characterData' && a.oldValue === '') + ? 1 + : 0; if (native.start.node === this.cursor.textNode) return; // cursor.restore() will handle + // TODO unclear if this has negative side effects this.emitter.once(Emitter.events.SCROLL_UPDATE, () => { try { if ( @@ -45,9 +61,9 @@ class Selection { ) { this.setNativeRange( native.start.node, - native.start.offset, + native.start.offset + hackOffset, native.end.node, - native.end.offset, + native.end.offset + hackOffset, ); } this.update(Emitter.sources.SILENT); @@ -184,7 +200,7 @@ class Selection { } getNativeRange() { - const selection = document.getSelection(); + const selection = this.rootDocument.getSelection(); if (selection == null || selection.rangeCount <= 0) return null; const nativeRange = selection.getRangeAt(0); if (nativeRange == null) return null; @@ -202,8 +218,8 @@ class Selection { hasFocus() { return ( - document.activeElement === this.root || - contains(this.root, document.activeElement) + this.rootDocument.activeElement === this.root || + contains(this.root, this.rootDocument.activeElement) ); } @@ -325,7 +341,7 @@ class Selection { ) { return; } - const selection = document.getSelection(); + const selection = this.rootDocument.getSelection(); if (selection == null) return; if (startNode != null) { if (!this.hasFocus()) this.root.focus(); @@ -416,6 +432,8 @@ class Selection { } } +// TODO ShadowDom consider? https://github.com/ing-bank/lion/blob/master/packages/overlays/src/utils/deep-contains.js +// TODO note that handleDOM has a contains impl as well.. function contains(parent, descendant) { try { // Firefox inserts inaccessible nodes around video elements diff --git a/core/shadow-selection-polyfill.js b/core/shadow-selection-polyfill.js new file mode 100644 index 0000000000..260c70df38 --- /dev/null +++ b/core/shadow-selection-polyfill.js @@ -0,0 +1,103 @@ +// see https://github.com/GoogleChromeLabs/shadow-selection-polyfill/issues/11 +const SUPPORTS_SHADOW_SELECTION = typeof window.ShadowRoot.prototype.getSelection === 'function'; +const SUPPORTS_BEFORE_INPUT = window.InputEvent && typeof window.InputEvent.prototype.getTargetRanges === 'function'; +const IS_FIREFOX = window.navigator.userAgent.toLowerCase().indexOf('firefox') > -1; +const IS_MSIE = !!(window.navigator.userAgent.match(/Trident/) && !window.navigator.userAgent.match(/MSIE/)); +const IS_EDGE = window.navigator.userAgent.match(/Edge/); + +let processing = false; +export class ShadowSelection { + constructor() { + this._ranges = []; + } + + get rangeCount() { + return this._ranges.length; + } + + getRangeAt(index) { + return this._ranges[index]; + } + + addRange(range) { + this._ranges.push(range); + if (!processing) { + let windowSel = window.getSelection(); + windowSel.removeAllRanges(); + windowSel.addRange(range); + } + } + + removeAllRanges() { + this._ranges = []; + } + + // todo: implement remaining `Selection` methods and properties. +} + +function getActiveElement() { + let active = document.activeElement; + + /* eslint-disable no-constant-condition */ + while (true) { + if (active && active.shadowRoot && active.shadowRoot.activeElement) { + active = active.shadowRoot.activeElement; + } else { + break; + } + } + + return active; +} + +if ((IS_FIREFOX || IS_MSIE || IS_EDGE) && !SUPPORTS_SHADOW_SELECTION) { + window.ShadowRoot.prototype.getSelection = function() { + return document.getSelection(); + } +} + +if (!IS_FIREFOX && !SUPPORTS_SHADOW_SELECTION && SUPPORTS_BEFORE_INPUT) { + let selection = new ShadowSelection(); + + window.ShadowRoot.prototype.getSelection = function() { + return selection; + } + + window.addEventListener('selectionchange', () => { + if (!processing) { + processing = true; + + const active = getActiveElement(); + + if (active && (active.getAttribute('contenteditable') === 'true')) { + document.execCommand('indent'); + } else { + selection.removeAllRanges(); + } + + processing = false; + } + }, true); + + window.addEventListener('beforeinput', (event) => { + if (processing) { + const ranges = event.getTargetRanges(); + const range = ranges[0]; + + const newRange = new Range(); + + newRange.setStart(range.startContainer, range.startOffset); + newRange.setEnd(range.endContainer, range.endOffset); + + selection.removeAllRanges(); + selection.addRange(newRange); + + event.preventDefault(); + event.stopImmediatePropagation(); + } + }, true); + + window.addEventListener('selectstart', () => { + selection.removeAllRanges(); + }, true); +} diff --git a/modules/clipboard.js b/modules/clipboard.js index 8eadd406a6..fc5a81d088 100644 --- a/modules/clipboard.js +++ b/modules/clipboard.js @@ -155,7 +155,8 @@ class Clipboard extends Module { if (!html && files.length > 0) { this.quill.uploader.upload(range, files); return; - } else if (html && files.length > 0) { + } + if (html && files.length > 0) { const doc = new DOMParser().parseFromString(html, 'text/html'); if ( doc.body.childElementCount === 1 && diff --git a/modules/toolbar.js b/modules/toolbar.js index a16923ec0a..e4d753adb9 100644 --- a/modules/toolbar.js +++ b/modules/toolbar.js @@ -4,6 +4,7 @@ import Quill from '../core/quill'; import logger from '../core/logger'; import Module from '../core/module'; +const supportsRootNode = 'getRootNode' in document; const debug = logger('quill:toolbar'); class Toolbar extends Module { @@ -15,7 +16,10 @@ 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/package.json b/package.json index 53492be397..1372397a99 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ } }, "eslintIgnore": [ + "core/shadow-selection-polyfill.js", "dist/", "docs/", "node_modules/" diff --git a/test/unit/core/selection.js b/test/unit/core/selection.js index 164f5f8bed..a29eeacf31 100644 --- a/test/unit/core/selection.js +++ b/test/unit/core/selection.js @@ -41,6 +41,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() { + const selection = this.initialize(Selection, '0123
', container); + selection.setNativeRange(container.firstChild.firstChild, 1); + const [range] = selection.getRange(); + expect(range.index).toEqual(1); + expect(range.length).toEqual(0); + }); + + it('setRange()', function() { + const selection = this.initialize(Selection, '', container); + const expected = new Range(0); + selection.setRange(expected); + const [range] = selection.getRange(); + expect(range).toEqual(expected); + expect(selection.hasFocus()).toBe(true); + }); + }); + describe('getRange()', function() { it('empty document', function() { const selection = this.initialize(Selection, ''); diff --git a/test/unit/modules/toolbar.js b/test/unit/modules/toolbar.js index 7e04fc911d..e5d960886e 100644 --- a/test/unit/modules/toolbar.js +++ b/test/unit/modules/toolbar.js @@ -106,6 +106,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() { const container = this.initialize(