/** * @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 with the overflow. if ( !isBody( source ) ) { let parent = source.parentNode || source.commonAncestorContainer; // Check the ancestors all the way up to the . 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.} 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 HTML element. // // @private // @param {HTMLElement|Range} elementOrRange // @returns {Boolean} function isBody( elementOrRange ) { if ( !isElement( elementOrRange ) ) { return false; } return elementOrRange === elementOrRange.ownerDocument.body; }