8
0

position.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module utils/dom/position
  7. */
  8. import global from './global';
  9. import Rect from './rect';
  10. import getPositionedAncestor from './getpositionedancestor';
  11. import getBorderWidths from './getborderwidths';
  12. import { isFunction } from 'lodash-es';
  13. /**
  14. * Calculates the `position: absolute` coordinates of a given element so it can be positioned with respect to the
  15. * target in the visually most efficient way, taking various restrictions like viewport or limiter geometry
  16. * into consideration.
  17. *
  18. * // The element which is to be positioned.
  19. * const element = document.body.querySelector( '#toolbar' );
  20. *
  21. * // A target to which the element is positioned relatively.
  22. * const target = document.body.querySelector( '#container' );
  23. *
  24. * // Finding the optimal coordinates for the positioning.
  25. * const { left, top, name } = getOptimalPosition( {
  26. * element: element,
  27. * target: target,
  28. *
  29. * // The algorithm will chose among these positions to meet the requirements such
  30. * // as "limiter" element or "fitInViewport", set below. The positions are considered
  31. * // in the order of the array.
  32. * positions: [
  33. * //
  34. * // [ Target ]
  35. * // +-----------------+
  36. * // | Element |
  37. * // +-----------------+
  38. * //
  39. * targetRect => ( {
  40. * top: targetRect.bottom,
  41. * left: targetRect.left,
  42. * name: 'mySouthEastPosition'
  43. * } ),
  44. *
  45. * //
  46. * // +-----------------+
  47. * // | Element |
  48. * // +-----------------+
  49. * // [ Target ]
  50. * //
  51. * ( targetRect, elementRect ) => ( {
  52. * top: targetRect.top - elementRect.height,
  53. * left: targetRect.left,
  54. * name: 'myNorthEastPosition'
  55. * } )
  56. * ],
  57. *
  58. * // Find a position such guarantees the element remains within visible boundaries of <body>.
  59. * limiter: document.body,
  60. *
  61. * // Find a position such guarantees the element remains within visible boundaries of the browser viewport.
  62. * fitInViewport: true
  63. * } );
  64. *
  65. * // The best position which fits into document.body and the viewport. May be useful
  66. * // to set proper class on the `element`.
  67. * console.log( name ); // -> "myNorthEastPosition"
  68. *
  69. * // Using the absolute coordinates which has been found to position the element
  70. * // as in the diagram depicting the "myNorthEastPosition" position.
  71. * element.style.top = top;
  72. * element.style.left = left;
  73. *
  74. * @param {module:utils/dom/position~Options} options Positioning options object.
  75. * @returns {module:utils/dom/position~Position}
  76. */
  77. export function getOptimalPosition( { element, target, positions, limiter, fitInViewport } ) {
  78. // If the {@link module:utils/dom/position~Options#target} is a function, use what it returns.
  79. // https://github.com/ckeditor/ckeditor5-utils/issues/157
  80. if ( isFunction( target ) ) {
  81. target = target();
  82. }
  83. // If the {@link module:utils/dom/position~Options#limiter} is a function, use what it returns.
  84. // https://github.com/ckeditor/ckeditor5-ui/issues/260
  85. if ( isFunction( limiter ) ) {
  86. limiter = limiter();
  87. }
  88. const positionedElementAncestor = getPositionedAncestor( element );
  89. const elementRect = new Rect( element );
  90. const targetRect = new Rect( target );
  91. let bestPositionRect;
  92. let bestPositionName;
  93. // If there are no limits, just grab the very first position and be done with that drama.
  94. if ( !limiter && !fitInViewport ) {
  95. [ bestPositionName, bestPositionRect ] = getPositionNameAndRect( positions[ 0 ], targetRect, elementRect );
  96. } else {
  97. const limiterRect = limiter && new Rect( limiter ).getVisible();
  98. const viewportRect = fitInViewport && new Rect( global.window );
  99. const bestPosition = getBestPositionNameAndRect( positions, { targetRect, elementRect, limiterRect, viewportRect } );
  100. // If there's no best position found, i.e. when all intersections have no area because
  101. // rects have no width or height, then just use the first available position.
  102. [ bestPositionName, bestPositionRect ] = bestPosition || getPositionNameAndRect( positions[ 0 ], targetRect, elementRect );
  103. }
  104. let absoluteRectCoordinates = getAbsoluteRectCoordinates( bestPositionRect );
  105. if ( positionedElementAncestor ) {
  106. absoluteRectCoordinates = shiftRectCoordinatesDueToPositionedAncestor( absoluteRectCoordinates, positionedElementAncestor );
  107. }
  108. return {
  109. left: absoluteRectCoordinates.left,
  110. top: absoluteRectCoordinates.top,
  111. name: bestPositionName
  112. };
  113. }
  114. // For given position function, returns a corresponding `Rect` instance.
  115. //
  116. // @private
  117. // @param {Function} position A function returning {@link module:utils/dom/position~Position}.
  118. // @param {utils/dom/rect~Rect} targetRect A rect of the target.
  119. // @param {utils/dom/rect~Rect} elementRect A rect of positioned element.
  120. // @returns {Array|null} An array containing position name and its Rect (or null if position should be ignored).
  121. function getPositionNameAndRect( position, targetRect, elementRect ) {
  122. const positionData = position( targetRect, elementRect );
  123. if ( !positionData ) {
  124. return null;
  125. }
  126. const { left, top, name } = positionData;
  127. return [ name, elementRect.clone().moveTo( left, top ) ];
  128. }
  129. // For a given array of positioning functions, returns such that provides the best
  130. // fit of the `elementRect` into the `limiterRect` and `viewportRect`.
  131. //
  132. // @private
  133. //
  134. // @param {Object} options
  135. // @param {module:utils/dom/position~Options#positions} positions Functions returning {@link module:utils/dom/position~Position}
  136. // to be checked, in the order of preference.
  137. // @param {Object} options
  138. // @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}.
  139. // @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}.
  140. // @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}.
  141. // @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport.
  142. //
  143. // @returns {Array} An array containing the name of the position and it's rect.
  144. function getBestPositionNameAndRect( positions, options ) {
  145. const { elementRect, viewportRect } = options;
  146. // This is when element is fully visible.
  147. const elementRectArea = elementRect.getArea();
  148. // Let's calculate intersection areas for positions. It will end early if best match is found.
  149. const processedPositions = processPositionsToAreas( positions, options );
  150. // First let's check all positions that fully fit in the viewport.
  151. if ( viewportRect ) {
  152. const processedPositionsInViewport = processedPositions.filter( ( { viewportIntersectArea } ) => {
  153. return viewportIntersectArea === elementRectArea;
  154. } );
  155. // Try to find best position from those which fit completely in viewport.
  156. const bestPositionData = getBestOfProcessedPositions( processedPositionsInViewport, elementRectArea );
  157. if ( bestPositionData ) {
  158. return bestPositionData;
  159. }
  160. }
  161. // Either there is no viewportRect or there is no position that fits completely in the viewport.
  162. return getBestOfProcessedPositions( processedPositions, elementRectArea );
  163. }
  164. // For a given array of positioning functions, calculates intersection areas for them.
  165. //
  166. // Note: If some position fully fits into the `limiterRect`, it will be returned early, without further consideration
  167. // of other positions.
  168. //
  169. // @private
  170. //
  171. // @param {module:utils/dom/position~Options#positions} positions Functions returning {@link module:utils/dom/position~Position}
  172. // to be checked, in the order of preference.
  173. // @param {Object} options
  174. // @param {utils/dom/rect~Rect} options.targetRect A rect of the {@link module:utils/dom/position~Options#target}.
  175. // @param {utils/dom/rect~Rect} options.elementRect A rect of positioned {@link module:utils/dom/position~Options#element}.
  176. // @param {utils/dom/rect~Rect} options.limiterRect A rect of the {@link module:utils/dom/position~Options#limiter}.
  177. // @param {utils/dom/rect~Rect} options.viewportRect A rect of the viewport.
  178. //
  179. // @returns {Array.<Object>} Array of positions with calculated intersection areas. Each item is an object containing:
  180. // * {String} positionName Name of position.
  181. // * {utils/dom/rect~Rect} positionRect Rect of position.
  182. // * {Number} limiterIntersectArea Area of intersection of the position with limiter part that is in the viewport.
  183. // * {Number} viewportIntersectArea Area of intersection of the position with viewport.
  184. function processPositionsToAreas( positions, { targetRect, elementRect, limiterRect, viewportRect } ) {
  185. const processedPositions = [];
  186. // This is when element is fully visible.
  187. const elementRectArea = elementRect.getArea();
  188. for ( const position of positions ) {
  189. const positionData = getPositionNameAndRect( position, targetRect, elementRect );
  190. if ( !positionData ) {
  191. continue;
  192. }
  193. const [ positionName, positionRect ] = positionData;
  194. let limiterIntersectArea = 0;
  195. let viewportIntersectArea = 0;
  196. if ( limiterRect ) {
  197. if ( viewportRect ) {
  198. // Consider only the part of the limiter which is visible in the viewport. So the limiter is getting limited.
  199. const limiterViewportIntersectRect = limiterRect.getIntersection( viewportRect );
  200. if ( limiterViewportIntersectRect ) {
  201. // If the limiter is within the viewport, then check the intersection between that part of the
  202. // limiter and actual position.
  203. limiterIntersectArea = limiterViewportIntersectRect.getIntersectionArea( positionRect );
  204. }
  205. } else {
  206. limiterIntersectArea = limiterRect.getIntersectionArea( positionRect );
  207. }
  208. }
  209. if ( viewportRect ) {
  210. viewportIntersectArea = viewportRect.getIntersectionArea( positionRect );
  211. }
  212. const processedPosition = {
  213. positionName,
  214. positionRect,
  215. limiterIntersectArea,
  216. viewportIntersectArea
  217. };
  218. // If a such position is found that element is fully contained by the limiter then, obviously,
  219. // there will be no better one, so finishing.
  220. if ( limiterIntersectArea === elementRectArea ) {
  221. return [ processedPosition ];
  222. }
  223. processedPositions.push( processedPosition );
  224. }
  225. return processedPositions;
  226. }
  227. // For a given array of processed position data (with calculated Rects for positions and intersection areas)
  228. // returns such that provides the best fit of the `elementRect` into the `limiterRect` and `viewportRect` at the same time.
  229. //
  230. // **Note**: It will return early if some position fully fits into the `limiterRect`.
  231. //
  232. // @private
  233. // @param {Array.<Object>} Array of positions with calculated intersection areas (in order of preference).
  234. // Each item is an object containing:
  235. //
  236. // * {String} positionName Name of position.
  237. // * {utils/dom/rect~Rect} positionRect Rect of position.
  238. // * {Number} limiterIntersectArea Area of intersection of the position with limiter part that is in the viewport.
  239. // * {Number} viewportIntersectArea Area of intersection of the position with viewport.
  240. //
  241. // @param {Number} elementRectArea Area of positioned {@link module:utils/dom/position~Options#element}.
  242. // @returns {Array|null} An array containing the name of the position and it's rect, or null if not found.
  243. function getBestOfProcessedPositions( processedPositions, elementRectArea ) {
  244. let maxFitFactor = 0;
  245. let bestPositionRect;
  246. let bestPositionName;
  247. for ( const { positionName, positionRect, limiterIntersectArea, viewportIntersectArea } of processedPositions ) {
  248. // If a such position is found that element is fully container by the limiter then, obviously,
  249. // there will be no better one, so finishing.
  250. if ( limiterIntersectArea === elementRectArea ) {
  251. return [ positionName, positionRect ];
  252. }
  253. // To maximize both viewport and limiter intersection areas we use distance on viewportIntersectArea
  254. // and limiterIntersectArea plane (without sqrt because we are looking for max value).
  255. const fitFactor = viewportIntersectArea ** 2 + limiterIntersectArea ** 2;
  256. if ( fitFactor > maxFitFactor ) {
  257. maxFitFactor = fitFactor;
  258. bestPositionRect = positionRect;
  259. bestPositionName = positionName;
  260. }
  261. }
  262. return bestPositionRect ? [ bestPositionName, bestPositionRect ] : null;
  263. }
  264. // For a given absolute Rect coordinates object and a positioned element ancestor, it returns an object with
  265. // new Rect coordinates that make up for the position and the scroll of the ancestor.
  266. //
  267. // This is necessary because while Rects (and DOMRects) are relative to the browser's viewport, their coordinates
  268. // are used in real–life to position elements with `position: absolute`, which are scoped by any positioned
  269. // (and scrollable) ancestors.
  270. //
  271. // @private
  272. //
  273. // @param {Object} absoluteRectCoordinates An object with absolute rect coordinates.
  274. // @param {Object} absoluteRectCoordinates.top
  275. // @param {Object} absoluteRectCoordinates.left
  276. // @param {HTMLElement} positionedElementAncestor An ancestor element that should be considered.
  277. //
  278. // @returns {Object} An object corresponding to `absoluteRectCoordinates` input but with values shifted
  279. // to make up for the positioned element ancestor.
  280. function shiftRectCoordinatesDueToPositionedAncestor( { left, top }, positionedElementAncestor ) {
  281. const ancestorPosition = getAbsoluteRectCoordinates( new Rect( positionedElementAncestor ) );
  282. const ancestorBorderWidths = getBorderWidths( positionedElementAncestor );
  283. // (https://github.com/ckeditor/ckeditor5-ui-default/issues/126)
  284. // If there's some positioned ancestor of the panel, then its `Rect` must be taken into
  285. // consideration. `Rect` is always relative to the viewport while `position: absolute` works
  286. // with respect to that positioned ancestor.
  287. left -= ancestorPosition.left;
  288. top -= ancestorPosition.top;
  289. // (https://github.com/ckeditor/ckeditor5-utils/issues/139)
  290. // If there's some positioned ancestor of the panel, not only its position must be taken into
  291. // consideration (see above) but also its internal scrolls. Scroll have an impact here because `Rect`
  292. // is relative to the viewport (it doesn't care about scrolling), while `position: absolute`
  293. // must compensate that scrolling.
  294. left += positionedElementAncestor.scrollLeft;
  295. top += positionedElementAncestor.scrollTop;
  296. // (https://github.com/ckeditor/ckeditor5-utils/issues/139)
  297. // If there's some positioned ancestor of the panel, then its `Rect` includes its CSS `borderWidth`
  298. // while `position: absolute` positioning does not consider it.
  299. // E.g. `{ position: absolute, top: 0, left: 0 }` means upper left corner of the element,
  300. // not upper-left corner of its border.
  301. left -= ancestorBorderWidths.left;
  302. top -= ancestorBorderWidths.top;
  303. return { left, top };
  304. }
  305. // DOMRect (also Rect) works in a scroll–independent geometry but `position: absolute` doesn't.
  306. // This function converts Rect to `position: absolute` coordinates.
  307. //
  308. // @private
  309. // @param {utils/dom/rect~Rect} rect A rect to be converted.
  310. // @returns {Object} Object containing `left` and `top` properties, in absolute coordinates.
  311. function getAbsoluteRectCoordinates( { left, top } ) {
  312. const { scrollX, scrollY } = global.window;
  313. return {
  314. left: left + scrollX,
  315. top: top + scrollY
  316. };
  317. }
  318. /**
  319. * The `getOptimalPosition()` helper options.
  320. *
  321. * @interface module:utils/dom/position~Options
  322. */
  323. /**
  324. * Element that is to be positioned.
  325. *
  326. * @member {HTMLElement} #element
  327. */
  328. /**
  329. * Target with respect to which the `element` is to be positioned.
  330. *
  331. * @member {HTMLElement|Range|ClientRect|Rect|Function} #target
  332. */
  333. /**
  334. * An array of functions which return {@link module:utils/dom/position~Position} relative
  335. * to the `target`, in the order of preference.
  336. *
  337. * **Note**: If a function returns `null`, it is ignored by the `getOptimalPosition()`.
  338. *
  339. * @member {Array.<Function>} #positions
  340. */
  341. /**
  342. * When set, the algorithm will chose position which fits the most in the
  343. * limiter's bounding rect.
  344. *
  345. * @member {HTMLElement|Range|ClientRect|Rect|Function} #limiter
  346. */
  347. /**
  348. * When set, the algorithm will chose such a position which fits `element`
  349. * the most inside visible viewport.
  350. *
  351. * @member {Boolean} #fitInViewport
  352. */
  353. /**
  354. * An object describing a position in `position: absolute` coordinate
  355. * system, along with position name.
  356. *
  357. * @typedef {Object} module:utils/dom/position~Position
  358. *
  359. * @property {Number} top Top position offset.
  360. * @property {Number} left Left position offset.
  361. * @property {String} name Name of the position.
  362. */