diff --git a/core/quill.ts b/core/quill.ts index 294932d7c2..798609d49f 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -15,6 +15,7 @@ import logger, { DebugLevel } from './logger'; import Module from './module'; import Selection, { Range } from './selection'; import Composition from './composition'; +import { compute } from 'compute-scroll-into-view'; import Theme, { ThemeConstructor } from './theme'; const debug = logger('quill'); @@ -282,9 +283,11 @@ class Quill { this.container.classList.toggle('ql-disabled', !enabled); } - focus() { + focus(options: { preventScroll?: boolean } = {}) { this.selection.focus(); - this.scrollSelectionIntoView(); + if (!options.preventScroll) { + this.scrollSelectionIntoView(); + } } format( @@ -611,72 +614,49 @@ class Quill { ); } + /** + * @deprecated Use Quill#scrollSelectionIntoView() instead. + */ + scrollIntoView() { + 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; if (range == null) return; const bounds = this.selection.getBounds(range.index, range.length); if (bounds == null) return; - let { top, bottom } = bounds; - - const { body, defaultView } = this.root.ownerDocument; - if (!defaultView) return; - - let targetTop = 0; - let targetBottom = 0; - let targetScaleY = 0; - let element: HTMLElement | null = this.root; - - while (element !== null) { - const isBodyElement = element === body; - if (isBodyElement) { - targetTop = 0; - targetBottom = defaultView.innerHeight; - const targetRect = element.getBoundingClientRect(); - targetScaleY = targetRect.height / element.offsetHeight; - } else { - const targetRect = element.getBoundingClientRect(); - targetTop = targetRect.top; - targetBottom = targetRect.bottom; - targetScaleY = targetRect.height / element.offsetHeight; - } - let diff = 0; - - const style = getComputedStyle(element); - const borderTop = parseInt(style.borderTopWidth as string, 10); - const borderBottom = parseInt(style.borderBottomWidth as string, 10); - - if (top < targetTop) { - diff = top - targetTop; - } else if (bottom > targetBottom) { - const scrollbarHeight = - element.offsetHeight - - element.clientHeight - - borderTop - - borderBottom; - - diff = - (bottom - targetBottom) / targetScaleY + - scrollbarHeight + - borderBottom; - } - - if (diff) { - if (isBodyElement) { - defaultView.scrollBy(0, diff); - } else { - const scrollTop = element.scrollTop; - element.scrollTop += diff; - const yOffset = (element.scrollTop - scrollTop) * targetScaleY; - top -= yOffset; - bottom -= yOffset; - } - } - if (isBodyElement) { - break; - } - element = element.parentElement; - } + // Virtual element is not supported so we have to use a workaround. + // https://github.com/scroll-into-view/compute-scroll-into-view + const virtualElement = { + nodeType: 1, + parentElement: this.root, + getBoundingClientRect() { + return new DOMRect( + bounds.left, + bounds.top, + bounds.width, + bounds.height, + ); + }, + } as unknown as Element; + + compute(virtualElement, { + scrollMode: 'if-needed', + block: 'nearest', + inline: 'nearest', + }).forEach(({ el, top, left }) => { + el.scrollTop = top; + el.scrollLeft = left; + }); } setContents( 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 ce6f3459a8..906a3fe4a2 100644 --- a/test/unit/core/quill.spec.ts +++ b/test/unit/core/quill.spec.ts @@ -999,49 +999,109 @@ describe('Quill', () => { }); describe('scrollSelectionIntoView', () => { - const getAncestorScrollTops = (element: HTMLElement) => { - const scrollTops: number[] = []; - let current = element.parentElement; - while (current) { - scrollTops.push(current.scrollTop); - current = current.parentElement; - } - return scrollTops; + 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('scrolls multiple ancestors', () => { - document.body.style.height = '500px'; - const container = document.body.appendChild( - document.createElement('div'), - ); + describe('scroll upward', () => { + test('scrolls multiple ancestors', async () => { + document.body.style.height = '500px'; + const container = document.body.appendChild( + document.createElement('div'), + ); - Object.assign(container.style, { - height: '100px', - overflow: 'scroll', - }); + 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', + }); - const editorContainer = container.appendChild( - document.createElement('div'), - ); - Object.assign(editorContainer.style, { - height: '100px', - border: '10px solid red', - overflow: 'scroll', + const space = container.appendChild(document.createElement('div')); + space.style.height = '800px'; + + const quill = new Quill(editorContainer); + + let text = ''; + for (let i = 1; i < 200; i += 1) { + text += `line ${i}\n`; + } + quill.setContents(new Delta().insert(text)); + quill.setSelection({ index: text.indexOf('line 10'), length: 4 }); + + container.scrollTop = -500; + + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(10)') as HTMLElement, + ), + ).toEqual(1); + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(11)') as HTMLElement, + ), + ).toEqual(0); }); + }); + + describe('scroll downward', () => { + test('scrolls multiple ancestors', async () => { + document.body.style.height = '500px'; + const container = document.body.appendChild( + document.createElement('div'), + ); - const quill = new Quill(editorContainer); + Object.assign(container.style, { + height: '100px', + overflow: 'scroll', + }); - let text = ''; - for (let i = 0; i < 200; i += 1) { - text += `line ${i}\n`; - } - quill.setContents(new Delta().insert(text)); - quill.setSelection({ index: text.indexOf('line 100'), length: 4 }); + const space = container.appendChild(document.createElement('div')); + space.style.height = '80px'; - expect(getAncestorScrollTops(editorContainer)).toEqual([90, 0, 0]); + const editorContainer = container.appendChild( + document.createElement('div'), + ); + Object.assign(editorContainer.style, { + height: '100px', + overflow: 'scroll', + }); + + const quill = new Quill(editorContainer); + + let text = ''; + for (let i = 1; i < 200; i += 1) { + text += `line ${i}\n`; + } + quill.setContents(new Delta().insert(text)); + quill.setSelection({ index: text.indexOf('line 100'), length: 4 }); + + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(100)') as HTMLElement, + ), + ).toEqual(1); + expect( + await viewportRatio( + editorContainer.querySelector('p:nth-child(101)') as HTMLElement, + ), + ).toEqual(0); + }); }); }); }); diff --git a/themes/base.ts b/themes/base.ts index 4fa5dc6193..d41fc5472b 100644 --- a/themes/base.ts +++ b/themes/base.ts @@ -260,7 +260,7 @@ class BaseTooltip extends Tooltip { } restoreFocus() { - this.quill.focus(); + this.quill.focus({ preventScroll: true }); } save() {