8
0
Эх сурвалжийг харах

Added getOptimalPosition helper with tests.

Aleksander Nowodzinski 9 жил өмнө
parent
commit
9440ea2b9c

+ 180 - 0
packages/ckeditor5-utils/src/dom/position.js

@@ -0,0 +1,180 @@
+/**
+ * @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.
+ *
+ * TODO: more docs and example.
+ *
+ * @param {module:utils/dom/position~Options} options Positioning options object.
+ * @returns {Object} An object containing CSS `top`, `left` coordinates ready to use with `position: absolute` and the `name`
+ * of the position.
+ */
+export function getOptimalPosition( { element, target, positions, limiter, fitInViewport } ) {
+	const elementRect = new Rect( element );
+	const targetRect = new Rect( target );
+	const positionedElementAncestor = getPositionedAncestor( element.parentElement );
+
+	let bestPositionName;
+	let left;
+	let top;
+
+	// If there are no limits, just grab the very first position and be done with that drama.
+	if ( !limiter && !fitInViewport ) {
+		[ bestPositionName, { left, top } ] = getPositionRect( positions[ 0 ], targetRect, elementRect );
+	} else {
+		const limiterRect = limiter && new Rect( limiter );
+		const viewportRect = fitInViewport && Rect.getViewportRect();
+
+		[ bestPositionName, { left, top } ] = getBestPositionRect( positions, targetRect, elementRect, limiterRect, viewportRect );
+	}
+
+	// (#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 { top: ancestorTop, left: ancestorLeft } = new Rect( positionedElementAncestor );
+
+		top -= ancestorTop;
+		left -= ancestorLeft;
+	}
+
+	// DOMRect works in a scroll–independent geometry but `position: absolute` doesn't.
+	// Let's fix it at this stage.
+	top += window.scrollY;
+	left += window.scrollX;
+
+	return {
+		top, left,
+		name: bestPositionName
+	};
+}
+
+// For given position function, returns a corresponding `Rect` instance.
+//
+// @private
+// @param {Function} position A function returning {@link module:utils/dom/position~Position}.
+// @param {Rect} targetRect A rect of the target.
+// @param {Rect} elementRect A rect of positioned element.
+// @returns {Array} An array containing position name and its Rect.
+function getPositionRect( 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 {Rect} targetRect A rect of the {@link module:utils/dom/position~Options#target}.
+// @param {Rect} elementRect A rect of positioned {@link module:utils/dom/position~Options#element}.
+// @param {Rect} limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}.
+// @param {Rect} viewportRect A rect of the viewport.
+// @returns {Array} An array containing the name of the position and it's rect.
+function getBestPositionRect( positions, targetRect, elementRect, limiterRect, viewportRect ) {
+	let maxLimiterIntersectArea = -1;
+	let maxViewportIntersectArea = -1;
+	let bestPositionRect;
+	let bestPositionName;
+
+	// This is when element is fully visible.
+	const elementRectArea = elementRect.getArea();
+
+	positions.some( position => {
+		const [ positionName, positionRect ] = getPositionRect( position, targetRect, elementRect );
+		let limiterIntersectArea;
+		let viewportIntersectArea;
+
+		if ( limiterRect ) {
+			limiterIntersectArea = limiterRect.getIntersectionArea( positionRect );
+		}
+
+		if ( viewportRect ) {
+			viewportIntersectArea = viewportRect.getIntersectionArea( positionRect );
+		}
+
+		if (
+			// The primary criterion is whether the element visibility in the viewport has improved.
+			( viewportRect ? viewportIntersectArea >= maxViewportIntersectArea : true ) &&
+			// The secondary criterion is whether the element is more container by the limiter.
+			( limiterRect ? limiterIntersectArea > maxLimiterIntersectArea : true )
+		) {
+			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 [ bestPositionName, bestPositionRect ];
+}
+
+/**
+ * The `getOptimalPosition` helper options.
+ *
+ * @interface module:utils/dom/position~Options
+ */
+
+/**
+ * Element that is to be positioned.
+ *
+ * @member {HTMLElement} module:utils/dom/position~Options#element
+ */
+
+/**
+ * Target with respect to which the `element` is to be positioned.
+ *
+ * @member {HTMLElement|Range} module:utils/dom/position~Options#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>} module:utils/dom/position~Options#positions
+ */
+
+/**
+ * When set, the algorithm will chose position which fits the most in the
+ * limiter's bounding rect.
+ *
+ * @member {HTMLElement|Range} module:utils/dom/position~Options#limiter
+ */
+
+/**
+ * When set, the algorithm will chose such a position which fits `element`
+ * the most inside visible viewport.
+ *
+ * @member {Boolean} module:utils/dom/position~Options#fitInViewport
+ */
+
+/**
+ * An object describing the position in `position: absolute` coordinate
+ * system.
+ *
+ * @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.
+ */

+ 332 - 0
packages/ckeditor5-utils/tests/dom/position.js

@@ -0,0 +1,332 @@
+/**
+ * @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;
+
+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
+		} ) );
+	} );
+
+	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)', () => {
+			const revert = stubWindowScroll( 100, 100 );
+
+			assertPosition( { element, target, positions: [ attachLeft ] }, {
+				top: 200,
+				left: 180,
+				name: 'left'
+			} );
+
+			revert();
+		} );
+
+		it( 'should return coordinates (positioned element parent)', () => {
+			const positionedParent = document.createElement( 'div' );
+
+			Object.assign( positionedParent.style, {
+				position: 'absolute',
+				top: '100px',
+				left: '100px'
+			} );
+
+			document.body.appendChild( positionedParent );
+			positionedParent.appendChild( element );
+
+			assertPosition( { element, target, positions: [ attachLeft ] }, {
+				top: 0,
+				left: -20,
+				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: -90,
+				name: 'left'
+			} );
+		} );
+
+		it( 'should return coordinates (#2)', () => {
+			assertPosition( {
+				element, target, limiter,
+				positions: [ attachRight, attachLeft ]
+			}, {
+				top: 100,
+				left: -90,
+				name: 'left'
+			} );
+		} );
+	} );
+
+	describe( 'with fitInViewport on', () => {
+		beforeEach( setElementTargetLimiterPlayground );
+
+		it( 'should return coordinates (#1)', () => {
+			assertPosition( {
+				element, target,
+				positions: [ attachLeft, attachRight ],
+				fitInViewport: true
+			}, {
+				top: 100,
+				left: 120,
+				name: 'right'
+			} );
+		} );
+
+		it( 'should return coordinates (#2)', () => {
+			assertPosition( {
+				element, target,
+				positions: [ attachRight, attachLeft ],
+				fitInViewport: true
+			}, {
+				top: 100,
+				left: 120,
+				name: 'right'
+			} );
+		} );
+
+		it( 'should return coordinates (#3)', () => {
+			assertPosition( {
+				element, target,
+				positions: [ attachLeft, attachRight, attachBottom ],
+				fitInViewport: true
+			}, {
+				top: 110,
+				left: 110,
+				name: 'bottom'
+			} );
+		} );
+	} );
+
+	describe( 'with limiter and fitInViewport on', () => {
+		beforeEach( setElementTargetLimiterPlayground );
+
+		it( 'should return coordinates (viewport is more important than limiter #1)', () => {
+			assertPosition( {
+				element, target, limiter,
+				positions: [ attachLeft, attachRight ],
+				fitInViewport: true
+			}, {
+				top: 100,
+				left: -90,
+				name: 'left'
+			} );
+		} );
+
+		it( 'should return coordinates (viewport is more important than limiter #2)', () => {
+			assertPosition( {
+				element, target, limiter,
+				positions: [ attachRight, attachLeft ],
+				fitInViewport: true
+			}, {
+				top: 100,
+				left: 120,
+				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'
+} );
+
+function stubWindowScroll( x, y ) {
+	const { scrollX: savedX, scrollY: savedY } = window;
+
+	window.scrollX = x;
+	window.scrollY = y;
+
+	return () => {
+		window.scrollX = savedX;
+		window.scrollY = savedY;
+	};
+}
+
+function stubElementRect( element, rect ) {
+	testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( rect );
+}
+
+//        <-- 100px ->
+//
+//    ^   +--------------[ BODY ]----------
+//    |   |
+//  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
+	} );
+}
+
+//        <-- 100px -->
+//
+//    ^   +--------------[ BODY ]----------------------
+//    |   |
+//  100px |
+//    |   |            <--------- 20px ------->
+//	  |   |                        <-- 10px -->
+//	  V   |
+//	      |   ^   ^    +------------+---------+
+//	      |   |   |    |            |         |
+//	      |   |  10px  |            |    T    |
+//	      |   |   |    |            |         |
+//	      |  20px V    |            +---------+
+//	      |   |        |                      |
+//	      |   |        |                      |
+//	      |   |        |                      |
+//        |   V        +------[ LIMITER ]-----+
+//        |
+//        |
+//
+function setElementTargetLimiterPlayground() {
+	element = document.createElement( 'div' );
+	target = document.createElement( 'div' );
+	limiter = document.createElement( 'div' );
+
+	stubElementRect( element, {
+		top: 0,
+		right: 200,
+		bottom: 200,
+		left: 0,
+		width: 200,
+		height: 200
+	} );
+
+	stubElementRect( limiter, {
+		top: 100,
+		right: 120,
+		bottom: 120,
+		left: 100,
+		width: 20,
+		height: 20
+	} );
+
+	stubElementRect( target, {
+		top: 100,
+		right: 120,
+		bottom: 110,
+		left: 110,
+		width: 10,
+		height: 10
+	} );
+}