mentionui.js 46 KB

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