Przeglądaj źródła

Merge pull request #106 from ckeditor/t/105

t/105: Moved rect utilities from BalloonPanelView.
Piotrek Koszuliński 9 lat temu
rodzic
commit
e79a2a6646

+ 28 - 0
packages/ckeditor5-utils/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;
+}

+ 18 - 0
packages/ckeditor5-utils/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]';
+}

+ 272 - 0
packages/ckeditor5-utils/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 <body>.
+ *			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.<Function>} #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.
+ */

+ 200 - 0
packages/ckeditor5-utils/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
+		} );
+	}
+}

+ 54 - 0
packages/ckeditor5-utils/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 );
+	} );
+} );

+ 20 - 0
packages/ckeditor5-utils/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;
+	} );
+} );

+ 418 - 0
packages/ckeditor5-utils/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
+	} );
+}

+ 347 - 0
packages/ckeditor5-utils/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 );
+}