diff --git a/src/dom/getpositionedancestor.js b/src/dom/getpositionedancestor.js new file mode 100644 index 0000000..8ea17c6 --- /dev/null +++ b/src/dom/getpositionedancestor.js @@ -0,0 +1,28 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +/** + * @module utils/dom/getpositionedancestor + */ + +/** + * For a given element, returns the nearest ancestor element which CSS position is not "static". + * + * @param {HTMLElement} element Native DOM element to be checked. + * @returns {HTMLElement|null} + */ +export default function getPositionedAncestor( element ) { + while ( element && element.tagName.toLowerCase() != 'html' ) { + if ( window.getComputedStyle( element ).position != 'static' ) { + return element; + } + + element = element.parentElement; + } + + return null; +} diff --git a/src/dom/isrange.js b/src/dom/isrange.js new file mode 100644 index 0000000..d65b286 --- /dev/null +++ b/src/dom/isrange.js @@ -0,0 +1,18 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module utils/dom/isrange + */ + +/** + * Checks if the object is a native DOM Range. + * + * @param {*} obj + * @returns {Boolean} + */ +export default function isRange( obj ) { + return Object.prototype.toString.apply( obj ) == '[object Range]'; +} diff --git a/src/dom/position.js b/src/dom/position.js new file mode 100644 index 0000000..e4e8602 --- /dev/null +++ b/src/dom/position.js @@ -0,0 +1,272 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals window */ + +/** + * @module utils/dom/position + */ + +import Rect from './rect.js'; +import getPositionedAncestor from './getpositionedancestor.js'; + +/** + * Calculates the `position: absolute` coordinates of a given element so it can be positioned with respect to the + * target in the visually most efficient way, taking various restrictions like viewport or limiter geometry + * into consideration. + * + * // The element which is to be positioned. + * const element = document.body.querySelector( '#toolbar' ); + * + * // A target to which the element is positioned relatively. + * const target = document.body.querySelector( '#container' ); + * + * // Finding the optimal coordinates for the positioning. + * const { left, top, name } = getOptimalPosition( { + * element: element, + * target: target, + * + * // The algorithm will chose among these positions to meet the requirements such + * // as "limiter" element or "fitInViewport", set below. The positions are considered + * // in the order of the array. + * positions: [ + * // + * // [ Target ] + * // +-----------------+ + * // | Element | + * // +-----------------+ + * // + * targetRect => ( { + * top: targetRect.bottom, + * left: targetRect.left, + * name: 'mySouthEastPosition' + * } ), + * + * // + * // +-----------------+ + * // | Element | + * // +-----------------+ + * // [ Target ] + * // + * ( targetRect, elementRect ) => ( { + * top: targetRect.top - elementRect.height, + * left: targetRect.left, + * name: 'myNorthEastPosition' + * } ) + * ], + * + * // Find a position such guarantees the element remains within visible boundaries of . + * limiter: document.body, + * + * // Find a position such guarantees the element remains within visible boundaries of the browser viewport. + * fitInViewport: true + * } ); + * + * // The best position which fits into document.body and the viewport. May be useful + * // to set proper class on the `element`. + * console.log( name ); -> "myNorthEastPosition" + * + * // Using the absolute coordinates which has been found to position the element + * // as in the diagram depicting the "myNorthEastPosition" position. + * element.style.top = top; + * element.style.left = left; + * + * @param {module:utils/dom/position~Options} options Positioning options object. + * @returns {module:utils/dom/position~Position} + */ +export function getOptimalPosition( { element, target, positions, limiter, fitInViewport } ) { + const positionedElementAncestor = getPositionedAncestor( element.parentElement ); + const elementRect = new Rect( element ); + const targetRect = new Rect( target ); + + let bestPosition; + let name; + + // If there are no limits, just grab the very first position and be done with that drama. + if ( !limiter && !fitInViewport ) { + [ name, bestPosition ] = getPosition( positions[ 0 ], targetRect, elementRect ); + } else { + const limiterRect = limiter && new Rect( limiter ); + const viewportRect = fitInViewport && Rect.getViewportRect(); + + [ name, bestPosition ] = + getBestPosition( positions, targetRect, elementRect, limiterRect, viewportRect ) || + // If there's no best position found, i.e. when all intersections have no area because + // rects have no width or height, then just use the first available position. + getPosition( positions[ 0 ], targetRect, elementRect ); + } + + let { left, top } = getAbsoluteRectCoordinates( bestPosition ); + + // (#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. + if ( positionedElementAncestor ) { + const ancestorPosition = getAbsoluteRectCoordinates( new Rect( positionedElementAncestor ) ); + + left -= ancestorPosition.left; + top -= ancestorPosition.top; + } + + return { left, top, name }; +} + +// For given position function, returns a corresponding `Rect` instance. +// +// @private +// @param {Function} position A function returning {@link module:utils/dom/position~Position}. +// @param {utils/dom/rect~Rect} targetRect A rect of the target. +// @param {utils/dom/rect~Rect} elementRect A rect of positioned element. +// @returns {Array} An array containing position name and its Rect. +function getPosition( position, targetRect, elementRect ) { + const { left, top, name } = position( targetRect, elementRect ); + + return [ name, elementRect.clone().moveTo( left, top ) ]; +} + +// For a given array of positioning functions, returns such that provides the best +// fit of the `elementRect` into the `limiterRect` and `viewportRect`. +// +// @private +// @param {module:utils/dom/position~Options#positions} positions Functions returning +// {@link module:utils/dom/position~Position} to be checked, in the order of preference. +// @param {utils/dom/rect~Rect} targetRect A rect of the {@link module:utils/dom/position~Options#target}. +// @param {utils/dom/rect~Rect} elementRect A rect of positioned {@link module:utils/dom/position~Options#element}. +// @param {utils/dom/rect~Rect} limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}. +// @param {utils/dom/rect~Rect} viewportRect A rect of the viewport. +// @returns {Array} An array containing the name of the position and it's rect. +function getBestPosition( positions, targetRect, elementRect, limiterRect, viewportRect ) { + let maxLimiterIntersectArea = 0; + let maxViewportIntersectArea = 0; + let bestPositionRect; + let bestPositionName; + + // This is when element is fully visible. + const elementRectArea = elementRect.getArea(); + + positions.some( position => { + const [ positionName, positionRect ] = getPosition( position, targetRect, elementRect ); + let limiterIntersectArea; + let viewportIntersectArea; + + if ( limiterRect ) { + if ( viewportRect ) { + // Consider only the part of the limiter which is visible in the viewport. So the limiter is getting limited. + const limiterViewportIntersectRect = limiterRect.getIntersection( viewportRect ); + + if ( limiterViewportIntersectRect ) { + // If the limiter is within the viewport, then check the intersection between that part of the + // limiter and actual position. + limiterIntersectArea = limiterViewportIntersectRect.getIntersectionArea( positionRect ); + } else { + limiterIntersectArea = 0; + } + } else { + limiterIntersectArea = limiterRect.getIntersectionArea( positionRect ); + } + } + + if ( viewportRect ) { + viewportIntersectArea = viewportRect.getIntersectionArea( positionRect ); + } + + // The only criterion: intersection with the viewport. + if ( viewportRect && !limiterRect ) { + if ( viewportIntersectArea > maxViewportIntersectArea ) { + setBestPosition(); + } + } + // The only criterion: intersection with the limiter. + else if ( !viewportRect && limiterRect ) { + if ( limiterIntersectArea > maxLimiterIntersectArea ) { + setBestPosition(); + } + } + // Two criteria: intersection with the viewport and the limiter visible in the viewport. + else { + if ( viewportIntersectArea > maxViewportIntersectArea && limiterIntersectArea >= maxLimiterIntersectArea ) { + setBestPosition(); + } else if ( viewportIntersectArea >= maxViewportIntersectArea && limiterIntersectArea > maxLimiterIntersectArea ) { + setBestPosition(); + } + } + + function setBestPosition() { + maxViewportIntersectArea = viewportIntersectArea; + maxLimiterIntersectArea = limiterIntersectArea; + bestPositionRect = positionRect; + bestPositionName = positionName; + } + + // If a such position is found that element is fully container by the limiter then, obviously, + // there will be no better one, so finishing. + return limiterIntersectArea === elementRectArea; + } ); + + return bestPositionRect ? [ bestPositionName, bestPositionRect ] : null; +} + +// DOMRect (also Rect) works in a scroll–independent geometry but `position: absolute` doesn't. +// This function converts Rect to `position: absolute` coordinates. +// +// @private +// @param {utils/dom/rect~Rect} rect A rect to be converted. +// @returns {Object} Object containing `left` and `top` properties, in absolute coordinates. +function getAbsoluteRectCoordinates( { left, top } ) { + return { + left: left + window.scrollX, + top: top + window.scrollY, + }; +} + +/** + * The `getOptimalPosition` helper options. + * + * @interface module:utils/dom/position~Options + */ + +/** + * Element that is to be positioned. + * + * @member {HTMLElement} #element + */ + +/** + * Target with respect to which the `element` is to be positioned. + * + * @member {HTMLElement|Range|ClientRect} #target + */ + +/** + * An array of functions which return {@link module:utils/dom/position~Position} relative + * to the `target`, in the order of preference. + * + * @member {Array.} #positions + */ + +/** + * When set, the algorithm will chose position which fits the most in the + * limiter's bounding rect. + * + * @member {HTMLElement|Range|ClientRect} #limiter + */ + +/** + * When set, the algorithm will chose such a position which fits `element` + * the most inside visible viewport. + * + * @member {Boolean} #fitInViewport + */ + +/** + * An object describing a position in `position: absolute` coordinate + * system, along with position name. + * + * @typedef {Object} module:utils/dom/position~Position + * + * @property {Number} top Top position offset. + * @property {Number} left Left position offset. + * @property {String} name Name of the position. + */ diff --git a/src/dom/rect.js b/src/dom/rect.js new file mode 100644 index 0000000..c78d5b1 --- /dev/null +++ b/src/dom/rect.js @@ -0,0 +1,200 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global window */ + +/** + * @module utils/dom/rect + */ + +import isRange from './isrange.js'; +import isElement from '../../utils/lib/lodash/isElement.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 { + /** + * 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 out of an object. + * const rectC = new Rect( { top: 0, right: 10, bottom: 10, left: 0, width: 10, height: 10 } ); + * + * // Rect out of another Rect instance. + * const rectD = new Rect( rectC ); + * + * // Rect out of a ClientRect. + * const rectE = new Rect( document.body.getClientRects().item( 0 ) ); + * + * @param {HTMLElement|Range|ClientRect|module:utils/dom/rect~Rect|Object} obj A source object to create the rect. + */ + constructor( obj ) { + if ( isElement( obj ) || isRange( obj ) ) { + obj = obj.getBoundingClientRect(); + } + + rectProperties.forEach( p => this[ p ] = obj[ p ] ); + + /** + * 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 rect of the web browser viewport. + * + * @returns {module:utils/dom/rect~Rect} A viewport rect. + */ + static getViewportRect() { + const { innerWidth, innerHeight } = window; + + return new Rect( { + top: 0, + right: innerWidth, + bottom: innerHeight, + left: 0, + width: innerWidth, + height: innerHeight + } ); + } +} diff --git a/tests/dom/getpositionedancestor.js b/tests/dom/getpositionedancestor.js new file mode 100644 index 0000000..801766d --- /dev/null +++ b/tests/dom/getpositionedancestor.js @@ -0,0 +1,54 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global document */ + +import getPositionedAncestor from 'ckeditor5/utils/dom/getpositionedancestor.js'; + +describe( 'getPositionedAncestor', () => { + let element; + + beforeEach( () => { + element = document.createElement( 'a' ); + + document.body.appendChild( element ); + } ); + + it( 'should return null when there is no element', () => { + expect( getPositionedAncestor() ).to.be.null; + } ); + + it( 'should return null when there is no parent', () => { + expect( getPositionedAncestor( element ) ).to.be.null; + } ); + + it( 'should consider passed element', () => { + element.style.position = 'relative'; + + expect( getPositionedAncestor( element ) ).to.equal( element ); + } ); + + it( 'should find the positioned ancestor (direct parent)', () => { + const parent = document.createElement( 'div' ); + + parent.appendChild( element ); + document.body.appendChild( parent ); + parent.style.position = 'absolute'; + + expect( getPositionedAncestor( element ) ).to.equal( parent ); + } ); + + it( 'should find the positioned ancestor (far ancestor)', () => { + const parentA = document.createElement( 'div' ); + const parentB = document.createElement( 'div' ); + + parentB.appendChild( element ); + parentA.appendChild( parentB ); + document.body.appendChild( parentA ); + parentA.style.position = 'absolute'; + + expect( getPositionedAncestor( element ) ).to.equal( parentA ); + } ); +} ); diff --git a/tests/dom/isrange.js b/tests/dom/isrange.js new file mode 100644 index 0000000..7cf0d80 --- /dev/null +++ b/tests/dom/isrange.js @@ -0,0 +1,20 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global Range */ + +import isRange from 'ckeditor5/utils/dom/isrange.js'; + +describe( 'isRange()', () => { + it( 'detects native DOM Range', () => { + expect( isRange( new Range() ) ).to.be.true; + + expect( isRange( {} ) ).to.be.false; + expect( isRange( null ) ).to.be.false; + expect( isRange( undefined ) ).to.be.false; + expect( isRange( new Date() ) ).to.be.false; + expect( isRange( 42 ) ).to.be.false; + } ); +} ); diff --git a/tests/dom/position.js b/tests/dom/position.js new file mode 100644 index 0000000..fb9087b --- /dev/null +++ b/tests/dom/position.js @@ -0,0 +1,418 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global document, window */ + +import { getOptimalPosition } from 'ckeditor5/utils/dom/position.js'; +import Rect from 'ckeditor5/utils/dom/rect.js'; +import testUtils from 'tests/core/_utils/utils.js'; + +testUtils.createSinonSandbox(); + +let element, target, limiter, revertWindowScroll; + +describe( 'getOptimalPosition', () => { + beforeEach( () => { + // Give us a lot of space. + testUtils.sinon.stub( Rect, 'getViewportRect' ).returns( new Rect( { + top: 0, + right: 10000, + bottom: 10000, + left: 0, + width: 10000, + height: 10000 + } ) ); + } ); + + afterEach( () => { + if ( revertWindowScroll ) { + revertWindowScroll(); + } + } ); + + describe( 'for single position', () => { + beforeEach( setElementTargetPlayground ); + + it( 'should return coordinates', () => { + assertPosition( { element, target, positions: [ attachLeft ] }, { + top: 100, + left: 80, + name: 'left' + } ); + } ); + + it( 'should return coordinates (window scroll)', () => { + stubWindowScroll( 100, 100 ); + + assertPosition( { element, target, positions: [ attachLeft ] }, { + top: 200, + left: 180, + name: 'left' + } ); + } ); + + it( 'should return coordinates (positioned element parent)', () => { + const positionedParent = document.createElement( 'div' ); + stubWindowScroll( 1000, 1000 ); + + Object.assign( positionedParent.style, { + position: 'absolute', + top: '1000px', + left: '1000px' + } ); + + document.body.appendChild( positionedParent ); + positionedParent.appendChild( element ); + + assertPosition( { element, target, positions: [ attachLeft ] }, { + top: -900, + left: -920, + name: 'left' + } ); + } ); + } ); + + describe( 'for multiple positions', () => { + beforeEach( setElementTargetPlayground ); + + it( 'should return coordinates', () => { + assertPosition( { + element, target, + positions: [ attachLeft, attachRight ] + }, { + top: 100, + left: 80, + name: 'left' + } ); + } ); + + it( 'should return coordinates (position preference order)', () => { + assertPosition( { + element, target, + positions: [ attachRight, attachLeft ] + }, { + top: 100, + left: 110, + name: 'right' + } ); + } ); + } ); + + describe( 'with a limiter', () => { + beforeEach( setElementTargetLimiterPlayground ); + + it( 'should return coordinates (#1)', () => { + assertPosition( { + element, target, limiter, + positions: [ attachLeft, attachRight ] + }, { + top: 100, + left: -20, + name: 'left' + } ); + } ); + + it( 'should return coordinates (#2)', () => { + assertPosition( { + element, target, limiter, + positions: [ attachRight, attachLeft ] + }, { + top: 100, + left: -20, + name: 'left' + } ); + } ); + } ); + + describe( 'with fitInViewport on', () => { + beforeEach( setElementTargetLimiterPlayground ); + + it( 'should return coordinates (#1)', () => { + assertPosition( { + element, target, + positions: [ attachLeft, attachRight ], + fitInViewport: true + }, { + top: 100, + left: 10, + name: 'right' + } ); + } ); + + it( 'should return coordinates (#2)', () => { + assertPosition( { + element, target, + positions: [ attachRight, attachLeft ], + fitInViewport: true + }, { + top: 100, + left: 10, + name: 'right' + } ); + } ); + + it( 'should return coordinates (#3)', () => { + assertPosition( { + element, target, + positions: [ attachLeft, attachBottom, attachRight ], + fitInViewport: true + }, { + top: 110, + left: 0, + name: 'bottom' + } ); + } ); + } ); + + describe( 'with limiter and fitInViewport on', () => { + beforeEach( setElementTargetLimiterPlayground ); + + it( 'should return coordinates (#1)', () => { + assertPosition( { + element, target, limiter, + positions: [ attachLeft, attachRight ], + fitInViewport: true + }, { + top: 100, + left: 10, + name: 'right' + } ); + } ); + + it( 'should return coordinates (#2)', () => { + assertPosition( { + element, target, limiter, + positions: [ attachRight, attachLeft ], + fitInViewport: true + }, { + top: 100, + left: 10, + name: 'right' + } ); + } ); + + it( 'should return coordinates (#3)', () => { + assertPosition( { + element, target, limiter, + positions: [ attachRight, attachLeft, attachBottom ], + fitInViewport: true + }, { + top: 110, + left: 0, + name: 'bottom' + } ); + } ); + + it( 'should return coordinates (#4)', () => { + assertPosition( { + element, target, limiter, + positions: [ attachTop, attachRight ], + fitInViewport: true + }, { + top: 100, + left: 10, + name: 'right' + } ); + } ); + + it( 'should return the very first coordinates if no fitting position with a positive intersection has been found', () => { + assertPosition( { + element, target, limiter, + positions: [ + () => ( { + left: -10000, + top: -10000, + name: 'no-intersect-position' + } ) + ], + fitInViewport: true + }, { + left: -10000, + top: -10000, + name: 'no-intersect-position' + } ); + } ); + + it( 'should return the very first coordinates if limiter does not fit into the viewport', () => { + stubElementRect( limiter, { + top: -100, + right: -80, + bottom: -80, + left: -100, + width: 20, + height: 20 + } ); + + assertPosition( { + element, target, limiter, + positions: [ attachRight, attachTop ], + fitInViewport: true + }, { + top: 100, + left: 10, + name: 'right' + } ); + } ); + } ); +} ); + +function assertPosition( options, expected ) { + const position = getOptimalPosition( options ); + + expect( position ).to.deep.equal( expected ); +} + +// +--------+-----+ +// | E | T | +// +--------+-----+ +const attachLeft = ( targetRect, elementRect ) => ( { + top: targetRect.top, + left: targetRect.left - elementRect.width, + name: 'left' +} ); + +// +-----+--------+ +// | T | E | +// +-----+--------+ +const attachRight = ( targetRect ) => ( { + top: targetRect.top, + left: targetRect.left + targetRect.width, + name: 'right' +} ); + +// +-----+ +// | T | +// +-----+--+ +// | E | +// +--------+ +const attachBottom = ( targetRect ) => ( { + top: targetRect.bottom, + left: targetRect.left, + name: 'bottom' +} ); + +// +--------+ +// | E | +// +--+-----+ +// | T | +// +-----+ +const attachTop = ( targetRect, elementRect ) => ( { + top: targetRect.top - elementRect.height, + left: targetRect.left - ( elementRect.width - targetRect.width ), + name: 'bottom' +} ); + +function stubWindowScroll( x, y ) { + const { scrollX: savedX, scrollY: savedY } = window; + + window.scrollX = x; + window.scrollY = y; + + revertWindowScroll = () => { + window.scrollX = savedX; + window.scrollY = savedY; + + revertWindowScroll = null; + }; +} + +function stubElementRect( element, rect ) { + if ( element.getBoundingClientRect.restore ) { + element.getBoundingClientRect.restore(); + } + + testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( rect ); +} + +// <-- 100px -> +// +// ^ +--------------[ Viewport ]---------- +// | | +// 100px | +// | | <-- 10px --> +// V | +// | ^ +---------+ +// | | | | +// | 10px | T | +// | | | | +// | V +---------+ +// | +// +function setElementTargetPlayground() { + element = document.createElement( 'div' ); + target = document.createElement( 'div' ); + + stubElementRect( element, { + top: 0, + right: 20, + bottom: 20, + left: 0, + width: 20, + height: 20 + } ); + + stubElementRect( target, { + top: 100, + right: 110, + bottom: 110, + left: 100, + width: 10, + height: 10 + } ); +} + +// +// +// ^ +-----------[ Viewport ]---------------------- +// | | +// 100px | +// | <--------- 20px -------> +// | <-- 10px --> +// V | +// +------------+---------+ ^ ^ +// | | | | | +// | | T | 10px | +// | | | | | +// | +---------+ V 20px +// | | | | +// | | | | +// | | | | +// +------[ Limiter ]-----+ V +// | +// | +// +// +function setElementTargetLimiterPlayground() { + element = document.createElement( 'div' ); + target = document.createElement( 'div' ); + limiter = document.createElement( 'div' ); + + stubElementRect( element, { + top: 0, + right: 20, + bottom: 20, + left: 0, + width: 20, + height: 20 + } ); + + stubElementRect( limiter, { + top: 100, + right: 10, + bottom: 120, + left: -10, + width: 20, + height: 20 + } ); + + stubElementRect( target, { + top: 100, + right: 10, + bottom: 110, + left: 0, + width: 10, + height: 10 + } ); +} diff --git a/tests/dom/rect.js b/tests/dom/rect.js new file mode 100644 index 0000000..a2e7b7b --- /dev/null +++ b/tests/dom/rect.js @@ -0,0 +1,347 @@ +/** + * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* global document, window */ + +import Rect from 'ckeditor5/utils/dom/rect.js'; +import testUtils from 'tests/core/_utils/utils.js'; + +testUtils.createSinonSandbox(); + +describe( 'Rect', () => { + let geometry; + + beforeEach( () => { + geometry = { + top: 10, + right: 40, + bottom: 30, + left: 20, + width: 20, + height: 20 + }; + } ); + + describe( 'constructor()', () => { + it( 'should accept HTMLElement', () => { + const element = document.createElement( 'div' ); + + testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( geometry ); + + assertRect( new Rect( element ), geometry ); + } ); + + it( 'should accept Range', () => { + const range = document.createRange(); + + testUtils.sinon.stub( range, 'getBoundingClientRect' ).returns( geometry ); + + assertRect( new Rect( range ), geometry ); + } ); + + it( 'should accept Rect', () => { + const sourceRect = new Rect( geometry ); + const rect = new Rect( sourceRect ); + + expect( rect ).to.not.equal( sourceRect ); + assertRect( rect, geometry ); + } ); + + it( 'should accept ClientRect', () => { + const clientRect = document.body.getBoundingClientRect(); + const { top, right, bottom, left, width, height } = clientRect; + const rect = new Rect( clientRect ); + + assertRect( rect, { top, right, bottom, left, width, height } ); + } ); + + it( 'should accept geometry object', () => { + assertRect( new Rect( geometry ), geometry ); + } ); + + it( 'should copy the properties (Rect)', () => { + const sourceGeometry = Object.assign( {}, geometry ); + const sourceRect = new Rect( geometry ); + const rect = new Rect( sourceRect ); + + assertRect( rect, geometry ); + + rect.top = 100; + rect.width = 200; + + assertRect( sourceRect, sourceGeometry ); + } ); + + it( 'should copy the properties (geomerty object)', () => { + const sourceGeometry = Object.assign( {}, geometry ); + const rect = new Rect( geometry ); + + assertRect( rect, geometry ); + + rect.top = 100; + rect.width = 200; + + assertRect( geometry, sourceGeometry ); + } ); + } ); + + describe( 'clone()', () => { + it( 'should clone the source rect', () => { + const rect = new Rect( geometry ); + const clone = rect.clone(); + + expect( clone ).to.be.instanceOf( Rect ); + expect( clone ).not.equal( rect ); + assertRect( clone, rect ); + } ); + } ); + + describe( 'moveTo()', () => { + it( 'should return the source rect', () => { + const rect = new Rect( geometry ); + const returned = rect.moveTo( 100, 200 ); + + expect( returned ).to.equal( rect ); + } ); + + it( 'should move the rect', () => { + const rect = new Rect( geometry ); + + rect.moveTo( 100, 200 ); + + assertRect( rect, { + top: 200, + right: 120, + bottom: 220, + left: 100, + width: 20, + height: 20 + } ); + } ); + } ); + + describe( 'moveBy()', () => { + it( 'should return the source rect', () => { + const rect = new Rect( geometry ); + const returned = rect.moveBy( 100, 200 ); + + expect( returned ).to.equal( rect ); + } ); + + it( 'should move the rect', () => { + const rect = new Rect( geometry ); + + rect.moveBy( 100, 200 ); + + assertRect( rect, { + top: 210, + right: 140, + bottom: 230, + left: 120, + width: 20, + height: 20 + } ); + } ); + } ); + + describe( 'getIntersection()', () => { + it( 'should return a new rect', () => { + const rect = new Rect( geometry ); + const insersect = rect.getIntersection( new Rect( geometry ) ); + + expect( insersect ).to.be.instanceOf( Rect ); + expect( insersect ).to.not.equal( rect ); + } ); + + it( 'should calculate the geometry (#1)', () => { + const rectA = new Rect( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const rectB = new Rect( { + top: 50, + right: 150, + bottom: 150, + left: 50, + width: 100, + height: 100 + } ); + + const insersect = rectA.getIntersection( rectB ); + + assertRect( insersect, { + top: 50, + right: 100, + bottom: 100, + left: 50, + width: 50, + height: 50 + } ); + } ); + + it( 'should calculate the geometry (#2)', () => { + const rectA = new Rect( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const rectB = new Rect( { + top: 0, + right: 200, + bottom: 100, + left: 100, + width: 100, + height: 100 + } ); + + const insersect = rectA.getIntersection( rectB ); + + assertRect( insersect, { + top: 0, + right: 100, + bottom: 100, + left: 100, + width: 0, + height: 100 + } ); + } ); + + it( 'should calculate the geometry (#3)', () => { + const rectA = new Rect( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const rectB = new Rect( { + top: 100, + right: 300, + bottom: 200, + left: 200, + width: 100, + height: 100 + } ); + + expect( rectA.getIntersection( rectB ) ).to.be.null; + } ); + } ); + + describe( 'getIntersectionArea()', () => { + it( 'should calculate the area (#1)', () => { + const rectA = new Rect( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const rectB = new Rect( { + top: 50, + right: 150, + bottom: 150, + left: 50, + width: 100, + height: 100 + } ); + + expect( rectA.getIntersectionArea( rectB ) ).to.equal( 2500 ); + } ); + + it( 'should calculate the area (#2)', () => { + const rectA = new Rect( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const rectB = new Rect( { + top: 0, + right: 200, + bottom: 100, + left: 100, + width: 100, + height: 100 + } ); + + expect( rectA.getIntersectionArea( rectB ) ).to.equal( 0 ); + } ); + + it( 'should calculate the area (#3)', () => { + const rectA = new Rect( { + top: 0, + right: 100, + bottom: 100, + left: 0, + width: 100, + height: 100 + } ); + + const rectB = new Rect( { + top: 100, + right: 300, + bottom: 200, + left: 200, + width: 100, + height: 100 + } ); + + expect( rectA.getIntersectionArea( rectB ) ).to.equal( 0 ); + } ); + } ); + + describe( 'getArea()', () => { + it( 'should calculate the area', () => { + const rect = new Rect( { + width: 100, + height: 50 + } ); + + expect( rect.getArea() ).to.equal( 5000 ); + } ); + } ); + + describe( 'getViewportRect()', () => { + it( 'should reaturn a rect', () => { + expect( Rect.getViewportRect() ).to.be.instanceOf( Rect ); + } ); + + it( 'should return the viewport\'s rect', () => { + window.scrollX = 100; + window.scrollY = 200; + window.innerWidth = 1000; + window.innerHeight = 500; + + assertRect( Rect.getViewportRect(), { + top: 0, + right: 1000, + bottom: 500, + left: 0, + width: 1000, + height: 500 + } ); + } ); + } ); +} ); + +function assertRect( rect, expected ) { + expect( rect ).to.deep.equal( expected ); +}