mentionui.js 53 KB

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