Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quill misbehaves inside of Shadow DOM #4250

Open
tylerc opened this issue Jun 10, 2024 · 3 comments
Open

Quill misbehaves inside of Shadow DOM #4250

tylerc opened this issue Jun 10, 2024 · 3 comments

Comments

@tylerc
Copy link

tylerc commented Jun 10, 2024

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
Copy link

enoguchi-lmi commented Jul 23, 2024

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
Copy link

mamiu commented Jul 30, 2024

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
Copy link

ADAMC133 commented Aug 6, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants