mentionui.js 54 KB

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