bindtwostepcarettoattribute.js 20 KB

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