slab / quill

Quill is a modern WYSIWYG editor built for compatibility and extensibility
https://quilljs.com
BSD 3-Clause "New" or "Revised" License
43.95k stars 3.41k forks source link

Quill misbehaves inside of Shadow DOM #4250

Open tylerc opened 5 months ago

tylerc commented 5 months ago

When embedding Quill inside a custom web component that uses the shadow DOM, Quill exhibits a number of odd behaviors:

  1. Text cursor moves incorrectly when typing the first couple of characters. In this GIF, I type ABC but it comes out BCA:

    Quill Initial Insert Bug Shadow DOM

  2. Some styling options in toolbar do not have any effect:

    Quill Shadow DOM styling no effect

  3. Also, shortcuts like CTRL+C and CTRL+X for copy/paste do not appear to function at all.

There may be other issues I haven't yet discovered.

Steps for Reproduction

  1. Visit https://jsfiddle.net/xgeuda36/
  2. Type some things, try the styling buttons, etc.

Expected behavior:

Quill should behave the same whether embedded inside Shadow DOM or not.

Actual behavior:

Quill exhibits cursor, toolbar, and keyboard shortcut issues.

Platforms:

Tested on Chrome 125.0.6422.142 on Windows, and Safari 17.5 on iOS.

Version:

Quill 2.0.2


The fiddle to reproduce these issues is very small, it is merely this code:

<script src="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.js"></script>

<h1>Quill here:</h1>
<quill-component></quill-component>

<script>
class QuillComponent extends HTMLElement {
  initialized = false;

  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/quill@2.0.2/dist/quill.snow.css" /><div><div class="quill-inside" style="height: 200px;"></div></div>';
  }

  connectedCallback() {
    if (!this.initialized) {
      this.initialized = true;
      new Quill(this.shadowRoot.querySelector('.quill-inside'), {
        theme: "snow",
      });
    }
  }
}

customElements.define('quill-component', QuillComponent);
</script>
enoguchi-lmi commented 4 months ago

We have the exact same problem since upgrading to v 2.0.2, seems to be an ongoing issue with Quill. At least in v1.3.7 it was only issue 2

My fiddle https://jsfiddle.net/yov9c3mh/8/

mamiu commented 4 months ago

If anyone is using Svelte, I've build a fully functional Quill editor component that works in both the document DOM as well as shadow DOM (it automatically detects it and mounts the correct version).

This component is quite long because it fixes a lot of bugs.

<script lang="ts">
    import type Quill from 'quill';
    import { createEventDispatcher } from 'svelte';
    import type { Action } from 'svelte/action';

    export let html: string = '';
    export let mode: 'minimal' | 'full' = 'full';
    export let placeholder: string = 'Write your text here...';
    export let theme: 'bubble' | 'snow' = 'bubble';
    export let linkPlaceholder: string = 'https://www.google.com';

    let editor: Quill & {
        theme: { tooltip?: { root: HTMLElement; textbox: HTMLInputElement; hide: () => void } };
    };

    const dispatch = createEventDispatcher<{ htmlChange: string }>();

    const handleChange = () => {
        if (!editor) return;
        html = editor.root.innerHTML;
        dispatch('htmlChange', html);
    };

    const insertHtml = (html: string) => {
        if (editor && editor.clipboard) {
            const delta = editor.clipboard.convert({ html, text: '' });
            editor.setContents(delta, 'silent');
        }
    };

    const toolbarOptionsMinimal = [
        ['bold', 'italic', 'underline', 'strike'],
        [{ color: [] }, { background: [] }],
        ['link']
    ];

    const toolbarOptionsFull = [
        [{ header: '1' }, { header: '2' }, { font: [] }],
        [{ list: 'ordered' }, { list: 'bullet' }],
        ['bold', 'italic', 'underline', 'strike'],
        [{ color: [] }, { background: [] }],
        [{ align: [] }],
        ['link', 'blockquote']
    ];

    const mountInShadowDom = (quill: typeof Quill, container: HTMLElement) => {
        editor = new quill(container, {
            modules: {
                toolbar: mode === 'minimal' ? toolbarOptionsMinimal : toolbarOptionsFull
            },
            placeholder,
            theme
        });

        const linkElement = container.querySelector('input[data-link]') as HTMLInputElement;
        linkElement.setAttribute('data-link', linkPlaceholder);

        insertHtml(html);

        editor.on('text-change', handleChange);

        const getNativeSelection = (rootNode: ShadowRoot): Selection | null => {
            try {
                if ('getSelection' in rootNode && typeof rootNode.getSelection === 'function') {
                    return rootNode.getSelection();
                } else {
                    return window.getSelection();
                }
            } catch {
                return null;
            }
        };

        // Each browser engine has a different implementation for retrieving the Range
        const getNativeRange = (rootNode: ShadowRoot): Range | null => {
            const selection = getNativeSelection(rootNode);
            if (!selection?.anchorNode) return null;

            if (
                selection &&
                'getComposedRanges' in selection &&
                typeof selection.getComposedRanges === 'function'
            ) {
                // Webkit range retrieval is done with getComposedRanges (see: https://bugs.webkit.org/show_bug.cgi?id=163921)
                return selection.getComposedRanges(rootNode)[0];
            }

            // Chromium based brwosers implement the range API properly in Native Shadow
            // Gecko implements the range API properly in Native Shadow: https://developer.mozilla.org/en-US/docs/Web/API/Selection/getRangeAt
            return selection.getRangeAt(0);
        };

        /**
         * Original implementation uses document.active element which does not work in Native Shadow.
         * Replace document.activeElement with shadowRoot.activeElement
         **/
        editor.selection.hasFocus = () => {
            const rootNode = editor.root.getRootNode() as ShadowRoot;
            return rootNode.activeElement === editor.root;
        };

        /**
         * Original implementation uses document.getSelection which does not work in Native Shadow.
         * Replace document.getSelection with shadow dom equivalent (different for each browser)
         **/
        editor.selection.getNativeRange = () => {
            const rootNode = editor.root.getRootNode() as ShadowRoot;
            const nativeRange = getNativeRange(rootNode);
            return !!nativeRange ? editor.selection.normalizeNative(nativeRange) : null;
        };

        /**
         * Original implementation relies on Selection.addRange to programatically set the range, which does not work
         * in Webkit with Native Shadow. Selection.addRange works fine in Chromium and Gecko.
         **/
        editor.selection.setNativeRange = function (startNode, startOffset) {
            let endNode =
                arguments.length > 2 && arguments[2] !== undefined ? (arguments[2] as Node) : startNode;
            let endOffset =
                arguments.length > 3 && arguments[3] !== undefined ? (arguments[3] as number) : startOffset;
            const force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;

            if (
                startNode != null &&
                (editor.selection.root.parentNode == null ||
                    startNode.parentNode == null ||
                    (endNode && endNode.parentNode == null))
            ) {
                return;
            }

            const selection = document.getSelection();

            if (selection == null) return;

            if (startNode != null && endNode != null) {
                if (!editor.selection.hasFocus()) editor.selection.root.focus();

                const native = (editor.selection.getNativeRange() || {}).native;

                if (
                    native == null ||
                    force ||
                    startNode !== native.startContainer ||
                    startOffset !== native.startOffset ||
                    endNode !== native.endContainer ||
                    endOffset !== native.endOffset
                ) {
                    if ('tagName' in startNode && startNode.tagName == 'BR') {
                        startOffset = ([] as Node[]).indexOf.call(startNode?.parentNode?.childNodes, startNode);
                        startNode = startNode.parentNode;
                    }

                    if ('tagName' in endNode && endNode.tagName == 'BR') {
                        endOffset = ([] as Node[]).indexOf.call(endNode?.parentNode?.childNodes, endNode);
                        endNode = endNode.parentNode;
                    }

                    startNode &&
                        endNode &&
                        typeof startOffset === 'number' &&
                        typeof endOffset === 'number' &&
                        selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
                }
            } else {
                selection.removeAllRanges();
                editor.selection.root.blur();
                container.focus();
            }
        };
    };

    const mountInDocumentDom = async (quill: typeof Quill, container: HTMLElement) => {
        await import('quill/dist/quill.bubble.css');

        editor = new quill(container, {
            modules: {
                toolbar: mode === 'minimal' ? toolbarOptionsMinimal : toolbarOptionsFull
            },
            placeholder,
            theme
        });

        const linkElement = document.querySelector('input[data-link]') as HTMLInputElement;
        linkElement.setAttribute('data-link', linkPlaceholder);

        insertHtml(html);

        editor.on('text-change', handleChange);
    };

    const initializeEditor: Action<HTMLElement, string> = (editorElement) => {
        import('quill').then(({ default: quill }) => {
            const rootNode = editorElement.getRootNode() as Document | ShadowRoot;
            const isDocument = rootNode === document;

            isDocument
                ? mountInDocumentDom(quill, editorElement)
                : mountInShadowDom(quill, editorElement);

            /**
             * Subscribe to selection change separately, because emitter in Quill doesn't catch this event in Shadow DOM
             **/
            const handleSelectionChange = () => {
                const { activeElement } = rootNode;
                const { tooltip } = editor.theme;
                if (!tooltip) return;

                if (
                    tooltip.root !== activeElement &&
                    tooltip.textbox !== activeElement &&
                    !tooltip.root.contains(activeElement) &&
                    !editor.hasFocus()
                ) {
                    tooltip.hide();
                    document.removeEventListener('selectionchange', handleSelectionChange);
                    return;
                }

                !isDocument && editor.selection.update();
            };

            /**
             * The 'selectionchange' event is not emitted in Shadow DOM, therefore listen for the
             * 'selectstart' event first and then subscribe to the 'selectionchange' event on the document
             */
            editorElement.addEventListener('selectstart', () =>
                document.addEventListener('selectionchange', handleSelectionChange)
            );
        });

        return {
            update: (html: string) => !editor.hasFocus() && insertHtml(html),
            destroy: () => {
                if (editor) {
                    editor.off('text-change', handleChange);
                }
            }
        };
    };
</script>

<div use:initializeEditor={html} class={$$props.class || ''}></div>

I also have a file called QuillAdoptableStylesheet.ts which injects the styles into the shadow DOM vis adoptable style sheets:

import snowTheme from 'quill/dist/quill.snow.css?inline';
import bubbleTheme from 'quill/dist/quill.bubble.css?inline';

const supportsAdoptingStyleSheets =
    typeof ShadowRoot !== 'undefined' &&
    (typeof ShadyCSS === 'undefined' || ShadyCSS.nativeShadow) &&
    'adoptedStyleSheets' in Document.prototype &&
    'replace' in CSSStyleSheet.prototype;

function getShadowRoot(element: HTMLElement): ShadowRoot {
    return element.shadowRoot || element.attachShadow({ mode: 'open' });
}

const adoptable = () => {
    const snowThemeStyleSheet = new CSSStyleSheet();
    snowThemeStyleSheet.replaceSync(snowTheme);
    const bubbleThemeStyleSheet = new CSSStyleSheet();
    bubbleThemeStyleSheet.replaceSync(bubbleTheme);
    bubbleThemeStyleSheet.insertRule('.ql-bubble > .ql-editor { overflow-y: unset; }');
    bubbleThemeStyleSheet.insertRule('.ql-container > .ql-tooltip { z-index: 99999; }');
    bubbleThemeStyleSheet.insertRule(
        '.ql-container > .ql-editor.ql-blank::before { left: unset; right: unset; }'
    );
    bubbleThemeStyleSheet.insertRule(
        `.ql-inline.ql-container, .ql-inline.ql-container > .ql-editor {
            font: inherit !important;
            margin: 0 !important;
            padding: 0 !important;
        `
    );
    bubbleThemeStyleSheet.insertRule(
        `.ql-container.ql-bubble .ql-tooltip:not(.ql-flip) span.ql-tooltip-arrow {
            border-left-width: 7px;
        border-right-width: 7px;
            border-bottom-width: 7px;
            margin-left: -7px;
        }`
    );

    return (element: HTMLElement) => {
        const shadowRoot = getShadowRoot(element);
        shadowRoot.adoptedStyleSheets = [
            ...shadowRoot.adoptedStyleSheets,
            snowThemeStyleSheet,
            bubbleThemeStyleSheet
        ];
    };
};

const injectQuillStyles = supportsAdoptingStyleSheets ? adoptable() : () => {};

export const withQuill = <
    T extends {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        new (...args: any[]): HTMLElement & {
            connectedCallback?(): void;
            disconnectedCallback?(): void;
        };
    }
>(
    BaseElement: T
) => {
    return class WithQuillElement extends BaseElement {
        override connectedCallback() {
            super.connectedCallback?.();
            injectQuillStyles(this);
        }
    };
};

Now you can bind these styles via the withQuill() function:

import WebComponent from './WebComponent.svelte';

if (!customElements.get(sectionName)) {
    customElements.define('my-web-component', withQuill(WebComponent.element));
}
ADAMC133 commented 3 months ago

does anyone know what is causing this? I'm trying to do some debugging, but un-clear why this is happening, My current thinking is that the events are being clobbered by the shadowdom