mentionui.js 49 KB

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