mentionui.js 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* global 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 CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  17. import env from '@ckeditor/ckeditor5-utils/src/env';
  18. import MentionUI from '../src/mentionui';
  19. import MentionEditing from '../src/mentionediting';
  20. import MentionsView from '../src/ui/mentionsview';
  21. describe( 'MentionUI', () => {
  22. let editor, model, doc, editingView, mentionUI, editorElement, mentionsView, panelView;
  23. const staticConfig = {
  24. feeds: [
  25. {
  26. feed: [ '@Barney', '@Lily', '@Marshall', '@Robin', '@Ted' ],
  27. marker: '@'
  28. }
  29. ]
  30. };
  31. testUtils.createSinonSandbox();
  32. beforeEach( () => {
  33. editorElement = document.createElement( 'div' );
  34. document.body.appendChild( editorElement );
  35. } );
  36. afterEach( () => {
  37. sinon.restore();
  38. editorElement.remove();
  39. if ( editor ) {
  40. return editor.destroy();
  41. }
  42. } );
  43. it( 'should create a plugin instance', () => {
  44. return createClassicTestEditor().then( () => {
  45. expect( mentionUI ).to.instanceOf( Plugin );
  46. expect( mentionUI ).to.instanceOf( MentionUI );
  47. } );
  48. } );
  49. describe( 'init()', () => {
  50. it( 'should throw if marker was not provided for feed', () => {
  51. return createClassicTestEditor( { feeds: [ { feed: [ 'a' ] } ] } ).catch( error => {
  52. expect( error ).to.be.instanceOf( CKEditorError );
  53. expect( error.message ).to.match( /mentionconfig-incorrect-marker/ );
  54. } );
  55. } );
  56. it( 'should throw if marker is empty string', () => {
  57. return createClassicTestEditor( { feeds: [ { marker: '', feed: [ 'a' ] } ] } ).catch( error => {
  58. expect( error ).to.be.instanceOf( CKEditorError );
  59. expect( error.message ).to.match( /mentionconfig-incorrect-marker/ );
  60. } );
  61. } );
  62. it( 'should throw if marker is longer then 1 character', () => {
  63. return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => {
  64. expect( error ).to.be.instanceOf( CKEditorError );
  65. expect( error.message ).to.match( /mentionconfig-incorrect-marker/ );
  66. } );
  67. } );
  68. } );
  69. describe( 'pluginName', () => {
  70. it( 'should return plugin by its name', () => {
  71. return createClassicTestEditor().then( () => {
  72. expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI );
  73. } );
  74. } );
  75. } );
  76. describe( 'child views', () => {
  77. beforeEach( () => createClassicTestEditor() );
  78. describe( 'panelView', () => {
  79. it( 'should create a view instance', () => {
  80. expect( panelView ).to.instanceof( BalloonPanelView );
  81. } );
  82. it( 'should be added to the ui.view.body collection', () => {
  83. expect( Array.from( editor.ui.view.body ) ).to.include( panelView );
  84. } );
  85. it( 'should have disabled arrow', () => {
  86. expect( panelView.withArrow ).to.be.false;
  87. } );
  88. it( 'should have added MentionView as a child', () => {
  89. expect( panelView.content.get( 0 ) ).to.be.instanceof( MentionsView );
  90. } );
  91. } );
  92. } );
  93. describe( 'position', () => {
  94. let pinSpy;
  95. const caretRect = {
  96. bottom: 118,
  97. height: 18,
  98. left: 500,
  99. right: 501,
  100. top: 100,
  101. width: 1
  102. };
  103. const balloonRect = {
  104. bottom: 150,
  105. height: 150,
  106. left: 0,
  107. right: 200,
  108. top: 0,
  109. width: 200
  110. };
  111. beforeEach( () => {
  112. return createClassicTestEditor( staticConfig ).then( () => {
  113. pinSpy = sinon.spy( panelView, 'pin' );
  114. } );
  115. } );
  116. it( 'should properly calculate position data', () => {
  117. setData( model, '<paragraph>foo []</paragraph>' );
  118. stubSelectionRects( [ caretRect ] );
  119. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  120. model.change( writer => {
  121. writer.insertText( '@', doc.selection.getFirstPosition() );
  122. } );
  123. return waitForDebounce()
  124. .then( () => {
  125. const pinArgument = pinSpy.firstCall.args[ 0 ];
  126. const { target, positions } = pinArgument;
  127. expect( positions ).to.have.length( 4 );
  128. // Mention UI should not set limiter or default values.
  129. expect( pinArgument.limiter ).to.be.undefined;
  130. expect( pinArgument.fitInViewport ).to.be.undefined;
  131. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  132. const mentionMarker = editor.model.markers.get( 'mention' );
  133. const focus = doc.selection.focus;
  134. const expectedRange = editor.model.createRange( focus.getShiftedBy( -1 ), focus );
  135. // It should create a model marker for matcher marker character ('@').
  136. expect( expectedRange.isEqual( mentionMarker.getRange() ) ).to.be.true;
  137. const toViewRangeSpy = sinon.spy( editor.editing.mapper, 'toViewRange' );
  138. expect( target() ).to.deep.equal( caretRect );
  139. sinon.assert.calledOnce( toViewRangeSpy );
  140. const range = toViewRangeSpy.firstCall.args[ 0 ];
  141. expect( mentionMarker.getRange().isEqual( range ), 'Should position to mention marker.' );
  142. const caretSouthEast = positions[ 0 ];
  143. const caretSouthWest = positions[ 1 ];
  144. const caretNorthEast = positions[ 2 ];
  145. const caretNorthWest = positions[ 3 ];
  146. expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( {
  147. left: 501,
  148. name: 'caret_se',
  149. top: 121
  150. } );
  151. expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( {
  152. left: 501,
  153. name: 'caret_ne',
  154. top: -53
  155. } );
  156. expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( {
  157. left: 301,
  158. name: 'caret_sw',
  159. top: 121
  160. } );
  161. expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( {
  162. left: 301,
  163. name: 'caret_nw',
  164. top: -53
  165. } );
  166. } );
  167. } );
  168. it( 'should re-calculate position on typing and stay on selected position', () => {
  169. setData( model, '<paragraph>foo []</paragraph>' );
  170. stubSelectionRects( [ caretRect ] );
  171. model.change( writer => {
  172. writer.insertText( '@', doc.selection.getFirstPosition() );
  173. } );
  174. let positionAfterFirstShow;
  175. return waitForDebounce()
  176. .then( () => {
  177. sinon.assert.calledOnce( pinSpy );
  178. const pinArgument = pinSpy.firstCall.args[ 0 ];
  179. const { positions } = pinArgument;
  180. expect( positions ).to.have.length( 4 );
  181. positionAfterFirstShow = panelView.position;
  182. model.change( writer => {
  183. writer.insertText( 't', doc.selection.getFirstPosition() );
  184. } );
  185. } )
  186. .then( waitForDebounce )
  187. .then( () => {
  188. sinon.assert.calledTwice( pinSpy );
  189. const pinArgument = pinSpy.secondCall.args[ 0 ];
  190. const { positions } = pinArgument;
  191. expect( positions, 'should reuse first matched position' ).to.have.length( 1 );
  192. expect( positions[ 0 ].name ).to.equal( positionAfterFirstShow );
  193. } );
  194. } );
  195. } );
  196. describe( 'typing integration', () => {
  197. it( 'should show panel for matched marker after typing minimum characters', () => {
  198. return createClassicTestEditor( { feeds: [ Object.assign( { minimumCharacters: 2 }, staticConfig.feeds[ 0 ] ) ] } )
  199. .then( () => {
  200. setData( model, '<paragraph>foo []</paragraph>' );
  201. model.change( writer => {
  202. writer.insertText( '@', doc.selection.getFirstPosition() );
  203. } );
  204. } )
  205. .then( () => {
  206. model.change( writer => {
  207. writer.insertText( 'B', doc.selection.getFirstPosition() );
  208. } );
  209. } )
  210. .then( waitForDebounce )
  211. .then( () => {
  212. expect( panelView.isVisible ).to.be.false;
  213. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  214. } )
  215. .then( waitForDebounce )
  216. .then( () => {
  217. model.change( writer => {
  218. writer.insertText( 'a', doc.selection.getFirstPosition() );
  219. } );
  220. } )
  221. .then( waitForDebounce )
  222. .then( () => {
  223. expect( panelView.isVisible ).to.be.true;
  224. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  225. expect( mentionsView.items ).to.have.length( 1 );
  226. model.change( writer => {
  227. writer.insertText( 'r', doc.selection.getFirstPosition() );
  228. } );
  229. } )
  230. .then( waitForDebounce )
  231. .then( () => {
  232. expect( panelView.isVisible ).to.be.true;
  233. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  234. expect( mentionsView.items ).to.have.length( 1 );
  235. } );
  236. } );
  237. describe( 'static list with large set of results', () => {
  238. const bigList = {
  239. marker: '@',
  240. feed: [
  241. '@a01', '@a02', '@a03', '@a04', '@a05', '@a06', '@a07', '@a08', '@a09', '@a10', '@a11', '@a12'
  242. ]
  243. };
  244. beforeEach( () => {
  245. return createClassicTestEditor( { feeds: [ bigList ] } );
  246. } );
  247. it( 'should show panel with no more then 10 items for default static feed', () => {
  248. setData( model, '<paragraph>foo []</paragraph>' );
  249. model.change( writer => {
  250. writer.insertText( '@', doc.selection.getFirstPosition() );
  251. } );
  252. return waitForDebounce()
  253. .then( () => {
  254. expect( panelView.isVisible ).to.be.true;
  255. expect( mentionsView.items ).to.have.length( 10 );
  256. } );
  257. } );
  258. it( 'should scroll mention panel to the selected item', () => {
  259. setData( model, '<paragraph>foo []</paragraph>' );
  260. model.change( writer => {
  261. writer.insertText( '@', doc.selection.getFirstPosition() );
  262. } );
  263. const arrowDownEvtData = {
  264. keyCode: keyCodes.arrowdown,
  265. preventDefault: sinon.spy(),
  266. stopPropagation: sinon.spy()
  267. };
  268. const arrowUpEvtData = {
  269. keyCode: keyCodes.arrowup,
  270. preventDefault: sinon.spy(),
  271. stopPropagation: sinon.spy()
  272. };
  273. const mentionElementSpy = testUtils.sinon.spy( mentionsView.element, 'scrollTop', [ 'set' ] );
  274. return waitForDebounce()
  275. .then( () => {
  276. // The scroll test highly depends on browser styles.
  277. // Some CI test environments might not load theme which will result that tests will not render on CI as locally.
  278. // To make this test repeatable across different environments it enforces mentions view size to 100px...
  279. const reset = 'padding:0px;margin:0px;border:0 none;line-height: 1em;';
  280. mentionsView.element.style = `min-height:100px;height:100px;max-height:100px;${ reset };`;
  281. // ...and each list view item size to 25px...
  282. Array.from( mentionsView.items ).forEach( item => {
  283. const listItemElement = item.children.get( 0 ).element;
  284. listItemElement.style = `min-height:unset;height:25px;max-height:25px;${ reset };min-width:12em;`;
  285. } );
  286. // ...so after those changes it is safe to assume that:
  287. // - base offset is 0
  288. // - only 4 items are visible at once
  289. // - if scrolled to the last element scrollTop will be set to 150px. The 150px is the offset of the 7th item in the
  290. // list as last four (7, 8, 9 & 10) will be visible.
  291. expect( panelView.isVisible ).to.be.true;
  292. expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
  293. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  294. if ( !env.isEdge ) {
  295. sinon.assert.callCount( mentionElementSpy.set, 0 );
  296. }
  297. fireKeyDownEvent( arrowDownEvtData );
  298. expectChildViewsIsOnState( [ false, true, false, false, false, false, false, false, false, false ] );
  299. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  300. if ( !env.isEdge ) {
  301. expect( mentionsView.element.scrollTop ).to.equal( 0 );
  302. sinon.assert.callCount( mentionElementSpy.set, 0 );
  303. }
  304. fireKeyDownEvent( arrowUpEvtData );
  305. expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
  306. expect( mentionsView.element.scrollTop ).to.equal( 0 );
  307. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  308. if ( !env.isEdge ) {
  309. sinon.assert.callCount( mentionElementSpy.set, 0 );
  310. }
  311. fireKeyDownEvent( arrowUpEvtData );
  312. expectChildViewsIsOnState( [ false, false, false, false, false, false, false, false, false, true ] );
  313. // We want 150, but sometimes we get e.g. 151.
  314. expect( mentionsView.element.scrollTop ).to.be.within( 140, 160, 'last item highlighted' );
  315. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  316. if ( !env.isEdge ) {
  317. sinon.assert.callCount( mentionElementSpy.set, 1 );
  318. }
  319. fireKeyDownEvent( arrowDownEvtData );
  320. expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
  321. // We want 0, but sometimes we get e.g. 1. (Firefox)
  322. expect( mentionsView.element.scrollTop ).to.be.within( 0, 10 );
  323. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  324. if ( !env.isEdge ) {
  325. sinon.assert.callCount( mentionElementSpy.set, 2 );
  326. }
  327. } );
  328. } );
  329. } );
  330. describe( 'static list with default trigger', () => {
  331. beforeEach( () => {
  332. return createClassicTestEditor( staticConfig );
  333. } );
  334. it( 'should show panel for matched marker', () => {
  335. setData( model, '<paragraph>foo []</paragraph>' );
  336. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  337. model.change( writer => {
  338. writer.insertText( '@', doc.selection.getFirstPosition() );
  339. } );
  340. return waitForDebounce()
  341. .then( () => {
  342. expect( panelView.isVisible ).to.be.true;
  343. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  344. expect( mentionsView.items ).to.have.length( 5 );
  345. } );
  346. } );
  347. it( 'should show panel for matched marker at the beginning of paragraph', () => {
  348. setData( model, '<paragraph>[] foo</paragraph>' );
  349. model.change( writer => {
  350. writer.insertText( '@', doc.selection.getFirstPosition() );
  351. } );
  352. return waitForDebounce()
  353. .then( () => {
  354. expect( panelView.isVisible ).to.be.true;
  355. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  356. expect( mentionsView.items ).to.have.length( 5 );
  357. } );
  358. } );
  359. it( 'should not show panel for marker in the middle of other word', () => {
  360. setData( model, '<paragraph>foo[]</paragraph>' );
  361. model.change( writer => {
  362. writer.insertText( '@', doc.selection.getFirstPosition() );
  363. } );
  364. return waitForDebounce()
  365. .then( () => {
  366. expect( panelView.isVisible ).to.be.false;
  367. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  368. } );
  369. } );
  370. it( 'should not show panel when selection is inside a mention', () => {
  371. setData( model, '<paragraph>foo [@Lily] bar</paragraph>' );
  372. model.change( writer => {
  373. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, doc.selection.getFirstRange() );
  374. } );
  375. model.change( writer => {
  376. writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
  377. } );
  378. return waitForDebounce()
  379. .then( () => {
  380. expect( panelView.isVisible ).to.be.false;
  381. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  382. } );
  383. } );
  384. it( 'should not show panel when selection is at the end of a mention', () => {
  385. setData( model, '<paragraph>foo [@Lily] bar</paragraph>' );
  386. model.change( writer => {
  387. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, doc.selection.getFirstRange() );
  388. } );
  389. model.change( writer => {
  390. writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
  391. } );
  392. return waitForDebounce()
  393. .then( () => {
  394. expect( panelView.isVisible ).to.be.false;
  395. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  396. } );
  397. } );
  398. it( 'should not show panel when selection is not collapsed', () => {
  399. setData( model, '<paragraph>foo []</paragraph>' );
  400. model.change( writer => {
  401. writer.insertText( '@', doc.selection.getFirstPosition() );
  402. } );
  403. return waitForDebounce()
  404. .then( () => {
  405. expect( panelView.isVisible ).to.be.true;
  406. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  407. model.change( () => {
  408. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  409. } );
  410. } )
  411. .then( waitForDebounce )
  412. .then( () => {
  413. expect( panelView.isVisible ).to.be.false;
  414. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  415. } );
  416. } );
  417. it( 'should not show panel when selection is changing (non-collapsed)', () => {
  418. setData( model, '<paragraph>foo []</paragraph>' );
  419. model.change( writer => {
  420. writer.insertText( '@', doc.selection.getFirstPosition() );
  421. } );
  422. return waitForDebounce()
  423. .then( () => {
  424. expect( panelView.isVisible ).to.be.true;
  425. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  426. model.change( () => {
  427. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  428. } );
  429. } )
  430. .then( waitForDebounce )
  431. .then( () => {
  432. expect( panelView.isVisible ).to.be.false;
  433. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  434. } )
  435. .then( () => {
  436. model.change( () => {
  437. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  438. } );
  439. } )
  440. .then( waitForDebounce )
  441. .then( () => {
  442. expect( panelView.isVisible ).to.be.false;
  443. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  444. } );
  445. } );
  446. it( 'should not show panel when selection is after existing mention', () => {
  447. setData( model, '<paragraph>foo [@Lily] bar[]</paragraph>' );
  448. model.change( writer => {
  449. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, doc.selection.getFirstRange() );
  450. } );
  451. return waitForDebounce()
  452. .then( () => {
  453. expect( panelView.isVisible ).to.be.false;
  454. model.change( writer => {
  455. writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
  456. } );
  457. } )
  458. .then( waitForDebounce )
  459. .then( () => {
  460. expect( panelView.isVisible ).to.be.false;
  461. } );
  462. } );
  463. it( 'should not show panel when selection moves inside existing mention', () => {
  464. setData( model, '<paragraph>foo [@Lily] bar</paragraph>' );
  465. model.change( writer => {
  466. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, doc.selection.getFirstRange() );
  467. } );
  468. return waitForDebounce()
  469. .then( () => {
  470. model.change( writer => {
  471. writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
  472. } );
  473. } )
  474. .then( waitForDebounce )
  475. .then( () => {
  476. expect( panelView.isVisible ).to.be.false;
  477. model.change( writer => {
  478. writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
  479. } );
  480. } )
  481. .then( waitForDebounce )
  482. .then( () => {
  483. expect( panelView.isVisible ).to.be.false;
  484. } );
  485. } );
  486. it( 'should show filtered results for matched text', () => {
  487. setData( model, '<paragraph>foo []</paragraph>' );
  488. model.change( writer => {
  489. writer.insertText( '@', doc.selection.getFirstPosition() );
  490. } );
  491. model.change( writer => {
  492. writer.insertText( 'T', doc.selection.getFirstPosition() );
  493. } );
  494. return waitForDebounce()
  495. .then( () => {
  496. expect( panelView.isVisible ).to.be.true;
  497. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  498. expect( mentionsView.items ).to.have.length( 1 );
  499. } );
  500. } );
  501. it( 'should focus the first item in panel', () => {
  502. setData( model, '<paragraph>foo []</paragraph>' );
  503. model.change( writer => {
  504. writer.insertText( '@', doc.selection.getFirstPosition() );
  505. } );
  506. return waitForDebounce()
  507. .then( () => {
  508. const button = mentionsView.items.get( 0 ).children.get( 0 );
  509. expect( button.isOn ).to.be.true;
  510. } );
  511. } );
  512. it( 'should hide panel if no matched items', () => {
  513. setData( model, '<paragraph>foo []</paragraph>' );
  514. model.change( writer => {
  515. writer.insertText( '@', doc.selection.getFirstPosition() );
  516. } );
  517. return waitForDebounce()
  518. .then( () => expect( panelView.isVisible ).to.be.true )
  519. .then( () => {
  520. model.change( writer => {
  521. writer.insertText( 'x', doc.selection.getFirstPosition() );
  522. } );
  523. } )
  524. .then( waitForDebounce )
  525. .then( () => {
  526. expect( panelView.isVisible ).to.be.false;
  527. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  528. expect( mentionsView.items ).to.have.length( 0 );
  529. } );
  530. } );
  531. it( 'should hide panel when text was unmatched', () => {
  532. setData( model, '<paragraph>foo []</paragraph>' );
  533. model.change( writer => {
  534. writer.insertText( '@', doc.selection.getFirstPosition() );
  535. } );
  536. return waitForDebounce()
  537. .then( () => expect( panelView.isVisible ).to.be.true )
  538. .then( () => {
  539. model.change( writer => {
  540. const end = doc.selection.getFirstPosition();
  541. const start = end.getShiftedBy( -1 );
  542. writer.remove( writer.createRange( start, end ) );
  543. } );
  544. } )
  545. .then( waitForDebounce )
  546. .then( () => expect( panelView.isVisible ).to.be.false );
  547. } );
  548. } );
  549. describe( 'asynchronous list with custom trigger', () => {
  550. beforeEach( () => {
  551. const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
  552. return createClassicTestEditor( {
  553. feeds: [
  554. {
  555. marker: '#',
  556. feed: feedText => {
  557. return new Promise( resolve => {
  558. setTimeout( () => {
  559. resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
  560. }, 20 );
  561. } );
  562. }
  563. }
  564. ]
  565. } );
  566. } );
  567. it( 'should show panel for matched marker', () => {
  568. setData( model, '<paragraph>foo []</paragraph>' );
  569. model.change( writer => {
  570. writer.insertText( '#', doc.selection.getFirstPosition() );
  571. } );
  572. return waitForDebounce()
  573. .then( () => {
  574. expect( panelView.isVisible ).to.be.true;
  575. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  576. expect( mentionsView.items ).to.have.length( 4 );
  577. } );
  578. } );
  579. it( 'should show filtered results for matched text', () => {
  580. setData( model, '<paragraph>foo []</paragraph>' );
  581. model.change( writer => {
  582. writer.insertText( '#', doc.selection.getFirstPosition() );
  583. } );
  584. model.change( writer => {
  585. writer.insertText( '2', doc.selection.getFirstPosition() );
  586. } );
  587. return waitForDebounce()
  588. .then( () => {
  589. expect( panelView.isVisible ).to.be.true;
  590. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  591. expect( mentionsView.items ).to.have.length( 1 );
  592. } );
  593. } );
  594. it( 'should hide panel if no matched items', () => {
  595. setData( model, '<paragraph>foo []</paragraph>' );
  596. model.change( writer => {
  597. writer.insertText( '#', doc.selection.getFirstPosition() );
  598. } );
  599. return waitForDebounce()
  600. .then( () => expect( panelView.isVisible ).to.be.true )
  601. .then( () => {
  602. model.change( writer => {
  603. writer.insertText( 'x', doc.selection.getFirstPosition() );
  604. } );
  605. } )
  606. .then( waitForDebounce )
  607. .then( () => {
  608. expect( panelView.isVisible ).to.be.false;
  609. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  610. expect( mentionsView.items ).to.have.length( 0 );
  611. } );
  612. } );
  613. it( 'should hide panel when text was unmatched', () => {
  614. setData( model, '<paragraph>foo []</paragraph>' );
  615. model.change( writer => {
  616. writer.insertText( '#', doc.selection.getFirstPosition() );
  617. } );
  618. return waitForDebounce()
  619. .then( () => expect( panelView.isVisible ).to.be.true )
  620. .then( () => {
  621. model.change( writer => {
  622. const end = doc.selection.getFirstPosition();
  623. const start = end.getShiftedBy( -1 );
  624. writer.remove( writer.createRange( start, end ) );
  625. } );
  626. } )
  627. .then( waitForDebounce )
  628. .then( () => expect( panelView.isVisible ).to.be.false );
  629. } );
  630. } );
  631. } );
  632. describe( 'panel behavior', () => {
  633. it( 'should close the opened panel on esc', () => {
  634. return createClassicTestEditor( staticConfig )
  635. .then( () => {
  636. setData( model, '<paragraph>foo []</paragraph>' );
  637. model.change( writer => {
  638. writer.insertText( '@', doc.selection.getFirstPosition() );
  639. } );
  640. } )
  641. .then( waitForDebounce )
  642. .then( () => {
  643. expect( panelView.isVisible ).to.be.true;
  644. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  645. fireKeyDownEvent( {
  646. keyCode: keyCodes.esc,
  647. preventDefault: sinon.spy(),
  648. stopPropagation: sinon.spy()
  649. } );
  650. expect( panelView.isVisible ).to.be.false;
  651. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  652. } );
  653. } );
  654. it( 'should close the opened panel when click outside the panel', () => {
  655. return createClassicTestEditor( staticConfig )
  656. .then( () => {
  657. setData( model, '<paragraph>foo []</paragraph>' );
  658. model.change( writer => {
  659. writer.insertText( '@', doc.selection.getFirstPosition() );
  660. } );
  661. } )
  662. .then( waitForDebounce )
  663. .then( () => {
  664. expect( panelView.isVisible ).to.be.true;
  665. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  666. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
  667. expect( panelView.isVisible ).to.be.false;
  668. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  669. } );
  670. } );
  671. it( 'should hide the panel on selection change', () => {
  672. return createClassicTestEditor( staticConfig )
  673. .then( () => {
  674. setData( model, '<paragraph>foo []</paragraph>' );
  675. model.change( writer => {
  676. writer.insertText( '@', doc.selection.getFirstPosition() );
  677. } );
  678. } )
  679. .then( waitForDebounce )
  680. .then( () => {
  681. expect( panelView.isVisible ).to.be.true;
  682. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  683. model.change( writer => {
  684. // Place position at the beginning of a paragraph.
  685. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  686. } );
  687. expect( panelView.isVisible ).to.be.false;
  688. expect( panelView.position ).to.be.undefined;
  689. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  690. } );
  691. } );
  692. it( 'should hide the panel on selection change triggered by mouse click', () => {
  693. return createClassicTestEditor( staticConfig )
  694. .then( () => {
  695. setData( model, '<paragraph>foo []</paragraph>' );
  696. model.change( writer => {
  697. writer.insertText( '@', doc.selection.getFirstPosition() );
  698. } );
  699. } )
  700. .then( waitForDebounce )
  701. .then( () => {
  702. expect( panelView.isVisible ).to.be.true;
  703. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  704. // This happens when user clicks outside the panel view and selection is changed.
  705. // Two panel closing mechanisms are run:
  706. // - clickOutsideHandler
  707. // - unmatched text in text watcher
  708. // which may fail when trying to remove mention marker twice.
  709. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
  710. model.change( writer => {
  711. // Place position at the beginning of a paragraph.
  712. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  713. } );
  714. expect( panelView.isVisible ).to.be.false;
  715. expect( panelView.position ).to.be.undefined;
  716. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  717. } );
  718. } );
  719. describe( 'default list item', () => {
  720. // Create map of expected feed items as objects as they will be stored internally.
  721. const feedItems = staticConfig.feeds[ 0 ].feed.map( text => ( { text: `${ text }`, id: `${ text }` } ) );
  722. beforeEach( () => {
  723. return createClassicTestEditor( staticConfig );
  724. } );
  725. describe( 'on arrows', () => {
  726. it( 'should cycle down on arrow down', () => {
  727. setData( model, '<paragraph>foo []</paragraph>' );
  728. model.change( writer => {
  729. writer.insertText( '@', doc.selection.getFirstPosition() );
  730. } );
  731. return waitForDebounce()
  732. .then( () => {
  733. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  734. const keyEvtData = {
  735. keyCode: keyCodes.arrowdown,
  736. preventDefault: sinon.spy(),
  737. stopPropagation: sinon.spy()
  738. };
  739. fireKeyDownEvent( keyEvtData );
  740. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  741. fireKeyDownEvent( keyEvtData );
  742. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  743. fireKeyDownEvent( keyEvtData );
  744. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  745. fireKeyDownEvent( keyEvtData );
  746. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  747. fireKeyDownEvent( keyEvtData );
  748. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  749. } );
  750. } );
  751. it( 'should cycle up on arrow up', () => {
  752. setData( model, '<paragraph>foo []</paragraph>' );
  753. model.change( writer => {
  754. writer.insertText( '@', doc.selection.getFirstPosition() );
  755. } );
  756. return waitForDebounce()
  757. .then( () => {
  758. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  759. const keyEvtData = {
  760. keyCode: keyCodes.arrowup,
  761. preventDefault: sinon.spy(),
  762. stopPropagation: sinon.spy()
  763. };
  764. fireKeyDownEvent( keyEvtData );
  765. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  766. fireKeyDownEvent( keyEvtData );
  767. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  768. fireKeyDownEvent( keyEvtData );
  769. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  770. fireKeyDownEvent( keyEvtData );
  771. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  772. fireKeyDownEvent( keyEvtData );
  773. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  774. } );
  775. } );
  776. it( 'should not cycle with only one item in the list', () => {
  777. setData( model, '<paragraph>foo []</paragraph>' );
  778. const keyDownEvtData = {
  779. keyCode: keyCodes.arrowdown,
  780. preventDefault: sinon.spy(),
  781. stopPropagation: sinon.spy()
  782. };
  783. const keyUpEvtData = {
  784. keyCode: keyCodes.arrowdown,
  785. preventDefault: sinon.spy(),
  786. stopPropagation: sinon.spy()
  787. };
  788. model.change( writer => {
  789. writer.insertText( '@T', doc.selection.getFirstPosition() );
  790. } );
  791. return waitForDebounce()
  792. .then( () => {
  793. expectChildViewsIsOnState( [ true ] );
  794. fireKeyDownEvent( keyDownEvtData );
  795. expectChildViewsIsOnState( [ true ] );
  796. fireKeyDownEvent( keyUpEvtData );
  797. expectChildViewsIsOnState( [ true ] );
  798. } );
  799. } );
  800. } );
  801. describe( 'on "execute" keys', () => {
  802. testExecuteKey( 'enter', keyCodes.enter, feedItems );
  803. testExecuteKey( 'tab', keyCodes.tab, feedItems );
  804. testExecuteKey( 'space', keyCodes.space, feedItems );
  805. } );
  806. } );
  807. describe( 'default list item with custom feed', () => {
  808. const issues = [
  809. { id: '@Ted' },
  810. { id: '@Barney' },
  811. { id: '@Robin' },
  812. { id: '@Lily' },
  813. { id: '@Marshal' }
  814. ];
  815. beforeEach( () => {
  816. return createClassicTestEditor( {
  817. feeds: [
  818. {
  819. marker: '@',
  820. feed: feedText => issues.filter( issue => issue.id.includes( feedText ) )
  821. }
  822. ]
  823. } );
  824. } );
  825. it( 'should show panel for matched marker', () => {
  826. setData( model, '<paragraph>foo []</paragraph>' );
  827. model.change( writer => {
  828. writer.insertText( '@', doc.selection.getFirstPosition() );
  829. } );
  830. return waitForDebounce()
  831. .then( () => {
  832. expect( panelView.isVisible ).to.be.true;
  833. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  834. expect( mentionsView.items ).to.have.length( 5 );
  835. } );
  836. } );
  837. } );
  838. describe( 'custom list item (string)', () => {
  839. const issues = [
  840. { id: '@1002', title: 'Some bug in editor.' },
  841. { id: '@1003', title: 'Introduce this feature.' },
  842. { id: '@1004', title: 'Missing docs.' },
  843. { id: '@1005', title: 'Another bug.' },
  844. { id: '@1006', title: 'More bugs' }
  845. ];
  846. beforeEach( () => {
  847. return createClassicTestEditor( {
  848. feeds: [
  849. {
  850. marker: '@',
  851. feed: issues,
  852. itemRenderer: item => item.title
  853. }
  854. ]
  855. } );
  856. } );
  857. it( 'should show panel for matched marker', () => {
  858. setData( model, '<paragraph>foo []</paragraph>' );
  859. model.change( writer => {
  860. writer.insertText( '@', doc.selection.getFirstPosition() );
  861. } );
  862. return waitForDebounce()
  863. .then( () => {
  864. expect( panelView.isVisible ).to.be.true;
  865. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  866. expect( mentionsView.items ).to.have.length( 5 );
  867. } );
  868. } );
  869. describe( 'keys', () => {
  870. describe( 'on arrows', () => {
  871. it( 'should cycle down on arrow down', () => {
  872. setData( model, '<paragraph>foo []</paragraph>' );
  873. model.change( writer => {
  874. writer.insertText( '@', doc.selection.getFirstPosition() );
  875. } );
  876. return waitForDebounce()
  877. .then( () => {
  878. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  879. const keyEvtData = {
  880. keyCode: keyCodes.arrowdown,
  881. preventDefault: sinon.spy(),
  882. stopPropagation: sinon.spy()
  883. };
  884. fireKeyDownEvent( keyEvtData );
  885. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  886. fireKeyDownEvent( keyEvtData );
  887. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  888. fireKeyDownEvent( keyEvtData );
  889. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  890. fireKeyDownEvent( keyEvtData );
  891. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  892. fireKeyDownEvent( keyEvtData );
  893. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  894. } );
  895. } );
  896. it( 'should cycle up on arrow up', () => {
  897. setData( model, '<paragraph>foo []</paragraph>' );
  898. model.change( writer => {
  899. writer.insertText( '@', doc.selection.getFirstPosition() );
  900. } );
  901. return waitForDebounce()
  902. .then( () => {
  903. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  904. const keyEvtData = {
  905. keyCode: keyCodes.arrowup,
  906. preventDefault: sinon.spy(),
  907. stopPropagation: sinon.spy()
  908. };
  909. fireKeyDownEvent( keyEvtData );
  910. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  911. fireKeyDownEvent( keyEvtData );
  912. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  913. fireKeyDownEvent( keyEvtData );
  914. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  915. fireKeyDownEvent( keyEvtData );
  916. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  917. fireKeyDownEvent( keyEvtData );
  918. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  919. } );
  920. } );
  921. } );
  922. describe( 'on "execute" keys', () => {
  923. testExecuteKey( 'enter', keyCodes.enter, issues );
  924. testExecuteKey( 'tab', keyCodes.tab, issues );
  925. testExecuteKey( 'space', keyCodes.space, issues );
  926. } );
  927. } );
  928. } );
  929. describe( 'custom list item (DOM Element)', () => {
  930. const issues = [
  931. { id: '@1002', title: 'Some bug in editor.' },
  932. { id: '@1003', title: 'Introduce this feature.' },
  933. { id: '@1004', title: 'Missing docs.' },
  934. { id: '@1005', title: 'Another bug.' },
  935. { id: '@1006', title: 'More bugs' }
  936. ];
  937. beforeEach( () => {
  938. return createClassicTestEditor( {
  939. feeds: [
  940. {
  941. marker: '@',
  942. feed: feedText => {
  943. return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) );
  944. },
  945. itemRenderer: item => {
  946. const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
  947. span.innerHTML = `<span id="issue-${ item.id.slice( 1 ) }">@${ item.title }</span>`;
  948. return span;
  949. }
  950. }
  951. ]
  952. } );
  953. } );
  954. it( 'should show panel for matched marker', () => {
  955. setData( model, '<paragraph>foo []</paragraph>' );
  956. model.change( writer => {
  957. writer.insertText( '@', doc.selection.getFirstPosition() );
  958. } );
  959. return waitForDebounce()
  960. .then( () => {
  961. expect( panelView.isVisible ).to.be.true;
  962. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  963. expect( mentionsView.items ).to.have.length( 5 );
  964. } );
  965. } );
  966. describe( 'keys', () => {
  967. describe( 'on arrows', () => {
  968. it( 'should cycle down on arrow down', () => {
  969. setData( model, '<paragraph>foo []</paragraph>' );
  970. model.change( writer => {
  971. writer.insertText( '@', doc.selection.getFirstPosition() );
  972. } );
  973. return waitForDebounce()
  974. .then( () => {
  975. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  976. const keyEvtData = {
  977. keyCode: keyCodes.arrowdown,
  978. preventDefault: sinon.spy(),
  979. stopPropagation: sinon.spy()
  980. };
  981. fireKeyDownEvent( keyEvtData );
  982. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  983. fireKeyDownEvent( keyEvtData );
  984. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  985. fireKeyDownEvent( keyEvtData );
  986. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  987. fireKeyDownEvent( keyEvtData );
  988. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  989. fireKeyDownEvent( keyEvtData );
  990. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  991. } );
  992. } );
  993. it( 'should cycle up on arrow up', () => {
  994. setData( model, '<paragraph>foo []</paragraph>' );
  995. model.change( writer => {
  996. writer.insertText( '@', doc.selection.getFirstPosition() );
  997. } );
  998. return waitForDebounce()
  999. .then( () => {
  1000. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1001. const keyEvtData = {
  1002. keyCode: keyCodes.arrowup,
  1003. preventDefault: sinon.spy(),
  1004. stopPropagation: sinon.spy()
  1005. };
  1006. fireKeyDownEvent( keyEvtData );
  1007. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1008. fireKeyDownEvent( keyEvtData );
  1009. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1010. fireKeyDownEvent( keyEvtData );
  1011. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1012. fireKeyDownEvent( keyEvtData );
  1013. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1014. fireKeyDownEvent( keyEvtData );
  1015. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1016. } );
  1017. } );
  1018. } );
  1019. describe( 'on "execute" keys', () => {
  1020. testExecuteKey( 'enter', keyCodes.enter, issues );
  1021. testExecuteKey( 'tab', keyCodes.tab, issues );
  1022. testExecuteKey( 'space', keyCodes.space, issues );
  1023. } );
  1024. describe( 'mouse', () => {
  1025. it( 'should execute selected button on mouse click', () => {
  1026. setData( model, '<paragraph>foo []</paragraph>' );
  1027. model.change( writer => {
  1028. writer.insertText( '@', doc.selection.getFirstPosition() );
  1029. } );
  1030. const command = editor.commands.get( 'mention' );
  1031. const spy = testUtils.sinon.spy( command, 'execute' );
  1032. return waitForDebounce()
  1033. .then( () => {
  1034. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1035. const element = panelView.element.querySelector( '#issue-1004' );
  1036. element.dispatchEvent( new Event( 'click', { bubbles: true } ) );
  1037. sinon.assert.calledOnce( spy );
  1038. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  1039. const item = issues[ 2 ];
  1040. expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item );
  1041. expect( commandOptions ).to.have.property( 'marker', '@' );
  1042. expect( commandOptions ).to.have.property( 'range' );
  1043. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  1044. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  1045. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  1046. } );
  1047. } );
  1048. } );
  1049. } );
  1050. } );
  1051. describe( 'multiple feeds configuration', () => {
  1052. beforeEach( () => {
  1053. return createClassicTestEditor( {
  1054. feeds: [
  1055. {
  1056. marker: '@',
  1057. feed: [ '@a1', '@a2', '@a3' ]
  1058. },
  1059. {
  1060. marker: '$',
  1061. feed: [ '$a1', '$a2', '$a3', '$a4', '$a5' ]
  1062. }
  1063. ]
  1064. } );
  1065. } );
  1066. it( 'should show panel for matched marker', () => {
  1067. setData( model, '<paragraph>foo []</paragraph>' );
  1068. model.change( writer => {
  1069. writer.insertText( '@', doc.selection.getFirstPosition() );
  1070. } );
  1071. return waitForDebounce()
  1072. .then( () => {
  1073. expect( panelView.isVisible ).to.be.true;
  1074. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1075. expect( mentionsView.items ).to.have.length( 3 );
  1076. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1077. } )
  1078. .then( waitForDebounce )
  1079. .then( () => {
  1080. expect( panelView.isVisible ).to.be.false;
  1081. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1082. model.change( writer => {
  1083. writer.insertText( '$', doc.selection.getFirstPosition() );
  1084. } );
  1085. } )
  1086. .then( waitForDebounce )
  1087. .then( () => {
  1088. expect( panelView.isVisible ).to.be.true;
  1089. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1090. expect( mentionsView.items ).to.have.length( 5 );
  1091. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1092. } )
  1093. .then( waitForDebounce )
  1094. .then( () => {
  1095. expect( panelView.isVisible ).to.be.false;
  1096. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1097. model.change( writer => {
  1098. writer.insertText( '@', doc.selection.getFirstPosition() );
  1099. } );
  1100. } )
  1101. .then( waitForDebounce )
  1102. .then( () => {
  1103. expect( panelView.isVisible ).to.be.true;
  1104. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1105. expect( mentionsView.items ).to.have.length( 3 );
  1106. } );
  1107. } );
  1108. } );
  1109. function testExecuteKey( name, keyCode, feedItems ) {
  1110. it( 'should execute selected button on ' + name, () => {
  1111. setData( model, '<paragraph>foo []</paragraph>' );
  1112. model.change( writer => {
  1113. writer.insertText( '@', doc.selection.getFirstPosition() );
  1114. } );
  1115. const command = editor.commands.get( 'mention' );
  1116. const spy = testUtils.sinon.spy( command, 'execute' );
  1117. return waitForDebounce()
  1118. .then( () => {
  1119. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1120. fireKeyDownEvent( {
  1121. keyCode: keyCodes.arrowup,
  1122. preventDefault: sinon.spy(),
  1123. stopPropagation: sinon.spy()
  1124. } );
  1125. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1126. fireKeyDownEvent( {
  1127. keyCode,
  1128. preventDefault: sinon.spy(),
  1129. stopPropagation: sinon.spy()
  1130. } );
  1131. sinon.assert.calledOnce( spy );
  1132. assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] );
  1133. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  1134. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  1135. expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true;
  1136. } );
  1137. } );
  1138. it( 'should do nothing if panel is not visible on ' + name, () => {
  1139. setData( model, '<paragraph>foo []</paragraph>' );
  1140. model.change( writer => {
  1141. writer.insertText( '@', doc.selection.getFirstPosition() );
  1142. } );
  1143. const command = editor.commands.get( 'mention' );
  1144. const spy = testUtils.sinon.spy( command, 'execute' );
  1145. return waitForDebounce()
  1146. .then( () => {
  1147. expect( panelView.isVisible ).to.be.true;
  1148. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1149. fireKeyDownEvent( {
  1150. keyCode: keyCodes.esc,
  1151. preventDefault: sinon.spy(),
  1152. stopPropagation: sinon.spy()
  1153. } );
  1154. expect( panelView.isVisible ).to.be.false;
  1155. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1156. fireKeyDownEvent( {
  1157. keyCode,
  1158. preventDefault: sinon.spy(),
  1159. stopPropagation: sinon.spy()
  1160. } );
  1161. sinon.assert.notCalled( spy );
  1162. expect( panelView.isVisible ).to.be.false;
  1163. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1164. } );
  1165. } );
  1166. }
  1167. } );
  1168. describe( 'execute', () => {
  1169. beforeEach( () => createClassicTestEditor( staticConfig ) );
  1170. it( 'should call the mention command with proper options', () => {
  1171. setData( model, '<paragraph>foo []</paragraph>' );
  1172. model.change( writer => {
  1173. writer.insertText( '@', doc.selection.getFirstPosition() );
  1174. } );
  1175. const command = editor.commands.get( 'mention' );
  1176. const spy = testUtils.sinon.spy( command, 'execute' );
  1177. return waitForDebounce()
  1178. .then( () => {
  1179. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1180. sinon.assert.calledOnce( spy );
  1181. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  1182. assertCommandOptions( commandOptions, '@', { id: '@Barney', text: '@Barney' } );
  1183. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  1184. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  1185. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  1186. } );
  1187. } );
  1188. it( 'should hide panel on execute', () => {
  1189. setData( model, '<paragraph>foo []</paragraph>' );
  1190. model.change( writer => {
  1191. writer.insertText( '@', doc.selection.getFirstPosition() );
  1192. } );
  1193. return waitForDebounce()
  1194. .then( () => {
  1195. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1196. expect( panelView.isVisible ).to.be.false;
  1197. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1198. } );
  1199. } );
  1200. it( 'should focus view after command execution', () => {
  1201. const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
  1202. setData( model, '<paragraph>foo []</paragraph>' );
  1203. model.change( writer => {
  1204. writer.insertText( '@', doc.selection.getFirstPosition() );
  1205. } );
  1206. return waitForDebounce()
  1207. .then( () => {
  1208. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1209. sinon.assert.calledOnce( focusSpy );
  1210. } );
  1211. } );
  1212. } );
  1213. function createClassicTestEditor( mentionConfig ) {
  1214. return ClassicTestEditor
  1215. .create( editorElement, {
  1216. plugins: [ Paragraph, MentionEditing, MentionUI ],
  1217. mention: mentionConfig
  1218. } )
  1219. .then( newEditor => {
  1220. editor = newEditor;
  1221. model = editor.model;
  1222. doc = model.document;
  1223. editingView = editor.editing.view;
  1224. mentionUI = editor.plugins.get( MentionUI );
  1225. panelView = mentionUI.panelView;
  1226. mentionsView = mentionUI._mentionsView;
  1227. } );
  1228. }
  1229. function waitForDebounce() {
  1230. return new Promise( resolve => {
  1231. setTimeout( () => {
  1232. resolve();
  1233. }, 50 );
  1234. } );
  1235. }
  1236. function fireKeyDownEvent( options ) {
  1237. const eventInfo = new EventInfo( editingView.document, 'keydown' );
  1238. const eventData = new DomEventData( editingView.document, {
  1239. target: document.body
  1240. }, options );
  1241. editingView.document.fire( eventInfo, eventData );
  1242. }
  1243. function stubSelectionRects( rects ) {
  1244. const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
  1245. // Mock selection rect.
  1246. sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => {
  1247. const domRange = originalViewRangeToDom.apply( editingView.domConverter, args );
  1248. sinon.stub( domRange, 'getClientRects' )
  1249. .returns( rects );
  1250. return domRange;
  1251. } );
  1252. }
  1253. function expectChildViewsIsOnState( expectedState ) {
  1254. const childViews = [ ...mentionsView.items ].map( item => item.children.get( 0 ) );
  1255. expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState );
  1256. }
  1257. function assertCommandOptions( commandOptions, marker, item ) {
  1258. expect( commandOptions ).to.have.property( 'marker', marker );
  1259. expect( commandOptions ).to.have.property( 'range' );
  1260. expect( commandOptions ).to.have.property( 'mention' );
  1261. const mentionForCommand = commandOptions.mention;
  1262. for ( const key of Object.keys( item ) ) {
  1263. expect( mentionForCommand[ key ] ).to.equal( item[ key ] );
  1264. }
  1265. }
  1266. } );