diff --git a/packages/quill/src/core/editor.ts b/packages/quill/src/core/editor.ts index a19485840d..6fddf26a09 100644 --- a/packages/quill/src/core/editor.ts +++ b/packages/quill/src/core/editor.ts @@ -8,6 +8,7 @@ import CursorBlot from '../blots/cursor.js'; import type Scroll from '../blots/scroll.js'; import TextBlot, { escapeText } from '../blots/text.js'; import { Range } from './selection.js'; +import { isElement } from './utils/crossRealmIsElement.js'; const ASCII = /^[ -~]*$/; @@ -406,7 +407,7 @@ function convertHTML( } return `${start}>${parts.join('')}<${end}`; } - return blot.domNode instanceof Element ? blot.domNode.outerHTML : ''; + return isElement(blot.domNode) ? blot.domNode.outerHTML : ''; } function combineFormats( diff --git a/packages/quill/src/core/emitter.ts b/packages/quill/src/core/emitter.ts index 7e981ed47b..851dbbe580 100644 --- a/packages/quill/src/core/emitter.ts +++ b/packages/quill/src/core/emitter.ts @@ -5,17 +5,6 @@ import logger from './logger.js'; const debug = logger('quill:events'); const EVENTS = ['selectionchange', 'mousedown', 'mouseup', 'click']; -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); - } - }); - }); -}); - class Emitter extends EventEmitter { static events = { EDITOR_CHANGE: 'editor-change', @@ -39,6 +28,24 @@ class Emitter extends EventEmitter { USER: 'user', } as const; + private static registeredDocuments = new WeakMap(); + + static registerEventsOnDocument(doc: Document) { + if (Emitter.registeredDocuments.get(doc)) return; + Emitter.registeredDocuments.set(doc, true); + + EVENTS.forEach((eventName) => { + doc.addEventListener(eventName, (...args) => { + Array.from(doc.querySelectorAll('.ql-container')).forEach((node) => { + const quill = instances.get(node); + if (quill && quill.emitter) { + quill.emitter.handleDOM(...args); + } + }); + }); + }); + } + protected domListeners: Record; constructor() { @@ -62,6 +69,7 @@ class Emitter extends EventEmitter { } listenDOM(eventName: string, node: Node, handler: EventListener) { + Emitter.registerEventsOnDocument(node.ownerDocument ?? document); if (!this.domListeners[eventName]) { this.domListeners[eventName] = []; } diff --git a/packages/quill/src/core/quill.ts b/packages/quill/src/core/quill.ts index dae5267bcb..79c6aa5965 100644 --- a/packages/quill/src/core/quill.ts +++ b/packages/quill/src/core/quill.ts @@ -177,6 +177,7 @@ class Quill { container: HTMLElement; root: HTMLDivElement; + rootDocument: Document; scroll: Scroll; emitter: Emitter; protected allowReadOnlyEdits: boolean; @@ -195,6 +196,7 @@ class Quill { constructor(container: HTMLElement | string, options: QuillOptions = {}) { this.options = expandConfig(container, options); this.container = this.options.container; + this.rootDocument = this.container.ownerDocument; if (this.container == null) { debug.error('Invalid Quill container', container); return; diff --git a/packages/quill/src/core/selection.ts b/packages/quill/src/core/selection.ts index 33f7f2a576..e8f241c0f4 100644 --- a/packages/quill/src/core/selection.ts +++ b/packages/quill/src/core/selection.ts @@ -5,6 +5,7 @@ import type { EmitterSource } from './emitter.js'; import logger from './logger.js'; import type Cursor from '../blots/cursor.js'; import type Scroll from '../blots/scroll.js'; +import { isElement } from './utils/crossRealmIsElement.js'; const debug = logger('quill:selection'); @@ -42,6 +43,7 @@ class Selection { mouseDown: boolean; root: HTMLElement; + rootDocument: Document; cursor: Cursor; savedRange: Range; lastRange: Range | null; @@ -53,6 +55,7 @@ class Selection { this.composing = false; this.mouseDown = false; this.root = this.scroll.domNode; + this.rootDocument = this.root.ownerDocument; // @ts-expect-error this.cursor = this.scroll.create('cursor', this); // savedRange is last non-null range @@ -132,10 +135,10 @@ class Selection { } handleDragging() { - this.emitter.listenDOM('mousedown', document.body, () => { + this.emitter.listenDOM('mousedown', this.rootDocument.body, () => { this.mouseDown = true; }); - this.emitter.listenDOM('mouseup', document.body, () => { + this.emitter.listenDOM('mouseup', this.rootDocument.body, () => { this.mouseDown = false; this.update(Emitter.sources.USER); }); @@ -224,7 +227,7 @@ class Selection { } rect = range.getBoundingClientRect(); } else { - if (!(leaf.domNode instanceof Element)) return null; + if (!isElement(leaf.domNode)) return null; rect = leaf.domNode.getBoundingClientRect(); if (offset > 0) side = 'right'; } @@ -239,7 +242,7 @@ class Selection { } getNativeRange(): NormalizedRange | null { - 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; @@ -262,10 +265,10 @@ class Selection { } hasFocus(): boolean { + const doc = this.rootDocument; return ( - document.activeElement === this.root || - (document.activeElement != null && - contains(this.root, document.activeElement)) + doc.activeElement === this.root || + (doc.activeElement != null && contains(this.root, doc.activeElement)) ); } @@ -372,7 +375,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({ preventScroll: true }); @@ -385,14 +388,14 @@ class Selection { endNode !== native.endContainer || endOffset !== native.endOffset ) { - if (startNode instanceof Element && startNode.tagName === 'BR') { + if (isElement(startNode) && startNode.tagName === 'BR') { // @ts-expect-error Fix me later startOffset = Array.from(startNode.parentNode.childNodes).indexOf( startNode, ); startNode = startNode.parentNode; } - if (endNode instanceof Element && endNode.tagName === 'BR') { + if (isElement(endNode) && endNode.tagName === 'BR') { // @ts-expect-error Fix me later endOffset = Array.from(endNode.parentNode.childNodes).indexOf( endNode, diff --git a/packages/quill/src/core/utils/crossRealmIsElement.ts b/packages/quill/src/core/utils/crossRealmIsElement.ts new file mode 100644 index 0000000000..3e4c242b0e --- /dev/null +++ b/packages/quill/src/core/utils/crossRealmIsElement.ts @@ -0,0 +1,16 @@ +function isElement(value: any): value is Element { + return ( + value instanceof Element || + value instanceof (value?.ownerDocument?.defaultView?.Element ?? Element) + ); +} + +function isHTMLElement(value: any): value is HTMLElement { + return ( + value instanceof HTMLElement || + value instanceof + (value?.ownerDocument?.defaultView?.HTMLElement ?? HTMLElement) + ); +} + +export { isElement, isHTMLElement }; diff --git a/packages/quill/src/core/utils/scrollRectIntoView.ts b/packages/quill/src/core/utils/scrollRectIntoView.ts index 42c80a24ac..f76133d245 100644 --- a/packages/quill/src/core/utils/scrollRectIntoView.ts +++ b/packages/quill/src/core/utils/scrollRectIntoView.ts @@ -59,6 +59,7 @@ const getScrollDistance = ( const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => { const document = root.ownerDocument; + const win = document.defaultView ?? window; let rect = targetRect; @@ -69,11 +70,9 @@ const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => { ? { top: 0, right: - window.visualViewport?.width ?? - document.documentElement.clientWidth, + win.visualViewport?.width ?? document.documentElement.clientWidth, bottom: - window.visualViewport?.height ?? - document.documentElement.clientHeight, + win.visualViewport?.height ?? document.documentElement.clientHeight, left: 0, } : getElementRect(current); diff --git a/packages/quill/src/modules/clipboard.ts b/packages/quill/src/modules/clipboard.ts index e4c3f755b5..1bc7451539 100644 --- a/packages/quill/src/modules/clipboard.ts +++ b/packages/quill/src/modules/clipboard.ts @@ -23,6 +23,7 @@ import { FontStyle } from '../formats/font.js'; import { SizeStyle } from '../formats/size.js'; import { deleteRange } from './keyboard.js'; import normalizeExternalHTML from './normalizeExternalHTML/index.js'; +import { isElement, isHTMLElement } from '../core/utils/crossRealmIsElement.js'; const debug = logger('quill:clipboard'); @@ -312,7 +313,7 @@ function deltaEndsWith(delta: Delta, text: string) { } function isLine(node: Node, scroll: ScrollBlot) { - if (!(node instanceof Element)) return false; + if (!isElement(node)) return false; const match = scroll.query(node); // @ts-expect-error if (match && match.prototype instanceof EmbedBlot) return false; @@ -554,7 +555,8 @@ function matchNewline(node: Node, delta: Delta, scroll: ScrollBlot) { if (!deltaEndsWith(delta, '\n')) { if ( isLine(node, scroll) && - (node.childNodes.length > 0 || node instanceof HTMLParagraphElement) + (node.childNodes.length > 0 || + (isHTMLElement(node) && node.tagName === 'P')) ) { return delta.insert('\n'); } @@ -649,8 +651,7 @@ function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot) { (node.previousSibling == null && node.parentElement != null && isLine(node.parentElement, scroll)) || - (node.previousSibling instanceof Element && - isLine(node.previousSibling, scroll)) + (isElement(node.previousSibling) && isLine(node.previousSibling, scroll)) ) { text = text.replace(/^\s+/, replacer.bind(replacer, false)); } @@ -658,7 +659,7 @@ function matchText(node: HTMLElement, delta: Delta, scroll: ScrollBlot) { (node.nextSibling == null && node.parentElement != null && isLine(node.parentElement, scroll)) || - (node.nextSibling instanceof Element && isLine(node.nextSibling, scroll)) + (isElement(node.nextSibling) && isLine(node.nextSibling, scroll)) ) { text = text.replace(/\s+$/, replacer.bind(replacer, false)); } diff --git a/packages/quill/src/modules/syntax.ts b/packages/quill/src/modules/syntax.ts index da99fdc41d..a3df45c509 100644 --- a/packages/quill/src/modules/syntax.ts +++ b/packages/quill/src/modules/syntax.ts @@ -235,10 +235,11 @@ class Syntax extends Module { initListener() { this.quill.on(Quill.events.SCROLL_BLOT_MOUNT, (blot: Blot) => { if (!(blot instanceof SyntaxCodeBlockContainer)) return; - const select = this.quill.root.ownerDocument.createElement('select'); + const doc = this.quill.rootDocument; + const select = doc.createElement('select'); // @ts-expect-error Fix me later this.options.languages.forEach(({ key, label }) => { - const option = select.ownerDocument.createElement('option'); + const option = doc.createElement('option'); option.textContent = label; option.setAttribute('value', key); select.appendChild(option); @@ -299,7 +300,7 @@ class Syntax extends Module { return delta.insert(line); }, new Delta()); } - const container = this.quill.root.ownerDocument.createElement('div'); + const container = this.quill.rootDocument.createElement('div'); container.classList.add(CodeBlock.className); container.innerHTML = highlight(this.options.hljs, language, text); return traverse( diff --git a/packages/quill/src/modules/toolbar.ts b/packages/quill/src/modules/toolbar.ts index a178d25936..44d9007cc7 100644 --- a/packages/quill/src/modules/toolbar.ts +++ b/packages/quill/src/modules/toolbar.ts @@ -4,6 +4,7 @@ import Quill from '../core/quill.js'; import logger from '../core/logger.js'; import Module from '../core/module.js'; import type { Range } from '../core/selection.js'; +import { isHTMLElement } from '../core/utils/crossRealmIsElement.js'; const debug = logger('quill:toolbar'); @@ -36,11 +37,11 @@ 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); + this.container = quill.rootDocument.querySelector(this.options.container); } else { this.container = this.options.container; } - if (!(this.container instanceof HTMLElement)) { + if (!isHTMLElement(this.container)) { debug.error('Container required for toolbar', this.options); return; } diff --git a/packages/quill/src/modules/uiNode.ts b/packages/quill/src/modules/uiNode.ts index 780713a3ba..32bf299ceb 100644 --- a/packages/quill/src/modules/uiNode.ts +++ b/packages/quill/src/modules/uiNode.ts @@ -94,13 +94,13 @@ class UINode extends Module { } }; - document.addEventListener('selectionchange', listener, { + this.quill.rootDocument.addEventListener('selectionchange', listener, { once: true, }); } private handleSelectionChange() { - const selection = document.getSelection(); + const selection = this.quill.rootDocument.getSelection(); if (!selection) return; const range = selection.getRangeAt(0); if (range.collapsed !== true || range.startOffset !== 0) return; diff --git a/packages/quill/src/modules/uploader.ts b/packages/quill/src/modules/uploader.ts index c53058c51b..65de23df36 100644 --- a/packages/quill/src/modules/uploader.ts +++ b/packages/quill/src/modules/uploader.ts @@ -17,12 +17,13 @@ class Uploader extends Module { quill.root.addEventListener('drop', (e) => { e.preventDefault(); let native: ReturnType | null = null; - if (document.caretRangeFromPoint) { - native = document.caretRangeFromPoint(e.clientX, e.clientY); + const doc = quill.rootDocument; + if (doc.caretRangeFromPoint) { + native = doc.caretRangeFromPoint(e.clientX, e.clientY); // @ts-expect-error - } else if (document.caretPositionFromPoint) { + } else if (doc.caretPositionFromPoint) { // @ts-expect-error - const position = document.caretPositionFromPoint(e.clientX, e.clientY); + const position = doc.caretPositionFromPoint(e.clientX, e.clientY); native = document.createRange(); native.setStart(position.offsetNode, position.offset); native.setEnd(position.offsetNode, position.offset); diff --git a/packages/quill/src/themes/base.ts b/packages/quill/src/themes/base.ts index 3caf771ad1..82fba666a3 100644 --- a/packages/quill/src/themes/base.ts +++ b/packages/quill/src/themes/base.ts @@ -66,9 +66,10 @@ class BaseTheme extends Theme { constructor(quill: Quill, options: ThemeOptions) { super(quill, options); + const doc = quill.rootDocument ?? document; const listener = (e: MouseEvent) => { - if (!document.body.contains(quill.root)) { - document.body.removeEventListener('click', listener); + if (!doc.body.contains(quill.root)) { + doc.body.removeEventListener('click', listener); return; } if ( @@ -76,7 +77,7 @@ class BaseTheme extends Theme { // @ts-expect-error !this.tooltip.root.contains(e.target) && // @ts-expect-error - document.activeElement !== this.tooltip.textbox && + doc.activeElement !== this.tooltip.textbox && !this.quill.hasFocus() ) { this.tooltip.hide(); @@ -90,7 +91,7 @@ class BaseTheme extends Theme { }); } }; - quill.emitter.listenDOM('click', document.body, listener); + quill.emitter.listenDOM('click', doc.body, listener); } addModule(name: 'clipboard'): Clipboard; diff --git a/packages/quill/src/themes/bubble.ts b/packages/quill/src/themes/bubble.ts index 5bb8519141..0dc43c0dad 100644 --- a/packages/quill/src/themes/bubble.ts +++ b/packages/quill/src/themes/bubble.ts @@ -28,6 +28,7 @@ class BubbleTooltip extends BaseTooltip { this.quill.on( Emitter.events.EDITOR_CHANGE, (type, range, oldRange, source) => { + const doc = quill.rootDocument; if (type !== Emitter.events.SELECTION_CHANGE) return; if ( range != null && @@ -58,7 +59,7 @@ class BubbleTooltip extends BaseTooltip { } } } else if ( - document.activeElement !== this.textbox && + doc.activeElement !== this.textbox && this.quill.hasFocus() ) { this.hide(); diff --git a/packages/quill/src/ui/tooltip.ts b/packages/quill/src/ui/tooltip.ts index 07bf0013f8..6d59d9c50d 100644 --- a/packages/quill/src/ui/tooltip.ts +++ b/packages/quill/src/ui/tooltip.ts @@ -13,7 +13,7 @@ class Tooltip { constructor(quill: Quill, boundsContainer?: HTMLElement) { this.quill = quill; - this.boundsContainer = boundsContainer || document.body; + this.boundsContainer = boundsContainer || quill.rootDocument.body; this.root = quill.addContainer('ql-tooltip'); // @ts-expect-error this.root.innerHTML = this.constructor.TEMPLATE; diff --git a/packages/quill/test/e2e/__dev_server__/iframe.html b/packages/quill/test/e2e/__dev_server__/iframe.html new file mode 100644 index 0000000000..1b44556495 --- /dev/null +++ b/packages/quill/test/e2e/__dev_server__/iframe.html @@ -0,0 +1,95 @@ + + + + + + + Quill E2E Tests - Iframe + + + + + + + + + + diff --git a/packages/quill/test/e2e/__dev_server__/webpack.config.cjs b/packages/quill/test/e2e/__dev_server__/webpack.config.cjs index bc1b18fab4..bca22f2597 100644 --- a/packages/quill/test/e2e/__dev_server__/webpack.config.cjs +++ b/packages/quill/test/e2e/__dev_server__/webpack.config.cjs @@ -6,8 +6,9 @@ const common = require('../../../webpack.common.cjs'); const { merge } = require('webpack-merge'); require('webpack-dev-server'); -module.exports = (env) => - merge(common, { +module.exports = (env) => { + console.log(env); + return merge(common, { plugins: [ new HtmlWebpackPlugin({ publicPath: '/', @@ -17,6 +18,14 @@ module.exports = (env) => inject: 'head', scriptLoading: 'blocking', }), + new HtmlWebpackPlugin({ + publicPath: '/', + filename: 'iframe.html', + template: path.resolve(__dirname, 'iframe.html'), + chunks: ['quill'], + inject: 'head', + scriptLoading: 'blocking', + }), ], devServer: { port: env.port, @@ -30,3 +39,4 @@ module.exports = (env) => webSocketServer: false, }, }); +}; diff --git a/packages/quill/test/e2e/fixtures/index.ts b/packages/quill/test/e2e/fixtures/index.ts index c4c006757f..ecb73e8920 100644 --- a/packages/quill/test/e2e/fixtures/index.ts +++ b/packages/quill/test/e2e/fixtures/index.ts @@ -4,32 +4,36 @@ import Composition from './Composition.js'; import Locker from './utils/Locker.js'; import Clipboard from './Clipboard.js'; -export const test = base.extend<{ - editorPage: EditorPage; - clipboard: Clipboard; - composition: Composition; -}>({ - editorPage: ({ page }, use) => { - use(new EditorPage(page)); - }, - composition: ({ page, browserName }, use) => { - test.fail( - browserName !== 'chromium', - 'CDPSession is only available in Chromium', - ); +export const testWithMode = function (mode: 'regular' | 'iframe') { + return base.extend<{ + editorPage: EditorPage; + clipboard: Clipboard; + composition: Composition; + }>({ + editorPage: ({ page }, use) => { + use(new EditorPage(page, mode)); + }, + composition: ({ page, browserName }, use) => { + test.fail( + browserName !== 'chromium', + 'CDPSession is only available in Chromium', + ); - use(new Composition(page, browserName)); - }, - clipboard: [ - async ({ page }, use) => { - const locker = new Locker('clipboard'); - await locker.lock(); - await use(new Clipboard(page)); - await locker.release(); + use(new Composition(page, browserName)); }, - { timeout: 30000 }, - ], -}); + clipboard: [ + async ({ page }, use) => { + const locker = new Locker('clipboard'); + await locker.lock(); + await use(new Clipboard(page)); + await locker.release(); + }, + { timeout: 30000 }, + ], + }); +}; + +export const test = testWithMode('regular'); export const CHAPTER = 'Chapter 1. Loomings.'; export const P1 = diff --git a/packages/quill/test/e2e/full.spec.ts b/packages/quill/test/e2e/full.spec.ts index 01e2b75a1b..1d9899772c 100644 --- a/packages/quill/test/e2e/full.spec.ts +++ b/packages/quill/test/e2e/full.spec.ts @@ -1,212 +1,220 @@ import { expect } from '@playwright/test'; import { getSelectionInTextNode, SHORTKEY } from './utils/index.js'; -import { test, CHAPTER, P1, P2 } from './fixtures/index.js'; +import { CHAPTER, P1, P2, testWithMode } from './fixtures/index.js'; -test('compose an epic', async ({ page, editorPage }) => { - await editorPage.open(); - await editorPage.root.pressSequentially('The Whale'); - expect(await editorPage.root.innerHTML()).toEqual('

The Whale

'); +function epicTest(mode: 'regular' | 'iframe' = 'regular') { + testWithMode(mode)( + `compose an epic (${mode})`, + async ({ page, editorPage }) => { + await editorPage.open(); + await editorPage.root.pressSequentially('The Whale'); + expect(await editorPage.root.innerHTML()).toEqual('

The Whale

'); - await page.keyboard.press('Enter'); - expect(await editorPage.root.innerHTML()).toEqual( - '

The Whale


', - ); + await page.keyboard.press('Enter'); + expect(await editorPage.root.innerHTML()).toEqual( + '

The Whale


', + ); - await page.keyboard.press('Enter'); - await page.keyboard.press('Tab'); - await editorPage.root.pressSequentially(P1); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await editorPage.root.pressSequentially(P2); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

The Whale

', - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); + await page.keyboard.press('Enter'); + await page.keyboard.press('Tab'); + await page.keyboard.type(P1); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await editorPage.root.pressSequentially(P2); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

The Whale

', + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); - // More than enough to get to top - await Promise.all( - Array(40) - .fill(0) - .map(() => page.keyboard.press('ArrowUp')), - ); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - await page.type('.ql-editor', CHAPTER); - await page.keyboard.press('Enter'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

The Whale

', - '


', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); + // More than enough to get to top + await Promise.all( + Array(40) + .fill(0) + .map(() => page.keyboard.press('ArrowUp')), + ); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await editorPage.root.pressSequentially(CHAPTER); + await page.keyboard.press('Enter'); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

The Whale

', + '


', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); - // More than enough to get to top - await Promise.all( - Array(20) - .fill(0) - .map(() => page.keyboard.press('ArrowUp')), - ); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('ArrowRight'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - await page.keyboard.press('Backspace'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

Whale

', - '


', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); + // More than enough to get to top + await Promise.all( + Array(20) + .fill(0) + .map(() => page.keyboard.press('ArrowUp')), + ); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

Whale

', + '


', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); - await page.keyboard.press('Delete'); - await page.keyboard.press('Delete'); - await page.keyboard.press('Delete'); - await page.keyboard.press('Delete'); - await page.keyboard.press('Delete'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '


', - '


', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); + await page.keyboard.press('Delete'); + await page.keyboard.press('Delete'); + await page.keyboard.press('Delete'); + await page.keyboard.press('Delete'); + await page.keyboard.press('Delete'); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '


', + '


', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); - await page.keyboard.press('Delete'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '


', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); + await page.keyboard.press('Delete'); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '


', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); - await page.click('.ql-toolbar .ql-bold'); - await page.click('.ql-toolbar .ql-italic'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

\uFEFF

', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); - let bold = await page.$('.ql-toolbar .ql-bold.ql-active'); - let italic = await page.$('.ql-toolbar .ql-italic.ql-active'); - expect(bold).not.toBe(null); - expect(italic).not.toBe(null); + await editorPage.toolbar.locator('.ql-bold').click(); + await editorPage.toolbar.locator('.ql-italic').click(); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

\uFEFF

', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); + let bold = editorPage.toolbar.locator('.ql-bold'); + let italic = editorPage.toolbar.locator('.ql-italic'); + expect(bold).toHaveClass(/ql-active/); + expect(italic).toHaveClass(/ql-active/); - await editorPage.root.pressSequentially('Moby Dick'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

Moby Dick

', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); - bold = await page.$('.ql-toolbar .ql-bold.ql-active'); - italic = await page.$('.ql-toolbar .ql-italic.ql-active'); - expect(bold).not.toBe(null); - expect(italic).not.toBe(null); + await editorPage.root.pressSequentially('Moby Dick'); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

Moby Dick

', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); + bold = editorPage.toolbar.locator('.ql-bold'); + italic = editorPage.toolbar.locator('.ql-italic'); + expect(bold).toHaveClass(/ql-active/); + expect(italic).toHaveClass(/ql-active/); - await page.keyboard.press('ArrowRight'); - await page.keyboard.down('Shift'); - await Promise.all( - Array(CHAPTER.length) - .fill(0) - .map(() => page.keyboard.press('ArrowRight')), - ); - await page.keyboard.up('Shift'); - bold = await page.$('.ql-toolbar .ql-bold.ql-active'); - italic = await page.$('.ql-toolbar .ql-italic.ql-active'); - expect(bold).toBe(null); - expect(italic).toBe(null); + await page.keyboard.press('ArrowRight'); + await page.keyboard.down('Shift'); + await Promise.all( + Array(CHAPTER.length) + .fill(0) + .map(() => page.keyboard.press('ArrowRight')), + ); + await page.keyboard.up('Shift'); + bold = editorPage.toolbar.locator('.ql-bold'); + italic = editorPage.toolbar.locator('.ql-italic'); + expect(bold).not.toHaveClass(/ql-active/); + expect(italic).not.toHaveClass(/ql-active/); - await page.keyboard.down(SHORTKEY); - await page.keyboard.press('b'); - await page.keyboard.up(SHORTKEY); - bold = await page.$('.ql-toolbar .ql-bold.ql-active'); - expect(bold).not.toBe(null); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

Moby Dick

', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); + await page.keyboard.down(SHORTKEY); + await page.keyboard.press('b'); + await page.keyboard.up(SHORTKEY); + bold = editorPage.toolbar.locator('.ql-bold'); + expect(bold).toHaveClass(/ql-active/); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

Moby Dick

', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.press('ArrowUp'); - await page.click('.ql-toolbar .ql-header[value="1"]'); - expect(await editorPage.root.innerHTML()).toEqual( - [ - '

Moby Dick

', - `

${CHAPTER}

`, - '


', - `

\t${P1}

`, - '


', - `

${P2}

`, - ].join(''), - ); - const header = await page.$('.ql-toolbar .ql-header.ql-active[value="1"]'); - expect(header).not.toBe(null); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowUp'); + await editorPage.toolbar.locator('.ql-header[value="1"]').click(); + expect(await editorPage.root.innerHTML()).toEqual( + [ + '

Moby Dick

', + `

${CHAPTER}

`, + '


', + `

\t${P1}

`, + '


', + `

${P2}

`, + ].join(''), + ); + const header = editorPage.toolbar.locator('.ql-header[value="1"]'); + expect(header).toHaveClass(/ql-active/); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.keyboard.press('ArrowUp'); - await editorPage.root.pressSequentially('AA'); - await page.keyboard.press('ArrowLeft'); - await page.keyboard.down(SHORTKEY); - await page.keyboard.press('b'); - await page.keyboard.press('b'); - await page.keyboard.up(SHORTKEY); - await editorPage.root.pressSequentially('B'); - expect(await editorPage.root.locator('p').nth(2).innerHTML()).toBe('ABA'); - await page.keyboard.down(SHORTKEY); - await page.keyboard.press('b'); - await page.keyboard.up(SHORTKEY); - await editorPage.root.pressSequentially('C'); - await page.keyboard.down(SHORTKEY); - await page.keyboard.press('b'); - await page.keyboard.up(SHORTKEY); - await editorPage.root.pressSequentially('D'); - expect(await editorPage.root.locator('p').nth(2).innerHTML()).toBe( - 'ABCDA', + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowUp'); + await editorPage.root.pressSequentially('AA'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.down(SHORTKEY); + await page.keyboard.press('b'); + await page.keyboard.press('b'); + await page.keyboard.up(SHORTKEY); + await editorPage.root.pressSequentially('B'); + expect(await editorPage.root.locator('p').nth(2).innerHTML()).toBe('ABA'); + await page.keyboard.down(SHORTKEY); + await page.keyboard.press('b'); + await page.keyboard.up(SHORTKEY); + await editorPage.root.pressSequentially('C'); + await page.keyboard.down(SHORTKEY); + await page.keyboard.press('b'); + await page.keyboard.up(SHORTKEY); + await editorPage.root.pressSequentially('D'); + expect(await editorPage.root.locator('p').nth(2).innerHTML()).toBe( + 'ABCDA', + ); + const selection = await page.evaluate(getSelectionInTextNode); + expect(selection).toBe('["DA",1,"DA",1]'); + }, ); - const selection = await page.evaluate(getSelectionInTextNode); - expect(selection).toBe('["DA",1,"DA",1]'); -}); +} + +epicTest(); +epicTest('iframe'); diff --git a/packages/quill/test/e2e/pageobjects/EditorPage.ts b/packages/quill/test/e2e/pageobjects/EditorPage.ts index 0e770e0891..292cfbae2d 100644 --- a/packages/quill/test/e2e/pageobjects/EditorPage.ts +++ b/packages/quill/test/e2e/pageobjects/EditorPage.ts @@ -55,15 +55,33 @@ const updateSelectionDef = [ ]; export default class EditorPage { - constructor(protected readonly page: Page) {} + constructor( + protected readonly page: Page, + protected readonly mode: 'regular' | 'iframe' = 'regular', + ) {} get root() { + if (this.mode === 'iframe') { + return this.page.frameLocator('iframe').locator('.ql-editor'); + } return this.page.locator('.ql-editor'); } + get toolbar() { + if (this.mode === 'iframe') { + return this.page.frameLocator('iframe').locator('#toolbar-container'); + } + return this.page.locator('#toolbar-container'); + } + async open() { - await this.page.goto('/'); - await this.page.waitForSelector('.ql-editor', { timeout: 10000 }); + await this.page.goto(this.mode === 'iframe' ? '/iframe.html' : '/'); + await this.page.waitForSelector( + this.mode === 'iframe' ? 'iframe' : '.ql-editor', + { + timeout: 10000, + }, + ); } async html(content: string, title = '') { diff --git a/packages/quill/test/e2e/utils/index.ts b/packages/quill/test/e2e/utils/index.ts index dc2eb0f3b0..4607938651 100644 --- a/packages/quill/test/e2e/utils/index.ts +++ b/packages/quill/test/e2e/utils/index.ts @@ -2,7 +2,11 @@ export const isMac = process.platform === 'darwin'; export const SHORTKEY = isMac ? 'Meta' : 'Control'; export function getSelectionInTextNode() { - const selection = document.getSelection(); + const doc = + (document.activeElement?.tagName === 'IFRAME' + ? (document.activeElement as HTMLIFrameElement).contentDocument + : document) ?? document; + const selection = doc.getSelection(); if (!selection) { throw new Error('Selection is null'); } diff --git a/packages/quill/test/unit/__helpers__/expect.ts b/packages/quill/test/unit/__helpers__/expect.ts index 6df26a2bc6..48d6d87e6c 100644 --- a/packages/quill/test/unit/__helpers__/expect.ts +++ b/packages/quill/test/unit/__helpers__/expect.ts @@ -1,5 +1,6 @@ import { expect } from 'vitest'; import { normalizeHTML } from './utils.js'; +import { isHTMLElement } from '../../../src/core/utils/crossRealmIsElement.js'; const sortAttributes = (element: HTMLElement) => { const attributes = Array.from(element.attributes); @@ -16,7 +17,7 @@ const sortAttributes = (element: HTMLElement) => { } element.childNodes.forEach((child) => { - if (child instanceof HTMLElement) { + if (isHTMLElement(child)) { sortAttributes(child); } }); diff --git a/packages/quill/test/unit/core/quill.spec.ts b/packages/quill/test/unit/core/quill.spec.ts index c128c0dc12..9fc03c3296 100644 --- a/packages/quill/test/unit/core/quill.spec.ts +++ b/packages/quill/test/unit/core/quill.spec.ts @@ -1377,4 +1377,25 @@ describe('Quill', () => { ).toEqual(0); }); }); + + describe('iframe', () => { + const createDocument = (): Document => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + return iframe.contentDocument!; + }; + const createContainer = (html: string | { html: string } = '') => { + const doc = createDocument(); + const container = doc!.createElement('div'); + container.innerHTML = normalizeHTML(html); + doc!.body.appendChild(container); + return container; + }; + + test('initialize empty', () => { + const quill = new Quill(createContainer('0123')); + expect(quill.getContents()).toEqual(new Delta().insert('0123\n')); + expect(quill.root.innerHTML).toMatchInlineSnapshot('"

0123

"'); + }); + }); });