mentionui.js 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* global window, document, setTimeout, Event, console */
  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 ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
  16. import env from '@ckeditor/ckeditor5-utils/src/env';
  17. import MentionUI, { createRegExp } from '../src/mentionui';
  18. import MentionEditing from '../src/mentionediting';
  19. import MentionsView from '../src/ui/mentionsview';
  20. import { assertCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
  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. assertCKEditorError( error, /mentionconfig-incorrect-marker/, null );
  58. } );
  59. } );
  60. it( 'should throw if marker is empty string', () => {
  61. return createClassicTestEditor( { feeds: [ { marker: '', feed: [ 'a' ] } ] } ).catch( error => {
  62. assertCKEditorError( error, /mentionconfig-incorrect-marker/, null );
  63. } );
  64. } );
  65. it( 'should throw if marker is longer then 1 character', () => {
  66. return createClassicTestEditor( { feeds: [ { marker: '$$', feed: [ 'a' ] } ] } ).catch( error => {
  67. assertCKEditorError( error, /mentionconfig-incorrect-marker/, null );
  68. } );
  69. } );
  70. } );
  71. describe( 'pluginName', () => {
  72. it( 'should return plugin by its name', () => {
  73. return createClassicTestEditor().then( () => {
  74. expect( editor.plugins.get( 'MentionUI' ) ).to.equal( mentionUI );
  75. } );
  76. } );
  77. } );
  78. describe( 'contextual balloon', () => {
  79. let balloonAddSpy;
  80. beforeEach( () => {
  81. return createClassicTestEditor( staticConfig )
  82. .then( () => {
  83. setData( model, '<paragraph>foo []</paragraph>' );
  84. const contextualBalloon = editor.plugins.get( ContextualBalloon );
  85. balloonAddSpy = sinon.spy( contextualBalloon, 'add' );
  86. model.change( writer => {
  87. writer.insertText( '@', doc.selection.getFirstPosition() );
  88. } );
  89. } )
  90. .then( waitForDebounce );
  91. } );
  92. it( 'should disable arrow', () => {
  93. sinon.assert.calledOnce( balloonAddSpy );
  94. sinon.assert.calledWithExactly( balloonAddSpy, sinon.match( data => data.singleViewMode ) );
  95. expect( panelView.isVisible ).to.be.true;
  96. expect( panelView.withArrow ).to.be.false;
  97. } );
  98. it( 'should add MentionView to a panel', () => {
  99. expect( editor.plugins.get( ContextualBalloon ).visibleView ).to.be.instanceof( MentionsView );
  100. } );
  101. } );
  102. describe( 'position', () => {
  103. let pinSpy;
  104. const caretRect = {
  105. bottom: 118,
  106. height: 18,
  107. left: 500,
  108. right: 501,
  109. top: 100,
  110. width: 1
  111. };
  112. const balloonRect = {
  113. bottom: 150,
  114. height: 150,
  115. left: 0,
  116. right: 200,
  117. top: 0,
  118. width: 200
  119. };
  120. beforeEach( () => {
  121. return createClassicTestEditor( staticConfig ).then( () => {
  122. pinSpy = sinon.spy( panelView, 'pin' );
  123. } );
  124. } );
  125. it( 'should properly calculate position data', () => {
  126. const editableElement = editingView.document.selection.editableElement;
  127. setData( model, '<paragraph>foo []</paragraph>' );
  128. stubSelectionRects( [ caretRect ] );
  129. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  130. model.change( writer => {
  131. writer.insertText( '@', doc.selection.getFirstPosition() );
  132. } );
  133. return waitForDebounce()
  134. .then( () => {
  135. const pinArgument = pinSpy.firstCall.args[ 0 ];
  136. const { target, positions, limiter, fitInViewport } = pinArgument;
  137. expect( positions ).to.have.length( 4 );
  138. // Mention UI should set limiter to the editable area.
  139. expect( limiter() ).to.equal( editingView.domConverter.mapViewToDom( editableElement ) );
  140. expect( fitInViewport ).to.be.undefined;
  141. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  142. const mentionMarker = editor.model.markers.get( 'mention' );
  143. const focus = doc.selection.focus;
  144. const expectedRange = editor.model.createRange( focus.getShiftedBy( -1 ), focus );
  145. // It should create a model marker for matcher marker character ('@').
  146. expect( expectedRange.isEqual( mentionMarker.getRange() ) ).to.be.true;
  147. const toViewRangeSpy = sinon.spy( editor.editing.mapper, 'toViewRange' );
  148. expect( target() ).to.deep.equal( caretRect );
  149. sinon.assert.calledOnce( toViewRangeSpy );
  150. const range = toViewRangeSpy.firstCall.args[ 0 ];
  151. expect( mentionMarker.getRange().isEqual( range ), 'Should position to mention marker.' );
  152. const caretSouthEast = positions[ 0 ];
  153. const caretSouthWest = positions[ 1 ];
  154. const caretNorthEast = positions[ 2 ];
  155. const caretNorthWest = positions[ 3 ];
  156. expect( caretSouthEast( caretRect, balloonRect ) ).to.deep.equal( {
  157. left: 501,
  158. name: 'caret_se',
  159. top: 121
  160. } );
  161. expect( caretSouthWest( caretRect, balloonRect ) ).to.deep.equal( {
  162. left: 301,
  163. name: 'caret_sw',
  164. top: 121
  165. } );
  166. expect( caretNorthEast( caretRect, balloonRect ) ).to.deep.equal( {
  167. left: 501,
  168. name: 'caret_ne',
  169. top: -53
  170. } );
  171. expect( caretNorthWest( caretRect, balloonRect ) ).to.deep.equal( {
  172. left: 301,
  173. name: 'caret_nw',
  174. top: -53
  175. } );
  176. } );
  177. } );
  178. it( 'should re-calculate position on typing and stay on selected position', () => {
  179. setData( model, '<paragraph>foo []</paragraph>' );
  180. stubSelectionRects( [ caretRect ] );
  181. model.change( writer => {
  182. writer.insertText( '@', doc.selection.getFirstPosition() );
  183. } );
  184. let positionAfterFirstShow;
  185. return waitForDebounce()
  186. .then( () => {
  187. sinon.assert.calledOnce( pinSpy );
  188. const pinArgument = pinSpy.firstCall.args[ 0 ];
  189. const { positions } = pinArgument;
  190. expect( positions ).to.have.length( 4 );
  191. positionAfterFirstShow = mentionsView.position;
  192. model.change( writer => {
  193. writer.insertText( 't', doc.selection.getFirstPosition() );
  194. } );
  195. } )
  196. .then( waitForDebounce )
  197. .then( () => {
  198. sinon.assert.calledTwice( pinSpy );
  199. const pinArgument = pinSpy.secondCall.args[ 0 ];
  200. const { positions } = pinArgument;
  201. expect( positions, 'should reuse first matched position' ).to.have.length( 1 );
  202. expect( positions[ 0 ].name ).to.equal( positionAfterFirstShow );
  203. } );
  204. } );
  205. it( 'does not fail if selection has no #editableElement', () => {
  206. setData( model, '<paragraph>foo []</paragraph>' );
  207. stubSelectionRects( [ caretRect ] );
  208. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  209. model.change( writer => {
  210. writer.insertText( '@', doc.selection.getFirstPosition() );
  211. } );
  212. return waitForDebounce()
  213. .then( () => {
  214. const pinArgument = pinSpy.firstCall.args[ 0 ];
  215. const { limiter } = pinArgument;
  216. sinon.stub( editingView.document.selection, 'editableElement' ).value( null );
  217. // Should not break;
  218. expect( limiter() ).to.be.null;
  219. } );
  220. } );
  221. } );
  222. describe( 'typing integration', () => {
  223. it( 'should show panel for matched marker after typing minimum characters', () => {
  224. return createClassicTestEditor( { feeds: [ Object.assign( { minimumCharacters: 2 }, staticConfig.feeds[ 0 ] ) ] } )
  225. .then( () => {
  226. setData( model, '<paragraph>foo []</paragraph>' );
  227. model.change( writer => {
  228. writer.insertText( '@', doc.selection.getFirstPosition() );
  229. } );
  230. } )
  231. .then( () => {
  232. model.change( writer => {
  233. writer.insertText( 'B', doc.selection.getFirstPosition() );
  234. } );
  235. } )
  236. .then( waitForDebounce )
  237. .then( () => {
  238. expect( panelView.isVisible ).to.be.false;
  239. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  240. } )
  241. .then( waitForDebounce )
  242. .then( () => {
  243. model.change( writer => {
  244. writer.insertText( 'a', 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. model.change( writer => {
  253. writer.insertText( 'r', doc.selection.getFirstPosition() );
  254. } );
  255. } )
  256. .then( waitForDebounce )
  257. .then( () => {
  258. expect( panelView.isVisible ).to.be.true;
  259. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  260. expect( mentionsView.items ).to.have.length( 1 );
  261. } );
  262. } );
  263. it( 'should update the marker if the selection was moved from one valid position to another', () => {
  264. const spy = sinon.spy();
  265. return createClassicTestEditor( staticConfig )
  266. .then( () => {
  267. setData( model, '<paragraph>foo @ bar []</paragraph>' );
  268. model.change( writer => {
  269. writer.insertText( '@', doc.selection.getFirstPosition() );
  270. } );
  271. } )
  272. .then( waitForDebounce )
  273. .then( () => {
  274. expect( panelView.isVisible ).to.be.true;
  275. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  276. } )
  277. .then( () => {
  278. editor.model.markers.on( 'update', spy );
  279. model.change( writer => {
  280. writer.setSelection( doc.getRoot().getChild( 0 ), 5 );
  281. } );
  282. sinon.assert.calledOnce( spy );
  283. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  284. } );
  285. } );
  286. describe( 'static list with large set of results', () => {
  287. const bigList = {
  288. marker: '@',
  289. feed: [
  290. '@a01', '@a02', '@a03', '@a04', '@a05', '@a06', '@a07', '@a08', '@a09', '@a10', '@a11', '@a12'
  291. ]
  292. };
  293. beforeEach( () => {
  294. return createClassicTestEditor( { feeds: [ bigList ] } );
  295. } );
  296. it( 'should show panel with no more then 10 items for default static feed', () => {
  297. setData( model, '<paragraph>foo []</paragraph>' );
  298. model.change( writer => {
  299. writer.insertText( '@', doc.selection.getFirstPosition() );
  300. } );
  301. return waitForDebounce()
  302. .then( () => {
  303. expect( panelView.isVisible ).to.be.true;
  304. expect( mentionsView.items ).to.have.length( 10 );
  305. } );
  306. } );
  307. it( 'should scroll mention panel to the selected item', () => {
  308. setData( model, '<paragraph>foo []</paragraph>' );
  309. model.change( writer => {
  310. writer.insertText( '@', doc.selection.getFirstPosition() );
  311. } );
  312. const arrowDownEvtData = {
  313. keyCode: keyCodes.arrowdown,
  314. preventDefault: sinon.spy(),
  315. stopPropagation: sinon.spy()
  316. };
  317. const arrowUpEvtData = {
  318. keyCode: keyCodes.arrowup,
  319. preventDefault: sinon.spy(),
  320. stopPropagation: sinon.spy()
  321. };
  322. return waitForDebounce()
  323. .then( () => {
  324. // The scroll test highly depends on browser styles.
  325. // Some CI test environments might not load theme which will result that tests will not render on CI as locally.
  326. // To make this test repeatable across different environments it enforces mentions view size to 100px...
  327. const reset = 'padding:0px;margin:0px;border:0 none;line-height: 1em;';
  328. const mentionElementSpy = testUtils.sinon.spy( mentionsView.element, 'scrollTop', [ 'set' ] );
  329. mentionsView.element.style = `min-height:100px;height:100px;max-height:100px;${ reset };`;
  330. // ...and each list view item size to 25px...
  331. Array.from( mentionsView.items ).forEach( item => {
  332. const listItemElement = item.children.get( 0 ).element;
  333. listItemElement.style = `min-height:unset;height:25px;max-height:25px;${ reset };min-width:12em;`;
  334. } );
  335. // ...so after those changes it is safe to assume that:
  336. // - base offset is 0
  337. // - only 4 items are visible at once
  338. // - if scrolled to the last element scrollTop will be set to 150px. The 150px is the offset of the 7th item in the
  339. // list as last four (7, 8, 9 & 10) will be visible.
  340. expect( panelView.isVisible ).to.be.true;
  341. expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
  342. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  343. if ( !env.isEdge ) {
  344. sinon.assert.callCount( mentionElementSpy.set, 0 );
  345. }
  346. fireKeyDownEvent( arrowDownEvtData );
  347. expectChildViewsIsOnState( [ false, true, false, false, false, false, false, false, false, false ] );
  348. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  349. if ( !env.isEdge ) {
  350. expect( mentionsView.element.scrollTop ).to.equal( 0 );
  351. sinon.assert.callCount( mentionElementSpy.set, 0 );
  352. }
  353. fireKeyDownEvent( arrowUpEvtData );
  354. expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
  355. expect( mentionsView.element.scrollTop ).to.equal( 0 );
  356. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  357. if ( !env.isEdge ) {
  358. sinon.assert.callCount( mentionElementSpy.set, 0 );
  359. }
  360. fireKeyDownEvent( arrowUpEvtData );
  361. expectChildViewsIsOnState( [ false, false, false, false, false, false, false, false, false, true ] );
  362. // We want 150, but sometimes we get e.g. 151.
  363. expect( mentionsView.element.scrollTop ).to.be.within( 140, 160, 'last item highlighted' );
  364. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  365. if ( !env.isEdge ) {
  366. sinon.assert.callCount( mentionElementSpy.set, 1 );
  367. }
  368. fireKeyDownEvent( arrowDownEvtData );
  369. expectChildViewsIsOnState( [ true, false, false, false, false, false, false, false, false, false ] );
  370. // We want 0, but sometimes we get e.g. 1. (Firefox)
  371. expect( mentionsView.element.scrollTop ).to.be.within( 0, 10 );
  372. // Edge browser always tries to scroll in tests environment: See ckeditor5-utils#282.
  373. if ( !env.isEdge ) {
  374. sinon.assert.callCount( mentionElementSpy.set, 2 );
  375. }
  376. } );
  377. } );
  378. } );
  379. describe( 'ES2018 RegExp Unicode property escapes fallback', () => {
  380. let regExpStub;
  381. // Cache the original value to restore it after the tests.
  382. const originalGroupSupport = env.features.isRegExpUnicodePropertySupported;
  383. before( () => {
  384. env.features.isRegExpUnicodePropertySupported = false;
  385. } );
  386. beforeEach( () => {
  387. return createClassicTestEditor( staticConfig )
  388. .then( editor => {
  389. regExpStub = sinon.stub( window, 'RegExp' );
  390. return editor;
  391. } );
  392. } );
  393. after( () => {
  394. env.features.isRegExpUnicodePropertySupported = originalGroupSupport;
  395. } );
  396. it( 'returns a simplified RegExp for browsers not supporting Unicode punctuation groups', () => {
  397. env.features.isRegExpUnicodePropertySupported = false;
  398. createRegExp( '@', 2 );
  399. sinon.assert.calledOnce( regExpStub );
  400. sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\(\\[{"\'])([@])([\\S]{2,})$', 'u' );
  401. } );
  402. it( 'returns a ES2018 RegExp for browsers supporting Unicode punctuation groups', () => {
  403. env.features.isRegExpUnicodePropertySupported = true;
  404. createRegExp( '@', 2 );
  405. sinon.assert.calledOnce( regExpStub );
  406. sinon.assert.calledWithExactly( regExpStub, '(?:^|[ \\p{Ps}\\p{Pi}"\'])([@])([\\S]{2,})$', 'u' );
  407. } );
  408. } );
  409. describe( 'static list with default trigger', () => {
  410. beforeEach( () => {
  411. return createClassicTestEditor( staticConfig );
  412. } );
  413. it( 'should show panel for matched marker', () => {
  414. setData( model, '<paragraph>foo []</paragraph>' );
  415. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  416. model.change( writer => {
  417. writer.insertText( '@', doc.selection.getFirstPosition() );
  418. } );
  419. return waitForDebounce()
  420. .then( () => {
  421. expect( panelView.isVisible ).to.be.true;
  422. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  423. expect( mentionsView.items ).to.have.length( 5 );
  424. } );
  425. } );
  426. it( 'should show panel for matched marker at the beginning of paragraph', () => {
  427. setData( model, '<paragraph>[] foo</paragraph>' );
  428. model.change( writer => {
  429. writer.insertText( '@', doc.selection.getFirstPosition() );
  430. } );
  431. return waitForDebounce()
  432. .then( () => {
  433. expect( panelView.isVisible ).to.be.true;
  434. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  435. expect( mentionsView.items ).to.have.length( 5 );
  436. } );
  437. } );
  438. it( 'should show panel for matched marker after a <softBreak>', () => {
  439. model.schema.register( 'softBreak', {
  440. allowWhere: '$text',
  441. isInline: true
  442. } );
  443. editor.conversion.for( 'upcast' )
  444. .elementToElement( {
  445. model: 'softBreak',
  446. view: 'br'
  447. } );
  448. editor.conversion.for( 'downcast' )
  449. .elementToElement( {
  450. model: 'softBreak',
  451. view: ( modelElement, viewWriter ) => viewWriter.createEmptyElement( 'br' )
  452. } );
  453. setData( model, '<paragraph>abc<softBreak></softBreak>[] foo</paragraph>' );
  454. model.change( writer => {
  455. writer.insertText( '@', doc.selection.getFirstPosition() );
  456. } );
  457. return waitForDebounce()
  458. .then( () => {
  459. expect( panelView.isVisible ).to.be.true;
  460. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  461. expect( mentionsView.items ).to.have.length( 5 );
  462. } );
  463. } );
  464. // Opening parenthesis type characters that should be supported on all environments.
  465. for ( const character of [ '(', '\'', '"', '[', '{' ] ) {
  466. testOpeningPunctuationCharacter( character );
  467. }
  468. // Excerpt of opening parenthesis type characters that tests ES2018 Unicode property escapes on supported environment.
  469. for ( const character of [
  470. // Belongs to Ps (Punctuation, Open) group:
  471. '〈', '„', '﹛', '⦅', '{',
  472. // Belongs to Pi (Punctuation, Initial quote) group:
  473. '«', '‹', '⸌', ' ⸂', '⸠'
  474. ] ) {
  475. testOpeningPunctuationCharacter( character, !env.features.isRegExpUnicodePropertySupported );
  476. }
  477. it( 'should not show panel for marker in the middle of other word', () => {
  478. setData( model, '<paragraph>foo[]</paragraph>' );
  479. model.change( writer => {
  480. writer.insertText( '@', doc.selection.getFirstPosition() );
  481. } );
  482. return waitForDebounce()
  483. .then( () => {
  484. expect( panelView.isVisible ).to.be.false;
  485. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  486. } );
  487. } );
  488. it( 'should not show panel when selection is inside a mention', () => {
  489. setData( model, '<paragraph>foo @Lily bar[]</paragraph>' );
  490. model.change( writer => {
  491. const range = writer.createRange(
  492. writer.createPositionAt( doc.getRoot().getChild( 0 ), 4 ),
  493. writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 )
  494. );
  495. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, range );
  496. } );
  497. return waitForDebounce()
  498. .then( () => {
  499. model.change( writer => {
  500. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  501. } );
  502. expect( panelView.isVisible ).to.be.false;
  503. model.change( writer => {
  504. writer.setSelection( doc.getRoot().getChild( 0 ), 7 );
  505. } );
  506. } )
  507. .then( () => {
  508. expect( panelView.isVisible ).to.be.false;
  509. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  510. } );
  511. } );
  512. it( 'should not show panel when selection is at the end of a mention', () => {
  513. setData( model, '<paragraph>foo @Lily bar[]</paragraph>' );
  514. model.change( writer => {
  515. const range = writer.createRange(
  516. writer.createPositionAt( doc.getRoot().getChild( 0 ), 4 ),
  517. writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 )
  518. );
  519. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, range );
  520. } );
  521. return waitForDebounce()
  522. .then( () => {
  523. model.change( writer => {
  524. writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
  525. } );
  526. } )
  527. .then( () => {
  528. expect( panelView.isVisible ).to.be.false;
  529. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  530. } );
  531. } );
  532. it( 'should not show panel when selection is not collapsed', () => {
  533. setData( model, '<paragraph>foo []</paragraph>' );
  534. model.change( writer => {
  535. writer.insertText( '@', doc.selection.getFirstPosition() );
  536. } );
  537. return waitForDebounce()
  538. .then( () => {
  539. expect( panelView.isVisible ).to.be.true;
  540. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  541. model.change( () => {
  542. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  543. } );
  544. } )
  545. .then( waitForDebounce )
  546. .then( () => {
  547. expect( panelView.isVisible ).to.be.false;
  548. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  549. } );
  550. } );
  551. it( 'should not show panel when selection is changing (non-collapsed)', () => {
  552. setData( model, '<paragraph>foo []</paragraph>' );
  553. model.change( writer => {
  554. writer.insertText( '@', doc.selection.getFirstPosition() );
  555. } );
  556. return waitForDebounce()
  557. .then( () => {
  558. expect( panelView.isVisible ).to.be.true;
  559. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  560. model.change( () => {
  561. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  562. } );
  563. } )
  564. .then( waitForDebounce )
  565. .then( () => {
  566. expect( panelView.isVisible ).to.be.false;
  567. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  568. } )
  569. .then( () => {
  570. model.change( () => {
  571. model.modifySelection( doc.selection, { direction: 'backward', unit: 'character' } );
  572. } );
  573. } )
  574. .then( waitForDebounce )
  575. .then( () => {
  576. expect( panelView.isVisible ).to.be.false;
  577. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  578. } );
  579. } );
  580. it( 'should not show panel when selection is after existing mention', () => {
  581. setData( model, '<paragraph>foo [@Lily] bar[]</paragraph>' );
  582. model.change( writer => {
  583. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, doc.selection.getFirstRange() );
  584. } );
  585. return waitForDebounce()
  586. .then( () => {
  587. expect( panelView.isVisible ).to.be.false;
  588. model.change( writer => {
  589. writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
  590. } );
  591. } )
  592. .then( waitForDebounce )
  593. .then( () => {
  594. expect( panelView.isVisible ).to.be.false;
  595. } );
  596. } );
  597. it( 'should not show panel when selection moves inside existing mention', () => {
  598. setData( model, '<paragraph>foo @Lily bar[]</paragraph>' );
  599. model.change( writer => {
  600. const range = writer.createRange(
  601. writer.createPositionAt( doc.getRoot().getChild( 0 ), 4 ),
  602. writer.createPositionAt( doc.getRoot().getChild( 0 ), 9 )
  603. );
  604. writer.setAttribute( 'mention', { id: '@Lily', _uid: 1234 }, range );
  605. } );
  606. return waitForDebounce()
  607. .then( () => {
  608. model.change( writer => {
  609. writer.setSelection( doc.getRoot().getChild( 0 ), 9 );
  610. } );
  611. } )
  612. .then( waitForDebounce )
  613. .then( () => {
  614. expect( panelView.isVisible ).to.be.false;
  615. model.change( writer => {
  616. writer.setSelection( doc.getRoot().getChild( 0 ), 8 );
  617. } );
  618. } )
  619. .then( waitForDebounce )
  620. .then( () => {
  621. expect( panelView.isVisible ).to.be.false;
  622. } );
  623. } );
  624. it( 'should show filtered results for matched text', () => {
  625. setData( model, '<paragraph>foo []</paragraph>' );
  626. model.change( writer => {
  627. writer.insertText( '@', doc.selection.getFirstPosition() );
  628. } );
  629. model.change( writer => {
  630. writer.insertText( 'T', doc.selection.getFirstPosition() );
  631. } );
  632. return waitForDebounce()
  633. .then( () => {
  634. expect( panelView.isVisible ).to.be.true;
  635. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  636. expect( mentionsView.items ).to.have.length( 1 );
  637. } );
  638. } );
  639. it( 'should focus the first item in panel', () => {
  640. setData( model, '<paragraph>foo []</paragraph>' );
  641. model.change( writer => {
  642. writer.insertText( '@', doc.selection.getFirstPosition() );
  643. } );
  644. return waitForDebounce()
  645. .then( () => {
  646. const button = mentionsView.items.get( 0 ).children.get( 0 );
  647. expect( button.isOn ).to.be.true;
  648. } );
  649. } );
  650. it( 'should hide panel if no matched items', () => {
  651. setData( model, '<paragraph>foo []</paragraph>' );
  652. model.change( writer => {
  653. writer.insertText( '@', doc.selection.getFirstPosition() );
  654. } );
  655. return waitForDebounce()
  656. .then( () => expect( panelView.isVisible ).to.be.true )
  657. .then( () => {
  658. model.change( writer => {
  659. writer.insertText( 'x', doc.selection.getFirstPosition() );
  660. } );
  661. } )
  662. .then( waitForDebounce )
  663. .then( () => {
  664. expect( panelView.isVisible ).to.be.false;
  665. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  666. expect( mentionsView.items ).to.have.length( 0 );
  667. } );
  668. } );
  669. it( 'should hide panel when text was unmatched', () => {
  670. setData( model, '<paragraph>foo []</paragraph>' );
  671. model.change( writer => {
  672. writer.insertText( '@', doc.selection.getFirstPosition() );
  673. } );
  674. return waitForDebounce()
  675. .then( () => expect( panelView.isVisible ).to.be.true )
  676. .then( () => {
  677. model.change( writer => {
  678. const end = doc.selection.getFirstPosition();
  679. const start = end.getShiftedBy( -1 );
  680. writer.remove( writer.createRange( start, end ) );
  681. } );
  682. } )
  683. .then( waitForDebounce )
  684. .then( () => expect( panelView.isVisible ).to.be.false );
  685. } );
  686. } );
  687. describe( 'unicode', () => {
  688. beforeEach( () => {
  689. return createClassicTestEditor( {
  690. feeds: [
  691. {
  692. // Always return 5 items
  693. feed: [ '@תַפּוּחַ', '@אַגָס', '@apple', '@pear' ],
  694. marker: '@'
  695. }
  696. ]
  697. } );
  698. } );
  699. it( 'should open panel for unicode character ב', function() {
  700. if ( !env.features.isRegExpUnicodePropertySupported ) {
  701. this.skip();
  702. }
  703. setData( model, '<paragraph>foo []</paragraph>' );
  704. model.change( writer => {
  705. writer.insertText( '@ס', doc.selection.getFirstPosition() );
  706. } );
  707. return waitForDebounce()
  708. .then( () => {
  709. expect( panelView.isVisible, 'panel is visible' ).to.be.true;
  710. expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
  711. expect( mentionsView.items ).to.have.length( 1 );
  712. } );
  713. } );
  714. } );
  715. describe( 'callback function using data from editor', () => {
  716. beforeEach( () => {
  717. return createClassicTestEditor( {
  718. feeds: [
  719. {
  720. marker: '#',
  721. feed() {
  722. expect( this ).to.equal( editor );
  723. return Promise.resolve( [ 'foo', 'bar' ] );
  724. }
  725. }
  726. ]
  727. } );
  728. } );
  729. it( 'should bind the instance panel for matched marker', () => {
  730. setData( model, '<paragraph>foo []</paragraph>' );
  731. model.change( writer => {
  732. writer.insertText( '#', doc.selection.getFirstPosition() );
  733. } );
  734. return waitForDebounce()
  735. .then( () => {
  736. expect( panelView.isVisible ).to.be.true;
  737. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  738. expect( mentionsView.items ).to.have.length( 2 );
  739. } );
  740. } );
  741. } );
  742. describe( 'asynchronous list with custom trigger', () => {
  743. const issuesNumbers = [ '#100', '#101', '#102', '#103' ];
  744. let feedCallbackStub, feedCallbackTimeout, feedCallbackCallTimes;
  745. beforeEach( () => {
  746. feedCallbackTimeout = 20;
  747. feedCallbackCallTimes = 0;
  748. function feedCallback( feedText ) {
  749. return new Promise( resolve => {
  750. setTimeout( () => {
  751. feedCallbackCallTimes++;
  752. resolve( issuesNumbers.filter( number => number.includes( feedText ) ) );
  753. }, feedCallbackTimeout );
  754. } );
  755. }
  756. feedCallbackStub = testUtils.sinon.stub().callsFake( feedCallback );
  757. return createClassicTestEditor( {
  758. feeds: [
  759. {
  760. marker: '#',
  761. feed: feedCallbackStub
  762. }
  763. ]
  764. } );
  765. } );
  766. it( 'should show panel for matched marker', () => {
  767. setData( model, '<paragraph>foo []</paragraph>' );
  768. model.change( writer => {
  769. writer.insertText( '#', doc.selection.getFirstPosition() );
  770. } );
  771. return waitForDebounce()
  772. .then( () => {
  773. expect( panelView.isVisible ).to.be.true;
  774. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  775. expect( mentionsView.items ).to.have.length( 4 );
  776. } );
  777. } );
  778. it( 'should fire requestFeed:response when request feed return a response', () => {
  779. setData( model, '<paragraph>foo []</paragraph>' );
  780. const eventSpy = sinon.spy();
  781. mentionUI.on( 'requestFeed:response', eventSpy );
  782. model.change( writer => {
  783. writer.insertText( '#', doc.selection.getFirstPosition() );
  784. } );
  785. return waitForDebounce()
  786. .then( () => {
  787. sinon.assert.calledOnce( eventSpy );
  788. sinon.assert.calledWithExactly(
  789. eventSpy,
  790. sinon.match.any,
  791. {
  792. feed: issuesNumbers,
  793. marker: '#',
  794. feedText: ''
  795. }
  796. );
  797. expect( panelView.isVisible ).to.be.true;
  798. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  799. expect( mentionsView.items ).to.have.length( 4 );
  800. } );
  801. } );
  802. it( 'should show filtered results for matched text', () => {
  803. setData( model, '<paragraph>foo []</paragraph>' );
  804. model.change( writer => {
  805. writer.insertText( '#', doc.selection.getFirstPosition() );
  806. } );
  807. model.change( writer => {
  808. writer.insertText( '2', doc.selection.getFirstPosition() );
  809. } );
  810. return waitForDebounce()
  811. .then( () => {
  812. expect( panelView.isVisible ).to.be.true;
  813. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  814. expect( mentionsView.items ).to.have.length( 1 );
  815. } );
  816. } );
  817. it( 'should hide panel if no matched items', () => {
  818. setData( model, '<paragraph>foo []</paragraph>' );
  819. model.change( writer => {
  820. writer.insertText( '#', doc.selection.getFirstPosition() );
  821. } );
  822. return waitForDebounce()
  823. .then( () => expect( panelView.isVisible ).to.be.true )
  824. .then( () => {
  825. model.change( writer => {
  826. writer.insertText( 'x', doc.selection.getFirstPosition() );
  827. } );
  828. } )
  829. .then( waitForDebounce )
  830. .then( () => {
  831. expect( panelView.isVisible ).to.be.false;
  832. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  833. expect( mentionsView.items ).to.have.length( 0 );
  834. } );
  835. } );
  836. it( 'should hide panel when text was unmatched', () => {
  837. setData( model, '<paragraph>foo []</paragraph>' );
  838. model.change( writer => {
  839. writer.insertText( '#', doc.selection.getFirstPosition() );
  840. } );
  841. return waitForDebounce()
  842. .then( () => expect( panelView.isVisible ).to.be.true )
  843. .then( () => {
  844. model.change( writer => {
  845. const end = doc.selection.getFirstPosition();
  846. const start = end.getShiftedBy( -1 );
  847. writer.remove( writer.createRange( start, end ) );
  848. } );
  849. } )
  850. .then( waitForDebounce )
  851. .then( () => expect( panelView.isVisible ).to.be.false );
  852. } );
  853. it( 'should show panel debounced', () => {
  854. setData( model, '<paragraph>foo []</paragraph>' );
  855. model.change( writer => {
  856. writer.insertText( '#', doc.selection.getFirstPosition() );
  857. } );
  858. sinon.assert.notCalled( feedCallbackStub );
  859. return Promise.resolve()
  860. .then( wait( 20 ) )
  861. .then( () => {
  862. sinon.assert.notCalled( feedCallbackStub );
  863. model.change( writer => {
  864. writer.insertText( '1', doc.selection.getFirstPosition() );
  865. } );
  866. } )
  867. .then( wait( 20 ) )
  868. .then( () => {
  869. sinon.assert.notCalled( feedCallbackStub );
  870. model.change( writer => {
  871. writer.insertText( '0', doc.selection.getFirstPosition() );
  872. } );
  873. } )
  874. .then( waitForDebounce )
  875. .then( () => {
  876. sinon.assert.calledOnce( feedCallbackStub );
  877. // Should be called with all typed letters before debounce.
  878. sinon.assert.calledWithExactly( feedCallbackStub, '10' );
  879. expect( panelView.isVisible ).to.be.true;
  880. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  881. expect( mentionsView.items ).to.have.length( 4 );
  882. } );
  883. } );
  884. it( 'should discard requested feed if they came out of order', () => {
  885. setData( model, '<paragraph>foo []</paragraph>' );
  886. model.change( writer => {
  887. writer.insertText( '#', doc.selection.getFirstPosition() );
  888. } );
  889. sinon.assert.notCalled( feedCallbackStub );
  890. const panelShowSpy = sinon.spy( panelView, 'show' );
  891. // Increase the response time to extend the debounce time out.
  892. feedCallbackTimeout = 300;
  893. return Promise.resolve()
  894. .then( wait( 20 ) )
  895. .then( () => {
  896. sinon.assert.notCalled( feedCallbackStub );
  897. model.change( writer => {
  898. writer.insertText( '1', doc.selection.getFirstPosition() );
  899. } );
  900. } )
  901. .then( waitForDebounce )
  902. .then( () => {
  903. sinon.assert.calledOnce( feedCallbackStub );
  904. sinon.assert.calledWithExactly( feedCallbackStub, '1' );
  905. expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
  906. expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
  907. // Make second callback resolve before first.
  908. feedCallbackTimeout = 50;
  909. model.change( writer => {
  910. writer.insertText( '0', doc.selection.getFirstPosition() );
  911. } );
  912. } )
  913. .then( wait( 300 ) ) // Wait longer so the longer callback will be resolved.
  914. .then( () => {
  915. sinon.assert.calledTwice( feedCallbackStub );
  916. sinon.assert.calledWithExactly( feedCallbackStub.getCall( 1 ), '10' );
  917. sinon.assert.calledOnce( panelShowSpy );
  918. expect( feedCallbackCallTimes ).to.equal( 2 );
  919. expect( panelView.isVisible, 'panel is visible' ).to.be.true;
  920. expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
  921. expect( mentionsView.items ).to.have.length( 4 );
  922. } );
  923. } );
  924. it( 'should fire requestFeed:discarded event when requested feed came out of order', () => {
  925. setData( model, '<paragraph>foo []</paragraph>' );
  926. model.change( writer => {
  927. writer.insertText( '#', doc.selection.getFirstPosition() );
  928. } );
  929. sinon.assert.notCalled( feedCallbackStub );
  930. const panelShowSpy = sinon.spy( panelView, 'show' );
  931. const eventSpy = sinon.spy();
  932. mentionUI.on( 'requestFeed:discarded', eventSpy );
  933. // Increase the response time to extend the debounce time out.
  934. feedCallbackTimeout = 300;
  935. return Promise.resolve()
  936. .then( wait( 20 ) )
  937. .then( () => {
  938. sinon.assert.notCalled( feedCallbackStub );
  939. model.change( writer => {
  940. writer.insertText( '1', doc.selection.getFirstPosition() );
  941. } );
  942. } )
  943. .then( waitForDebounce )
  944. .then( () => {
  945. sinon.assert.calledOnce( feedCallbackStub );
  946. sinon.assert.calledWithExactly( feedCallbackStub, '1' );
  947. expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
  948. expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
  949. // Make second callback resolve before first.
  950. feedCallbackTimeout = 50;
  951. model.change( writer => {
  952. writer.insertText( '0', doc.selection.getFirstPosition() );
  953. } );
  954. } )
  955. .then( wait( 300 ) ) // Wait longer so the longer callback will be resolved.
  956. .then( () => {
  957. sinon.assert.calledTwice( feedCallbackStub );
  958. sinon.assert.calledWithExactly( feedCallbackStub.getCall( 1 ), '10' );
  959. sinon.assert.calledOnce( panelShowSpy );
  960. sinon.assert.calledOnce( eventSpy );
  961. sinon.assert.calledWithExactly(
  962. eventSpy,
  963. sinon.match.any,
  964. {
  965. feed: issuesNumbers,
  966. marker: '#',
  967. feedText: '1'
  968. }
  969. );
  970. expect( feedCallbackCallTimes ).to.equal( 2 );
  971. expect( panelView.isVisible, 'panel is visible' ).to.be.true;
  972. expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
  973. expect( mentionsView.items ).to.have.length( 4 );
  974. } );
  975. } );
  976. it( 'should discard requested feed if mention UI is hidden', () => {
  977. setData( model, '<paragraph>foo []</paragraph>' );
  978. model.change( writer => {
  979. writer.insertText( '#', doc.selection.getFirstPosition() );
  980. } );
  981. sinon.assert.notCalled( feedCallbackStub );
  982. feedCallbackTimeout = 200;
  983. return Promise.resolve()
  984. .then( waitForDebounce )
  985. .then( () => {
  986. expect( panelView.isVisible ).to.be.false; // Should be still hidden;
  987. // Should be called with empty string.
  988. sinon.assert.calledWithExactly( feedCallbackStub, '' );
  989. model.change( writer => {
  990. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  991. } );
  992. } )
  993. .then( waitForDebounce )
  994. .then( () => {
  995. expect( panelView.isVisible ).to.be.false;
  996. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  997. } );
  998. } );
  999. it( 'should fire requestFeed:error and log warning if requested feed failed', () => {
  1000. setData( model, '<paragraph>foo []</paragraph>' );
  1001. feedCallbackStub.returns( Promise.reject( 'Request timeout' ) );
  1002. const warnSpy = sinon.stub( console, 'warn' );
  1003. const eventSpy = sinon.spy();
  1004. mentionUI.on( 'requestFeed:error', eventSpy );
  1005. model.change( writer => {
  1006. writer.insertText( '#', doc.selection.getFirstPosition() );
  1007. } );
  1008. return waitForDebounce()
  1009. .then( () => {
  1010. expect( panelView.isVisible, 'panel is hidden' ).to.be.false;
  1011. expect( editor.model.markers.has( 'mention' ), 'there is no marker' ).to.be.false;
  1012. sinon.assert.calledWithExactly( warnSpy, sinon.match( /^mention-feed-callback-error:/ ) );
  1013. sinon.assert.calledOnce( eventSpy );
  1014. } );
  1015. } );
  1016. it( 'should not fail if marker was removed', () => {
  1017. setData( model, '<paragraph>foo []</paragraph>' );
  1018. const selectFirstMentionSpy = sinon.spy( mentionsView, 'selectFirst' );
  1019. model.change( writer => {
  1020. writer.insertText( '#', doc.selection.getFirstPosition() );
  1021. } );
  1022. sinon.assert.notCalled( feedCallbackStub );
  1023. // Increase the response time to extend the debounce time out.
  1024. feedCallbackTimeout = 500;
  1025. return Promise.resolve()
  1026. .then( waitForDebounce )
  1027. .then( wait( 20 ) )
  1028. .then( () => {
  1029. model.change( writer => {
  1030. writer.setSelection( doc.getRoot().getChild( 0 ), 2 );
  1031. } );
  1032. } )
  1033. .then( wait( 20 ) )
  1034. .then( () => {
  1035. feedCallbackTimeout = 1000;
  1036. model.change( writer => {
  1037. writer.setSelection( doc.getRoot().getChild( 0 ), 'end' );
  1038. } );
  1039. } )
  1040. .then( wait( 500 ) )
  1041. .then( () => {
  1042. expect( panelView.isVisible, 'panel is visible' ).to.be.true;
  1043. // If there were any errors this will not get called.
  1044. // The errors might come from unhandled promise rejections errors.
  1045. sinon.assert.calledOnce( selectFirstMentionSpy );
  1046. } );
  1047. } );
  1048. it( 'should not show panel if selection was moved during fetching a feed', () => {
  1049. setData( model, '<paragraph>foo [#101] bar</paragraph><paragraph></paragraph>' );
  1050. model.change( writer => {
  1051. writer.setAttribute( 'mention', { id: '#101', _uid: 1234 }, doc.selection.getFirstRange() );
  1052. } );
  1053. // Increase the response time to extend the debounce time out.
  1054. feedCallbackTimeout = 300;
  1055. model.change( writer => {
  1056. writer.setSelection( doc.getRoot().getChild( 1 ), 0 );
  1057. writer.insertText( '#', doc.selection.getFirstPosition() );
  1058. } );
  1059. sinon.assert.notCalled( feedCallbackStub );
  1060. return Promise.resolve()
  1061. .then( waitForDebounce )
  1062. .then( () => {
  1063. sinon.assert.calledOnce( feedCallbackStub );
  1064. model.change( writer => {
  1065. writer.setSelection( doc.getRoot().getChild( 0 ), 6 );
  1066. } );
  1067. expect( panelView.isVisible ).to.be.false;
  1068. } )
  1069. .then( waitForDebounce )
  1070. .then( wait( 20 ) )
  1071. .then( () => {
  1072. expect( panelView.isVisible ).to.be.false;
  1073. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1074. } );
  1075. } );
  1076. } );
  1077. function testOpeningPunctuationCharacter( character, skip = false ) {
  1078. it( `should show panel for matched marker after a "${ character }" character`, function() {
  1079. if ( skip ) {
  1080. this.skip();
  1081. }
  1082. setData( model, '<paragraph>[] foo</paragraph>' );
  1083. model.change( writer => {
  1084. writer.insertText( character, doc.selection.getFirstPosition() );
  1085. } );
  1086. model.change( writer => {
  1087. writer.insertText( '@', doc.selection.getFirstPosition() );
  1088. } );
  1089. return waitForDebounce()
  1090. .then( () => {
  1091. expect( panelView.isVisible, 'panel is visible' ).to.be.true;
  1092. expect( editor.model.markers.has( 'mention' ), 'marker is inserted' ).to.be.true;
  1093. expect( mentionsView.items ).to.have.length( 5 );
  1094. } );
  1095. } );
  1096. }
  1097. } );
  1098. describe( 'panel behavior', () => {
  1099. it( 'should close the opened panel on esc', () => {
  1100. return createClassicTestEditor( staticConfig )
  1101. .then( () => {
  1102. setData( model, '<paragraph>foo []</paragraph>' );
  1103. model.change( writer => {
  1104. writer.insertText( '@', doc.selection.getFirstPosition() );
  1105. } );
  1106. } )
  1107. .then( waitForDebounce )
  1108. .then( () => {
  1109. expect( panelView.isVisible ).to.be.true;
  1110. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1111. fireKeyDownEvent( {
  1112. keyCode: keyCodes.esc,
  1113. preventDefault: sinon.spy(),
  1114. stopPropagation: sinon.spy()
  1115. } );
  1116. expect( panelView.isVisible ).to.be.false;
  1117. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1118. } );
  1119. } );
  1120. it( 'should close the opened panel when click outside the panel', () => {
  1121. return createClassicTestEditor( staticConfig )
  1122. .then( () => {
  1123. setData( model, '<paragraph>foo []</paragraph>' );
  1124. model.change( writer => {
  1125. writer.insertText( '@', doc.selection.getFirstPosition() );
  1126. } );
  1127. } )
  1128. .then( waitForDebounce )
  1129. .then( () => {
  1130. expect( panelView.isVisible ).to.be.true;
  1131. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1132. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
  1133. expect( panelView.isVisible ).to.be.false;
  1134. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1135. } );
  1136. } );
  1137. it( 'should hide the panel on selection change', () => {
  1138. return createClassicTestEditor( staticConfig )
  1139. .then( () => {
  1140. setData( model, '<paragraph>foo []</paragraph>' );
  1141. model.change( writer => {
  1142. writer.insertText( '@', doc.selection.getFirstPosition() );
  1143. } );
  1144. } )
  1145. .then( waitForDebounce )
  1146. .then( () => {
  1147. expect( panelView.isVisible ).to.be.true;
  1148. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1149. model.change( writer => {
  1150. // Place position at the beginning of a paragraph.
  1151. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  1152. } );
  1153. expect( panelView.isVisible ).to.be.false;
  1154. expect( mentionsView.position ).to.be.undefined;
  1155. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1156. } );
  1157. } );
  1158. it( 'should hide the panel on selection change triggered by mouse click', () => {
  1159. return createClassicTestEditor( staticConfig )
  1160. .then( () => {
  1161. setData( model, '<paragraph>foo []</paragraph>' );
  1162. model.change( writer => {
  1163. writer.insertText( '@', doc.selection.getFirstPosition() );
  1164. } );
  1165. } )
  1166. .then( waitForDebounce )
  1167. .then( () => {
  1168. expect( panelView.isVisible ).to.be.true;
  1169. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1170. // This happens when user clicks outside the panel view and selection is changed.
  1171. // Two panel closing mechanisms are run:
  1172. // - clickOutsideHandler
  1173. // - unmatched text in text watcher
  1174. // which may fail when trying to remove mention marker twice.
  1175. document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
  1176. model.change( writer => {
  1177. // Place position at the beginning of a paragraph.
  1178. writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
  1179. } );
  1180. expect( panelView.isVisible ).to.be.false;
  1181. expect( mentionsView.position ).to.be.undefined;
  1182. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1183. } );
  1184. } );
  1185. describe( 'default list item', () => {
  1186. // Create map of expected feed items as objects as they will be stored internally.
  1187. const feedItems = staticConfig.feeds[ 0 ].feed.map( text => ( { text: `${ text }`, id: `${ text }` } ) );
  1188. beforeEach( () => {
  1189. return createClassicTestEditor( staticConfig );
  1190. } );
  1191. describe( 'on arrows', () => {
  1192. it( 'should cycle down on arrow down', () => {
  1193. setData( model, '<paragraph>foo []</paragraph>' );
  1194. model.change( writer => {
  1195. writer.insertText( '@', doc.selection.getFirstPosition() );
  1196. } );
  1197. return waitForDebounce()
  1198. .then( () => {
  1199. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1200. const keyEvtData = {
  1201. keyCode: keyCodes.arrowdown,
  1202. preventDefault: sinon.spy(),
  1203. stopPropagation: sinon.spy()
  1204. };
  1205. fireKeyDownEvent( keyEvtData );
  1206. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1207. fireKeyDownEvent( keyEvtData );
  1208. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1209. fireKeyDownEvent( keyEvtData );
  1210. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1211. fireKeyDownEvent( keyEvtData );
  1212. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1213. fireKeyDownEvent( keyEvtData );
  1214. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1215. } );
  1216. } );
  1217. it( 'should cycle up on arrow up', () => {
  1218. setData( model, '<paragraph>foo []</paragraph>' );
  1219. model.change( writer => {
  1220. writer.insertText( '@', doc.selection.getFirstPosition() );
  1221. } );
  1222. return waitForDebounce()
  1223. .then( () => {
  1224. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1225. const keyEvtData = {
  1226. keyCode: keyCodes.arrowup,
  1227. preventDefault: sinon.spy(),
  1228. stopPropagation: sinon.spy()
  1229. };
  1230. fireKeyDownEvent( keyEvtData );
  1231. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1232. fireKeyDownEvent( keyEvtData );
  1233. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1234. fireKeyDownEvent( keyEvtData );
  1235. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1236. fireKeyDownEvent( keyEvtData );
  1237. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1238. fireKeyDownEvent( keyEvtData );
  1239. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1240. } );
  1241. } );
  1242. it( 'should not cycle with only one item in the list', () => {
  1243. setData( model, '<paragraph>foo []</paragraph>' );
  1244. const keyDownEvtData = {
  1245. keyCode: keyCodes.arrowdown,
  1246. preventDefault: sinon.spy(),
  1247. stopPropagation: sinon.spy()
  1248. };
  1249. const keyUpEvtData = {
  1250. keyCode: keyCodes.arrowdown,
  1251. preventDefault: sinon.spy(),
  1252. stopPropagation: sinon.spy()
  1253. };
  1254. model.change( writer => {
  1255. writer.insertText( '@T', doc.selection.getFirstPosition() );
  1256. } );
  1257. return waitForDebounce()
  1258. .then( () => {
  1259. expectChildViewsIsOnState( [ true ] );
  1260. fireKeyDownEvent( keyDownEvtData );
  1261. expectChildViewsIsOnState( [ true ] );
  1262. fireKeyDownEvent( keyUpEvtData );
  1263. expectChildViewsIsOnState( [ true ] );
  1264. } );
  1265. } );
  1266. } );
  1267. describe( 'on "execute" keys', () => {
  1268. testExecuteKey( 'enter', keyCodes.enter, feedItems );
  1269. testExecuteKey( 'tab', keyCodes.tab, feedItems );
  1270. testExecuteKey( 'space', keyCodes.space, feedItems );
  1271. } );
  1272. } );
  1273. describe( 'default list item with custom feed', () => {
  1274. const issues = [
  1275. { id: '@Ted' },
  1276. { id: '@Barney' },
  1277. { id: '@Robin' },
  1278. { id: '@Lily' },
  1279. { id: '@Marshal' }
  1280. ];
  1281. beforeEach( () => {
  1282. return createClassicTestEditor( {
  1283. feeds: [
  1284. {
  1285. marker: '@',
  1286. feed: feedText => issues.filter( issue => issue.id.includes( feedText ) )
  1287. }
  1288. ]
  1289. } );
  1290. } );
  1291. it( 'should show panel for matched marker', () => {
  1292. setData( model, '<paragraph>foo []</paragraph>' );
  1293. model.change( writer => {
  1294. writer.insertText( '@', doc.selection.getFirstPosition() );
  1295. } );
  1296. return waitForDebounce()
  1297. .then( () => {
  1298. expect( panelView.isVisible ).to.be.true;
  1299. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1300. expect( mentionsView.items ).to.have.length( 5 );
  1301. } );
  1302. } );
  1303. } );
  1304. describe( 'custom list item (string)', () => {
  1305. const issues = [
  1306. { id: '@1002', title: 'Some bug in editor.' },
  1307. { id: '@1003', title: 'Introduce this feature.' },
  1308. { id: '@1004', title: 'Missing docs.' },
  1309. { id: '@1005', title: 'Another bug.' },
  1310. { id: '@1006', title: 'More bugs' }
  1311. ];
  1312. beforeEach( () => {
  1313. return createClassicTestEditor( {
  1314. feeds: [
  1315. {
  1316. marker: '@',
  1317. feed: issues,
  1318. itemRenderer: item => item.title
  1319. }
  1320. ]
  1321. } );
  1322. } );
  1323. it( 'should show panel for matched marker', () => {
  1324. setData( model, '<paragraph>foo []</paragraph>' );
  1325. model.change( writer => {
  1326. writer.insertText( '@', doc.selection.getFirstPosition() );
  1327. } );
  1328. return waitForDebounce()
  1329. .then( () => {
  1330. expect( panelView.isVisible ).to.be.true;
  1331. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1332. expect( mentionsView.items ).to.have.length( 5 );
  1333. } );
  1334. } );
  1335. describe( 'keys', () => {
  1336. describe( 'on arrows', () => {
  1337. it( 'should cycle down on arrow down', () => {
  1338. setData( model, '<paragraph>foo []</paragraph>' );
  1339. model.change( writer => {
  1340. writer.insertText( '@', doc.selection.getFirstPosition() );
  1341. } );
  1342. return waitForDebounce()
  1343. .then( () => {
  1344. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1345. const keyEvtData = {
  1346. keyCode: keyCodes.arrowdown,
  1347. preventDefault: sinon.spy(),
  1348. stopPropagation: sinon.spy()
  1349. };
  1350. fireKeyDownEvent( keyEvtData );
  1351. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1352. fireKeyDownEvent( keyEvtData );
  1353. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1354. fireKeyDownEvent( keyEvtData );
  1355. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1356. fireKeyDownEvent( keyEvtData );
  1357. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1358. fireKeyDownEvent( keyEvtData );
  1359. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1360. } );
  1361. } );
  1362. it( 'should cycle up on arrow up', () => {
  1363. setData( model, '<paragraph>foo []</paragraph>' );
  1364. model.change( writer => {
  1365. writer.insertText( '@', doc.selection.getFirstPosition() );
  1366. } );
  1367. return waitForDebounce()
  1368. .then( () => {
  1369. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1370. const keyEvtData = {
  1371. keyCode: keyCodes.arrowup,
  1372. preventDefault: sinon.spy(),
  1373. stopPropagation: sinon.spy()
  1374. };
  1375. fireKeyDownEvent( keyEvtData );
  1376. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1377. fireKeyDownEvent( keyEvtData );
  1378. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1379. fireKeyDownEvent( keyEvtData );
  1380. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1381. fireKeyDownEvent( keyEvtData );
  1382. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1383. fireKeyDownEvent( keyEvtData );
  1384. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1385. } );
  1386. } );
  1387. } );
  1388. describe( 'on "execute" keys', () => {
  1389. testExecuteKey( 'enter', keyCodes.enter, issues );
  1390. testExecuteKey( 'tab', keyCodes.tab, issues );
  1391. testExecuteKey( 'space', keyCodes.space, issues );
  1392. } );
  1393. } );
  1394. } );
  1395. describe( 'custom list item (DOM Element)', () => {
  1396. const issues = [
  1397. { id: '@1002', title: 'Some bug in editor.' },
  1398. { id: '@1003', title: 'Introduce this feature.' },
  1399. { id: '@1004', title: 'Missing docs.' },
  1400. { id: '@1005', title: 'Another bug.' },
  1401. { id: '@1006', title: 'More bugs' }
  1402. ];
  1403. beforeEach( () => {
  1404. return createClassicTestEditor( {
  1405. feeds: [
  1406. {
  1407. marker: '@',
  1408. feed: feedText => {
  1409. return Promise.resolve( issues.filter( issue => issue.id.includes( feedText ) ) );
  1410. },
  1411. itemRenderer: item => {
  1412. const span = global.document.createElementNS( 'http://www.w3.org/1999/xhtml', 'span' );
  1413. span.innerHTML = `<span id="issue-${ item.id.slice( 1 ) }">@${ item.title }</span>`;
  1414. return span;
  1415. }
  1416. }
  1417. ]
  1418. } );
  1419. } );
  1420. it( 'should show panel for matched marker', () => {
  1421. setData( model, '<paragraph>foo []</paragraph>' );
  1422. model.change( writer => {
  1423. writer.insertText( '@', doc.selection.getFirstPosition() );
  1424. } );
  1425. return waitForDebounce()
  1426. .then( () => {
  1427. expect( panelView.isVisible ).to.be.true;
  1428. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1429. expect( mentionsView.items ).to.have.length( 5 );
  1430. } );
  1431. } );
  1432. describe( 'keys', () => {
  1433. describe( 'on arrows', () => {
  1434. it( 'should cycle down on arrow down', () => {
  1435. setData( model, '<paragraph>foo []</paragraph>' );
  1436. model.change( writer => {
  1437. writer.insertText( '@', doc.selection.getFirstPosition() );
  1438. } );
  1439. return waitForDebounce()
  1440. .then( () => {
  1441. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1442. const keyEvtData = {
  1443. keyCode: keyCodes.arrowdown,
  1444. preventDefault: sinon.spy(),
  1445. stopPropagation: sinon.spy()
  1446. };
  1447. fireKeyDownEvent( keyEvtData );
  1448. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1449. fireKeyDownEvent( keyEvtData );
  1450. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1451. fireKeyDownEvent( keyEvtData );
  1452. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1453. fireKeyDownEvent( keyEvtData );
  1454. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1455. fireKeyDownEvent( keyEvtData );
  1456. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1457. } );
  1458. } );
  1459. it( 'should cycle up on arrow up', () => {
  1460. setData( model, '<paragraph>foo []</paragraph>' );
  1461. model.change( writer => {
  1462. writer.insertText( '@', doc.selection.getFirstPosition() );
  1463. } );
  1464. return waitForDebounce()
  1465. .then( () => {
  1466. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1467. const keyEvtData = {
  1468. keyCode: keyCodes.arrowup,
  1469. preventDefault: sinon.spy(),
  1470. stopPropagation: sinon.spy()
  1471. };
  1472. fireKeyDownEvent( keyEvtData );
  1473. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1474. fireKeyDownEvent( keyEvtData );
  1475. expectChildViewsIsOnState( [ false, false, false, true, false ] );
  1476. fireKeyDownEvent( keyEvtData );
  1477. expectChildViewsIsOnState( [ false, false, true, false, false ] );
  1478. fireKeyDownEvent( keyEvtData );
  1479. expectChildViewsIsOnState( [ false, true, false, false, false ] );
  1480. fireKeyDownEvent( keyEvtData );
  1481. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1482. } );
  1483. } );
  1484. } );
  1485. describe( 'on "execute" keys', () => {
  1486. testExecuteKey( 'enter', keyCodes.enter, issues );
  1487. testExecuteKey( 'tab', keyCodes.tab, issues );
  1488. testExecuteKey( 'space', keyCodes.space, issues );
  1489. } );
  1490. describe( 'mouse', () => {
  1491. it( 'should execute selected button on mouse click', () => {
  1492. setData( model, '<paragraph>foo []</paragraph>' );
  1493. model.change( writer => {
  1494. writer.insertText( '@', doc.selection.getFirstPosition() );
  1495. } );
  1496. const command = editor.commands.get( 'mention' );
  1497. const spy = testUtils.sinon.spy( command, 'execute' );
  1498. return waitForDebounce()
  1499. .then( () => {
  1500. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1501. const element = panelView.element.querySelector( '#issue-1004' );
  1502. element.dispatchEvent( new Event( 'click', { bubbles: true } ) );
  1503. sinon.assert.calledOnce( spy );
  1504. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  1505. const item = issues[ 2 ];
  1506. expect( commandOptions ).to.have.property( 'mention' ).that.deep.equal( item );
  1507. expect( commandOptions ).to.have.property( 'marker', '@' );
  1508. expect( commandOptions ).to.have.property( 'range' );
  1509. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  1510. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  1511. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  1512. } );
  1513. } );
  1514. } );
  1515. } );
  1516. } );
  1517. describe( 'multiple feeds configuration', () => {
  1518. beforeEach( () => {
  1519. return createClassicTestEditor( {
  1520. feeds: [
  1521. {
  1522. marker: '@',
  1523. feed: [ '@a1', '@a2', '@a3' ]
  1524. },
  1525. {
  1526. marker: '$',
  1527. feed: [ '$a1', '$a2', '$a3', '$a4', '$a5' ]
  1528. }
  1529. ]
  1530. } );
  1531. } );
  1532. it( 'should show panel for matched marker', () => {
  1533. setData( model, '<paragraph>foo []</paragraph>' );
  1534. model.change( writer => {
  1535. writer.insertText( '@', doc.selection.getFirstPosition() );
  1536. } );
  1537. return waitForDebounce()
  1538. .then( () => {
  1539. expect( panelView.isVisible ).to.be.true;
  1540. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1541. expect( mentionsView.items ).to.have.length( 3 );
  1542. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1543. } )
  1544. .then( waitForDebounce )
  1545. .then( () => {
  1546. expect( panelView.isVisible ).to.be.false;
  1547. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1548. model.change( writer => {
  1549. writer.insertText( '$', doc.selection.getFirstPosition() );
  1550. } );
  1551. } )
  1552. .then( waitForDebounce )
  1553. .then( () => {
  1554. expect( panelView.isVisible ).to.be.true;
  1555. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1556. expect( mentionsView.items ).to.have.length( 5 );
  1557. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1558. } )
  1559. .then( waitForDebounce )
  1560. .then( () => {
  1561. expect( panelView.isVisible ).to.be.false;
  1562. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1563. model.change( writer => {
  1564. writer.insertText( '@', doc.selection.getFirstPosition() );
  1565. } );
  1566. } )
  1567. .then( waitForDebounce )
  1568. .then( () => {
  1569. expect( panelView.isVisible ).to.be.true;
  1570. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1571. expect( mentionsView.items ).to.have.length( 3 );
  1572. } );
  1573. } );
  1574. } );
  1575. function testExecuteKey( name, keyCode, feedItems ) {
  1576. it( 'should execute selected button on ' + name, () => {
  1577. setData( model, '<paragraph>foo []</paragraph>' );
  1578. model.change( writer => {
  1579. writer.insertText( '@', doc.selection.getFirstPosition() );
  1580. } );
  1581. const command = editor.commands.get( 'mention' );
  1582. const spy = testUtils.sinon.spy( command, 'execute' );
  1583. return waitForDebounce()
  1584. .then( () => {
  1585. expectChildViewsIsOnState( [ true, false, false, false, false ] );
  1586. fireKeyDownEvent( {
  1587. keyCode: keyCodes.arrowup,
  1588. preventDefault: sinon.spy(),
  1589. stopPropagation: sinon.spy()
  1590. } );
  1591. expectChildViewsIsOnState( [ false, false, false, false, true ] );
  1592. fireKeyDownEvent( {
  1593. keyCode,
  1594. preventDefault: sinon.spy(),
  1595. stopPropagation: sinon.spy()
  1596. } );
  1597. sinon.assert.calledOnce( spy );
  1598. assertCommandOptions( spy.getCall( 0 ).args[ 0 ], '@', feedItems[ 4 ] );
  1599. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  1600. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  1601. expect( spy.getCall( 0 ).args[ 0 ].range.isEqual( expectedRange ) ).to.be.true;
  1602. } );
  1603. } );
  1604. it( 'should do nothing if panel is not visible on ' + name, () => {
  1605. setData( model, '<paragraph>foo []</paragraph>' );
  1606. model.change( writer => {
  1607. writer.insertText( '@', doc.selection.getFirstPosition() );
  1608. } );
  1609. const command = editor.commands.get( 'mention' );
  1610. const spy = testUtils.sinon.spy( command, 'execute' );
  1611. return waitForDebounce()
  1612. .then( () => {
  1613. expect( panelView.isVisible ).to.be.true;
  1614. expect( editor.model.markers.has( 'mention' ) ).to.be.true;
  1615. fireKeyDownEvent( {
  1616. keyCode: keyCodes.esc,
  1617. preventDefault: sinon.spy(),
  1618. stopPropagation: sinon.spy()
  1619. } );
  1620. expect( panelView.isVisible ).to.be.false;
  1621. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1622. fireKeyDownEvent( {
  1623. keyCode,
  1624. preventDefault: sinon.spy(),
  1625. stopPropagation: sinon.spy()
  1626. } );
  1627. sinon.assert.notCalled( spy );
  1628. expect( panelView.isVisible ).to.be.false;
  1629. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1630. } );
  1631. } );
  1632. }
  1633. } );
  1634. describe( 'execute', () => {
  1635. beforeEach( () => createClassicTestEditor( staticConfig ) );
  1636. it( 'should call the mention command with proper options', () => {
  1637. setData( model, '<paragraph>foo []</paragraph>' );
  1638. model.change( writer => {
  1639. writer.insertText( '@', doc.selection.getFirstPosition() );
  1640. } );
  1641. const command = editor.commands.get( 'mention' );
  1642. const spy = testUtils.sinon.spy( command, 'execute' );
  1643. return waitForDebounce()
  1644. .then( () => {
  1645. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1646. sinon.assert.calledOnce( spy );
  1647. const commandOptions = spy.getCall( 0 ).args[ 0 ];
  1648. assertCommandOptions( commandOptions, '@', { id: '@Barney', text: '@Barney' } );
  1649. const start = model.createPositionAt( doc.getRoot().getChild( 0 ), 4 );
  1650. const expectedRange = model.createRange( start, start.getShiftedBy( 1 ) );
  1651. expect( commandOptions.range.isEqual( expectedRange ) ).to.be.true;
  1652. } );
  1653. } );
  1654. it( 'should hide panel on execute', () => {
  1655. setData( model, '<paragraph>foo []</paragraph>' );
  1656. model.change( writer => {
  1657. writer.insertText( '@', doc.selection.getFirstPosition() );
  1658. } );
  1659. return waitForDebounce()
  1660. .then( () => {
  1661. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1662. expect( panelView.isVisible ).to.be.false;
  1663. expect( editor.model.markers.has( 'mention' ) ).to.be.false;
  1664. } );
  1665. } );
  1666. it( 'should focus view after command execution', () => {
  1667. const focusSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
  1668. setData( model, '<paragraph>foo []</paragraph>' );
  1669. model.change( writer => {
  1670. writer.insertText( '@', doc.selection.getFirstPosition() );
  1671. } );
  1672. return waitForDebounce()
  1673. .then( () => {
  1674. mentionsView.items.get( 0 ).children.get( 0 ).fire( 'execute' );
  1675. sinon.assert.calledOnce( focusSpy );
  1676. } );
  1677. } );
  1678. } );
  1679. function createClassicTestEditor( mentionConfig ) {
  1680. return ClassicTestEditor
  1681. .create( editorElement, {
  1682. plugins: [ Paragraph, MentionEditing, MentionUI ],
  1683. mention: mentionConfig
  1684. } )
  1685. .then( newEditor => {
  1686. editor = newEditor;
  1687. model = editor.model;
  1688. doc = model.document;
  1689. editingView = editor.editing.view;
  1690. mentionUI = editor.plugins.get( MentionUI );
  1691. panelView = editor.plugins.get( ContextualBalloon ).view;
  1692. mentionsView = mentionUI._mentionsView;
  1693. } );
  1694. }
  1695. function wait( timeout ) {
  1696. return () => new Promise( resolve => {
  1697. setTimeout( () => {
  1698. resolve();
  1699. }, timeout );
  1700. } );
  1701. }
  1702. function waitForDebounce() {
  1703. return wait( 180 )();
  1704. }
  1705. function fireKeyDownEvent( options ) {
  1706. const eventInfo = new EventInfo( editingView.document, 'keydown' );
  1707. const eventData = new DomEventData( editingView.document, {
  1708. target: document.body
  1709. }, options );
  1710. editingView.document.fire( eventInfo, eventData );
  1711. }
  1712. function stubSelectionRects( rects ) {
  1713. const originalViewRangeToDom = editingView.domConverter.viewRangeToDom;
  1714. // Mock selection rect.
  1715. sinon.stub( editingView.domConverter, 'viewRangeToDom' ).callsFake( ( ...args ) => {
  1716. const domRange = originalViewRangeToDom.apply( editingView.domConverter, args );
  1717. sinon.stub( domRange, 'getClientRects' )
  1718. .returns( rects );
  1719. return domRange;
  1720. } );
  1721. }
  1722. function expectChildViewsIsOnState( expectedState ) {
  1723. const childViews = [ ...mentionsView.items ].map( item => item.children.get( 0 ) );
  1724. expect( childViews.map( child => child.isOn ) ).to.deep.equal( expectedState );
  1725. }
  1726. function assertCommandOptions( commandOptions, marker, item ) {
  1727. expect( commandOptions ).to.have.property( 'marker', marker );
  1728. expect( commandOptions ).to.have.property( 'range' );
  1729. expect( commandOptions ).to.have.property( 'mention' );
  1730. const mentionForCommand = commandOptions.mention;
  1731. for ( const key of Object.keys( item ) ) {
  1732. expect( mentionForCommand[ key ] ).to.equal( item[ key ] );
  1733. }
  1734. }
  1735. } );