mentionui.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /* global window, document, setTimeout, Event */
  6. import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
  7. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  8. import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
  9. import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
  10. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  11. import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
  12. import global from '@ckeditor/ckeditor5-utils/src/dom/global';
  13. import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
  14. import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
  15. import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
  16. import MentionUI from '../src/mentionui';
  17. import MentionEditing from '../src/mentionediting';
  18. import MentionsView from '../src/ui/mentionsview';
  19. describe( 'MentionUI', () => {
  20. let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView, listView;
  21. const staticConfig = [
  22. { feed: [ 'Barney', 'Lily', 'Marshall', 'Robin', 'Ted' ] }
  23. ];
  24. testUtils.createSinonSandbox();
  25. beforeEach( () => {
  26. editorElement = document.createElement( 'div' );
  27. document.body.appendChild( editorElement );
  28. } );
  29. afterEach( () => {
  30. sinon.restore();
  31. editorElement.remove();
  32. return editor.destroy();
  33. } );
  34. it( 'should create a plugin instance', () => {
  35. return createClassicTestEditor().then( () => {
  36. expect( mentionUI ).to.instanceOf( Plugin );
  37. expect( mentionUI ).to.instanceOf( MentionUI );
  38. } );
  39. } );
  40. describe( 'pluginName', () => {
  41. it( 'should return plugin by its name', () => {
  42. return createClassicTestEditor().then( () => {
  43. expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI );
  44. } );
  45. } );
  46. } );
  47. describe( 'child views', () => {
  48. beforeEach( () => createClassicTestEditor() );
  49. describe( 'panelView', () => {
  50. it( 'should create a view instance', () => {
  51. expect( panelView ).to.instanceof( BalloonPanelView );
  52. } );
  53. it( 'should be added to the ui.view.body collection', () => {
  54. expect( Array.from( editor.ui.view.body ) ).to.include( panelView );
  55. } );
  56. it( 'should have disabled arrow', () => {
  57. expect( panelView.withArrow ).to.be.false;
  58. } );
  59. it( 'should have added MentionView as a child', () => {
  60. expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView );
  61. } );
  62. } );
  63. } );
  64. describe( 'position', () => {
  65. let pinSpy;
  66. const caretRect = {
  67. bottom: 118,
  68. height: 18,
  69. left: 500,
  70. right: 501,
  71. top: 100,
  72. width: 1
  73. };
  74. const balloonRect = {
  75. bottom: 150,
  76. height: 150,
  77. left: 0,
  78. right: 200,
  79. top: 0,
  80. width: 200
  81. };
  82. beforeEach( () => {
  83. return createClassicTestEditor( staticConfig ).then( () => {
  84. pinSpy = sinon.spy( panelView, 'pin' );
  85. } );
  86. } );
  87. it( 'should properly calculate position data', () => {
  88. setData( model, '<paragraph>foo []</paragraph>' );
  89. stubSelectionRects( [ caretRect ] );
  90. model.change( writer => {
  91. writer.insertText( '@', doc.selection.getFirstPosition() );
  92. } );
  93. return waitForDebounce()
  94. .then( () => {
  95. const pinArgument = pinSpy.firstCall.args[ 0 ];
  96. const { target, positions } = pinArgument;
  97. expect( target() ).to.deep.equal( caretRect );
  98. expect( positions ).to.have.length( 4 );
  99. const caretSouthEast = positions[ 0 ];
  100. const caretNorthEast = positions[ 1 ];
  101. const caretSouthWest = positions[ 2 ];
  102. const caretNorthWest = positions[ 3 ];
  103. expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( {
  104. left: 501,
  105. name: 'caret_se',
  106. top: 123
  107. } );
  108. expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( {
  109. left: 501,
  110. name: 'caret_ne',
  111. top: -55
  112. } );
  113. expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( {
  114. left: 301,
  115. name: 'caret_sw',
  116. top: 123
  117. } );
  118. expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( {
  119. left: 301,
  120. name: 'caret_nw',
  121. top: -55
  122. } );
  123. } );
  124. } );
  125. it( 'should re-calculate position on typing', () => {
  126. setData( model, '<paragraph>foo []</paragraph>' );
  127. stubSelectionRects( [ caretRect ] );
  128. model.change( writer => {
  129. writer.insertText( '@', doc.selection.getFirstPosition() );
  130. } );
  131. return waitForDebounce()
  132. .then( () => {
  133. sinon.assert.calledOnce( pinSpy );
  134. model.change( writer => {
  135. writer.insertText( 't', doc.selection.getFirstPosition() );
  136. } );
  137. } )
  138. .then( waitForDebounce )
  139. .then( () => {
  140. sinon.assert.calledTwice( pinSpy );
  141. } );
  142. } );
  143. } );
  144. describe( 'typing integration', () => {
  145. it( 'should show panel for matched marker after typing minimum characters', () => {
  146. return createClassicTestEditor( [ Object.assign( { minimumCharacters: 2 }, staticConfig[ 0 ] ) ] )
  147. .then( () => {
  148. setData( model, '<paragraph>foo []</paragraph>' );
  149. model.change( writer => {
  150. writer.insertText( '@', doc.selection.getFirstPosition() );
  151. } );
  152. } )
  153. .then( () => {
  154. model.change( writer => {
  155. writer.insertText( 'B', doc.selection.getFirstPosition() );
  156. } );
  157. } )
  158. .then( waitForDebounce )
  159. .then( () => {
  160. expect( panelView.isVisible ).to.be.false;
  161. } )
  162. .then( waitForDebounce )
  163. .then( () => {
  164. model.change( writer => {
  165. writer.insertText( 'a', doc.selection.getFirstPosition() );
  166. } );
  167. } )
  168. .then( waitForDebounce )
  169. .then( () => {
  170. expect( panelView.isVisible ).to.be.true;
  171. expect( listView.items ).to.have.length( 1 );
  172. model.change( writer => {
  173. writer.insertText( 'r', doc.selection.getFirstPosition() );
  174. } );
  175. } )
  176. .then( waitForDebounce )
  177. .then( () => {
  178. expect( panelView.isVisible ).to.be.true;
  179. expect( listView.items ).to.have.length( 1 );
  180. } );
  181. } );
  182. describe( 'static list with default trigger', () => {
  183. beforeEach( () => {
  184. return createClassicTestEditor( staticConfig );
  185. } );
  186. it( 'should show panel for matched marker', () => {
  187. setData( model, '<paragraph>foo []</paragraph>' );
  188. model.change( writer => {
  189. writer.insertText( '@', doc.selection.getFirstPosition() );
  190. } );
  191. return waitForDebounce()
  192. .then( () => {
  193. expect( panelView.isVisible ).to.be.true;
  194. expect( listView.items ).to.have.length( 5 );
  195. } );
  196. } );
  197. it( 'should show panel for matched marker at the beginning of paragraph', () => {
  198. setData( model, '<paragraph>[] foo</paragraph>' );
  199. model.change( writer => {
  200. writer.insertText( '@', doc.selection.getFirstPosition() );
  201. } );
  202. return waitForDebounce()
  203. .then( () => {
  204. expect( panelView.isVisible ).to.be.true;
  205. expect( listView.items ).to.have.length( 5 );
  206. } );
  207. } );
  208. it( 'should not show panel for marker in the middle of other word', () => {
  209. setData( model, '<paragraph>foo[]</paragraph>' );
  210. model.change( writer => {
  211. writer.insertText( '@', doc.selection.getFirstPosition() );
  212. } );
  213. return waitForDebounce()
  214. .then( () => {
  215. expect( panelView.isVisible ).to.be.false;
  216. } );
  217. } );
  218. it( 'should not show panel when selection is inside a mention', () => {
  219. setData( model, '<paragraph>foo <$text mention="{\'name\':\'John\'}">@John</$text> bar</paragraph>' );
  220. model.change( writer => {
  221. writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
  222. } );
  223. return waitForDebounce()
  224. .then( () => {
  225. expect( panelView.isVisible ).to.be.false;
  226. } );
  227. } );
  228. it( 'should not show panel when selection is at the end of a mention', () => {
  229. setData( model, '<paragraph>foo <$text mention="{\'name\':\'John\'}">@John</$text> bar</paragraph>' );
  230. model.change( writer => {
  231. writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
  232. } );
  233. return waitForDebounce()
  234. .then( () => {
  235. expect( panelView.isVisible ).to.be.false;
  236. } );
  237. } );
  238. it( 'should not show panel when selection is not collapsed', () => {
  239. setData( model, '<paragraph>foo []</paragraph>' );
  240. model.change( writer => {
  241. writer.insertText( '@', doc.selection.getFirstPosition() );
  242. } );
  243. return waitForDebounce()
  244. .then( () => {
  245. expect( panelView.isVisible ).to.be.true;
  246. model.change( () => {
  247. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  248. } );
  249. } )
  250. .then( waitForDebounce )
  251. .then( () => {
  252. expect( panelView.isVisible ).to.be.false;
  253. } );
  254. } );
  255. it( 'should show filtered results for matched text', () => {
  256. setData( model, '<paragraph>foo []</paragraph>' );
  257. model.change( writer => {
  258. writer.insertText( '@', doc.selection.getFirstPosition() );
  259. } );
  260. model.change( writer => {
  261. writer.insertText( 'T', doc.selection.getFirstPosition() );
  262. } );
  263. return waitForDebounce()
  264. .then( () => {
  265. expect( panelView.isVisible ).to.be.true;
  266. expect( listView.items ).to.have.length( 1 );
  267. } );
  268. } );
  269. it( 'should focus the first item in panel', () => {
  270. setData( model, '<paragraph>foo []</paragraph>' );
  271. model.change( writer => {
  272. writer.insertText( '@', doc.selection.getFirstPosition() );
  273. } );
  274. return waitForDebounce()
  275. .then( () => {
  276. const button = listView.items.get( 0 ).children.get( 0 );
  277. expect( button.isOn ).to.be.true;
  278. } );
  279. } );
  280. it( 'should hide panel if no matched items', () => {
  281. setData( model, '<paragraph>foo []</paragraph>' );
  282. model.change( writer => {
  283. writer.insertText( '@', doc.selection.getFirstPosition() );
  284. } );
  285. return waitForDebounce()
  286. .then( () => expect( panelView.isVisible ).to.be.true )
  287. .then( () => {
  288. model.change( writer => {
  289. writer.insertText( 'x', doc.selection.getFirstPosition() );
  290. } );
  291. } )
  292. .then( waitForDebounce )
  293. .then( () => {
  294. expect( panelView.isVisible ).to.be.false;
  295. expect( listView.items ).to.have.length( 0 );
  296. } );
  297. } );
  298. it( 'should hide panel when text was unmatched', () => {
  299. setData( model, '<paragraph>foo []</paragraph>' );
  300. model.change( writer => {
  301. writer.insertText( '@', doc.selection.getFirstPosition() );
  302. } );
  303. return waitForDebounce()
  304. .then( () => expect( panelView.isVisible ).to.be.true )
  305. .then( () => {
  306. model.change( writer => {
  307. const end = doc.selection.getFirstPosition();
  308. const start = end.getShiftedBy( -1 );
  309. writer.remove( writer.createRange( start, end ) );
  310. } );
  311. } )
  312. .then( waitForDebounce )
  313. .then( () => expect( panelView.isVisible ).to.be.false );
  314. } );
  315. } );
  316. describe( 'asynchronous list with custom trigger', () => {
  317. beforeEach( () => {
  318. const issuesNumbers = [ '100', '101', '102', '103' ];
  319. return createClassicTestEditor( [
  320. {
  321. marker: '#',
  322. feed: feedText => {
  323. return new Promise( resolve => {
  324. setTimeout( () => {
  325. resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
  326. }, 20 );
  327. } );
  328. }
  329. }
  330. ] );
  331. } );
  332. it( 'should show panel for matched marker', () => {
  333. setData( model, '<paragraph>foo []</paragraph>' );
  334. model.change( writer => {
  335. writer.insertText( '#', doc.selection.getFirstPosition() );
  336. } );
  337. return waitForDebounce()
  338. .then( () => {
  339. expect( panelView.isVisible ).to.be.true;
  340. expect( listView.items ).to.have.length( 4 );
  341. } );
  342. } );
  343. it( 'should show filtered results for matched text', () => {
  344. setData( model, '<paragraph>foo []</paragraph>' );
  345. model.change( writer => {
  346. writer.insertText( '#', doc.selection.getFirstPosition() );
  347. } );
  348. model.change( writer => {
  349. writer.insertText( '2', doc.selection.getFirstPosition() );
  350. } );
  351. return waitForDebounce()
  352. .then( () => {
  353. expect( panelView.isVisible ).to.be.true;
  354. expect( listView.items ).to.have.length( 1 );
  355. } );
  356. } );
  357. it( 'should hide panel if no matched items', () => {
  358. setData( model, '<paragraph>foo []</paragraph>' );
  359. model.change( writer => {
  360. writer.insertText( '#', doc.selection.getFirstPosition() );
  361. } );
  362. return waitForDebounce()
  363. .then( () => expect( panelView.isVisible ).to.be.true )
  364. .then( () => {
  365. model.change( writer => {
  366. writer.insertText( 'x', doc.selection.getFirstPosition() );
  367. } );
  368. } )
  369. .then( waitForDebounce )
  370. .then( () => {
  371. expect( panelView.isVisible ).to.be.false;
  372. expect( listView.items ).to.have.length( 0 );
  373. } );
  374. } );
  375. it( 'should hide panel when text was unmatched', () => {
  376. setData( model, '<paragraph>foo []</paragraph>' );
  377. model.change( writer => {
  378. writer.insertText( '#', doc.selection.getFirstPosition() );
  379. } );
  380. return waitForDebounce()
  381. .then( () => expect( panelView.isVisible ).to.be.true )
  382. .then( () => {
  383. model.change( writer => {
  384. const end = doc.selection.getFirstPosition();
  385. const start = end.getShiftedBy( -1 );
  386. writer.remove( writer.createRange( start, end ) );
  387. } );
  388. } )
  389. .then( waitForDebounce )
  390. .then( () => expect( panelView.isVisible ).to.be.false );
  391. } );
  392. } );
  393. } );
  394. describe( 'panel behavior', () => {
  395. it( 'should close the opened panel on esc', () => {
  396. return createClassicTestEditor( staticConfig )
  397. .then( () => {
  398. setData( model, '<paragraph>foo []</paragraph>' );
  399. model.change( writer => {
  400. writer.insertText( '@', doc.selection.getFirstPosition() );
  401. } );
  402. } )
  403. .then( waitForDebounce )
  404. .then( () => {
  405. expect( panelView.isVisible ).to.be.true;
  406. fireKeyDownEvent( {
  407. keyCode: keyCodes.esc,
  408. preventDefault: sinon.spy(),
  409. stopPropagation: sinon.spy()
  410. } );
  411. expect( panelView.isVisible ).to.be.false;
  412. } );
  413. } );
  414. it( 'should close the opened panel when click outside the panel', () => {
  415. return createClassicTestEditor( staticConfig )
  416. .then( () => {
  417. setData( model, '<paragraph>foo []</paragraph>' );
  418. model.change( writer => {
  419. writer.insertText( '@', doc.selection.getFirstPosition() );
  420. } );
  421. } )
  422. .then( waitForDebounce )
  423. .then( () => {
  424. expect( panelView.isVisible ).to.be.true;
  425. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
  426. expect( panelView.isVisible ).to.be.false;
  427. } );
  428. } );
  429. it( 'should hide the panel when click outside', () => {
  430. return createClassicTestEditor( staticConfig )
  431. .then( () => {
  432. setData( model, '<paragraph>foo []</paragraph>' );
  433. model.change( writer => {
  434. writer.insertText( '@', doc.selection.getFirstPosition() );
  435. } );
  436. } )
  437. .then( waitForDebounce )
  438. .then( () => {
  439. expect( panelView.isVisible ).to.be.true;
  440. model.change( writer => {
  441. // Place position at the begging of a paragraph.
  442. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  443. } );
  444. expect( panelView.isVisible ).to.be.false;
  445. } );
  446. } );
  447. describe( 'default list item', () => {
  448. const feedItems = staticConfig[ 0 ].feed.map( name => ( { name } ) );
  449. beforeEach( () => {
  450. return createClassicTestEditor( staticConfig );
  451. } );
  452. describe( 'on arrows', () => {
  453. it( 'should cycle down on arrow down', () => {
  454. setData( model, '<paragraph>foo []</paragraph>' );
  455. model.change( writer => {
  456. writer.insertText( '@', doc.selection.getFirstPosition() );
  457. } );
  458. return waitForDebounce()
  459. .then( () => {
  460. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  461. const keyEvtData = {
  462. keyCode: keyCodes.arrowdown,
  463. preventDefault: sinon.spy(),
  464. stopPropagation: sinon.spy()
  465. };
  466. fireKeyDownEvent( keyEvtData );
  467. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  468. fireKeyDownEvent( keyEvtData );
  469. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  470. fireKeyDownEvent( keyEvtData );
  471. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  472. fireKeyDownEvent( keyEvtData );
  473. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  474. fireKeyDownEvent( keyEvtData );
  475. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  476. } );
  477. } );
  478. it( 'should cycle up on arrow up', () => {
  479. setData( model, '<paragraph>foo []</paragraph>' );
  480. model.change( writer => {
  481. writer.insertText( '@', doc.selection.getFirstPosition() );
  482. } );
  483. return waitForDebounce()
  484. .then( () => {
  485. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  486. const keyEvtData = {
  487. keyCode: keyCodes.arrowup,
  488. preventDefault: sinon.spy(),
  489. stopPropagation: sinon.spy()
  490. };
  491. fireKeyDownEvent( keyEvtData );
  492. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  493. fireKeyDownEvent( keyEvtData );
  494. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  495. fireKeyDownEvent( keyEvtData );
  496. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  497. fireKeyDownEvent( keyEvtData );
  498. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  499. fireKeyDownEvent( keyEvtData );
  500. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  501. } );
  502. } );
  503. } );
  504. describe( 'on "execute" keys', () => {
  505. testExecuteKey( 'enter', keyCodes.enter, feedItems );
  506. testExecuteKey( 'tab', keyCodes.tab, feedItems );
  507. testExecuteKey( 'space', keyCodes.space, feedItems );
  508. } );
  509. } );
  510. describe( 'custom list item', () => {
  511. const issues = [
  512. { id: '1002', title: 'Some bug in editor.' },
  513. { id: '1003', title: 'Introduce this feature.' },
  514. { id: '1004', title: 'Missing docs.' },
  515. { id: '1005', title: 'Another bug.' },
  516. { id: '1006', title: 'More bugs' }
  517. ];
  518. beforeEach( () => {
  519. return createClassicTestEditor( [
  520. {
  521. marker: '@',
  522. feed: feedText => {
  523. return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) );
  524. },
  525. itemRenderer: item => {
  526. const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
  527. span.innerHTML = `<span id="issue-${ item.id }">@${ item.title }</span>`;
  528. return span;
  529. }
  530. }
  531. ] );
  532. } );
  533. it( 'should show panel for matched marker', () => {
  534. setData( model, '<paragraph>foo []</paragraph>' );
  535. model.change( writer => {
  536. writer.insertText( '@', doc.selection.getFirstPosition() );
  537. } );
  538. return waitForDebounce()
  539. .then( () => {
  540. expect( panelView.isVisible ).to.be.true;
  541. expect( listView.items ).to.have.length( 5 );
  542. } );
  543. } );
  544. describe( 'keys', () => {
  545. describe( 'on arrows', () => {
  546. it( 'should cycle down on arrow down', () => {
  547. setData( model, '<paragraph>foo []</paragraph>' );
  548. model.change( writer => {
  549. writer.insertText( '@', doc.selection.getFirstPosition() );
  550. } );
  551. return waitForDebounce()
  552. .then( () => {
  553. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  554. const keyEvtData = {
  555. keyCode: keyCodes.arrowdown,
  556. preventDefault: sinon.spy(),
  557. stopPropagation: sinon.spy()
  558. };
  559. fireKeyDownEvent( keyEvtData );
  560. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  561. fireKeyDownEvent( keyEvtData );
  562. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  563. fireKeyDownEvent( keyEvtData );
  564. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  565. fireKeyDownEvent( keyEvtData );
  566. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  567. fireKeyDownEvent( keyEvtData );
  568. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  569. } );
  570. } );
  571. it( 'should cycle up on arrow up', () => {
  572. setData( model, '<paragraph>foo []</paragraph>' );
  573. model.change( writer => {
  574. writer.insertText( '@', doc.selection.getFirstPosition() );
  575. } );
  576. return waitForDebounce()
  577. .then( () => {
  578. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  579. const keyEvtData = {
  580. keyCode: keyCodes.arrowup,
  581. preventDefault: sinon.spy(),
  582. stopPropagation: sinon.spy()
  583. };
  584. fireKeyDownEvent( keyEvtData );
  585. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  586. fireKeyDownEvent( keyEvtData );
  587. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  588. fireKeyDownEvent( keyEvtData );
  589. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  590. fireKeyDownEvent( keyEvtData );
  591. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  592. fireKeyDownEvent( keyEvtData );
  593. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  594. } );
  595. } );
  596. } );
  597. describe( 'on "execute" keys', () => {
  598. testExecuteKey( 'enter', keyCodes.enter, issues );
  599. testExecuteKey( 'tab', keyCodes.tab, issues );
  600. testExecuteKey( 'space', keyCodes.space, issues );
  601. } );
  602. describe( 'mouse', () => {
  603. it( 'should execute selected button on mouse click', () => {
  604. setData( model, '<paragraph>foo []</paragraph>' );
  605. model.change( writer => {
  606. writer.insertText( '@', doc.selection.getFirstPosition() );
  607. } );
  608. const command = editor.commands.get( 'mention' );
  609. const spy = testUtils.sinon.spy( command, 'execute' );
  610. return waitForDebounce()
  611. .then( () => {
  612. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  613. const element = panelView.element.querySelector( '#issue-1004' );
  614. element.dispatchEvent( new Event( 'click', { bubbles: true } ) );
  615. sinon.assert.calledOnce( spy );
  616. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  617. const item = issues[ 2 ];
  618. expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item );
  619. expect( commandOptions ).to.have.property( 'marker', '@' );
  620. expect( commandOptions ).to.have.property( 'range' );
  621. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  622. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  623. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  624. } );
  625. } );
  626. } );
  627. } );
  628. } );
  629. function testExecuteKey( name, keyCode, feedItems ) {
  630. it( 'should execute selected button on ' + name, () => {
  631. setData( model, '<paragraph>foo []</paragraph>' );
  632. model.change( writer => {
  633. writer.insertText( '@', doc.selection.getFirstPosition() );
  634. } );
  635. const command = editor.commands.get( 'mention' );
  636. const spy = testUtils.sinon.spy( command, 'execute' );
  637. return waitForDebounce()
  638. .then( () => {
  639. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  640. fireKeyDownEvent( {
  641. keyCode: keyCodes.arrowup,
  642. preventDefault: sinon.spy(),
  643. stopPropagation: sinon.spy()
  644. } );
  645. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  646. fireKeyDownEvent( {
  647. keyCode,
  648. preventDefault: sinon.spy(),
  649. stopPropagation: sinon.spy()
  650. } );
  651. sinon.assert.calledOnce( spy );
  652. assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] );
  653. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  654. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  655. expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true;
  656. } );
  657. } );
  658. it( 'should do nothing if panel is not visible on ' + name, () => {
  659. setData( model, '<paragraph>foo []</paragraph>' );
  660. model.change( writer => {
  661. writer.insertText( '@', doc.selection.getFirstPosition() );
  662. } );
  663. const command = editor.commands.get( 'mention' );
  664. const spy = testUtils.sinon.spy( command, 'execute' );
  665. return waitForDebounce()
  666. .then( () => {
  667. expect( panelView.isVisible ).to.be.true;
  668. fireKeyDownEvent( {
  669. keyCode: keyCodes.esc,
  670. preventDefault: sinon.spy(),
  671. stopPropagation: sinon.spy()
  672. } );
  673. expect( panelView.isVisible ).to.be.false;
  674. fireKeyDownEvent( {
  675. keyCode,
  676. preventDefault: sinon.spy(),
  677. stopPropagation: sinon.spy()
  678. } );
  679. sinon.assert.notCalled( spy );
  680. expect( panelView.isVisible ).to.be.false;
  681. } );
  682. } );
  683. }
  684. } );
  685. describe( 'execute', () => {
  686. beforeEach( () => createClassicTestEditor( staticConfig ) );
  687. it( 'should call the mention command with proper options', () => {
  688. setData( model, '<paragraph>foo []</paragraph>' );
  689. model.change( writer => {
  690. writer.insertText( '@', doc.selection.getFirstPosition() );
  691. } );
  692. const command = editor.commands.get( 'mention' );
  693. const spy = testUtils.sinon.spy( command, 'execute' );
  694. return waitForDebounce()
  695. .then( () => {
  696. listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  697. sinon.assert.calledOnce( spy );
  698. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  699. assertCommandOptions( commandOptions, '@', { name: 'Barney' } );
  700. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  701. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  702. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  703. } );
  704. } );
  705. it( 'should hide panel on execute', () => {
  706. setData( model, '<paragraph>foo []</paragraph>' );
  707. model.change( writer => {
  708. writer.insertText( '@', doc.selection.getFirstPosition() );
  709. } );
  710. return waitForDebounce()
  711. .then( () => {
  712. listView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  713. expect( panelView.isVisible ).to.be.false;
  714. } );
  715. } );
  716. } );
  717. function createClassicTestEditor( mentionConfig ) {
  718. return ClassicTestEditor
  719. .create( editorElement, {
  720. plugins: [ Paragraph, MentionEditing, MentionUI ],
  721. mention: mentionConfig
  722. } )
  723. .then( newEditor => {
  724. editor = newEditor;
  725. model = editor.model;
  726. doc = model.document;
  727. editingView = editor.editing.view;
  728. mentionUI = editor.plugins.get( MentionUI );
  729. panelView = mentionUI.panelView;
  730. mentionsView = mentionUI._mentionsView;
  731. listView = mentionsView.listView;
  732. editingView.attachDomRoot( editorElement );
  733. // Focus the engine.
  734. editingView.document.isFocused = true;
  735. editingView.getDomRoot().focus();
  736. // Remove all selection ranges from DOM before testing.
  737. window.getSelection().removeAllRanges();
  738. } );
  739. }
  740. function waitForDebounce() {
  741. return new Promise( resolve => {
  742. setTimeout( () => {
  743. resolve();
  744. }, 50 );
  745. } );
  746. }
  747. function fireKeyDownEvent( options ) {
  748. const eventInfo = new EventInfo( editingView.document, 'keydown' );
  749. const eventData = new DomEventData( editingView.document, {
  750. target: document.body
  751. }, options );
  752. editingView.document.fire( eventInfo, eventData );
  753. }
  754. function stubSelectionRects( rects ) {
  755. const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
  756. // Mock selection rect.
  757. sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => {
  758. const domRange = originalViewRangeToDom.apply( editingView.domConverter, args );
  759. sinon.stub( domRange, 'getClientRects' )
  760. .returns( rects );
  761. return domRange;
  762. } );
  763. }
  764. function expectChildViewsIsOnState( expectedState ) {
  765. const childViews = [ ...listView.items ].map( listView => listView.children.get( 0 ) );
  766. expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState );
  767. }
  768. function assertCommandOptions( commandOptions, marker, item ) {
  769. expect( commandOptions ).to.have.property( 'marker', marker );
  770. expect( commandOptions ).to.have.property( 'range' );
  771. expect( commandOptions ).to.have.property( 'mention' );
  772. const mentionForCommand = commandOptions.mention;
  773. for ( const key of Object.keys( item ) ) {
  774. expect( mentionForCommand[ key ] ).to.equal( item[ key ] );
  775. }
  776. }
  777. } );