src/dom/rect.js
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module utils/dom/rect
*/
import isRange from './isrange';
import isWindow from './iswindow';
import getBorderWidths from './getborderwidths';
import isText from './istext';
import { isElement } from 'lodash-es';
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 {
/**
* Creates an instance of rect.
*
* // 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 {HTMLElement|Range|Window|ClientRect|module:utils/dom/rect~Rect|Object} source A source object to create the rect.
*/
constructor( source ) {
const isSourceRange = isRange( source );
/**
* The object this rect is for.
*
* @protected
* @readonly
* @member {HTMLElement|Range|ClientRect|module:utils/dom/rect~Rect|Object} #_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 ( isElement( 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 ) {
copyRectProperties( this, Rect.getDomRangeRects( source )[ 0 ] );
} 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 );
}
/**
* The "top" value of the rect.
*
* @readonly
* @member {Number} #top
*/
/**
* The "right" value of the rect.
*
* @readonly
* @member {Number} #right
*/
/**
* The "bottom" value of the rect.
*
* @readonly
* @member {Number} #bottom
*/
/**
* The "left" value of the rect.
*
* @readonly
* @member {Number} #left
*/
/**
* The "width" value of the rect.
*
* @readonly
* @member {Number} #width
*/
/**
* The "height" value of the rect.
*
* @readonly
* @member {Number} #height
*/
}
/**
* Returns a clone of the rect.
*
* @returns {module:utils/dom/rect~Rect} A cloned rect.
*/
clone() {
return new Rect( this );
}
/**
* Moves the rect so that its upper–left corner lands in desired `[ x, y ]` location.
*
* @param {Number} x Desired horizontal location.
* @param {Number} y Desired vertical location.
* @returns {module:utils/dom/rect~Rect} 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 {Number} x A horizontal offset.
* @param {Number} y A vertical offset
* @returns {module:utils/dom/rect~Rect} 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.
*
* @param {module:utils/dom/rect~Rect} anotherRect
* @returns {module:utils/dom/rect~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 )
};
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
if ( rect.width < 0 || rect.height < 0 ) {
return null;
} else {
return new Rect( rect );
}
}
/**
* Returns the area of intersection with another rect.
*
* @param {module:utils/dom/rect~Rect} anotherRect [description]
* @returns {Number} Area of intersection.
*/
getIntersectionArea( anotherRect ) {
const rect = this.getIntersection( anotherRect );
if ( rect ) {
return rect.getArea();
} else {
return 0;
}
}
/**
* Returns the area of the rect.
*
* @returns {Number}
*/
getArea() {
return this.width * this.height;
}
/**
* Returns a new rect, a part of the original rect, which is actually visible to the user,
* 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.
*
* @returns {module:utils/dom/rect~Rect|null} 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 ) ) {
let parent = source.parentNode || source.commonAncestorContainer;
// Check the ancestors all the way up to the <body>.
while ( parent && !isBody( parent ) ) {
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;
}
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 {module:utils/dom/rect~Rect} rect A rect instance to compare with.
* @returns {Boolean} `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 {module:utils/dom/rect~Rect} anotherRect
* @returns {Boolean} `true` if contains, `false` otherwise.
*/
contains( anotherRect ) {
const intersectRect = this.getIntersection( anotherRect );
return !!( intersectRect && intersectRect.isEqual( anotherRect ) );
}
/**
* 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 {module:utils/dom/rect~Rect} 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( this._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} range A native DOM range.
* @returns {Array.<module:utils/dom/rect~Rect>} 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;
}
}
// Acquires all the rect properties from the passed source.
//
// @private
// @param {module:utils/dom/rect~Rect} rect
// @param {ClientRect|module:utils/dom/rect~Rect|Object} source
function copyRectProperties( rect, source ) {
for ( const p of rectProperties ) {
rect[ p ] = source[ p ];
}
}
// Checks if provided object is a <body> HTML element.
//
// @private
// @param {HTMLElement|Range} elementOrRange
// @returns {Boolean}
function isBody( elementOrRange ) {
if ( !isElement( elementOrRange ) ) {
return false;
}
return elementOrRange === elementOrRange.ownerDocument.body;
}