Skip to content

Commit

Permalink
Auto detect scrolling container
Browse files Browse the repository at this point in the history
  • Loading branch information
luin committed Aug 1, 2023
1 parent 837e4c9 commit ea2d802
Show file tree
Hide file tree
Showing 12 changed files with 367 additions and 41 deletions.
38 changes: 26 additions & 12 deletions core/quill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Module from './module';
import Selection, { Range } from './selection';
import Composition from './composition';
import Theme, { ThemeConstructor } from './theme';
import scrollBoundsIntoView from './utils/scrollBoundsIntoView';

const debug = logger('quill');

Expand All @@ -30,7 +31,6 @@ interface Options {
container?: HTMLElement | string;
placeholder?: string;
bounds?: HTMLElement | string | null;
scrollingContainer?: HTMLElement | string | null;
modules?: Record<string, unknown>;
}

Expand All @@ -40,7 +40,6 @@ interface ExpandedOptions extends Omit<Options, 'theme'> {
container: HTMLElement;
modules: Record<string, unknown>;
bounds?: HTMLElement | null;
scrollingContainer?: HTMLElement | null;
}

class Quill {
Expand All @@ -50,7 +49,6 @@ class Quill {
placeholder: '',
readOnly: false,
registry: globalRegistry,
scrollingContainer: null,
theme: 'default',
};
static events = Emitter.events;
Expand Down Expand Up @@ -129,7 +127,6 @@ class Quill {
}
}

scrollingContainer: HTMLElement;
container: HTMLElement;
root: HTMLDivElement;
scroll: Scroll;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
scrollBoundsIntoView(this.root, bounds);
}
}

setContents(
Expand Down Expand Up @@ -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();
}
}
}
Expand Down Expand Up @@ -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]);
}
Expand Down
17 changes: 2 additions & 15 deletions core/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class Selection {

focus() {
if (this.hasFocus()) return;
this.root.focus();
this.root.focus({ preventScroll: true });
this.setRange(this.savedRange);
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 ||
Expand Down
124 changes: 124 additions & 0 deletions core/utils/scrollBoundsIntoView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
export type Bounds = {
bottom: number;
left: number;
right: number;
top: number;
};

const getParentElement = (element: Node): Element | null =>
element.parentElement || (element.getRootNode() as ShadowRoot).host || null;

const getElementBounds = (element: Element): Bounds => {
const bounds = element.getBoundingClientRect();
const scaleX =
('offsetWidth' in element &&
bounds.width / (element as HTMLElement).offsetWidth) ||
1;
const scaleY =
('offsetHeight' in element &&
bounds.height / (element as HTMLElement).offsetHeight) ||
1;
return {
top: bounds.top,
right: bounds.left + element.clientWidth * scaleX,
bottom: bounds.top + element.clientHeight * scaleY,
left: bounds.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 scrollBoundsIntoView = (root: HTMLElement, targetBounds: Bounds) => {
const document = root.ownerDocument;

let bounds = targetBounds;

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,
}
: getElementBounds(current);

const style = getComputedStyle(current);
const scrollDistanceX = getScrollDistance(
bounds.left,
bounds.right,
bounding.left,
bounding.right,
paddingValueToInt(style.scrollPaddingLeft),
paddingValueToInt(style.scrollPaddingRight),
);
const scrollDistanceY = getScrollDistance(
bounds.top,
bounds.bottom,
bounding.top,
bounding.bottom,
paddingValueToInt(style.scrollPaddingTop),
paddingValueToInt(style.scrollPaddingBottom),
);
if (scrollDistanceX || scrollDistanceY) {
if (isDocumentBody) {
document.defaultView?.scrollBy(scrollDistanceX, scrollDistanceY);
} else {
const startX = current.scrollLeft,
startY = current.scrollTop;
if (scrollDistanceY) current.scrollTop += scrollDistanceY;
if (scrollDistanceX) current.scrollLeft += scrollDistanceX;
const scrolledX = current.scrollLeft - startX;
const scrolledY = current.scrollTop - startY;
bounds = {
left: bounds.left - scrolledX,
top: bounds.top - scrolledY,
right: bounds.right - scrolledX,
bottom: bounds.bottom - scrolledY,
};
}
}

current =
isDocumentBody || style.position === 'fixed'
? null
: getParentElement(current);
}
};

export default scrollBoundsIntoView;
2 changes: 1 addition & 1 deletion modules/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ class Clipboard extends Module<ClipboardOptions> {
delta.length() - range.length,
Quill.sources.SILENT,
);
this.quill.scrollIntoView();
this.quill.scrollSelectionIntoView();
}

prepareMatching(container: Element, nodeMatches: WeakMap<Node, Matcher[]>) {
Expand Down
4 changes: 2 additions & 2 deletions modules/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand All @@ -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': {
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit ea2d802

Please sign in to comment.