position.js 18 KB

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