@ckeditor/ckeditor5-utils
Version:
Miscellaneous utilities used by CKEditor 5.
517 lines (516 loc) • 20 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module utils/dom/rect
*/
import isRange from './isrange.js';
import isWindow from './iswindow.js';
import getBorderWidths from './getborderwidths.js';
import isText from './istext.js';
import getPositionedAncestor from './getpositionedancestor.js';
import global from './global.js';
const rectProperties = ['top', 'right', 'bottom', 'left', 'width', 'height'];
/**
* A helper class representing a `ClientRect` object, e.g. value returned by
* the native `object.getBoundingClientRect()` method. Provides a set of methods
* to manipulate the rect and compare it against other rect instances.
*/
export default class Rect {
/**
* The "top" value of the rect.
*
* @readonly
*/
top;
/**
* The "right" value of the rect.
*
* @readonly
*/
right;
/**
* The "bottom" value of the rect.
*
* @readonly
*/
bottom;
/**
* The "left" value of the rect.
*
* @readonly
*/
left;
/**
* The "width" value of the rect.
*
* @readonly
*/
width;
/**
* The "height" value of the rect.
*
* @readonly
*/
height;
/**
* The object this rect is for.
*
* @readonly
*/
_source;
/**
* Creates an instance of rect.
*
* ```ts
* // Rect of an HTMLElement.
* const rectA = new Rect( document.body );
*
* // Rect of a DOM Range.
* const rectB = new Rect( document.getSelection().getRangeAt( 0 ) );
*
* // Rect of a window (web browser viewport).
* const rectC = new Rect( window );
*
* // Rect out of an object.
* const rectD = new Rect( { top: 0, right: 10, bottom: 10, left: 0, width: 10, height: 10 } );
*
* // Rect out of another Rect instance.
* const rectE = new Rect( rectD );
*
* // Rect out of a ClientRect.
* const rectF = new Rect( document.body.getClientRects().item( 0 ) );
* ```
*
* **Note**: By default a rect of an HTML element includes its CSS borders and scrollbars (if any)
* ant the rect of a `window` includes scrollbars too. Use {@link #excludeScrollbarsAndBorders}
* to get the inner part of the rect.
*
* @param source A source object to create the rect.
*/
constructor(source) {
const isSourceRange = isRange(source);
Object.defineProperty(this, '_source', {
// If the source is a Rect instance, copy it's #_source.
value: source._source || source,
writable: true,
enumerable: false
});
if (isDomElement(source) || isSourceRange) {
// The `Rect` class depends on `getBoundingClientRect` and `getClientRects` DOM methods. If the source
// of a rect in an HTML element or a DOM range but it does not belong to any rendered DOM tree, these methods
// will fail to obtain the geometry and the rect instance makes little sense to the features using it.
// To get rid of this warning make sure the source passed to the constructor is a descendant of `window.document.body`.
// @if CK_DEBUG // const sourceNode = isSourceRange ? source.startContainer : source;
// @if CK_DEBUG // if ( !sourceNode.ownerDocument || !sourceNode.ownerDocument.body.contains( sourceNode ) ) {
// @if CK_DEBUG // console.warn(
// @if CK_DEBUG // 'rect-source-not-in-dom: The source of this rect does not belong to any rendered DOM tree.',
// @if CK_DEBUG // { source } );
// @if CK_DEBUG // }
if (isSourceRange) {
const rangeRects = Rect.getDomRangeRects(source);
copyRectProperties(this, Rect.getBoundingRect(rangeRects));
}
else {
copyRectProperties(this, source.getBoundingClientRect());
}
}
else if (isWindow(source)) {
const { innerWidth, innerHeight } = source;
copyRectProperties(this, {
top: 0,
right: innerWidth,
bottom: innerHeight,
left: 0,
width: innerWidth,
height: innerHeight
});
}
else {
copyRectProperties(this, source);
}
}
/**
* Returns a clone of the rect.
*
* @returns A cloned rect.
*/
clone() {
return new Rect(this);
}
/**
* Moves the rect so that its upper–left corner lands in desired `[ x, y ]` location.
*
* @param x Desired horizontal location.
* @param y Desired vertical location.
* @returns A rect which has been moved.
*/
moveTo(x, y) {
this.top = y;
this.right = x + this.width;
this.bottom = y + this.height;
this.left = x;
return this;
}
/**
* Moves the rect in–place by a dedicated offset.
*
* @param x A horizontal offset.
* @param y A vertical offset
* @returns A rect which has been moved.
*/
moveBy(x, y) {
this.top += y;
this.right += x;
this.left += x;
this.bottom += y;
return this;
}
/**
* Returns a new rect a a result of intersection with another rect.
*/
getIntersection(anotherRect) {
const rect = {
top: Math.max(this.top, anotherRect.top),
right: Math.min(this.right, anotherRect.right),
bottom: Math.min(this.bottom, anotherRect.bottom),
left: Math.max(this.left, anotherRect.left),
width: 0,
height: 0
};
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
if (rect.width < 0 || rect.height < 0) {
return null;
}
else {
const newRect = new Rect(rect);
newRect._source = this._source;
return newRect;
}
}
/**
* Returns the area of intersection with another rect.
*
* @returns Area of intersection.
*/
getIntersectionArea(anotherRect) {
const rect = this.getIntersection(anotherRect);
if (rect) {
return rect.getArea();
}
else {
return 0;
}
}
/**
* Returns the area of the rect.
*/
getArea() {
return this.width * this.height;
}
/**
* Returns a new rect, a part of the original rect, which is actually visible to the user and is relative to the,`body`,
* e.g. an original rect cropped by parent element rects which have `overflow` set in CSS
* other than `"visible"`.
*
* If there's no such visible rect, which is when the rect is limited by one or many of
* the ancestors, `null` is returned.
*
* **Note**: This method does not consider the boundaries of the viewport (window).
* To get a rect cropped by all ancestors and the viewport, use an intersection such as:
*
* ```ts
* const visibleInViewportRect = new Rect( window ).getIntersection( new Rect( source ).getVisible() );
* ```
*
* @returns A visible rect instance or `null`, if there's none.
*/
getVisible() {
const source = this._source;
let visibleRect = this.clone();
// There's no ancestor to crop <body> with the overflow.
if (isBody(source)) {
return visibleRect;
}
let child = source;
let parent = source.parentNode || source.commonAncestorContainer;
let absolutelyPositionedChildElement;
// Check the ancestors all the way up to the <body>.
while (parent && !isBody(parent)) {
const isParentOverflowVisible = getElementOverflow(parent) === 'visible';
if (child instanceof HTMLElement && getElementPosition(child) === 'absolute') {
absolutelyPositionedChildElement = child;
}
const parentElementPosition = getElementPosition(parent);
// The child will be cropped only if it has `position: absolute` and the parent has `position: relative` + some overflow.
// Otherwise there's no chance of visual clipping and the parent can be skipped
// https://github.com/ckeditor/ckeditor5/issues/14107.
//
// condition: isParentOverflowVisible
// +---------------------------+
// | #parent |
// | (overflow: visible) |
// | +-----------+---------------+
// | | child |
// | +-----------+---------------+
// +---------------------------+
//
// condition: absolutelyPositionedChildElement && parentElementPosition === 'relative' && isParentOverflowVisible
// +---------------------------+
// | parent |
// | (position: relative;) |
// | (overflow: visible;) |
// | +-----------+---------------+
// | | child |
// | | (position: absolute;) |
// | +-----------+---------------+
// +---------------------------+
//
// condition: absolutelyPositionedChildElement && parentElementPosition !== 'relative'
// +---------------------------+
// | parent |
// | (position: static;) |
// | +-----------+---------------+
// | | child |
// | | (position: absolute;) |
// | +-----------+---------------+
// +---------------------------+
if (isParentOverflowVisible ||
absolutelyPositionedChildElement && ((parentElementPosition === 'relative' && isParentOverflowVisible) ||
parentElementPosition !== 'relative')) {
child = parent;
parent = parent.parentNode;
continue;
}
const parentRect = new Rect(parent);
const intersectionRect = visibleRect.getIntersection(parentRect);
if (intersectionRect) {
if (intersectionRect.getArea() < visibleRect.getArea()) {
// Reduce the visible rect to the intersection.
visibleRect = intersectionRect;
}
}
else {
// There's no intersection, the rect is completely invisible.
return null;
}
child = parent;
parent = parent.parentNode;
}
return visibleRect;
}
/**
* Checks if all property values ({@link #top}, {@link #left}, {@link #right},
* {@link #bottom}, {@link #width} and {@link #height}) are the equal in both rect
* instances.
*
* @param anotherRect A rect instance to compare with.
* @returns `true` when Rects are equal. `false` otherwise.
*/
isEqual(anotherRect) {
for (const prop of rectProperties) {
if (this[prop] !== anotherRect[prop]) {
return false;
}
}
return true;
}
/**
* Checks whether a rect fully contains another rect instance.
*
* @param anotherRect
* @returns `true` if contains, `false` otherwise.
*/
contains(anotherRect) {
const intersectRect = this.getIntersection(anotherRect);
return !!(intersectRect && intersectRect.isEqual(anotherRect));
}
/**
* Recalculates screen coordinates to coordinates relative to the positioned ancestor offset.
*/
toAbsoluteRect() {
const { scrollX, scrollY } = global.window;
const absoluteRect = this.clone().moveBy(scrollX, scrollY);
if (isDomElement(absoluteRect._source)) {
const positionedAncestor = getPositionedAncestor(absoluteRect._source);
if (positionedAncestor) {
shiftRectToCompensatePositionedAncestor(absoluteRect, positionedAncestor);
}
}
return absoluteRect;
}
/**
* Excludes scrollbars and CSS borders from the rect.
*
* * Borders are removed when {@link #_source} is an HTML element.
* * Scrollbars are excluded from HTML elements and the `window`.
*
* @returns A rect which has been updated.
*/
excludeScrollbarsAndBorders() {
const source = this._source;
let scrollBarWidth, scrollBarHeight, direction;
if (isWindow(source)) {
scrollBarWidth = source.innerWidth - source.document.documentElement.clientWidth;
scrollBarHeight = source.innerHeight - source.document.documentElement.clientHeight;
direction = source.getComputedStyle(source.document.documentElement).direction;
}
else {
const borderWidths = getBorderWidths(source);
scrollBarWidth = source.offsetWidth - source.clientWidth - borderWidths.left - borderWidths.right;
scrollBarHeight = source.offsetHeight - source.clientHeight - borderWidths.top - borderWidths.bottom;
direction = source.ownerDocument.defaultView.getComputedStyle(source).direction;
this.left += borderWidths.left;
this.top += borderWidths.top;
this.right -= borderWidths.right;
this.bottom -= borderWidths.bottom;
this.width = this.right - this.left;
this.height = this.bottom - this.top;
}
this.width -= scrollBarWidth;
if (direction === 'ltr') {
this.right -= scrollBarWidth;
}
else {
this.left += scrollBarWidth;
}
this.height -= scrollBarHeight;
this.bottom -= scrollBarHeight;
return this;
}
/**
* Returns an array of rects of the given native DOM Range.
*
* @param range A native DOM range.
* @returns DOM Range rects.
*/
static getDomRangeRects(range) {
const rects = [];
// Safari does not iterate over ClientRectList using for...of loop.
const clientRects = Array.from(range.getClientRects());
if (clientRects.length) {
for (const rect of clientRects) {
rects.push(new Rect(rect));
}
}
// If there's no client rects for the Range, use parent container's bounding rect
// instead and adjust rect's width to simulate the actual geometry of such range.
// https://github.com/ckeditor/ckeditor5-utils/issues/153
// https://github.com/ckeditor/ckeditor5-ui/issues/317
else {
let startContainer = range.startContainer;
if (isText(startContainer)) {
startContainer = startContainer.parentNode;
}
const rect = new Rect(startContainer.getBoundingClientRect());
rect.right = rect.left;
rect.width = 0;
rects.push(rect);
}
return rects;
}
/**
* Returns a bounding rectangle that contains all the given `rects`.
*
* @param rects A list of rectangles that should be contained in the result rectangle.
* @returns Bounding rectangle or `null` if no `rects` were given.
*/
static getBoundingRect(rects) {
const boundingRectData = {
left: Number.POSITIVE_INFINITY,
top: Number.POSITIVE_INFINITY,
right: Number.NEGATIVE_INFINITY,
bottom: Number.NEGATIVE_INFINITY,
width: 0,
height: 0
};
let rectangleCount = 0;
for (const rect of rects) {
rectangleCount++;
boundingRectData.left = Math.min(boundingRectData.left, rect.left);
boundingRectData.top = Math.min(boundingRectData.top, rect.top);
boundingRectData.right = Math.max(boundingRectData.right, rect.right);
boundingRectData.bottom = Math.max(boundingRectData.bottom, rect.bottom);
}
if (rectangleCount == 0) {
return null;
}
boundingRectData.width = boundingRectData.right - boundingRectData.left;
boundingRectData.height = boundingRectData.bottom - boundingRectData.top;
return new Rect(boundingRectData);
}
}
/**
* Acquires all the rect properties from the passed source.
*/
function copyRectProperties(rect, source) {
for (const p of rectProperties) {
rect[p] = source[p];
}
}
/**
* Checks if provided object is a <body> HTML element.
*/
function isBody(value) {
if (!isDomElement(value)) {
return false;
}
return value === value.ownerDocument.body;
}
/**
* Checks if provided object "looks like" a DOM Element and has API required by `Rect` class.
*/
function isDomElement(value) {
// Note: earlier we used `isElement()` from lodash library, however that function is less performant because
// it makes complicated checks to make sure that given value is a DOM element.
return value !== null && typeof value === 'object' && value.nodeType === 1 && typeof value.getBoundingClientRect === 'function';
}
/**
* Returns the value of the `position` style of an `HTMLElement`.
*/
function getElementPosition(element) {
return element instanceof HTMLElement ? element.ownerDocument.defaultView.getComputedStyle(element).position : 'static';
}
/**
* Returns the value of the `overflow` style of an `HTMLElement` or a `Range`.
*/
function getElementOverflow(element) {
return element instanceof HTMLElement ? element.ownerDocument.defaultView.getComputedStyle(element).overflow : 'visible';
}
/**
* For a given absolute Rect coordinates object and a positioned element ancestor, it updates its
* coordinates that make up for the position and the scroll of the ancestor.
*
* This is necessary because while Rects (and DOMRects) are relative to the browser's viewport, their coordinates
* are used in real–life to position elements with `position: absolute`, which are scoped by any positioned
* (and scrollable) ancestors.
*/
function shiftRectToCompensatePositionedAncestor(rect, positionedElementAncestor) {
const ancestorPosition = new Rect(positionedElementAncestor);
const ancestorBorderWidths = getBorderWidths(positionedElementAncestor);
let moveX = 0;
let moveY = 0;
// (https://github.com/ckeditor/ckeditor5-ui-default/issues/126)
// If there's some positioned ancestor of the panel, then its `Rect` must be taken into
// consideration. `Rect` is always relative to the viewport while `position: absolute` works
// with respect to that positioned ancestor.
moveX -= ancestorPosition.left;
moveY -= ancestorPosition.top;
// (https://github.com/ckeditor/ckeditor5-utils/issues/139)
// If there's some positioned ancestor of the panel, not only its position must be taken into
// consideration (see above) but also its internal scrolls. Scroll have an impact here because `Rect`
// is relative to the viewport (it doesn't care about scrolling), while `position: absolute`
// must compensate that scrolling.
moveX += positionedElementAncestor.scrollLeft;
moveY += positionedElementAncestor.scrollTop;
// (https://github.com/ckeditor/ckeditor5-utils/issues/139)
// If there's some positioned ancestor of the panel, then its `Rect` includes its CSS `borderWidth`
// while `position: absolute` positioning does not consider it.
// E.g. `{ position: absolute, top: 0, left: 0 }` means upper left corner of the element,
// not upper-left corner of its border.
moveX -= ancestorBorderWidths.left;
moveY -= ancestorBorderWidths.top;
rect.moveBy(moveX, moveY);
}