| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- /**
- * @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/position
- */
- import global from './global';
- import Rect from './rect';
- import getPositionedAncestor from './getpositionedancestor';
- import getBorderWidths from './getborderwidths';
- import { isFunction } from 'lodash-es';
- /**
- * 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 } ) {
- // If the {@link module:utils/dom/position~Options#target} is a function, use what it returns.
- // https://github.com/ckeditor/ckeditor5-utils/issues/157
- if ( isFunction( target ) ) {
- target = target();
- }
- // If the {@link module:utils/dom/position~Options#limiter} is a function, use what it returns.
- // https://github.com/ckeditor/ckeditor5-ui/issues/260
- if ( isFunction( limiter ) ) {
- limiter = limiter();
- }
- const positionedElementAncestor = getPositionedAncestor( element );
- const elementRect = new Rect( element );
- const targetRect = new Rect( target );
- let bestPositionRect;
- let bestPositionName;
- // If there are no limits, just grab the very first position and be done with that drama.
- if ( !limiter && !fitInViewport ) {
- [ bestPositionName, bestPositionRect ] = getPositionNameAndRect( positions[ 0 ], targetRect, elementRect );
- } else {
- const limiterRect = limiter && new Rect( limiter ).getVisible();
- const viewportRect = fitInViewport && new Rect( global.window );
- const bestPosition = getBestPositionNameAndRect( 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.
- [ bestPositionName, bestPositionRect ] = bestPosition || getPositionNameAndRect( positions[ 0 ], targetRect, elementRect );
- }
- let absoluteRectCoordinates = getAbsoluteRectCoordinates( bestPositionRect );
- if ( positionedElementAncestor ) {
- absoluteRectCoordinates = shiftRectCoordinatesDueToPositionedAncestor( absoluteRectCoordinates, positionedElementAncestor );
- }
- return {
- left: absoluteRectCoordinates.left,
- top: absoluteRectCoordinates.top,
- 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 {utils/dom/rect~Rect} targetRect A rect of the target.
- // @param {utils/dom/rect~Rect} elementRect A rect of positioned element.
- // @returns {Array|null} An array containing position name and its Rect (or null if position should be ignored).
- function getPositionNameAndRect( position, targetRect, elementRect ) {
- const positionData = position( targetRect, elementRect );
- if ( !positionData ) {
- return null;
- }
- const { left, top, name } = positionData;
- 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 {Object} options
- // @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 {Object} options
- // @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}.
- // @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}.
- // @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}.
- // @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport.
- //
- // @returns {Array} An array containing the name of the position and it's rect.
- function getBestPositionNameAndRect( positions, options ) {
- const { elementRect, viewportRect } = options;
- // This is when element is fully visible.
- const elementRectArea = elementRect.getArea();
- // Let's calculate intersection areas for positions. It will end early if best match is found.
- const processedPositions = processPositionsToAreas( positions, options );
- // First let's check all positions that fully fit in the viewport.
- if ( viewportRect ) {
- const processedPositionsInViewport = processedPositions.filter( ( { viewportIntersectArea } ) => {
- return viewportIntersectArea === elementRectArea;
- } );
- // Try to find best position from those which fit completely in viewport.
- const bestPositionData = getBestOfProcessedPositions( processedPositionsInViewport, elementRectArea );
- if ( bestPositionData ) {
- return bestPositionData;
- }
- }
- // Either there is no viewportRect or there is no position that fits completely in the viewport.
- return getBestOfProcessedPositions( processedPositions, elementRectArea );
- }
- // For a given array of positioning functions, calculates intersection areas for them.
- //
- // Note: If some position fully fits into the `limiterRect`, it will be returned early, without further consideration
- // of other positions.
- //
- // @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 {Object} options
- // @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}.
- // @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}.
- // @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}.
- // @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport.
- //
- // @returns {Array.<Object>} Array of positions with calculated intersection areas. Each item is an object containing:
- // * {String} positionName Name of position.
- // * {utils/dom/rect~Rect} positionRect Rect of position.
- // * {Number} limiterIntersectArea Area of intersection of the position with limiter part that is in the viewport.
- // * {Number} viewportIntersectArea Area of intersection of the position with viewport.
- function processPositionsToAreas( positions, { targetRect, elementRect, limiterRect, viewportRect } ) {
- const processedPositions = [];
- // This is when element is fully visible.
- const elementRectArea = elementRect.getArea();
- for ( const position of positions ) {
- const positionData = getPositionNameAndRect( position, targetRect, elementRect );
- if ( !positionData ) {
- continue;
- }
- const [ positionName, positionRect ] = positionData;
- let limiterIntersectArea = 0;
- let viewportIntersectArea = 0;
- 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 = limiterRect.getIntersectionArea( positionRect );
- }
- }
- if ( viewportRect ) {
- viewportIntersectArea = viewportRect.getIntersectionArea( positionRect );
- }
- const processedPosition = {
- positionName,
- positionRect,
- limiterIntersectArea,
- viewportIntersectArea
- };
- // If a such position is found that element is fully contained by the limiter then, obviously,
- // there will be no better one, so finishing.
- if ( limiterIntersectArea === elementRectArea ) {
- return [ processedPosition ];
- }
- processedPositions.push( processedPosition );
- }
- return processedPositions;
- }
- // For a given array of processed position data (with calculated Rects for positions and intersection areas)
- // returns such that provides the best fit of the `elementRect` into the `limiterRect` and `viewportRect` at the same time.
- //
- // **Note**: It will return early if some position fully fits into the `limiterRect`.
- //
- // @private
- // @param {Array.<Object>} Array of positions with calculated intersection areas (in order of preference).
- // Each item is an object containing:
- //
- // * {String} positionName Name of position.
- // * {utils/dom/rect~Rect} positionRect Rect of position.
- // * {Number} limiterIntersectArea Area of intersection of the position with limiter part that is in the viewport.
- // * {Number} viewportIntersectArea Area of intersection of the position with viewport.
- //
- // @param {Number} elementRectArea Area of positioned {@link module:utils/dom/position~Options#element}.
- // @returns {Array|null} An array containing the name of the position and it's rect, or null if not found.
- function getBestOfProcessedPositions( processedPositions, elementRectArea ) {
- let maxFitFactor = 0;
- let bestPositionRect;
- let bestPositionName;
- for ( const { positionName, positionRect, limiterIntersectArea, viewportIntersectArea } of processedPositions ) {
- // If a such position is found that element is fully container by the limiter then, obviously,
- // there will be no better one, so finishing.
- if ( limiterIntersectArea === elementRectArea ) {
- return [ positionName, positionRect ];
- }
- // To maximize both viewport and limiter intersection areas we use distance on viewportIntersectArea
- // and limiterIntersectArea plane (without sqrt because we are looking for max value).
- const fitFactor = viewportIntersectArea ** 2 + limiterIntersectArea ** 2;
- if ( fitFactor > maxFitFactor ) {
- maxFitFactor = fitFactor;
- bestPositionRect = positionRect;
- bestPositionName = positionName;
- }
- }
- return bestPositionRect ? [ bestPositionName, bestPositionRect ] : null;
- }
- // For a given absolute Rect coordinates object and a positioned element ancestor, it returns an object with
- // new Rect coordinates that make up for the position and the scroll of the ancestor.
- //
- // This is necessary because while Rects (and DOMRects) are relative to the browser's viewport, their coordinates
- // are used in real–life to position elements with `position: absolute`, which are scoped by any positioned
- // (and scrollable) ancestors.
- //
- // @private
- //
- // @param {Object} absoluteRectCoordinates An object with absolute rect coordinates.
- // @param {Object} absoluteRectCoordinates.top
- // @param {Object} absoluteRectCoordinates.left
- // @param {HTMLElement} positionedElementAncestor An ancestor element that should be considered.
- //
- // @returns {Object} An object corresponding to `absoluteRectCoordinates` input but with values shifted
- // to make up for the positioned element ancestor.
- function shiftRectCoordinatesDueToPositionedAncestor( { left, top }, positionedElementAncestor ) {
- const ancestorPosition = getAbsoluteRectCoordinates( new Rect( positionedElementAncestor ) );
- const ancestorBorderWidths = getBorderWidths( positionedElementAncestor );
- // (https://github.com/ckeditor/ckeditor5-ui-default/issues/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.
- left -= ancestorPosition.left;
- top -= ancestorPosition.top;
- // (https://github.com/ckeditor/ckeditor5-utils/issues/139)
- // If there's some positioned ancestor of the panel, not only its position must be taken into
- // consideration (see above) but also its internal scrolls. Scroll have an impact here because `Rect`
- // is relative to the viewport (it doesn't care about scrolling), while `position: absolute`
- // must compensate that scrolling.
- left += positionedElementAncestor.scrollLeft;
- top += positionedElementAncestor.scrollTop;
- // (https://github.com/ckeditor/ckeditor5-utils/issues/139)
- // If there's some positioned ancestor of the panel, then its `Rect` includes its CSS `borderWidth`
- // while `position: absolute` positioning does not consider it.
- // E.g. `{ position: absolute, top: 0, left: 0 }` means upper left corner of the element,
- // not upper-left corner of its border.
- left -= ancestorBorderWidths.left;
- top -= ancestorBorderWidths.top;
- return { left, top };
- }
- // 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 } ) {
- const { scrollX, scrollY } = global.window;
- return {
- left: left + scrollX,
- top: top + 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|Rect|Function} #target
- */
- /**
- * An array of functions which return {@link module:utils/dom/position~Position} relative
- * to the `target`, in the order of preference.
- *
- * **Note**: If a function returns `null`, it is ignored by the `getOptimalPosition()`.
- *
- * @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|Rect|Function} #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.
- */
|