position.js 17 KB

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