Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,658 changes: 974 additions & 684 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions packages/quill/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"@babel/preset-typescript": "^7.23.3",
"@playwright/test": "1.44.1",
"@playwright/test": "^1.54.1",
"@types/highlight.js": "^9.12.4",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.10.0",
"@types/webpack": "^5.28.5",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitest/browser": "^1.1.3",
"@vitest/browser": "^3.2.4",
"babel-loader": "^9.1.3",
"babel-plugin-transform-define": "^2.1.4",
"css-loader": "^6.10.0",
Expand Down Expand Up @@ -53,7 +53,7 @@
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.2",
"vitest": "^1.1.3",
"vitest": "^3.2.4",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
Expand Down
13 changes: 8 additions & 5 deletions packages/quill/src/core/quill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import Composition from './composition.js';
import Theme from './theme.js';
import type { ThemeConstructor } from './theme.js';
import scrollRectIntoView from './utils/scrollRectIntoView.js';
import type { Rect } from './utils/scrollRectIntoView.js';
import type {
Rect,
ScrollRectIntoViewOptions,
} from './utils/scrollRectIntoView.js';
import createRegistryWithFormats from './utils/createRegistryWithFormats.js';

const debug = logger('quill');
Expand Down Expand Up @@ -670,8 +673,8 @@ class Quill {
);
}

scrollRectIntoView(rect: Rect) {
scrollRectIntoView(this.root, rect);
scrollRectIntoView(rect: Rect, options: ScrollRectIntoViewOptions = {}) {
scrollRectIntoView(this.root, rect, options);
}

/**
Expand All @@ -688,11 +691,11 @@ class Quill {
* Scroll the current selection into the visible area.
* If the selection is already visible, no scrolling will occur.
*/
scrollSelectionIntoView() {
scrollSelectionIntoView(options: ScrollRectIntoViewOptions = {}) {
const range = this.selection.lastRange;
const bounds = range && this.selection.getBounds(range.index, range.length);
if (bounds) {
this.scrollRectIntoView(bounds);
this.scrollRectIntoView(bounds, options);
}
}

Expand Down
43 changes: 41 additions & 2 deletions packages/quill/src/core/utils/scrollRectIntoView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,27 @@ const getScrollDistance = (
return 0;
};

const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => {
interface ScrollOffsetRecord {
element: Element | Window;
left: number;
top: number;
}

export interface ScrollRectIntoViewOptions {
smooth?: boolean;
}

const scrollRectIntoView = (
root: HTMLElement,
targetRect: Rect,
options: ScrollRectIntoViewOptions = {},
) => {
const document = root.ownerDocument;

let rect = targetRect;

const records: ScrollOffsetRecord[] = [];

let current: Element | null = root;
while (current) {
const isDocumentBody: boolean = current === document.body;
Expand Down Expand Up @@ -97,7 +113,14 @@ const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => {
);
if (scrollDistanceX || scrollDistanceY) {
if (isDocumentBody) {
document.defaultView?.scrollBy(scrollDistanceX, scrollDistanceY);
if (document.defaultView) {
records.push({
element: document.defaultView,
left: scrollDistanceX,
top: scrollDistanceY,
});
document.defaultView.scrollBy(scrollDistanceX, scrollDistanceY);
}
} else {
const { scrollLeft, scrollTop } = current;
if (scrollDistanceY) {
Expand All @@ -106,6 +129,13 @@ const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => {
if (scrollDistanceX) {
current.scrollLeft += scrollDistanceX;
}

records.push({
element: current,
left: scrollDistanceX,
top: scrollDistanceY,
});

const scrolledLeft = current.scrollLeft - scrollLeft;
const scrolledTop = current.scrollTop - scrollTop;
rect = {
Expand All @@ -122,6 +152,15 @@ const scrollRectIntoView = (root: HTMLElement, targetRect: Rect) => {
? null
: getParentElement(current);
}

if (options.smooth) {
// Revert all the changes in the scroll position
// and then scroll to the target position with smooth animation
records.forEach(({ element, top, left }) => {
element.scrollBy({ top: -top, left: -left, behavior: 'instant' });
element.scrollBy({ top, left, behavior: 'smooth' });
Comment on lines +157 to +161
Copy link

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The smooth scrolling implementation reverts all scroll changes and then re-applies them with animation. This could cause visible flashing or jarring movement, especially with multiple nested scrollable elements. Consider implementing smooth scrolling without the revert-and-replay pattern, or add a check to skip this when no actual scrolling occurred.

Suggested change
// Revert all the changes in the scroll position
// and then scroll to the target position with smooth animation
records.forEach(({ element, top, left }) => {
element.scrollBy({ top: -top, left: -left, behavior: 'instant' });
element.scrollBy({ top, left, behavior: 'smooth' });
// Smoothly scroll to the target position if scrolling is required
records.forEach(({ element, top, left }) => {
if (top !== 0 || left !== 0) {
element.scrollBy({ top, left, behavior: 'smooth' });
}

Copilot uses AI. Check for mistakes.
});
}
};

export default scrollRectIntoView;
4 changes: 2 additions & 2 deletions packages/quill/test/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ export const test = base.extend<{

export const CHAPTER = 'Chapter 1. Loomings.';
export const P1 =
'Call me Ishmael. Some years ago—never mind how long precisely-having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people’s hats off—then, I account it high time to get to sea as soon as I can. This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me.';
'Call me Ishmael. Some years ago—never mind how long precisely-having little or no money in my purse, and nothing particular to interest me on shore.';
export const P2 =
'There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs—commerce surrounds it with her surf. Right and left, the streets take you waterward. Its extreme downtown is the battery, where that noble mole is washed by waves, and cooled by breezes, which a few hours previous were out of sight of land. Look at the crowds of water-gazers there.';
'There now is your insular city of the Manhattoes, belted round by wharves as Indian isles by coral reefs—commerce surrounds it with her surf.';
14 changes: 10 additions & 4 deletions packages/quill/test/e2e/list.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from '@playwright/test';
import { test } from './fixtures/index.js';
import { isMac } from './utils/index.js';
import { isMac, sleep } from './utils/index.js';

const listTypes = ['bullet', 'checked'];

Expand All @@ -11,20 +11,24 @@ test.describe('list', () => {

for (const list of listTypes) {
test.describe(`navigation with shortcuts ${list}`, () => {
test('jump to line start', async ({ page, editorPage }) => {
test('jump to line start', async ({ editorPage }) => {
await editorPage.setContents([
{ insert: 'item 1' },
{ insert: '\n', attributes: { list } },
]);

await editorPage.root.click(); // required by Firefox
await editorPage.moveCursorAfterText('item 1');
await page.keyboard.press(isMac ? `Meta+ArrowLeft` : 'Home');
await editorPage.root.press(isMac ? `Meta+ArrowLeft` : 'Home');
await sleep(500); // internal(uiNode): wait for selectionchange to fire

expect(await editorPage.getSelection()).toEqual({
index: 0,
length: 0,
});

await page.keyboard.type('start ');
await sleep(500); // internal(uiNode): wait for selectionchange to fire
Comment on lines +23 to +30
Copy link

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coded 500ms sleep delays make tests brittle and slow. Consider using more robust waiting mechanisms like waitFor with condition checks or event listeners instead of arbitrary timeouts.

Copilot uses AI. Check for mistakes.
await editorPage.root.pressSequentially('start ');
expect(await editorPage.getContents()).toEqual([
{ insert: 'start item 1' },
{ insert: '\n', attributes: { list } },
Expand All @@ -49,6 +53,7 @@ test.describe('list', () => {
length: 0,
});
await page.keyboard.press('ArrowRight');
await sleep(500); // internal(uiNode): wait for selectionchange to fire
await page.keyboard.press('ArrowRight');
expect(await editorPage.getSelection()).toEqual({
index: firstLine.length + 2,
Expand All @@ -73,6 +78,7 @@ test.describe('list', () => {
length: 0,
});
await page.keyboard.press('ArrowLeft');
await sleep(500); // internal(uiNode): wait for selectionchange to fire
await page.keyboard.press('ArrowLeft');
expect(await editorPage.getSelection()).toEqual({
index: firstLine.length + 2,
Expand Down
7 changes: 7 additions & 0 deletions packages/quill/test/e2e/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ export function getSelectionInTextNode() {
focusOffset,
]);
}

export const sleep = (ms: number) =>
new Promise<void>((r) => {
setTimeout(() => {
r();
}, ms);
});
2 changes: 2 additions & 0 deletions packages/quill/test/fuzz/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ export default defineConfig({
test: {
include: ['test/fuzz/**/*.spec.ts'],
environment: 'jsdom',
testTimeout: 40000,
pool: 'threads',
},
});
5 changes: 5 additions & 0 deletions packages/quill/test/types/quill.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ const quill = new Quill('#editor');

{
quill.scrollSelectionIntoView();
quill.scrollSelectionIntoView({ smooth: true });
}

{
Expand All @@ -220,6 +221,10 @@ const quill = new Quill('#editor');
quill.scrollRectIntoView(
document.createElement('div').getBoundingClientRect(),
);
quill.scrollRectIntoView(
document.createElement('div').getBoundingClientRect(),
{ smooth: true },
);
}

{
Expand Down
61 changes: 58 additions & 3 deletions packages/quill/test/unit/core/quill.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import '../../../src/quill.js';
import Delta from 'quill-delta';
import { LeafBlot, Registry } from 'parchment';
import { afterEach, beforeEach, describe, expect, test, vitest } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
test,
vitest,
vi,
} from 'vitest';
import type { MockedFunction } from 'vitest';
import Emitter from '../../../src/core/emitter.js';
import Theme from '../../../src/core/theme.js';
Expand Down Expand Up @@ -1309,12 +1317,12 @@ describe('Quill', () => {
await viewportRatio(
container.querySelector('p:nth-child(10)') as HTMLElement,
),
).toBe(1);
).toBeGreaterThan(0.9);
expect(
await viewportRatio(
container.querySelector('p:nth-child(11)') as HTMLElement,
),
).toBe(1);
).toBeGreaterThan(0.9);
quill.root.style.scrollPaddingBottom = '0';
quill.setSelection(1, 'user');
quill.setSelection({ index: text.indexOf('text 10'), length: 4 }, 'user');
Expand Down Expand Up @@ -1376,5 +1384,52 @@ describe('Quill', () => {
),
).toEqual(0);
});

test('scroll smoothly', 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 },
'silent',
);
quill.scrollSelectionIntoView({ smooth: true });

await vi.waitFor(async () => {
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);
});
});
});
});
1 change: 0 additions & 1 deletion packages/quill/test/unit/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export default defineConfig({
enabled: true,
provider: 'playwright',
name: process.env.BROWSER || 'chromium',
slowHijackESM: false,
},
},
});