From 3ad2f6095a1d961453d2e6cbd465195fc92e8ddf Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Fri, 14 Jul 2023 10:48:43 +0800 Subject: [PATCH 1/3] Auto detect scrolling container --- core/quill.ts | 38 +++-- core/selection.ts | 17 +-- core/utils/scrollRectIntoView.ts | 127 ++++++++++++++++ modules/clipboard.ts | 2 +- modules/keyboard.ts | 4 +- package-lock.json | 11 ++ package.json | 1 + test/unit/core/quill.spec.ts | 193 ++++++++++++++++++++++++ themes/base.ts | 4 +- ui/tooltip.ts | 7 +- website/content/docs/configuration.mdx | 6 - website/content/standalone/autogrow.mdx | 1 - 12 files changed, 370 insertions(+), 41 deletions(-) create mode 100644 core/utils/scrollRectIntoView.ts diff --git a/core/quill.ts b/core/quill.ts index b4a7388148..0eb4d7ac66 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -16,6 +16,7 @@ import Module from './module'; import Selection, { Range } from './selection'; import Composition from './composition'; import Theme, { ThemeConstructor } from './theme'; +import scrollRectIntoView from './utils/scrollRectIntoView'; const debug = logger('quill'); @@ -30,7 +31,6 @@ interface Options { container?: HTMLElement | string; placeholder?: string; bounds?: HTMLElement | string | null; - scrollingContainer?: HTMLElement | string | null; modules?: Record; } @@ -40,7 +40,6 @@ interface ExpandedOptions extends Omit { container: HTMLElement; modules: Record; bounds?: HTMLElement | null; - scrollingContainer?: HTMLElement | null; } class Quill { @@ -50,7 +49,6 @@ class Quill { placeholder: '', readOnly: false, registry: globalRegistry, - scrollingContainer: null, theme: 'default', }; static events = Emitter.events; @@ -129,7 +127,6 @@ class Quill { } } - scrollingContainer: HTMLElement; container: HTMLElement; root: HTMLDivElement; scroll: Scroll; @@ -163,7 +160,6 @@ class Quill { instances.set(this.container, this); this.root = this.addContainer('ql-editor'); this.root.classList.add('ql-blank'); - this.scrollingContainer = this.options.scrollingContainer || this.root; this.emitter = new Emitter(); // @ts-expect-error TODO: fix BlotConstructor const ScrollBlot = this.options.registry.query( @@ -287,11 +283,11 @@ class Quill { this.container.classList.toggle('ql-disabled', !enabled); } - focus() { - const { scrollTop } = this.scrollingContainer; + focus(options: { preventScroll?: boolean } = {}) { this.selection.focus(); - this.scrollingContainer.scrollTop = scrollTop; - this.scrollIntoView(); + if (!options.preventScroll) { + this.scrollSelectionIntoView(); + } } format( @@ -618,8 +614,26 @@ class Quill { ); } + /** + * @deprecated Use Quill#scrollSelectionIntoView() instead. + */ scrollIntoView() { - this.selection.scrollIntoView(this.scrollingContainer); + console.warn( + 'Quill#scrollIntoView() has been deprecated and will be removed in the near future. Please use Quill#scrollSelectionIntoView() instead.', + ); + this.scrollSelectionIntoView(); + } + + /** + * Scroll the current selection into the visible area. + * If the selection is already visible, no scrolling will occur. + */ + scrollSelectionIntoView() { + const range = this.selection.lastRange; + const bounds = range && this.selection.getBounds(range.index, range.length); + if (bounds) { + scrollRectIntoView(this.root, bounds); + } } setContents( @@ -658,7 +672,7 @@ class Quill { [index, length, , source] = overload(index, length, source); this.selection.setRange(new Range(Math.max(0, index), length), source); if (source !== Emitter.sources.SILENT) { - this.scrollIntoView(); + this.scrollSelectionIntoView(); } } } @@ -758,7 +772,7 @@ function expandConfig( themeConfig, expandedConfig, ); - ['bounds', 'container', 'scrollingContainer'].forEach(key => { + ['bounds', 'container'].forEach(key => { if (typeof expandedConfig[key] === 'string') { expandedConfig[key] = document.querySelector(expandedConfig[key]); } diff --git a/core/selection.ts b/core/selection.ts index 430418b32e..f07c4cb139 100644 --- a/core/selection.ts +++ b/core/selection.ts @@ -127,7 +127,7 @@ class Selection { focus() { if (this.hasFocus()) return; - this.root.focus(); + this.root.focus({ preventScroll: true }); this.setRange(this.savedRange); } @@ -328,19 +328,6 @@ class Selection { ]; } - scrollIntoView(scrollingContainer: Element) { - const range = this.lastRange; - if (range == null) return; - const bounds = this.getBounds(range.index, range.length); - if (bounds == null) return; - const scrollBounds = scrollingContainer.getBoundingClientRect(); - if (bounds.top < scrollBounds.top) { - scrollingContainer.scrollTop -= scrollBounds.top - bounds.top; - } else if (bounds.bottom > scrollBounds.bottom) { - scrollingContainer.scrollTop += bounds.bottom - scrollBounds.bottom; - } - } - setNativeRange( startNode: Node | null, startOffset?: number, @@ -361,7 +348,7 @@ class Selection { const selection = document.getSelection(); if (selection == null) return; if (startNode != null) { - if (!this.hasFocus()) this.root.focus(); + if (!this.hasFocus()) this.root.focus({ preventScroll: true }); const { native } = this.getNativeRange() || {}; if ( native == null || diff --git a/core/utils/scrollRectIntoView.ts b/core/utils/scrollRectIntoView.ts new file mode 100644 index 0000000000..b4852f0468 --- /dev/null +++ b/core/utils/scrollRectIntoView.ts @@ -0,0 +1,127 @@ +type Rect = { + top: number; + right: number; + bottom: number; + left: number; +}; + +const getParentElement = (element: Node): Element | null => + element.parentElement || (element.getRootNode() as ShadowRoot).host || null; + +const getElementRect = (element: Element): Rect => { + const rect = element.getBoundingClientRect(); + const scaleX = + ('offsetWidth' in element && + rect.width / (element as HTMLElement).offsetWidth) || + 1; + const scaleY = + ('offsetHeight' in element && + rect.height / (element as HTMLElement).offsetHeight) || + 1; + return { + top: rect.top, + right: rect.left + element.clientWidth * scaleX, + bottom: rect.top + element.clientHeight * scaleY, + left: rect.left, + }; +}; + +const paddingValueToInt = (value: string) => { + const number = parseInt(value, 10); + return Number.isNaN(number) ? 0 : number; +}; + +// Follow the steps described in https://www.w3.org/TR/cssom-view-1/#element-scrolling-members, +// assuming that the scroll option is set to 'nearest'. +const getScrollDistance = ( + targetStart: number, + targetEnd: number, + scrollStart: number, + scrollEnd: number, + scrollPaddingStart: number, + scrollPaddingEnd: number, +) => { + if (targetStart < scrollStart && targetEnd > scrollEnd) { + return 0; + } + + if (targetStart < scrollStart) { + return -(scrollStart - targetStart + scrollPaddingStart); + } + + if (targetEnd > scrollEnd) { + return targetEnd - targetStart > scrollEnd - scrollStart + ? targetStart + scrollPaddingStart - scrollStart + : targetEnd - scrollEnd + scrollPaddingEnd; + } + return 0; +}; + +const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => { + const document = root.ownerDocument; + + let rect = targetRect; + + let current: Element | null = root; + while (current) { + const isDocumentBody = current === document.body; + const bounding = isDocumentBody + ? { + top: 0, + right: + window.visualViewport?.width ?? + document.documentElement.clientWidth, + bottom: + window.visualViewport?.height ?? + document.documentElement.clientHeight, + left: 0, + } + : getElementRect(current); + + const style = getComputedStyle(current); + const scrollDistanceX = getScrollDistance( + rect.left, + rect.right, + bounding.left, + bounding.right, + paddingValueToInt(style.scrollPaddingLeft), + paddingValueToInt(style.scrollPaddingRight), + ); + const scrollDistanceY = getScrollDistance( + rect.top, + rect.bottom, + bounding.top, + bounding.bottom, + paddingValueToInt(style.scrollPaddingTop), + paddingValueToInt(style.scrollPaddingBottom), + ); + if (scrollDistanceX || scrollDistanceY) { + if (isDocumentBody) { + document.defaultView?.scrollBy(scrollDistanceX, scrollDistanceY); + } else { + const { scrollLeft, scrollTop } = current; + if (scrollDistanceY) { + current.scrollTop += scrollDistanceY; + } + if (scrollDistanceX) { + current.scrollLeft += scrollDistanceX; + } + const scrolledLeft = current.scrollLeft - scrollLeft; + const scrolledTop = current.scrollTop - scrollTop; + rect = { + left: rect.left - scrolledLeft, + top: rect.top - scrolledTop, + right: rect.right - scrolledLeft, + bottom: rect.bottom - scrolledTop, + }; + } + } + + current = + isDocumentBody || style.position === 'fixed' + ? null + : getParentElement(current); + } +}; + +export default scrollRectIntoView; diff --git a/modules/clipboard.ts b/modules/clipboard.ts index f41f5e7097..e7668b8ec3 100644 --- a/modules/clipboard.ts +++ b/modules/clipboard.ts @@ -218,7 +218,7 @@ class Clipboard extends Module { delta.length() - range.length, Quill.sources.SILENT, ); - this.quill.scrollIntoView(); + this.quill.scrollSelectionIntoView(); } prepareMatching(container: Element, nodeMatches: WeakMap) { diff --git a/modules/keyboard.ts b/modules/keyboard.ts index 818bb62942..8e783a784b 100644 --- a/modules/keyboard.ts +++ b/modules/keyboard.ts @@ -460,7 +460,7 @@ const defaultOptions: KeyboardOptions = { .retain(1, { list: 'unchecked' }); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); - this.quill.scrollIntoView(); + this.quill.scrollSelectionIntoView(); }, }, 'header enter': { @@ -478,7 +478,7 @@ const defaultOptions: KeyboardOptions = { .retain(1, { header: null }); this.quill.updateContents(delta, Quill.sources.USER); this.quill.setSelection(range.index + 1, Quill.sources.SILENT); - this.quill.scrollIntoView(); + this.quill.scrollSelectionIntoView(); }, }, 'table backspace': { diff --git a/package-lock.json b/package-lock.json index 38e7326d69..927064fba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "website" ], "dependencies": { + "compute-scroll-into-view": "3.0.3", "eventemitter3": "^4.0.7", "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0", @@ -8030,6 +8031,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/compute-scroll-into-view": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.0.3.tgz", + "integrity": "sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -30975,6 +30981,11 @@ } } }, + "compute-scroll-into-view": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.0.3.tgz", + "integrity": "sha512-nadqwNxghAGTamwIqQSG433W6OADZx2vCo3UXHNrzTRHK/htu+7+L0zhjEoaeaQVNAi3YgqWDv8+tzf0hRfR+A==" + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 1a599f2ecf..ae9928e007 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "website" ], "dependencies": { + "compute-scroll-into-view": "3.0.3", "eventemitter3": "^4.0.7", "lodash.clonedeep": "^4.5.0", "lodash.isequal": "^4.5.0", diff --git a/test/unit/core/quill.spec.ts b/test/unit/core/quill.spec.ts index 2e0151ba61..47e78a5f2a 100644 --- a/test/unit/core/quill.spec.ts +++ b/test/unit/core/quill.spec.ts @@ -997,4 +997,197 @@ describe('Quill', () => { expect([...quill.root.classList]).not.toContain('ql-blank'); }); }); + + describe('scrollSelectionIntoView', () => { + const createContents = (separator: string) => + new Array(200) + .fill(0) + .map((_, i) => `text ${i + 1}`) + .join(separator); + + const viewportRatio = (element: Element): Promise => { + return new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + // Firefox doesn't call IntersectionObserver callback unless + // there are rafs. + requestAnimationFrame(() => {}); + }); + }; + + test('scroll upward', async () => { + document.body.style.height = '500px'; + const container = document.body.appendChild( + document.createElement('div'), + ); + + Object.assign(container.style, { + height: '100px', + overflow: 'scroll', + }); + + const editorContainer = container.appendChild( + document.createElement('div'), + ); + Object.assign(editorContainer.style, { + height: '100px', + overflow: 'scroll', + border: '10px solid red', + }); + + const space = container.appendChild(document.createElement('div')); + space.style.height = '800px'; + + const quill = new Quill(editorContainer); + + const text = createContents('\n'); + quill.setContents(new Delta().insert(text)); + quill.setSelection({ index: text.indexOf('text 10'), length: 4 }, 'user'); + + container.scrollTop = -500; + + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(10)') as HTMLElement, + ), + ).toBeGreaterThan(0.9); + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(11)') as HTMLElement, + ), + ).toEqual(0); + }); + + test('scroll downward', async () => { + document.body.style.height = '500px'; + const container = document.body.appendChild( + document.createElement('div'), + ); + + Object.assign(container.style, { + height: '100px', + overflow: 'scroll', + }); + + const space = container.appendChild(document.createElement('div')); + space.style.height = '80px'; + + const editorContainer = container.appendChild( + document.createElement('div'), + ); + Object.assign(editorContainer.style, { + height: '100px', + overflow: 'scroll', + border: '10px solid red', + }); + + const quill = new Quill(editorContainer); + + const text = createContents('\n'); + quill.setContents(new Delta().insert(text)); + quill.setSelection( + { index: text.indexOf('text 100'), length: 4 }, + 'user', + ); + + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(100)') as HTMLElement, + ), + ).toBeGreaterThan(0.9); + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(101)') as HTMLElement, + ), + ).toEqual(0); + }); + + test('scroll-padding', async () => { + const container = document.body.appendChild( + document.createElement('div'), + ); + const quill = new Quill(container); + Object.assign(quill.root.style, { + scrollPaddingBottom: '50px', + height: '200px', + overflow: 'auto', + }); + const text = createContents('\n'); + quill.setContents(new Delta().insert(text)); + quill.setSelection({ index: text.indexOf('text 10'), length: 4 }, 'user'); + expect( + await viewportRatio( + container.querySelector('p:nth-child(10)') as HTMLElement, + ), + ).toBe(1); + expect( + await viewportRatio( + container.querySelector('p:nth-child(11)') as HTMLElement, + ), + ).toBe(1); + quill.root.style.scrollPaddingBottom = '0'; + quill.setSelection(1, 'user'); + quill.setSelection({ index: text.indexOf('text 10'), length: 4 }, 'user'); + expect( + await viewportRatio( + container.querySelector('p:nth-child(11)') as HTMLElement, + ), + ).toBe(0); + }); + + test('inline scroll', async () => { + const container = document.body.appendChild( + document.createElement('div'), + ); + + Object.assign(container.style, { + width: '200px', + display: 'flex', + overflow: 'scroll', + }); + + const space = container.appendChild(document.createElement('div')); + space.style.width = '80px'; + + const editorContainer = container.appendChild( + document.createElement('div'), + ); + Object.assign(editorContainer.style, { + width: '100px', + overflow: 'scroll', + border: '10px solid red', + }); + + const quill = new Quill(editorContainer); + + Object.assign(quill.root.style, { + overflow: 'scroll', + whiteSpace: 'nowrap', + }); + + const text = createContents(' '); + const text100Index = text.indexOf('text 100'); + const delta = new Delta() + .insert(text) + .compose(new Delta().retain(text100Index).retain(8, { bold: true })); + quill.setContents(delta); + quill.setSelection({ index: text100Index, length: 8 }, 'user'); + + expect( + await viewportRatio( + editorContainer.querySelector('strong') as HTMLElement, + ), + ).toBeGreaterThan(0.9); + + quill.setSelection(0, 'user'); + expect( + await viewportRatio( + editorContainer.querySelector('strong') as HTMLElement, + ), + ).toEqual(0); + }); + }); }); diff --git a/themes/base.ts b/themes/base.ts index 5fcdc0308f..2f47ea4740 100644 --- a/themes/base.ts +++ b/themes/base.ts @@ -261,9 +261,7 @@ class BaseTooltip extends Tooltip { } restoreFocus() { - const { scrollTop } = this.quill.scrollingContainer; - this.quill.focus(); - this.quill.scrollingContainer.scrollTop = scrollTop; + this.quill.focus({ preventScroll: true }); } save() { diff --git a/ui/tooltip.ts b/ui/tooltip.ts index d672fc76c3..9dbabe477f 100644 --- a/ui/tooltip.ts +++ b/ui/tooltip.ts @@ -1,5 +1,10 @@ import Quill from '../core'; +const isScrollable = (el: Element) => { + const { overflowY } = getComputedStyle(el, null); + return overflowY !== 'visible' && overflowY !== 'clip'; +}; + class Tooltip { quill: Quill; boundsContainer: HTMLElement; @@ -11,7 +16,7 @@ class Tooltip { this.root = quill.addContainer('ql-tooltip'); // @ts-expect-error this.root.innerHTML = this.constructor.TEMPLATE; - if (this.quill.root === this.quill.scrollingContainer) { + if (isScrollable(this.quill.root)) { this.quill.root.addEventListener('scroll', () => { this.root.style.marginTop = `${-1 * this.quill.root.scrollTop}px`; }); diff --git a/website/content/docs/configuration.mdx b/website/content/docs/configuration.mdx index db0f725e50..72fcaf5b9c 100644 --- a/website/content/docs/configuration.mdx +++ b/website/content/docs/configuration.mdx @@ -76,12 +76,6 @@ Default: `false` Whether to instantiate the editor to read-only mode. -#### scrollingContainer - -Default: `null` - -DOM Element or a CSS selector for a DOM Element, specifying which container has the scrollbars (i.e. `overflow-y: auto`), if is has been changed from the default `ql-editor` with custom CSS. Necessary to fix scroll jumping bugs when Quill is set to [auto grow](/playground/#autogrow) its height, and another ancestor container is responsible from the scrolling. - #### theme Name of theme to use. The builtin options are "bubble" or "snow". An invalid or falsy value will load a default minimal theme. Note the theme's specific stylesheet still needs to be included manually. See [Themes](/docs/themes/) for more information. diff --git a/website/content/standalone/autogrow.mdx b/website/content/standalone/autogrow.mdx index d608351f6a..73811b9ca7 100644 --- a/website/content/standalone/autogrow.mdx +++ b/website/content/standalone/autogrow.mdx @@ -19,7 +19,6 @@ title: Autogrow Example ['image', 'code-block', 'link'], ], }, - scrollingContainer: '#scrolling-container', placeholder: 'Compose an epic...', theme: 'bubble', }} From 78de4ccde80200b74beded54c6c45c9f53193e84 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Tue, 1 Aug 2023 13:44:42 +0800 Subject: [PATCH 2/3] Support negative width and height In RFC the sizes can be negative when the origin is at the right/bottom side, to address this, we use abc() to get the expected value. --- core/utils/scrollRectIntoView.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/utils/scrollRectIntoView.ts b/core/utils/scrollRectIntoView.ts index b4852f0468..b0726c428c 100644 --- a/core/utils/scrollRectIntoView.ts +++ b/core/utils/scrollRectIntoView.ts @@ -1,4 +1,4 @@ -type Rect = { +export type Rect = { top: number; right: number; bottom: number; @@ -12,11 +12,11 @@ const getElementRect = (element: Element): Rect => { const rect = element.getBoundingClientRect(); const scaleX = ('offsetWidth' in element && - rect.width / (element as HTMLElement).offsetWidth) || + Math.abs(rect.width) / (element as HTMLElement).offsetWidth) || 1; const scaleY = ('offsetHeight' in element && - rect.height / (element as HTMLElement).offsetHeight) || + Math.abs(rect.height) / (element as HTMLElement).offsetHeight) || 1; return { top: rect.top, From 63343738d4edf950a433271179aaa1e62962a030 Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Tue, 1 Aug 2023 13:45:56 +0800 Subject: [PATCH 3/3] Expose scrollRectIntoView --- core/quill.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/quill.ts b/core/quill.ts index 0eb4d7ac66..b7165bedbd 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -16,7 +16,7 @@ import Module from './module'; import Selection, { Range } from './selection'; import Composition from './composition'; import Theme, { ThemeConstructor } from './theme'; -import scrollRectIntoView from './utils/scrollRectIntoView'; +import scrollRectIntoView, { Rect } from './utils/scrollRectIntoView'; const debug = logger('quill'); @@ -614,6 +614,10 @@ class Quill { ); } + scrollRectIntoView(rect: Rect) { + scrollRectIntoView(this.root, rect); + } + /** * @deprecated Use Quill#scrollSelectionIntoView() instead. */ @@ -632,7 +636,7 @@ class Quill { const range = this.selection.lastRange; const bounds = range && this.selection.getBounds(range.index, range.length); if (bounds) { - scrollRectIntoView(this.root, bounds); + this.scrollRectIntoView(bounds); } }