mentionui.js 54 KB

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