8
0

bindtwostepcarettoattribute.js 38 KB

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