Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

t/105: Moved rect utilities from BalloonPanelView #106

Merged
merged 19 commits into from
Dec 8, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/dom/getpositionedancestor.js
Original file line number Diff line number Diff line change
@@ -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 changes: 18 additions & 0 deletions src/dom/isrange.js
Original file line number Diff line number Diff line change
@@ -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 changes: 272 additions & 0 deletions src/dom/position.js
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And that's what?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Positions are named. This is useful because as the getOptimalPosition util selects the best position, some changes to the DOM may be needed, i.e. the position needs a proper class to be displayed (like an arrow in BalloonPanelView which changes with each position).

So, in fact, the knowledge about position geometry (top, left) is not enough. We need to know, which of the positioning functions passed to https://github.com/ckeditor/ckeditor5-ui-default/blob/t/131/src/balloonpanel/balloonpanelview.js#L145-L150 has been chosen.

That's why positioning functions are named, like https://github.com/ckeditor/ckeditor5-ui-default/blob/t/131/src/balloonpanel/balloonpanelview.js#L207-L211.


TBH, I don't like it either but I couldn't find any other way to simplify this API. I mean, to pass a number of functions, get the output data out of one of them (the best one) and to know precisely which function returned this output data. Ideas?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that, I didn't mean that it's wrong. I think we can live with it (TBH, I don't have energy to try to find a better solution because this one isn't that bad :D). I just wanted this to be better documented. A single example in this module would do enough.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the only place where position names look odd is https://github.com/ckeditor/ckeditor5-ui-default/blob/t/131/src/balloonpanel/balloonpanelview.js#L207. But it's reasonable there too. You can both, access the returned value satisfy the interface.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually 2, you could mention there that these functions returns objects implementing this specific interface, just to make it clear.

*/
Loading