position.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  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. /* global document, window */
  6. import { getOptimalPosition } from '../../src/dom/position';
  7. import Rect from '../../src/dom/rect';
  8. import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
  9. let element, target, limiter;
  10. // +--------+-----+
  11. // | E | T |
  12. // | |-----+
  13. // +--------+
  14. const attachLeftBottom = ( targetRect, elementRect ) => ( {
  15. top: targetRect.top,
  16. left: targetRect.left - elementRect.width,
  17. name: 'left-bottom'
  18. } );
  19. // +--------+
  20. // | E |-----+
  21. // | | T |
  22. // +--------+-----+
  23. const attachLeftTop = ( targetRect, elementRect ) => ( {
  24. top: targetRect.bottom - elementRect.height,
  25. left: targetRect.left - elementRect.width,
  26. name: 'left-top'
  27. } );
  28. // +-----+--------+
  29. // | T | E |
  30. // +-----| |
  31. // +--------+
  32. const attachRightBottom = targetRect => ( {
  33. top: targetRect.top,
  34. left: targetRect.right,
  35. name: 'right-bottom'
  36. } );
  37. // +--------+
  38. // +-----| E |
  39. // | T | |
  40. // +-----+--------+
  41. const attachRightTop = ( targetRect, elementRect ) => ( {
  42. top: targetRect.bottom - elementRect.height,
  43. left: targetRect.right,
  44. name: 'right-top'
  45. } );
  46. // +-----+
  47. // | T |
  48. // +-----+--+
  49. // | E |
  50. // +--------+
  51. const attachBottomRight = targetRect => ( {
  52. top: targetRect.bottom,
  53. left: targetRect.left,
  54. name: 'bottom-right'
  55. } );
  56. // +-----+
  57. // | T |
  58. // +--+-----+
  59. // | E |
  60. // +--------+
  61. const attachBottomLeft = ( targetRect, elementRect ) => ( {
  62. top: targetRect.bottom,
  63. left: targetRect.right - elementRect.width,
  64. name: 'bottom-left'
  65. } );
  66. // +--------+
  67. // | E |
  68. // +--+-----+
  69. // | T |
  70. // +-----+
  71. const attachTopLeft = ( targetRect, elementRect ) => ( {
  72. top: targetRect.top - elementRect.height,
  73. left: targetRect.right - elementRect.width,
  74. name: 'top-left'
  75. } );
  76. // +--------+
  77. // | E |
  78. // +-----+--+
  79. // | T |
  80. // +-----+
  81. const attachTopRight = ( targetRect, elementRect ) => ( {
  82. top: targetRect.top - elementRect.height,
  83. left: targetRect.left,
  84. name: 'top-right'
  85. } );
  86. const allPositions = [
  87. attachLeftBottom,
  88. attachLeftTop,
  89. attachRightBottom,
  90. attachRightTop,
  91. attachBottomRight,
  92. attachBottomLeft,
  93. attachTopLeft,
  94. attachTopRight
  95. ];
  96. describe( 'getOptimalPosition()', () => {
  97. testUtils.createSinonSandbox();
  98. beforeEach( () => {
  99. testUtils.sinon.stub( window, 'getComputedStyle' );
  100. window.getComputedStyle.callThrough();
  101. stubWindow( {
  102. innerWidth: 10000,
  103. innerHeight: 10000,
  104. scrollX: 0,
  105. scrollY: 0
  106. } );
  107. } );
  108. afterEach( () => {
  109. element.remove();
  110. target.remove();
  111. if ( limiter ) {
  112. limiter.remove();
  113. }
  114. } );
  115. it( 'should work when the target is a Function', () => {
  116. setElementTargetPlayground();
  117. assertPosition( {
  118. element,
  119. target: () => target,
  120. positions: [ attachLeftBottom ]
  121. }, {
  122. top: 100,
  123. left: 80,
  124. name: 'left-bottom'
  125. } );
  126. } );
  127. it( 'should work when the target is a Rect', () => {
  128. setElementTargetPlayground();
  129. assertPosition( {
  130. element,
  131. target: new Rect( target ),
  132. positions: [ attachLeftBottom ]
  133. }, {
  134. top: 100,
  135. left: 80,
  136. name: 'left-bottom'
  137. } );
  138. } );
  139. describe( 'for single position', () => {
  140. beforeEach( setElementTargetPlayground );
  141. it( 'should return coordinates', () => {
  142. assertPosition( { element, target, positions: [ attachLeftBottom ] }, {
  143. top: 100,
  144. left: 80,
  145. name: 'left-bottom'
  146. } );
  147. } );
  148. it( 'should return coordinates (window scroll)', () => {
  149. stubWindow( {
  150. innerWidth: 10000,
  151. innerHeight: 10000,
  152. scrollX: 100,
  153. scrollY: 100
  154. } );
  155. assertPosition( { element, target, positions: [ attachLeftBottom ] }, {
  156. top: 200,
  157. left: 180,
  158. name: 'left-bottom'
  159. } );
  160. } );
  161. describe( 'positioned element parent', () => {
  162. let parent;
  163. afterEach( () => {
  164. parent.remove();
  165. } );
  166. it( 'should return coordinates', () => {
  167. stubWindow( {
  168. innerWidth: 10000,
  169. innerHeight: 10000,
  170. scrollX: 1000,
  171. scrollY: 1000
  172. } );
  173. parent = getElement( {
  174. top: 1000,
  175. right: 1010,
  176. bottom: 1010,
  177. left: 1000,
  178. width: 10,
  179. height: 10
  180. }, {
  181. position: 'absolute'
  182. } );
  183. parent.appendChild( element );
  184. assertPosition( { element, target, positions: [ attachLeftBottom ] }, {
  185. top: -900,
  186. left: -920,
  187. name: 'left-bottom'
  188. } );
  189. } );
  190. it( 'should return coordinates (scroll and border)', () => {
  191. stubWindow( {
  192. innerWidth: 10000,
  193. innerHeight: 10000,
  194. scrollX: 1000,
  195. scrollY: 1000
  196. } );
  197. parent = getElement( {
  198. top: 0,
  199. right: 10,
  200. bottom: 10,
  201. left: 0,
  202. width: 10,
  203. height: 10
  204. }, {
  205. position: 'absolute',
  206. borderLeftWidth: '20px',
  207. borderTopWidth: '40px',
  208. overflow: 'scroll',
  209. width: '10px',
  210. height: '10px',
  211. background: 'red'
  212. } );
  213. Object.assign( element.style, {
  214. width: '20px',
  215. height: '20px',
  216. marginTop: '100px',
  217. marginLeft: '200px',
  218. background: 'green'
  219. } );
  220. parent.appendChild( element );
  221. document.body.appendChild( parent );
  222. parent.scrollLeft = 200;
  223. parent.scrollTop = 100;
  224. assertPosition( { element, target, positions: [ attachLeftBottom ] }, {
  225. top: 200,
  226. left: 280,
  227. name: 'left-bottom'
  228. } );
  229. } );
  230. } );
  231. } );
  232. describe( 'for multiple positions', () => {
  233. beforeEach( setElementTargetPlayground );
  234. it( 'should return coordinates', () => {
  235. assertPosition( {
  236. element, target,
  237. positions: [ attachLeftBottom, attachRightBottom ]
  238. }, {
  239. top: 100,
  240. left: 80,
  241. name: 'left-bottom'
  242. } );
  243. } );
  244. it( 'should return coordinates (position preference order)', () => {
  245. assertPosition( {
  246. element, target,
  247. positions: [ attachRightBottom, attachLeftBottom ]
  248. }, {
  249. top: 100,
  250. left: 110,
  251. name: 'right-bottom'
  252. } );
  253. } );
  254. } );
  255. describe( 'with a limiter', () => {
  256. beforeEach( setElementTargetLimiterPlayground );
  257. afterEach( () => {
  258. limiter.remove();
  259. } );
  260. it( 'should work when the limiter is a Function', () => {
  261. assertPosition( {
  262. element, target,
  263. limiter: () => limiter,
  264. positions: [ attachLeftBottom, attachRightBottom ]
  265. }, {
  266. top: 100,
  267. left: -20,
  268. name: 'left-bottom'
  269. } );
  270. } );
  271. it( 'should work when the limiter is a Rect', () => {
  272. assertPosition( {
  273. element, target,
  274. limiter: new Rect( limiter ),
  275. positions: [ attachLeftBottom, attachRightBottom ]
  276. }, {
  277. top: 100,
  278. left: -20,
  279. name: 'left-bottom'
  280. } );
  281. } );
  282. it( 'should return coordinates (#1)', () => {
  283. assertPosition( {
  284. element, target, limiter,
  285. positions: [ attachLeftBottom, attachRightBottom ]
  286. }, {
  287. top: 100,
  288. left: -20,
  289. name: 'left-bottom'
  290. } );
  291. } );
  292. it( 'should return coordinates (#2)', () => {
  293. assertPosition( {
  294. element, target, limiter,
  295. positions: [ attachRightBottom, attachLeftBottom ]
  296. }, {
  297. top: 100,
  298. left: -20,
  299. name: 'left-bottom'
  300. } );
  301. } );
  302. // https://github.com/ckeditor/ckeditor5-utils/issues/148
  303. it( 'should return coordinates (#3)', () => {
  304. const parentNode = getElement( {
  305. top: 100,
  306. left: 0,
  307. bottom: 110,
  308. right: 10,
  309. width: 10,
  310. height: 10
  311. } );
  312. parentNode.appendChild( limiter );
  313. document.body.appendChild( parentNode );
  314. assertPosition( {
  315. element, target, limiter,
  316. positions: [ attachRightBottom, attachLeftBottom ]
  317. }, {
  318. top: 100,
  319. left: 10,
  320. name: 'right-bottom'
  321. } );
  322. parentNode.remove();
  323. } );
  324. it( 'should return the first position that completely fits in the limiter', () => {
  325. const element = getElement( {
  326. top: 0,
  327. right: 5,
  328. bottom: 5,
  329. left: 0,
  330. width: 5,
  331. height: 5
  332. } );
  333. assertPosition( {
  334. element, target, limiter,
  335. positions: [ attachRightBottom, attachLeftBottom ]
  336. }, {
  337. top: 100,
  338. left: -5,
  339. name: 'left-bottom'
  340. } );
  341. element.remove();
  342. } );
  343. } );
  344. describe( 'with fitInViewport on', () => {
  345. beforeEach( setElementTargetLimiterPlayground );
  346. it( 'should return coordinates (#1)', () => {
  347. assertPosition( {
  348. element, target,
  349. positions: [ attachLeftBottom, attachRightBottom ],
  350. fitInViewport: true
  351. }, {
  352. top: 100,
  353. left: 10,
  354. name: 'right-bottom'
  355. } );
  356. } );
  357. it( 'should return coordinates (#2)', () => {
  358. assertPosition( {
  359. element, target,
  360. positions: [ attachRightBottom, attachLeftBottom ],
  361. fitInViewport: true
  362. }, {
  363. top: 100,
  364. left: 10,
  365. name: 'right-bottom'
  366. } );
  367. } );
  368. it( 'should return coordinates (#3)', () => {
  369. assertPosition( {
  370. element, target,
  371. positions: [ attachLeftBottom, attachBottomRight, attachRightBottom ],
  372. fitInViewport: true
  373. }, {
  374. top: 110,
  375. left: 0,
  376. name: 'bottom-right'
  377. } );
  378. } );
  379. } );
  380. describe( 'with limiter and fitInViewport on', () => {
  381. beforeEach( setElementTargetLimiterPlayground );
  382. it( 'should return coordinates (#1)', () => {
  383. assertPosition( {
  384. element, target, limiter,
  385. positions: [ attachLeftBottom, attachRightBottom ],
  386. fitInViewport: true
  387. }, {
  388. top: 100,
  389. left: 10,
  390. name: 'right-bottom'
  391. } );
  392. } );
  393. it( 'should return coordinates (#2)', () => {
  394. assertPosition( {
  395. element, target, limiter,
  396. positions: [ attachRightBottom, attachLeftBottom ],
  397. fitInViewport: true
  398. }, {
  399. top: 100,
  400. left: 10,
  401. name: 'right-bottom'
  402. } );
  403. } );
  404. it( 'should return coordinates (#3)', () => {
  405. assertPosition( {
  406. element, target, limiter,
  407. positions: [ attachRightBottom, attachLeftBottom, attachBottomRight ],
  408. fitInViewport: true
  409. }, {
  410. top: 110,
  411. left: 0,
  412. name: 'bottom-right'
  413. } );
  414. } );
  415. it( 'should return coordinates (#4)', () => {
  416. assertPosition( {
  417. element, target, limiter,
  418. positions: [ attachTopLeft, attachRightBottom ],
  419. fitInViewport: true
  420. }, {
  421. top: 100,
  422. left: 10,
  423. name: 'right-bottom'
  424. } );
  425. } );
  426. it( 'should return the very first coordinates if no fitting position with a positive intersection has been found', () => {
  427. assertPosition( {
  428. element, target, limiter,
  429. positions: [
  430. () => ( {
  431. left: -10000,
  432. top: -10000,
  433. name: 'no-intersect-position'
  434. } )
  435. ],
  436. fitInViewport: true
  437. }, {
  438. left: -10000,
  439. top: -10000,
  440. name: 'no-intersect-position'
  441. } );
  442. } );
  443. it( 'should return the very first coordinates if limiter does not fit into the viewport', () => {
  444. const limiter = getElement( {
  445. top: -100,
  446. right: -80,
  447. bottom: -80,
  448. left: -100,
  449. width: 20,
  450. height: 20
  451. } );
  452. assertPosition( {
  453. element, target, limiter,
  454. positions: [ attachRightBottom, attachTopLeft ],
  455. fitInViewport: true
  456. }, {
  457. top: 100,
  458. left: 10,
  459. name: 'right-bottom'
  460. } );
  461. limiter.remove();
  462. } );
  463. it( 'should prefer positions fitting entirely into the viewport', () => {
  464. const target = getElement( {
  465. top: 100,
  466. right: 35,
  467. bottom: 120,
  468. left: 15,
  469. width: 20,
  470. height: 20
  471. } );
  472. assertPosition( {
  473. element, target, limiter,
  474. positions: [ attachLeftBottom, attachRightBottom ],
  475. fitInViewport: true
  476. }, {
  477. top: 100,
  478. left: 35,
  479. name: 'right-bottom'
  480. } );
  481. target.remove();
  482. } );
  483. } );
  484. describe( 'optimisation in the context of both the limiter and the viewport', () => {
  485. beforeEach( setElementTargetBigLimiterPlayground );
  486. it( 'should prefer a position with a bigger intersection area (#1)', () => {
  487. target = getElement( {
  488. top: 90,
  489. right: -10,
  490. bottom: 110,
  491. left: -30,
  492. width: 20,
  493. height: 20
  494. } );
  495. assertPositionName( {
  496. element, target, limiter,
  497. positions: allPositions,
  498. fitInViewport: true
  499. }, 'right-bottom' );
  500. } );
  501. it( 'should prefer a position with a bigger intersection area (#2)', () => {
  502. target = getElement( {
  503. top: 290,
  504. right: -10,
  505. bottom: 310,
  506. left: -30,
  507. width: 20,
  508. height: 20
  509. } );
  510. assertPositionName( {
  511. element, target, limiter,
  512. positions: allPositions,
  513. fitInViewport: true
  514. }, 'right-top' );
  515. } );
  516. it( 'should prefer a position with a bigger intersection area (#3)', () => {
  517. target = getElement( {
  518. top: 90,
  519. right: 130,
  520. bottom: 110,
  521. left: 110,
  522. width: 20,
  523. height: 20
  524. } );
  525. assertPositionName( {
  526. element, target, limiter,
  527. positions: allPositions,
  528. fitInViewport: true
  529. }, 'left-bottom' );
  530. } );
  531. it( 'should prefer a position with a bigger intersection area (#4)', () => {
  532. target = getElement( {
  533. top: 290,
  534. right: 130,
  535. bottom: 310,
  536. left: 110,
  537. width: 20,
  538. height: 20
  539. } );
  540. assertPositionName( {
  541. element, target, limiter,
  542. positions: allPositions,
  543. fitInViewport: true
  544. }, 'left-top' );
  545. } );
  546. it( 'should not stick to the first biggest intersection in one area', () => {
  547. // First position intersects more with limiter but little with viewport,
  548. // second position intersects less with limiter but more with viewport and it should not be ignored.
  549. //
  550. // Target is outside viewport to force checking all positions, not only those completely fitting in viewport.
  551. const limiter = getElement( {
  552. top: -100,
  553. right: 100,
  554. bottom: 100,
  555. left: -100,
  556. width: 200,
  557. height: 200
  558. } );
  559. const target = getElement( {
  560. top: -30,
  561. right: 80,
  562. bottom: -10,
  563. left: 60,
  564. width: 20,
  565. height: 20
  566. } );
  567. const element = getElement( {
  568. top: 0,
  569. right: 200,
  570. bottom: 200,
  571. left: 0,
  572. width: 200,
  573. height: 200
  574. } );
  575. assertPositionName( {
  576. element, target, limiter,
  577. positions: [
  578. attachLeftBottom,
  579. attachRightBottom
  580. ],
  581. fitInViewport: true
  582. }, 'right-bottom' );
  583. limiter.remove();
  584. target.remove();
  585. element.remove();
  586. } );
  587. } );
  588. } );
  589. function assertPosition( options, expected ) {
  590. const position = getOptimalPosition( options );
  591. expect( position ).to.deep.equal( expected );
  592. }
  593. function assertPositionName( options, expected ) {
  594. const position = getOptimalPosition( options );
  595. expect( position.name ).to.equal( expected );
  596. }
  597. // Returns a synthetic element.
  598. //
  599. // @private
  600. // @param {Object} properties A set of properties for the element.
  601. // @param {Object} styles A set of styles in `window.getComputedStyle()` format.
  602. function getElement( rect = {}, styles = {} ) {
  603. expect( rect.right - rect.left ).to.equal( rect.width, 'getElement incorrect horizontal values' );
  604. expect( rect.bottom - rect.top ).to.equal( rect.height, 'getElement incorrect vertical values' );
  605. const element = document.createElement( 'div' );
  606. document.body.appendChild( element );
  607. sinon.stub( element, 'getBoundingClientRect' ).returns( rect );
  608. if ( !styles.borderLeftWidth ) {
  609. styles.borderLeftWidth = '0px';
  610. }
  611. if ( !styles.borderTopWidth ) {
  612. styles.borderTopWidth = '0px';
  613. }
  614. Object.assign( element.style, styles );
  615. return element;
  616. }
  617. // Stubs the window.
  618. //
  619. // @private
  620. // @param {Object} properties A set of properties the window should have.
  621. function stubWindow( properties ) {
  622. for ( const p in properties ) {
  623. testUtils.sinon.stub( window, p ).value( properties[ p ] );
  624. }
  625. }
  626. // <-- 100px ->
  627. //
  628. // ^ +--------------[ Viewport ]----------
  629. // | |
  630. // 100px |
  631. // | | <-- 10px -->
  632. // V |
  633. // | ^ +---------+
  634. // | | | |
  635. // | 10px | T |
  636. // | | | |
  637. // | V +---------+
  638. // |
  639. //
  640. function setElementTargetPlayground() {
  641. element = getElement( {
  642. top: 0,
  643. right: 20,
  644. bottom: 20,
  645. left: 0,
  646. width: 20,
  647. height: 20
  648. } );
  649. target = getElement( {
  650. top: 100,
  651. right: 110,
  652. bottom: 110,
  653. left: 100,
  654. width: 10,
  655. height: 10
  656. } );
  657. }
  658. //
  659. //
  660. // ^ +-----------[ Viewport ]----------------------
  661. // | |
  662. // 100px |
  663. // | <--------- 20px ------->
  664. // | <-- 10px -->
  665. // V |
  666. // +------------+---------+ ^ ^
  667. // | | | | |
  668. // | | T | 10px |
  669. // | | | | |
  670. // | +---------+ V 20px
  671. // | | | |
  672. // | | | |
  673. // | | | |
  674. // +------[ Limiter ]-----+ V
  675. // |
  676. // |
  677. //
  678. //
  679. function setElementTargetLimiterPlayground() {
  680. element = getElement( {
  681. top: 0,
  682. right: 20,
  683. bottom: 20,
  684. left: 0,
  685. width: 20,
  686. height: 20
  687. } );
  688. limiter = getElement( {
  689. top: 100,
  690. right: 10,
  691. bottom: 120,
  692. left: -10,
  693. width: 20,
  694. height: 20
  695. } );
  696. target = getElement( {
  697. top: 100,
  698. right: 10,
  699. bottom: 110,
  700. left: 0,
  701. width: 10,
  702. height: 10
  703. } );
  704. }
  705. //
  706. //
  707. // ^ +-----------[ Viewport ]----------------------
  708. // | |
  709. // 100px |
  710. // | <--------- 200px ------->
  711. // | <-- 100px -->
  712. // V |
  713. // +------------+---------+ ^
  714. // | | | |
  715. // | | | |
  716. // | | | |
  717. // | | | 200px
  718. // | | | |
  719. // | | | |
  720. // | | | |
  721. // +------[ Limiter ]-----+ V
  722. // |
  723. // |
  724. //
  725. //
  726. function setElementTargetBigLimiterPlayground() {
  727. element = getElement( {
  728. top: 0,
  729. right: 50,
  730. bottom: 50,
  731. left: 0,
  732. width: 50,
  733. height: 50
  734. } );
  735. limiter = getElement( {
  736. top: 100,
  737. right: 100,
  738. bottom: 300,
  739. left: -100,
  740. width: 200,
  741. height: 200
  742. } );
  743. }