bindtwostepcarettoattribute.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. /**
  2. * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module engine/utils/bindtwostepcarettoattribute
  7. */
  8. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  9. /**
  10. * This helper enabled the two-step caret (phantom) movement behavior for the given {@link module:engine/model/model~Model}
  11. * attribute on arrow right (<kbd>→</kbd>) and left (<kbd>←</kbd>) key press.
  12. *
  13. * Thanks to this (phantom) caret movement the user is able to type before/after as well as at the
  14. * beginning/end of an attribute.
  15. *
  16. * # Forward movement
  17. *
  18. * ## "Entering" an attribute:
  19. *
  20. * When this behavior is enabled for the `a` attribute and the selection is right before it
  21. * (at the attribute boundary), pressing the right arrow key will not move the selection but update its
  22. * attributes accordingly:
  23. *
  24. * * When enabled:
  25. *
  26. * foo{}<$text a="true">bar</$text>
  27. *
  28. * <kbd>→</kbd>
  29. *
  30. * foo<$text a="true">{}bar</$text>
  31. *
  32. * * When disabled:
  33. *
  34. * foo{}<$text a="true">bar</$text>
  35. *
  36. * <kbd>→</kbd>
  37. *
  38. * foo<$text a="true">b{}ar</$text>
  39. *
  40. *
  41. * ## "Leaving" an attribute:
  42. *
  43. * * When enabled:
  44. *
  45. * <$text a="true">bar{}</$text>baz
  46. *
  47. * <kbd>→</kbd>
  48. *
  49. * <$text a="true">bar</$text>{}baz
  50. *
  51. * * When disabled:
  52. *
  53. * <$text a="true">bar{}</$text>baz
  54. *
  55. * <kbd>→</kbd>
  56. *
  57. * <$text a="true">bar</$text>b{}az
  58. *
  59. * # Backward movement
  60. *
  61. * * When enabled:
  62. *
  63. * <$text a="true">bar</$text>{}baz
  64. *
  65. * <kbd>←</kbd>
  66. *
  67. * <$text a="true">bar{}</$text>baz
  68. *
  69. * * When disabled:
  70. *
  71. * <$text a="true">bar</$text>{}baz
  72. *
  73. * <kbd>←</kbd>
  74. *
  75. * <$text a="true">ba{}r</$text>b{}az
  76. *
  77. * @param {module:engine/view/view~View} view View controller instance.
  78. * @param {module:engine/model/model~Model} model Data model instance.
  79. * @param {module:utils/dom/emittermixin~Emitter} emitter The emitter to which this behavior should be added
  80. * (e.g. a plugin instance).
  81. * @param {String} attribute Attribute for which this behavior will be added.
  82. */
  83. export default function bindTwoStepCaretToAttribute( view, model, emitter, attribute ) {
  84. const twoStepCaretHandler = new TwoStepCaretHandler( model, emitter, attribute );
  85. const modelSelection = model.document.selection;
  86. // Listen to keyboard events and handle cursor before the move.
  87. emitter.listenTo( view.document, 'keydown', ( evt, data ) => {
  88. // This implementation works only for collapsed selection.
  89. if ( !modelSelection.isCollapsed ) {
  90. return;
  91. }
  92. // When user tries to expand the selection or jump over the whole word or to the beginning/end then
  93. // two-steps movement is not necessary.
  94. if ( data.shiftKey || data.altKey || data.ctrlKey ) {
  95. return;
  96. }
  97. const arrowRightPressed = data.keyCode == keyCodes.arrowright;
  98. const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
  99. // When neither left or right arrow has been pressed then do noting.
  100. if ( !arrowRightPressed && !arrowLeftPressed ) {
  101. return;
  102. }
  103. const position = modelSelection.getFirstPosition();
  104. if ( arrowRightPressed ) {
  105. twoStepCaretHandler.handleForwardMovement( position, data );
  106. } else {
  107. twoStepCaretHandler.handleBackwardMovement( position, data );
  108. }
  109. } );
  110. }
  111. /**
  112. * This is a private helper–class for {@link module:engine/utils/bindtwostepcarettoattribute}.
  113. * It handles the state of the 2-step caret movement for a single {@link module:engine/model/model~Model}
  114. * attribute upon the `keypress` in the {@link module:engine/view/view~View}.
  115. *
  116. * @private
  117. */
  118. class TwoStepCaretHandler {
  119. /*
  120. * Creates two step handler instance.
  121. *
  122. * @param {module:engine/model/model~Model} model Data model instance.
  123. * @param {module:utils/dom/emittermixin~Emitter} emitter The emitter to which this behavior should be added
  124. * (e.g. a plugin instance).
  125. * @param {String} attribute Attribute for which the behavior will be added.
  126. */
  127. constructor( model, emitter, attribute ) {
  128. /**
  129. * The model instance this class instance operates on.
  130. *
  131. * @readonly
  132. * @member {module:engine/model/model~Model#schema}
  133. */
  134. this.model = model;
  135. /**
  136. * The Attribute this class instance operates on.
  137. *
  138. * @readonly
  139. * @member {String}
  140. */
  141. this.attribute = attribute;
  142. /**
  143. * A reference to the document selection.
  144. *
  145. * @private
  146. * @member {module:engine/model/selection~Selection}
  147. */
  148. this._modelSelection = model.document.selection;
  149. /**
  150. * The current UID of the overridden gravity, as returned by
  151. * {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
  152. *
  153. * @private
  154. * @member {String}
  155. */
  156. this._overrideUid = null;
  157. /**
  158. * A flag indicating that the automatic gravity restoration for this attribute
  159. * should not happen upon the next
  160. * {@link module:engine/model/selection~Selection#event:change:range} event.
  161. *
  162. * @private
  163. * @member {String}
  164. */
  165. this._isNextGravityRestorationSkipped = false;
  166. // The automatic gravity restoration logic.
  167. emitter.listenTo( this._modelSelection, 'change:range', ( evt, data ) => {
  168. // Skipping the automatic restoration is needed if the selection should change
  169. // but the gravity must remain overridden afterwards. See the #handleBackwardMovement
  170. // to learn more.
  171. if ( this._isNextGravityRestorationSkipped ) {
  172. this._isNextGravityRestorationSkipped = false;
  173. return;
  174. }
  175. // Skip automatic restore when the gravity is not overridden — simply, there's nothing to restore
  176. // at this moment.
  177. if ( !this._isGravityOverridden ) {
  178. return;
  179. }
  180. // Skip automatic restore when the change is indirect AND the selection is at the attribute boundary.
  181. // It means that e.g. if the change was external (collaboration) and the user had their
  182. // selection around the link, its gravity should remain intact in this change:range event.
  183. if ( !data.directChange && isAtBoundary( this._modelSelection.getFirstPosition(), attribute ) ) {
  184. return;
  185. }
  186. this._restoreGravity();
  187. } );
  188. }
  189. /**
  190. * Updates the document selection and the view according to the two–step caret movement state
  191. * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
  192. *
  193. * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
  194. * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
  195. */
  196. handleForwardMovement( position, data ) {
  197. const attribute = this.attribute;
  198. // DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
  199. //
  200. // <paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
  201. //
  202. // or left the attribute
  203. //
  204. // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
  205. //
  206. // and the gravity will be restored automatically.
  207. if ( this._isGravityOverridden ) {
  208. return;
  209. }
  210. // DON'T ENGAGE 2-SCM when the selection is at the beginning of an attribute AND already has it:
  211. // * when the selection was initially set there using the mouse,
  212. // * when the editor has just started
  213. //
  214. // <paragraph><$text attribute>{}bar</$text>baz</paragraph>
  215. //
  216. if ( position.isAtStart && this._hasAttribute ) {
  217. return;
  218. }
  219. // ENGAGE 2-SCM when about to leave one attribute value and enter another:
  220. //
  221. // <paragraph><$text attribute="1">foo{}</$text><$text attribute="2">bar</$text></paragraph>
  222. //
  223. // but DON'T when already in between of them (no attribute selection):
  224. //
  225. // <paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
  226. //
  227. if ( isBetweenDifferentValues( position, attribute ) && this._hasAttribute ) {
  228. this._preventCaretMovement( data );
  229. this._removeSelectionAttribute();
  230. } else {
  231. // ENGAGE 2-SCM when entering an attribute:
  232. //
  233. // <paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
  234. //
  235. if ( isAtStartBoundary( position, attribute ) ) {
  236. this._preventCaretMovement( data );
  237. this._overrideGravity();
  238. return;
  239. }
  240. // ENGAGE 2-SCM when leaving an attribute:
  241. //
  242. // <paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
  243. //
  244. if ( isAtEndBoundary( position, attribute ) && this._hasAttribute ) {
  245. this._preventCaretMovement( data );
  246. this._overrideGravity();
  247. }
  248. }
  249. }
  250. /**
  251. * Updates the document selection and the view according to the two–step caret movement state
  252. * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
  253. *
  254. * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
  255. * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
  256. */
  257. handleBackwardMovement( position, data ) {
  258. const attribute = this.attribute;
  259. // When the gravity is already overridden...
  260. if ( this._isGravityOverridden ) {
  261. // ENGAGE 2-SCM & REMOVE SELECTION ATTRIBUTE
  262. // when about to leave one attribute value and enter another:
  263. //
  264. // <paragraph><$text attribute="1">foo</$text><$text attribute="2">{}bar</$text></paragraph>
  265. //
  266. // but DON'T when already in between of them (no attribute selection):
  267. //
  268. // <paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
  269. //
  270. if ( isBetweenDifferentValues( position, attribute ) && this._hasAttribute ) {
  271. this._preventCaretMovement( data );
  272. this._restoreGravity();
  273. this._removeSelectionAttribute();
  274. }
  275. // ENGAGE 2-SCM when at any boundary of the attribute:
  276. //
  277. // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
  278. // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
  279. //
  280. else {
  281. this._preventCaretMovement( data );
  282. this._restoreGravity();
  283. // REMOVE SELECTION ATRIBUTE at the beginning of the block.
  284. // It's like restoring gravity but towards a non-existent content when
  285. // the gravity is overridden:
  286. //
  287. // <paragraph><$text attribute>{}bar</$text></paragraph>
  288. //
  289. // becomes:
  290. //
  291. // <paragraph>{}<$text attribute>bar</$text></paragraph>
  292. //
  293. if ( position.isAtStart ) {
  294. this._removeSelectionAttribute();
  295. }
  296. }
  297. } else {
  298. // ENGAGE 2-SCM when between two different attribute values but selection has no attribute:
  299. //
  300. // <paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
  301. //
  302. if ( isBetweenDifferentValues( position, attribute ) && !this._hasAttribute ) {
  303. this._preventCaretMovement( data );
  304. this._setSelectionAttributeFromTheNodeBefore( position );
  305. return;
  306. }
  307. // DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we have already entered
  308. //
  309. // <paragraph><$text attribute>bar{}</$text></paragraph>
  310. //
  311. if ( position.isAtEnd && isAtBoundary( position, attribute ) ) {
  312. if ( this._hasAttribute ) {
  313. return;
  314. } else {
  315. this._preventCaretMovement( data );
  316. this._setSelectionAttributeFromTheNodeBefore( position );
  317. return;
  318. }
  319. }
  320. // REMOVE SELECTION ATRIBUTE when restoring gravity towards a non-existent content at the
  321. // beginning of the block.
  322. //
  323. // <paragraph>{}<$text attribute>bar</$text></paragraph>
  324. //
  325. if ( position.isAtStart && isAtBoundary( position, attribute ) ) {
  326. if ( this._hasAttribute ) {
  327. this._removeSelectionAttribute();
  328. return;
  329. }
  330. return;
  331. }
  332. // DON'T ENGAGE 2-SCM when about to enter of leave an attribute.
  333. // We need to check if the caret is a one position before the attribute boundary:
  334. //
  335. // <paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
  336. // <paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
  337. //
  338. if ( isAtBoundary( position.getShiftedBy( -1 ), attribute ) ) {
  339. // Skip the automatic gravity restore upon the next selection#change:range event.
  340. // If not skipped, it would automatically restore the gravity, which should remain
  341. // overridden.
  342. this._skipNextAutomaticGravityRestoration();
  343. this._overrideGravity();
  344. }
  345. }
  346. }
  347. /**
  348. * `true` when the gravity is overridden for the {@link #attribute}.
  349. *
  350. * @readonly
  351. * @private
  352. * @type {Boolean}
  353. */
  354. get _isGravityOverridden() {
  355. return !!this._overrideUid;
  356. }
  357. /**
  358. * `true` when the {@link module:engine/model/selection~Selection} has the {@link #attribute}.
  359. *
  360. * @readonly
  361. * @private
  362. * @type {Boolean}
  363. */
  364. get _hasAttribute() {
  365. return this._modelSelection.hasAttribute( this.attribute );
  366. }
  367. /**
  368. * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer}
  369. * and stores the information about this fact in the {@link #_overrideUid}.
  370. *
  371. * A shorthand for {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
  372. *
  373. * @private
  374. */
  375. _overrideGravity() {
  376. this._overrideUid = this.model.change( writer => writer.overrideSelectionGravity() );
  377. }
  378. /**
  379. * Restores the gravity using the {@link module:engine/model/writer~Writer model writer}.
  380. *
  381. * A shorthand for {@link module:engine/model/writer~Writer#restoreSelectionGravity}.
  382. *
  383. * @private
  384. */
  385. _restoreGravity() {
  386. this.model.change( writer => {
  387. writer.restoreSelectionGravity( this._overrideUid );
  388. this._overrideUid = null;
  389. } );
  390. }
  391. /**
  392. * Prevents the caret movement in the view by calling `preventDefault` on the event data.
  393. *
  394. * @private
  395. */
  396. _preventCaretMovement( data ) {
  397. data.preventDefault();
  398. }
  399. /**
  400. * Removes the {@link #attribute} from the selection using using the
  401. * {@link module:engine/model/writer~Writer model writer}.
  402. *
  403. * @private
  404. */
  405. _removeSelectionAttribute() {
  406. this.model.change( writer => {
  407. writer.removeSelectionAttribute( this.attribute );
  408. } );
  409. }
  410. /**
  411. * Applies the {@link #attribute} to the current selection using using the
  412. * value from the node before the current position. Uses
  413. * the {@link module:engine/model/writer~Writer model writer}.
  414. *
  415. * @private
  416. * @param {module:engine/model/position~Position} position
  417. */
  418. _setSelectionAttributeFromTheNodeBefore( position ) {
  419. const attribute = this.attribute;
  420. this.model.change( writer => {
  421. writer.setSelectionAttribute( this.attribute, position.nodeBefore.getAttribute( attribute ) );
  422. } );
  423. }
  424. /**
  425. * Skips the next automatic selection gravity restoration upon the
  426. * {@link module:engine/model/selection~Selection#event:change:range} event.
  427. *
  428. * See {@link #_isNextGravityRestorationSkipped}.
  429. *
  430. * @private
  431. */
  432. _skipNextAutomaticGravityRestoration() {
  433. this._isNextGravityRestorationSkipped = true;
  434. }
  435. }
  436. // @param {module:engine/model/position~Position} position
  437. // @param {String} attribute
  438. // @returns {Boolean} `true` when position between the nodes sticks to the bound of text with given attribute.
  439. function isAtBoundary( position, attribute ) {
  440. return isAtStartBoundary( position, attribute ) || isAtEndBoundary( position, attribute );
  441. }
  442. // @param {module:engine/model/position~Position} position
  443. // @param {String} attribute
  444. function isAtStartBoundary( position, attribute ) {
  445. const prevNode = position.nodeBefore;
  446. const nextNode = position.nodeAfter;
  447. const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
  448. const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
  449. if ( ( !isAttrInPrev && isAttrInNext ) || isBetweenDifferentValues( position, attribute ) ) {
  450. return true;
  451. }
  452. return false;
  453. }
  454. // @param {module:engine/model/position~Position} position
  455. // @param {String} attribute
  456. function isAtEndBoundary( position, attribute ) {
  457. const prevNode = position.nodeBefore;
  458. const nextNode = position.nodeAfter;
  459. const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
  460. const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
  461. if ( ( isAttrInPrev && !isAttrInNext ) || isBetweenDifferentValues( position, attribute ) ) {
  462. return true;
  463. }
  464. return false;
  465. }
  466. // @param {module:engine/model/position~Position} position
  467. // @param {String} attribute
  468. function isBetweenDifferentValues( position, attribute ) {
  469. const prevNode = position.nodeBefore;
  470. const nextNode = position.nodeAfter;
  471. const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
  472. const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
  473. if ( !isAttrInPrev || !isAttrInNext ) {
  474. return;
  475. }
  476. return nextNode.getAttribute( attribute ) !== prevNode.getAttribute( attribute );
  477. }