From 93b07d61b04cafdade8ca96094661cd563df13eb Mon Sep 17 00:00:00 2001 From: Zihua Li Date: Fri, 14 Jul 2023 14:45:02 +0800 Subject: [PATCH] wip --- core/quill.ts | 51 +- core/selection.ts | 4 +- modules/clipboard.ts | 2 +- test/unit/core/quill.js | 945 ++++++++++++++++++++++++ test/unit/core/quill.spec.ts | 47 ++ themes/base.ts | 2 - ui/tooltip.ts | 7 +- website/content/docs/configuration.mdx | 6 - website/content/standalone/autogrow.mdx | 1 - 9 files changed, 1031 insertions(+), 34 deletions(-) create mode 100644 test/unit/core/quill.js diff --git a/core/quill.ts b/core/quill.ts index 3a99625dc2..294932d7c2 100644 --- a/core/quill.ts +++ b/core/quill.ts @@ -30,7 +30,6 @@ interface Options { container?: HTMLElement | string; placeholder?: string; bounds?: HTMLElement | string | null; - scrollingContainer?: HTMLElement | string | null; modules?: Record; } @@ -40,7 +39,6 @@ interface ExpandedOptions extends Omit { container: HTMLElement; modules: Record; bounds?: HTMLElement | null; - scrollingContainer?: HTMLElement | null; } class Quill { @@ -50,7 +48,6 @@ class Quill { placeholder: '', readOnly: false, registry: globalRegistry, - scrollingContainer: null, theme: 'default', }; static events = Emitter.events; @@ -129,7 +126,6 @@ class Quill { } } - scrollingContainer: HTMLElement; container: HTMLElement; root: HTMLDivElement; scroll: Scroll; @@ -163,7 +159,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( @@ -288,9 +283,7 @@ class Quill { } focus() { - const { scrollTop } = this.scrollingContainer; this.selection.focus(); - this.scrollingContainer.scrollTop = scrollTop; this.scrollSelectionIntoView(); } @@ -621,17 +614,17 @@ class Quill { scrollSelectionIntoView() { const range = this.selection.lastRange; if (range == null) return; - const bounds = this.getBounds(range.index, range.length); + const bounds = this.selection.getBounds(range.index, range.length); if (bounds == null) return; - let { top: currentTop, bottom: currentBottom } = bounds; + + let { top, bottom } = bounds; const { body, defaultView } = this.root.ownerDocument; + if (!defaultView) return; - if (defaultView === null) { - return; - } let targetTop = 0; let targetBottom = 0; + let targetScaleY = 0; let element: HTMLElement | null = this.root; while (element !== null) { @@ -639,28 +632,44 @@ class Quill { 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; - if (currentTop < targetTop) { - diff = -(targetTop - currentTop); - } else if (currentBottom > targetBottom) { - diff = currentBottom - targetBottom; + 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 !== 0) { + if (diff) { if (isBodyElement) { defaultView.scrollBy(0, diff); } else { const scrollTop = element.scrollTop; element.scrollTop += diff; - const yOffset = element.scrollTop - scrollTop; - currentTop -= yOffset; - currentBottom -= yOffset; + const yOffset = (element.scrollTop - scrollTop) * targetScaleY; + top -= yOffset; + bottom -= yOffset; } } if (isBodyElement) { @@ -806,7 +815,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 31915b60f1..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); } @@ -348,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/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/test/unit/core/quill.js b/test/unit/core/quill.js new file mode 100644 index 0000000000..522fb9b306 --- /dev/null +++ b/test/unit/core/quill.js @@ -0,0 +1,945 @@ +import Delta from 'quill-delta'; +import Quill, { expandConfig, overload } from '../../../core/quill'; +import Theme from '../../../core/theme'; +import Emitter from '../../../core/emitter'; +import Toolbar from '../../../modules/toolbar'; +import Snow from '../../../themes/snow'; +import { Range } from '../../../core/selection'; + +describe('Quill', function () { + it('imports', function () { + Object.keys(Quill.imports).forEach(function (path) { + expect(Quill.import(path)).toBeTruthy(); + }); + }); + + describe('construction', function () { + it('empty', function () { + const quill = this.initialize(Quill, ''); + expect(quill.getContents()).toEqual(new Delta().insert('\n')); + expect(quill.root).toEqualHTML('


'); + }); + + it('text', function () { + const quill = this.initialize(Quill, '0123'); + expect(quill.getContents()).toEqual(new Delta().insert('0123\n')); + expect(quill.root).toEqualHTML('

0123

'); + }); + + it('newlines', function () { + const quill = this.initialize(Quill, '




'); + expect(quill.getContents()).toEqual(new Delta().insert('\n\n\n')); + expect(quill.root).toEqualHTML('




'); + }); + + it('formatted ending', function () { + const quill = this.initialize( + Quill, + '

Test

', + ); + expect(quill.getContents()).toEqual( + new Delta().insert('Test').insert('\n', { align: 'center' }), + ); + expect(quill.root).toEqualHTML('

Test

'); + }); + }); + + describe('api', function () { + beforeEach(function () { + this.quill = this.initialize(Quill, '

01234567

'); + this.oldDelta = this.quill.getContents(); + spyOn(this.quill.emitter, 'emit').and.callThrough(); + }); + + it('deleteText()', function () { + this.quill.deleteText(3, 2); + const change = new Delta().retain(3).delete(2); + expect(this.quill.root).toEqualHTML('

012567

'); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('format()', function () { + this.quill.setSelection(3, 2); + this.quill.format('bold', true); + const change = new Delta().retain(3).retain(2, { bold: true }); + expect(this.quill.root).toEqualHTML( + '

01234567

', + ); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + expect(this.quill.getSelection()).toEqual(new Range(3, 2)); + }); + + it('formatLine()', function () { + this.quill.formatLine(1, 1, 'header', 2); + const change = new Delta().retain(8).retain(1, { header: 2 }); + expect(this.quill.root).toEqualHTML('

01234567

'); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('formatText()', function () { + this.quill.formatText(3, 2, 'bold', true); + const change = new Delta().retain(3).retain(2, { bold: true }); + expect(this.quill.root).toEqualHTML( + '

01234567

', + ); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('insertEmbed()', function () { + this.quill.insertEmbed(5, 'image', '/assets/favicon.png'); + const change = new Delta() + .retain(5) + .insert({ image: '/assets/favicon.png' }, { italic: true }); + expect(this.quill.root).toEqualHTML( + '

01234567

', + ); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('insertText()', function () { + this.quill.insertText(5, '|', 'bold', true); + const change = new Delta() + .retain(5) + .insert('|', { bold: true, italic: true }); + expect(this.quill.root).toEqualHTML( + '

01234|567

', + ); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('enable/disable', function () { + this.quill.disable(); + expect(this.quill.root.getAttribute('contenteditable')).toEqual('false'); + this.quill.enable(); + expect(this.quill.root.getAttribute('contenteditable')).toBeTruthy(); + }); + + it('getBounds() index', function () { + expect(this.quill.getBounds(1)).toBeTruthy(); + }); + + it('getBounds() range', function () { + expect(this.quill.getBounds(new Range(3, 4))).toBeTruthy(); + }); + + it('getFormat()', function () { + const formats = this.quill.getFormat(5); + expect(formats).toEqual({ italic: true }); + }); + + it('getSelection()', function () { + expect(this.quill.getSelection()).toEqual(null); + const range = new Range(1, 2); + this.quill.setSelection(range); + expect(this.quill.getSelection()).toEqual(range); + }); + + it('removeFormat()', function () { + this.quill.removeFormat(5, 1); + const change = new Delta().retain(5).retain(1, { italic: null }); + expect(this.quill.root).toEqualHTML('

01234567

'); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + change, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('updateContents() delta', function () { + const delta = new Delta().retain(5).insert('|'); + this.quill.updateContents(delta); + expect(this.quill.root).toEqualHTML('

01234|567

'); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + delta, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('updateContents() ops array', function () { + const delta = new Delta().retain(5).insert('|'); + this.quill.updateContents(delta.ops); + expect(this.quill.root).toEqualHTML('

01234|567

'); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + delta, + this.oldDelta, + Emitter.sources.API, + ); + }); + }); + + describe('events', function () { + beforeEach(function () { + this.quill = this.initialize(Quill, '

0123

'); + this.quill.update(); + spyOn(this.quill.emitter, 'emit').and.callThrough(); + this.oldDelta = this.quill.getContents(); + }); + + it('api text insert', function () { + this.quill.insertText(2, '!'); + const delta = new Delta().retain(2).insert('!'); + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + delta, + this.oldDelta, + Emitter.sources.API, + ); + }); + + it('user text insert', function (done) { + this.container.firstChild.firstChild.firstChild.data = '01!23'; + const delta = new Delta().retain(2).insert('!'); + setTimeout(() => { + expect(this.quill.emitter.emit).toHaveBeenCalledWith( + Emitter.events.TEXT_CHANGE, + delta, + this.oldDelta, + Emitter.sources.USER, + ); + done(); + }, 1); + }); + + function editTest( + oldText, + oldSelection, + newText, + newSelection, + expectedDelta, + ) { + return function (done) { + this.quill.setText(`${oldText}\n`); + this.quill.setSelection(oldSelection); // number or Range + this.quill.update(); + const oldContents = this.quill.getContents(); + const textNode = this.container.firstChild.firstChild.firstChild; + textNode.data = newText; + if (typeof newSelection === 'number') { + this.quill.selection.setNativeRange(textNode, newSelection); + } else { + this.quill.selection.setNativeRange( + textNode, + newSelection.index, + textNode, + newSelection.index + newSelection.length, + ); + } + setTimeout(() => { + const calls = this.quill.emitter.emit.calls.all(); + if ( + calls[calls.length - 1].args[1] === Emitter.events.SELECTION_CHANGE + ) { + calls.pop(); + } + const { args } = calls.pop(); + expect(args).toEqual([ + Emitter.events.TEXT_CHANGE, + expectedDelta, + oldContents, + Emitter.sources.USER, + ]); + done(); + }, 1); + }; + } + + describe('insert a in aaaa', function () { + it( + 'at index 0', + editTest('aaaa', 0, 'aaaaa', 1, new Delta().insert('a')), + ); + it( + 'at index 1', + editTest('aaaa', 1, 'aaaaa', 2, new Delta().retain(1).insert('a')), + ); + it( + 'at index 2', + editTest('aaaa', 2, 'aaaaa', 3, new Delta().retain(2).insert('a')), + ); + it( + 'at index 3', + editTest('aaaa', 3, 'aaaaa', 4, new Delta().retain(3).insert('a')), + ); + }); + + describe('insert a in xaa', function () { + it( + 'at index 1', + editTest('xaa', 1, 'xaaa', 2, new Delta().retain(1).insert('a')), + ); + it( + 'at index 2', + editTest('xaa', 2, 'xaaa', 3, new Delta().retain(2).insert('a')), + ); + it( + 'at index 3', + editTest('xaa', 3, 'xaaa', 4, new Delta().retain(3).insert('a')), + ); + }); + + describe('insert aa in ax', function () { + it('at index 0', editTest('ax', 0, 'aaax', 2, new Delta().insert('aa'))); + it( + 'at index 1', + editTest('ax', 1, 'aaax', 3, new Delta().retain(1).insert('aa')), + ); + }); + + describe('delete a in xaa', function () { + it( + 'at index 1', + editTest('xaa', 2, 'xa', 1, new Delta().retain(1).delete(1)), + ); + it( + 'at index 2', + editTest('xaa', 3, 'xa', 2, new Delta().retain(2).delete(1)), + ); + }); + + describe('forward-delete a in xaa', function () { + it( + 'at index 1', + editTest('xaa', 1, 'xa', 1, new Delta().retain(1).delete(1)), + ); + it( + 'at index 2', + editTest('xaa', 2, 'xa', 2, new Delta().retain(2).delete(1)), + ); + }); + + it( + 'replace yay with y', + editTest( + 'yay', + new Range(0, 3), + 'y', + 1, + new Delta().insert('y').delete(3), + ), + ); + }); + + describe('setContents()', function () { + it('empty', function () { + const quill = this.initialize(Quill, ''); + const delta = new Delta().insert('\n'); + quill.setContents(delta); + expect(quill.getContents()).toEqual(delta); + expect(quill.root).toEqualHTML('


'); + }); + + it('single line', function () { + const quill = this.initialize(Quill, ''); + const delta = new Delta().insert('Hello World!\n'); + quill.setContents(delta); + expect(quill.getContents()).toEqual(delta); + expect(quill.root).toEqualHTML('

Hello World!

'); + }); + + it('multiple lines', function () { + const quill = this.initialize(Quill, ''); + const delta = new Delta().insert('Hello\n\nWorld!\n'); + quill.setContents(delta); + expect(quill.getContents()).toEqual(delta); + expect(quill.root).toEqualHTML('

Hello


World!

'); + }); + + it('basic formats', function () { + const quill = this.initialize(Quill, ''); + const delta = new Delta() + .insert('Welcome') + .insert('\n', { header: 1 }) + .insert('Hello\n') + .insert('World') + .insert('!', { bold: true }) + .insert('\n'); + quill.setContents(delta); + expect(quill.getContents()).toEqual(delta); + expect(quill.root).toEqualHTML(` +

Welcome

+

Hello

+

World!

+ `); + }); + + it('array of operations', function () { + const quill = this.initialize(Quill, ''); + const delta = new Delta() + .insert('test') + .insert('123', { bold: true }) + .insert('\n'); + quill.setContents(delta.ops); + expect(quill.getContents()).toEqual(delta); + }); + + it('json', function () { + const quill = this.initialize(Quill, ''); + const delta = { ops: [{ insert: 'test\n' }] }; + quill.setContents(delta); + expect(quill.getContents()).toEqual(new Delta(delta)); + }); + + it('no trailing newline', function () { + const quill = this.initialize(Quill, '

Welcome

'); + quill.setContents(new Delta().insert('0123')); + expect(quill.getContents()).toEqual(new Delta().insert('0123\n')); + }); + + it('inline formatting', function () { + const quill = this.initialize( + Quill, + '

Bold

Not bold

', + ); + const contents = quill.getContents(); + const delta = quill.setContents(contents); + expect(quill.getContents()).toEqual(contents); + expect(delta).toEqual(contents.delete(contents.length())); + }); + + it('block embed', function () { + const quill = this.initialize(Quill, '

Hello World!

'); + const contents = new Delta().insert({ video: '#' }); + quill.setContents(contents); + expect(quill.getContents()).toEqual(contents); + }); + }); + + describe('getText()', function () { + it('return all text by default', function () { + const quill = this.initialize(Quill, '

Welcome

'); + expect(quill.getText()).toEqualHTML('Welcome'); + }); + + it('works when only provide index', function () { + const quill = this.initialize(Quill, '

Welcome

'); + expect(quill.getText(2)).toEqualHTML('lcome'); + }); + + it('works with range', function () { + const quill = this.initialize(Quill, '

Welcome

'); + expect(quill.getText({ index: 1, length: 2 })).toEqualHTML('el'); + }); + }); + + describe('setText()', function () { + it('overwrite', function () { + const quill = this.initialize(Quill, '

Welcome

'); + quill.setText('abc'); + expect(quill.root).toEqualHTML('

abc

'); + }); + + it('set to newline', function () { + const quill = this.initialize(Quill, '

Welcome

'); + quill.setText('\n'); + expect(quill.root).toEqualHTML('


'); + }); + + it('multiple newlines', function () { + const quill = this.initialize(Quill, '

Welcome

'); + quill.setText('\n\n'); + expect(quill.root).toEqualHTML('



'); + }); + + it('content with trailing newline', function () { + const quill = this.initialize(Quill, '

Welcome

'); + quill.setText('abc\n'); + expect(quill.root).toEqualHTML('

abc

'); + }); + + it('return carriage', function () { + const quill = this.initialize(Quill, '

Test

'); + quill.setText('\r'); + expect(quill.root).toEqualHTML('


'); + }); + + it('return carriage newline', function () { + const quill = this.initialize(Quill, '

Test

'); + quill.setText('\r\n'); + expect(quill.root).toEqualHTML('


'); + }); + }); + + describe('expandConfig', function () { + it('user overwrite quill', function () { + const config = expandConfig('#test-container', { + placeholder: 'Test', + readOnly: true, + }); + expect(config.placeholder).toEqual('Test'); + expect(config.readOnly).toEqual(true); + }); + + it('convert css selectors', function () { + const config = expandConfig('#test-container', { + bounds: '#test-container', + }); + expect(config.bounds).toEqual(document.querySelector('#test-container')); + expect(config.container).toEqual( + document.querySelector('#test-container'), + ); + }); + + xit('convert module true to {}', function () { + Quill.debug(0); + const oldModules = Theme.DEFAULTS.modules; + Theme.DEFAULTS.modules = { + formula: true, + }; + const config = expandConfig('#test-container', { + modules: { + syntax: true, + }, + }); + Quill.debug('error'); + expect(config.modules.formula).toEqual({}); + expect(config.modules.syntax).toEqual({ + highlight: null, + interval: 1000, + }); + Theme.DEFAULTS.modules = oldModules; + }); + + describe('theme defaults', function () { + it('for Snow', function () { + const config = expandConfig('#test-container', { + modules: { + toolbar: true, + }, + theme: 'snow', + }); + expect(config.theme).toEqual(Snow); + expect(config.modules.toolbar.handlers.image).toEqual( + Snow.DEFAULTS.modules.toolbar.handlers.image, + ); + }); + + it('for false', function () { + const config = expandConfig('#test-container', { + theme: false, + }); + expect(config.theme).toEqual(Theme); + }); + + it('for undefined', function () { + const config = expandConfig('#test-container', { + theme: undefined, + }); + expect(config.theme).toEqual(Theme); + }); + + it('for null', function () { + const config = expandConfig('#test-container', { + theme: null, + }); + expect(config.theme).toEqual(Theme); + }); + }); + + it('quill < module < theme < user', function () { + const oldTheme = Theme.DEFAULTS.modules; + const oldToolbar = Toolbar.DEFAULTS; + Toolbar.DEFAULTS = { + option: 2, + module: true, + }; + Theme.DEFAULTS.modules = { + toolbar: { + option: 1, + theme: true, + }, + }; + const config = expandConfig('#test-container', { + modules: { + toolbar: { + option: 0, + user: true, + }, + }, + }); + expect(config.modules.toolbar).toEqual({ + option: 0, + module: true, + theme: true, + user: true, + }); + Theme.DEFAULTS.modules = oldTheme; + Toolbar.DEFAULTS = oldToolbar; + }); + + it('toolbar default', function () { + const config = expandConfig('#test-container', { + modules: { + toolbar: true, + }, + }); + expect(config.modules.toolbar).toEqual(Toolbar.DEFAULTS); + }); + + it('toolbar disabled', function () { + const config = expandConfig('#test-container', { + modules: { + toolbar: false, + }, + theme: 'snow', + }); + expect(config.modules.toolbar).toBe(undefined); + }); + + it('toolbar selector', function () { + const config = expandConfig('#test-container', { + modules: { + toolbar: { + container: '#test-container', + }, + }, + }); + expect(config.modules.toolbar).toEqual({ + container: '#test-container', + handlers: Toolbar.DEFAULTS.handlers, + }); + }); + + it('toolbar container shorthand', function () { + const config = expandConfig('#test-container', { + modules: { + toolbar: document.querySelector('#test-container'), + }, + }); + expect(config.modules.toolbar).toEqual({ + container: document.querySelector('#test-container'), + handlers: Toolbar.DEFAULTS.handlers, + }); + }); + + it('toolbar format array', function () { + const config = expandConfig('#test-container', { + modules: { + toolbar: ['bold'], + }, + }); + expect(config.modules.toolbar).toEqual({ + container: ['bold'], + handlers: Toolbar.DEFAULTS.handlers, + }); + }); + + it('toolbar custom handler, default container', function () { + const handler = function () {}; // eslint-disable-line func-style + const config = expandConfig('#test-container', { + modules: { + toolbar: { + handlers: { + bold: handler, + }, + }, + }, + }); + expect(config.modules.toolbar.container).toEqual(null); + expect(config.modules.toolbar.handlers.bold).toEqual(handler); + expect(config.modules.toolbar.handlers.clean).toEqual( + Toolbar.DEFAULTS.handlers.clean, + ); + }); + }); + + describe('overload', function () { + it('(index:number, length:number)', function () { + const [index, length, formats, source] = overload(0, 1); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.API); + }); + + it('(index:number, length:number, format:string, value:boolean, source:string)', function () { + const [index, length, formats, source] = overload( + 0, + 1, + 'bold', + true, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.USER); + }); + + it('(index:number, length:number, format:string, value:string, source:string)', function () { + const [index, length, formats, source] = overload( + 0, + 1, + 'color', + Quill.sources.USER, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ color: Quill.sources.USER }); + expect(source).toBe(Quill.sources.USER); + }); + + it('(index:number, length:number, format:string, value:string)', function () { + const [index, length, formats, source] = overload( + 0, + 1, + 'color', + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ color: Quill.sources.USER }); + expect(source).toBe(Quill.sources.API); + }); + + it('(index:number, length:number, format:object)', function () { + const [index, length, formats, source] = overload(0, 1, { bold: true }); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.API); + }); + + it('(index:number, length:number, format:object, source:string)', function () { + const [index, length, formats, source] = overload( + 0, + 1, + { bold: true }, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.USER); + }); + + it('(index:number, length:number, source:string)', function () { + const [index, length, formats, source] = overload( + 0, + 1, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.USER); + }); + + it('(index:number, source:string)', function () { + const [index, length, formats, source] = overload(0, Quill.sources.USER); + expect(index).toBe(0); + expect(length).toBe(0); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.USER); + }); + + it('(range:range)', function () { + const [index, length, formats, source] = overload(new Range(0, 1)); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.API); + }); + + it('(range:range, format:string, value:boolean, source:string)', function () { + const [index, length, formats, source] = overload( + new Range(0, 1), + 'bold', + true, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.USER); + }); + + it('(range:range, format:string, value:string, source:string)', function () { + const [index, length, formats, source] = overload( + new Range(0, 1), + 'color', + Quill.sources.API, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ color: Quill.sources.API }); + expect(source).toBe(Quill.sources.USER); + }); + + it('(range:range, format:string, value:string)', function () { + const [index, length, formats, source] = overload( + new Range(0, 1), + 'color', + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ color: Quill.sources.USER }); + expect(source).toBe(Quill.sources.API); + }); + + it('(range:range, format:object)', function () { + const [index, length, formats, source] = overload(new Range(0, 1), { + bold: true, + }); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.API); + }); + + it('(range:range, format:object, source:string)', function () { + const [index, length, formats, source] = overload( + new Range(0, 1), + { bold: true }, + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.USER); + }); + + it('(range:range, source:string)', function () { + const [index, length, formats, source] = overload( + new Range(0, 1), + Quill.sources.USER, + ); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.USER); + }); + + it('(range:range)', function () { + const [index, length, formats, source] = overload(new Range(0, 1)); + expect(index).toBe(0); + expect(length).toBe(1); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.API); + }); + + it('(range:range, dummy:number)', function () { + const [index, length, formats, source] = overload(new Range(10, 1), 0); + expect(index).toBe(10); + expect(length).toBe(1); + expect(formats).toEqual({}); + expect(source).toBe(Quill.sources.API); + }); + + it('(range:range, dummy:number, format:string, value:boolean)', function () { + const [index, length, formats, source] = overload( + new Range(10, 1), + 0, + 'bold', + true, + ); + expect(index).toBe(10); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.API); + }); + + it('(range:range, dummy:number, format:object, source:string)', function () { + const [index, length, formats, source] = overload( + new Range(10, 1), + 0, + { bold: true }, + Quill.sources.USER, + ); + expect(index).toBe(10); + expect(length).toBe(1); + expect(formats).toEqual({ bold: true }); + expect(source).toBe(Quill.sources.USER); + }); + }); + + describe('placeholder', function () { + beforeEach(function () { + this.initialize(HTMLElement, '

'); + this.quill = new Quill(this.container.firstChild, { + placeholder: 'a great day to be a placeholder', + }); + this.original = this.quill.getContents(); + }); + + it('blank editor', function () { + expect(this.quill.root.dataset.placeholder).toEqual( + 'a great day to be a placeholder', + ); + expect(this.quill.root.classList).toContain('ql-blank'); + }); + + it('with text', function () { + this.quill.setText('test'); + expect(this.quill.root.classList).not.toContain('ql-blank'); + }); + + it('formatted line', function () { + this.quill.formatLine(0, 1, 'list', 'ordered'); + expect(this.quill.root.classList).not.toContain('ql-blank'); + }); + }); + + describe('scrollSelectionIntoView', function () { + fit('scrolls multiple ancestors', function () { + const scrollingContainers = [ + document.createElement('div'), + document.createElement('div'), + document.createElement('div'), + ]; + + scrollingContainers.reduce((container, node) => { + container.appendChild(node); + return node; + }, this.container); + + scrollingContainers.forEach(node => { + node.style.overflowY = 'auto'; + node.style.height = '100px'; + }); + + this.quill = new Quill(scrollingContainers[2]); + const delta = new Delta(); + for (let i = 0; i < 200; i += 1) { + delta.insert(`line ${i}\n`); + } + this.quill.setContents(delta); + }); + }); +}); diff --git a/test/unit/core/quill.spec.ts b/test/unit/core/quill.spec.ts index 2e0151ba61..ce6f3459a8 100644 --- a/test/unit/core/quill.spec.ts +++ b/test/unit/core/quill.spec.ts @@ -997,4 +997,51 @@ describe('Quill', () => { expect([...quill.root.classList]).not.toContain('ql-blank'); }); }); + + describe('scrollSelectionIntoView', () => { + const getAncestorScrollTops = (element: HTMLElement) => { + const scrollTops: number[] = []; + let current = element.parentElement; + while (current) { + scrollTops.push(current.scrollTop); + current = current.parentElement; + } + return scrollTops; + }; + + test('scrolls multiple ancestors', () => { + 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', + border: '10px solid red', + overflow: 'scroll', + }); + + const quill = new Quill(editorContainer); + + 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 }); + + expect(getAncestorScrollTops(editorContainer)).toEqual([90, 0, 0]); + }); + }); }); diff --git a/themes/base.ts b/themes/base.ts index c3ca4d6590..4fa5dc6193 100644 --- a/themes/base.ts +++ b/themes/base.ts @@ -260,9 +260,7 @@ class BaseTooltip extends Tooltip { } restoreFocus() { - const { scrollTop } = this.quill.scrollingContainer; this.quill.focus(); - this.quill.scrollingContainer.scrollTop = scrollTop; } 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', }}