twostepcaretmovement.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* global document */
  6. import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
  7. import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
  8. import DomEventData from '@ckeditor/ckeditor5-engine/src/view/observer/domeventdata';
  9. import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
  10. import TwoStepCaretMovement, { TwoStepCaretHandler } from '../src/twostepcaretmovement';
  11. import Position from '@ckeditor/ckeditor5-engine/src/model/position';
  12. import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
  13. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  14. import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
  15. describe( 'TwoStepCaretMovement()', () => {
  16. let editor, model, emitter, selection, view, plugin;
  17. let preventDefaultSpy, evtStopSpy;
  18. testUtils.createSinonSandbox();
  19. beforeEach( () => {
  20. emitter = Object.create( DomEmitterMixin );
  21. return VirtualTestEditor.create( { plugins: [ TwoStepCaretMovement ] } ).then( newEditor => {
  22. editor = newEditor;
  23. model = editor.model;
  24. selection = model.document.selection;
  25. view = editor.editing.view;
  26. plugin = editor.plugins.get( TwoStepCaretMovement );
  27. preventDefaultSpy = sinon.spy();
  28. evtStopSpy = sinon.spy();
  29. editor.model.schema.extend( '$text', {
  30. allowAttributes: [ 'a', 'b', 'c' ],
  31. allowIn: '$root'
  32. } );
  33. model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
  34. editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
  35. editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
  36. editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
  37. editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  38. plugin.registerAttribute( 'a' );
  39. } );
  40. } );
  41. afterEach( () => {
  42. return editor.destroy();
  43. } );
  44. describe( 'moving right', () => {
  45. it( 'should do nothing for unrelated attribute (at the beginning)', () => {
  46. setData( model, '[]<$text c="true">foo</$text>' );
  47. testTwoStepCaretMovement( [
  48. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  49. '→',
  50. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  51. ] );
  52. } );
  53. it( 'should do nothing for unrelated attribute (at the end)', () => {
  54. setData( model, '<$text c="true">foo[]</$text>' );
  55. testTwoStepCaretMovement( [
  56. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  57. '→',
  58. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  59. ] );
  60. } );
  61. it( 'should "enter" the text with attribute in two steps', () => {
  62. setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
  63. testTwoStepCaretMovement( [
  64. // Gravity is not overridden, caret is at the beginning of the text but is "outside" of the text.
  65. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  66. '→',
  67. // Gravity is overridden, caret movement is blocked, selection at the beginning but "inside" the text.
  68. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  69. '→',
  70. // Caret movement was not blocked this time (still once) so everything works normally.
  71. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  72. ] );
  73. } );
  74. it( 'should "leave" the text with attribute in two steps', () => {
  75. setData( model, '<$text a="true" b="true">bar[]</$text><$text c="true">foo</$text>' );
  76. testTwoStepCaretMovement( [
  77. // Gravity is not overridden, caret is at the end of the text but is "inside" of the text.
  78. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  79. '→',
  80. // Gravity is overridden, caret movement is blocked, selection at the end but "outside" the text.
  81. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  82. '→',
  83. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  84. ] );
  85. } );
  86. it( 'should use two-steps movement when between nodes with the same attribute but different value', () => {
  87. setData( model, '<$text a="1">bar[]</$text><$text a="2">foo</$text>' );
  88. testTwoStepCaretMovement( [
  89. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  90. '→',
  91. // <$text a="1">bar</$text>[]<$text a="2">foo</$text>
  92. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  93. '→',
  94. // <$text a="1">bar</$text><$text a="2">[]foo</$text>
  95. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  96. '→',
  97. // <$text a="1">bar</$text><$text a="2">f[]oo</$text>
  98. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  99. ] );
  100. } );
  101. // https://github.com/ckeditor/ckeditor5/issues/937
  102. it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => {
  103. setData( model, '<$text a="1">fo[]o</$text><$text a="1" b="2">bar</$text><$text a="1">baz</$text>' );
  104. testTwoStepCaretMovement( [
  105. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  106. '→',
  107. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  108. '→',
  109. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  110. '→',
  111. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  112. '→',
  113. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  114. '→',
  115. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  116. ] );
  117. } );
  118. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  119. it( 'should handle passing through the only character in the block', () => {
  120. setData( model, '[]<$text a="1">x</$text>' );
  121. testTwoStepCaretMovement( [
  122. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  123. '→',
  124. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  125. '→',
  126. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  127. '→',
  128. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  129. ] );
  130. } );
  131. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  132. it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => {
  133. setData( model, '[]<$text a="1">x</$text>' );
  134. model.change( writer => writer.removeSelectionAttribute( 'a' ) );
  135. testTwoStepCaretMovement( [
  136. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  137. '→',
  138. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  139. '→',
  140. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  141. '→',
  142. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  143. '→',
  144. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 }
  145. ] );
  146. } );
  147. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  148. it( 'should handle passing through the only-child with an attribute (multiple characters)', () => {
  149. setData( model, '[]<$text a="1">xyz</$text>' );
  150. testTwoStepCaretMovement( [
  151. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  152. '→',
  153. // <$text a="1">x{}yz</$text>
  154. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  155. '→',
  156. // <$text a="1">xy{}z</$text>
  157. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  158. '→',
  159. // <$text a="1">xyz{}</$text>
  160. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  161. '→',
  162. // <$text a="1">xyz</$text>{}
  163. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  164. '→',
  165. // <$text a="1">xyz</$text>{}
  166. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  167. ] );
  168. } );
  169. it( 'should handle leaving an attribute followed by another block', () => {
  170. setData( model, '<paragraph><$text a="1">foo[]</$text></paragraph><paragraph><$text b="1">bar</$text></paragraph>' );
  171. testTwoStepCaretMovement( [
  172. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  173. '→',
  174. // <paragraph><$text a="1">bar</$text>[]</paragraph><paragraph>foo</paragraph>
  175. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  176. '→',
  177. // <paragraph><$text a="1">bar</$text></paragraph><paragraph>f[]oo</paragraph>
  178. { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  179. '→',
  180. // <paragraph><$text a="1">bar</$text></paragraph><paragraph>fo[]o</paragraph>
  181. { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  182. ] );
  183. } );
  184. } );
  185. describe( 'moving left', () => {
  186. it( 'should "enter" the text with attribute in two steps', () => {
  187. setData( model, '<$text>foo</$text><$text a="true" b="true">bar</$text><$text c="true">b[]iz</$text>' );
  188. testTwoStepCaretMovement( [
  189. // Gravity is not overridden, caret is a one character after the and of the text.
  190. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  191. '←',
  192. // Caret movement was not blocked but the gravity is overridden.
  193. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  194. '←',
  195. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  196. '←',
  197. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  198. ] );
  199. } );
  200. it( 'should "leave" the text with attribute in two steps', () => {
  201. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  202. testTwoStepCaretMovement( [
  203. // Gravity is not overridden, caret is a one character after the beginning of the text.
  204. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  205. '←',
  206. // Caret movement was not blocked.
  207. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  208. '←',
  209. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  210. '←',
  211. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  212. ] );
  213. } );
  214. it( 'should do nothing for unrelated attribute (at the beginning)', () => {
  215. setData( model, '<$text c="true">[]foo</$text>' );
  216. testTwoStepCaretMovement( [
  217. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  218. '←',
  219. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  220. '←',
  221. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  222. ] );
  223. } );
  224. it( 'should do nothing for unrelated attribute (at the end)', () => {
  225. setData( model, '<$text c="true">foo</$text>[]' );
  226. testTwoStepCaretMovement( [
  227. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  228. '←',
  229. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  230. '←',
  231. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  232. ] );
  233. } );
  234. it( 'should do nothing when caret is at the beginning of block element', () => {
  235. setData( model, '[]foo', { lastRangeBackward: true } );
  236. testTwoStepCaretMovement( [
  237. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  238. '←',
  239. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  240. ] );
  241. } );
  242. it( 'should require two-steps movement when caret goes between text node with the same attribute but different value', () => {
  243. setData( model, '<$text a="2">foo</$text><$text a="1">b[]ar</$text>' );
  244. testTwoStepCaretMovement( [
  245. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  246. '←',
  247. // <$text a="2">foo</$text><$text a="1">[]bar</$text>
  248. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  249. '←',
  250. // <$text a="2">foo</$text>[]<$text a="1">bar</$text>
  251. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  252. '←',
  253. // <$text a="2">foo[]</$text><$text a="1">bar</$text>
  254. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  255. '←',
  256. // <$text a="2">fo[]o</$text><$text a="1">bar</$text>
  257. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  258. ] );
  259. } );
  260. // https://github.com/ckeditor/ckeditor5/issues/937
  261. it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => {
  262. setData( model, '<$text a="1">foo</$text><$text a="1" b="2">bar</$text><$text a="1">b[]az</$text>' );
  263. testTwoStepCaretMovement( [
  264. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  265. '←',
  266. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  267. '←',
  268. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  269. '←',
  270. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  271. '←',
  272. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  273. ] );
  274. } );
  275. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  276. it( 'should handle passing through the only-child with an attribute (single character)', () => {
  277. setData( model, '<$text a="1">x</$text>[]' );
  278. testTwoStepCaretMovement( [
  279. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  280. '←',
  281. // <$text a="1">{}x</$text>
  282. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  283. '←',
  284. // {}<$text a="1">x</$text> (because it's a first-child)
  285. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  286. '←',
  287. // {}<$text a="1">x</$text>
  288. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  289. ] );
  290. } );
  291. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  292. it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => {
  293. setData( model, '<$text a="1">x</$text>[]' );
  294. model.change( writer => writer.removeSelectionAttribute( 'a' ) );
  295. testTwoStepCaretMovement( [
  296. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  297. '←',
  298. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  299. '←',
  300. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  301. '←',
  302. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  303. '←',
  304. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  305. ] );
  306. } );
  307. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  308. it( 'should handle passing through the only-child with an attribute (single character, text before)', () => {
  309. setData( model, 'abc<$text a="1">x</$text>[]' );
  310. testTwoStepCaretMovement( [
  311. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  312. '←',
  313. // abc<$text a="1">{}x</$text>
  314. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  315. '←',
  316. // abc{}<$text a="1">x</$text> (because it's a first-child)
  317. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  318. '←',
  319. // abc{}<$text a="1">x</$text>
  320. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  321. ] );
  322. } );
  323. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  324. it( 'should handle passing through the only-child with an attribute (multiple characters)', () => {
  325. setData( model, '<$text a="1">xyz</$text>[]' );
  326. testTwoStepCaretMovement( [
  327. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  328. '←',
  329. // <$text a="1">xy{}z</$text>
  330. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  331. '←',
  332. // <$text a="1">x{}yz</$text>
  333. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  334. '←',
  335. // <$text a="1">{}xyz</$text>
  336. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  337. '←',
  338. // {}<$text a="1">xyz</$text>
  339. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  340. '←',
  341. // {}<$text a="1">xyz</$text>
  342. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  343. ] );
  344. } );
  345. it( 'should handle leaving an attribute preceded by another block', () => {
  346. setData( model, '<paragraph><$text b="1">foo</$text></paragraph><paragraph><$text a="1">[]bar</$text></paragraph>' );
  347. testTwoStepCaretMovement( [
  348. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  349. '←',
  350. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  351. '←',
  352. { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  353. ] );
  354. } );
  355. } );
  356. describe( 'moving and typing around the attribute', () => {
  357. it( 'should handle typing after the attribute', () => {
  358. setData( model, '<$text a="1">x[]</$text>' );
  359. testTwoStepCaretMovement( [
  360. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  361. 'y',
  362. // <$text a="1">xy[]</$text>
  363. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  364. '→',
  365. // <$text a="1">xy</$text>[]
  366. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  367. 'z',
  368. // <$text a="1">xy</$text>z[]
  369. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  370. '←',
  371. // <$text a="1">xy</$text>[]z
  372. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  373. '←',
  374. // <$text a="1">xy[]</$text>z
  375. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  376. 'w',
  377. // <$text a="1">xyw[]</$text>
  378. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  379. ] );
  380. } );
  381. it( 'should handle typing before the attribute', () => {
  382. setData( model, '<$text a="1">[]x</$text>' );
  383. testTwoStepCaretMovement( [
  384. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  385. '←',
  386. // []<$text a="1">x</$text>
  387. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  388. 'z',
  389. // z[]<$text a="1">x</$text>
  390. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  391. 'x',
  392. // zx[]<$text a="1">x</$text>
  393. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  394. '→',
  395. // zx<$text a="1">[]x</$text>
  396. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  397. 'a',
  398. // zx<$text a="1">a[]x</$text>
  399. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  400. ] );
  401. } );
  402. // https://github.com/ckeditor/ckeditor5-engine/issues/1346
  403. // https://github.com/ckeditor/ckeditor5/issues/946
  404. it( 'should correctly re-renter the attribute', () => {
  405. setData( model, 'fo[]o <$text a="1">bar</$text>' );
  406. testTwoStepCaretMovement( [
  407. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  408. '→',
  409. // foo[] <$text a="1">bar</$text>
  410. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  411. '→',
  412. // foo []<$text a="1">bar</$text>
  413. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  414. '→',
  415. // foo <$text a="1">[]bar</$text>
  416. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  417. '→',
  418. // foo <$text a="1">b[]ar</$text>
  419. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  420. '←',
  421. // foo <$text a="1">[]bar</$text>
  422. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  423. '←',
  424. // foo []<$text a="1">bar</$text>
  425. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  426. '←',
  427. // foo[] <$text a="1">bar</$text>
  428. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  429. '←',
  430. // fo[]o <$text a="1">bar</$text>
  431. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  432. ] );
  433. } );
  434. // https://github.com/ckeditor/ckeditor5/issues/922
  435. it( 'should not lose the new attribute when typing (after)', () => {
  436. setData( model, '<$text a="1">x[]</$text>' );
  437. testTwoStepCaretMovement( [
  438. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  439. '→',
  440. // <$text a="1">x</$text>[]
  441. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  442. ] );
  443. model.change( writer => {
  444. writer.setSelectionAttribute( 'b', 1 );
  445. } );
  446. // <$text a="1">x</$text><$text b="1">[]</$text>
  447. expect( selection.isGravityOverridden ).to.be.true;
  448. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  449. model.change( writer => {
  450. writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() );
  451. } );
  452. // <$text a="1">x</$text><$text b="1">yz[]</$text>
  453. expect( selection.isGravityOverridden ).to.be.false;
  454. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  455. } );
  456. // https://github.com/ckeditor/ckeditor5/issues/922
  457. it( 'should not lose the new attribute when typing (before)', () => {
  458. setData( model, '<$text a="1">[]x</$text>' );
  459. testTwoStepCaretMovement( [
  460. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  461. '←',
  462. // []<$text a="1">x</$text>
  463. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  464. ] );
  465. model.change( writer => {
  466. writer.setSelectionAttribute( 'b', 1 );
  467. } );
  468. // <$text b="1">[]</$text><$text a="1">x</$text>
  469. expect( selection.isGravityOverridden ).to.be.false;
  470. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  471. model.change( writer => {
  472. writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() );
  473. } );
  474. // <$text b="1">yz[]</$text><$text a="1">x</$text>
  475. expect( selection.isGravityOverridden ).to.be.false;
  476. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  477. } );
  478. } );
  479. describe( 'multiple attributes', () => {
  480. beforeEach( () => {
  481. plugin.registerAttribute( 'c' );
  482. } );
  483. it( 'should work with the two-step caret movement (moving right)', () => {
  484. setData( model, 'fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux' );
  485. testTwoStepCaretMovement( [
  486. // fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  487. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  488. '→',
  489. // foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  490. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  491. '→',
  492. // foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  493. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  494. '→',
  495. '→',
  496. '→',
  497. // foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  498. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  499. '→',
  500. // foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
  501. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  502. '→',
  503. '→',
  504. '→',
  505. // foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
  506. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  507. '→',
  508. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
  509. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
  510. '→',
  511. '→',
  512. '→',
  513. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
  514. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
  515. '→',
  516. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
  517. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 4, evtStopCalled: 4 },
  518. '→',
  519. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
  520. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 }
  521. ] );
  522. } );
  523. it( 'should work with the two-step caret movement (moving left)', () => {
  524. setData( model, 'foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux' );
  525. testTwoStepCaretMovement( [
  526. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
  527. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  528. '←',
  529. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
  530. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  531. '←',
  532. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
  533. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  534. '←',
  535. '←',
  536. '←',
  537. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
  538. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  539. '←',
  540. // foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
  541. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  542. '←',
  543. '←',
  544. '←',
  545. // foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
  546. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  547. '←',
  548. // foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  549. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
  550. '←',
  551. '←',
  552. '←',
  553. // foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  554. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
  555. '←',
  556. // foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  557. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 },
  558. '←',
  559. // fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  560. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 }
  561. ] );
  562. } );
  563. } );
  564. describe( 'mouse', () => {
  565. it( 'should not override gravity when selection is placed at the beginning of text', () => {
  566. setData( model, '<$text a="true">[]foo</$text>' );
  567. expect( selection.isGravityOverridden ).to.be.false;
  568. } );
  569. it( 'should not override gravity when selection is placed at the end of text', () => {
  570. setData( model, '<$text a="true">foo[]</$text>' );
  571. expect( selection.isGravityOverridden ).to.be.false;
  572. } );
  573. } );
  574. it( 'should listen with the high+1 priority on view.document#keydown', () => {
  575. const highestPrioritySpy = sinon.spy();
  576. const highPrioritySpy = sinon.spy();
  577. const normalPrioritySpy = sinon.spy();
  578. setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
  579. emitter.listenTo( view.document, 'keydown', highestPrioritySpy, { priority: 'highest' } );
  580. emitter.listenTo( view.document, 'keydown', highPrioritySpy, { priority: 'high' } );
  581. emitter.listenTo( view.document, 'keydown', normalPrioritySpy, { priority: 'normal' } );
  582. fireKeyDownEvent( {
  583. keyCode: keyCodes.arrowright,
  584. preventDefault: preventDefaultSpy
  585. } );
  586. sinon.assert.callOrder(
  587. highestPrioritySpy,
  588. preventDefaultSpy );
  589. sinon.assert.notCalled( highPrioritySpy );
  590. sinon.assert.notCalled( normalPrioritySpy );
  591. } );
  592. it( 'should do nothing when key other then arrow left and right is pressed', () => {
  593. setData( model, '<$text a="true">foo[]</$text>' );
  594. expect( () => {
  595. fireKeyDownEvent( { keyCode: keyCodes.arrowup } );
  596. } ).to.not.throw();
  597. } );
  598. it( 'should do nothing for non-collapsed selection', () => {
  599. setData( model, '<$text c="true">fo[o]</$text><$text a="true" b="true">bar</$text>' );
  600. fireKeyDownEvent( { keyCode: keyCodes.arrowright } );
  601. expect( selection.isGravityOverridden ).to.be.false;
  602. } );
  603. it( 'should do nothing when shift key is pressed', () => {
  604. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  605. fireKeyDownEvent( {
  606. keyCode: keyCodes.arrowleft,
  607. shiftKey: true
  608. } );
  609. expect( selection.isGravityOverridden ).to.be.false;
  610. } );
  611. it( 'should do nothing when alt key is pressed', () => {
  612. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  613. fireKeyDownEvent( {
  614. keyCode: keyCodes.arrowleft,
  615. altKey: true
  616. } );
  617. expect( selection.isGravityOverridden ).to.be.false;
  618. } );
  619. it( 'should do nothing when ctrl key is pressed', () => {
  620. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  621. fireKeyDownEvent( {
  622. keyCode: keyCodes.arrowleft,
  623. ctrlKey: true
  624. } );
  625. expect( selection.isGravityOverridden ).to.be.false;
  626. } );
  627. it( 'should do nothing when the not a direct selection change but at the attribute boundary', () => {
  628. setData( model, '<$text a="true">foo[]</$text>bar' );
  629. testTwoStepCaretMovement( [
  630. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  631. '→',
  632. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  633. ] );
  634. // Simulate an external text insertion BEFORE the user selection to trigger #change:range.
  635. model.enqueueChange( 'transparent', writer => {
  636. writer.insertText( 'x', selection.getFirstPosition().getShiftedBy( -2 ) );
  637. } );
  638. expect( selection.isGravityOverridden ).to.be.true;
  639. expect( getSelectionAttributesArray( selection ) ).to.have.members( [] );
  640. } );
  641. describe( 'left–to–right and right–to–left content', () => {
  642. it( 'should call methods associated with the keys (LTR content direction)', () => {
  643. const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
  644. const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
  645. setData( model, '<$text>foo[]</$text><$text a="true">bar</$text>' );
  646. fireKeyDownEvent( {
  647. keyCode: keyCodes.arrowright
  648. } );
  649. sinon.assert.calledOnce( forwardStub );
  650. sinon.assert.notCalled( backwardStub );
  651. setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
  652. fireKeyDownEvent( {
  653. keyCode: keyCodes.arrowleft
  654. } );
  655. sinon.assert.calledOnce( backwardStub );
  656. sinon.assert.calledOnce( forwardStub );
  657. } );
  658. it( 'should use the opposite helper methods (RTL content direction)', () => {
  659. const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
  660. const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
  661. let model;
  662. return VirtualTestEditor
  663. .create( {
  664. plugins: [ TwoStepCaretMovement ],
  665. language: {
  666. content: 'ar'
  667. }
  668. } )
  669. .then( newEditor => {
  670. model = newEditor.model;
  671. selection = model.document.selection;
  672. view = newEditor.editing.view;
  673. newEditor.model.schema.extend( '$text', {
  674. allowAttributes: [ 'a', 'b', 'c' ],
  675. allowIn: '$root'
  676. } );
  677. model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
  678. newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
  679. newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
  680. newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
  681. newEditor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  682. newEditor.plugins.get( TwoStepCaretMovement ).registerAttribute( 'a' );
  683. return newEditor;
  684. } )
  685. .then( newEditor => {
  686. setData( model, '<$text>foo[]</$text><$text a="true">bar</$text>' );
  687. fireKeyDownEvent( {
  688. keyCode: keyCodes.arrowleft
  689. } );
  690. sinon.assert.calledOnce( forwardStub );
  691. sinon.assert.notCalled( backwardStub );
  692. setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
  693. fireKeyDownEvent( {
  694. keyCode: keyCodes.arrowright
  695. } );
  696. sinon.assert.calledOnce( backwardStub );
  697. sinon.assert.calledOnce( forwardStub );
  698. return newEditor.destroy();
  699. } );
  700. } );
  701. } );
  702. const keyMap = {
  703. '→': 'arrowright',
  704. '←': 'arrowleft'
  705. };
  706. function fireKeyDownEvent( options ) {
  707. const eventInfo = new EventInfo( view.document, 'keydown' );
  708. const eventData = new DomEventData( view.document, {
  709. target: document.body
  710. }, options );
  711. sinon.stub( eventInfo, 'stop' ).callsFake( evtStopSpy );
  712. view.document.fire( eventInfo, eventData );
  713. }
  714. function getSelectionAttributesArray( selection ) {
  715. return Array.from( selection.getAttributeKeys() );
  716. }
  717. function testTwoStepCaretMovement( scenario ) {
  718. for ( const step of scenario ) {
  719. if ( typeof step == 'string' ) {
  720. // An arrow key pressed. Fire the view event and update the model selection.
  721. if ( keyMap[ step ] ) {
  722. let preventDefaultCalled;
  723. fireKeyDownEvent( {
  724. keyCode: keyCodes[ keyMap[ step ] ],
  725. preventDefault: () => {
  726. preventDefaultSpy();
  727. preventDefaultCalled = true;
  728. }
  729. } );
  730. const position = selection.getFirstPosition();
  731. if ( !preventDefaultCalled ) {
  732. if ( step == '→' ) {
  733. model.change( writer => {
  734. if ( position.isAtEnd ) {
  735. const nextBlock = position.parent.nextSibling;
  736. if ( nextBlock ) {
  737. writer.setSelection( Position._createAt( nextBlock, 0 ) );
  738. }
  739. } else {
  740. writer.setSelection( selection.getFirstPosition().getShiftedBy( 1 ) );
  741. }
  742. } );
  743. } else if ( step == '←' ) {
  744. model.change( writer => {
  745. if ( position.isAtStart ) {
  746. const previousBlock = position.parent.previousSibling;
  747. if ( previousBlock ) {
  748. writer.setSelection( Position._createAt( previousBlock, 'end' ) );
  749. }
  750. } else {
  751. writer.setSelection( selection.getFirstPosition().getShiftedBy( -1 ) );
  752. }
  753. } );
  754. }
  755. }
  756. }
  757. // A regular key pressed. Type some text in the model.
  758. else {
  759. model.change( writer => {
  760. writer.insertText( step, selection.getAttributes(), selection.getFirstPosition() );
  761. } );
  762. }
  763. }
  764. // If not a key, then it's an assertion.
  765. else {
  766. const stepIndex = scenario.indexOf( step );
  767. const stepString = `in step #${ stepIndex } ${ JSON.stringify( step ) }`;
  768. expect( getSelectionAttributesArray( selection ) ).to.have.members(
  769. step.selectionAttributes, `#attributes ${ stepString }` );
  770. expect( selection.isGravityOverridden ).to.equal( step.isGravityOverridden, `#isGravityOverridden ${ stepString }` );
  771. expect( preventDefaultSpy.callCount ).to.equal( step.preventDefault, `#preventDefault ${ stepString }` );
  772. expect( evtStopSpy.callCount ).to.equal( step.evtStopCalled, `#evtStopCalled ${ stepString }` );
  773. }
  774. }
  775. }
  776. } );