| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596 |
- /**
- * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
- /**
- * @module engine/utils/bindtwostepcarettoattribute
- */
- import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
- import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
- /**
- * This helper enables the two-step caret (phantom) movement behavior for the given {@link module:engine/model/model~Model}
- * attribute on arrow right (<kbd>→</kbd>) and left (<kbd>←</kbd>) key press.
- *
- * Thanks to this (phantom) caret movement the user is able to type before/after as well as at the
- * beginning/end of an attribute.
- *
- * **Note:** This helper support right–to–left (Arabic, Hebrew, etc.) content by mirroring its behavior
- * but for the sake of simplicity examples showcase only left–to–right use–cases.
- *
- * # Forward movement
- *
- * ## "Entering" an attribute:
- *
- * When this behavior is enabled for the `a` attribute and the selection is right before it
- * (at the attribute boundary), pressing the right arrow key will not move the selection but update its
- * attributes accordingly:
- *
- * * When enabled:
- *
- * foo{}<$text a="true">bar</$text>
- *
- * <kbd>→</kbd>
- *
- * foo<$text a="true">{}bar</$text>
- *
- * * When disabled:
- *
- * foo{}<$text a="true">bar</$text>
- *
- * <kbd>→</kbd>
- *
- * foo<$text a="true">b{}ar</$text>
- *
- *
- * ## "Leaving" an attribute:
- *
- * * When enabled:
- *
- * <$text a="true">bar{}</$text>baz
- *
- * <kbd>→</kbd>
- *
- * <$text a="true">bar</$text>{}baz
- *
- * * When disabled:
- *
- * <$text a="true">bar{}</$text>baz
- *
- * <kbd>→</kbd>
- *
- * <$text a="true">bar</$text>b{}az
- *
- * # Backward movement
- *
- * * When enabled:
- *
- * <$text a="true">bar</$text>{}baz
- *
- * <kbd>←</kbd>
- *
- * <$text a="true">bar{}</$text>baz
- *
- * * When disabled:
- *
- * <$text a="true">bar</$text>{}baz
- *
- * <kbd>←</kbd>
- *
- * <$text a="true">ba{}r</$text>b{}az
- *
- * @param {Object} options Helper options.
- * @param {module:engine/view/view~View} options.view View controller instance.
- * @param {module:engine/model/model~Model} options.model Data model instance.
- * @param {module:utils/dom/emittermixin~Emitter} options.emitter The emitter to which this behavior should be added
- * (e.g. a plugin instance).
- * @param {String} options.attribute Attribute for which this behavior will be added.
- * @param {module:utils/locale~Locale} options.locale The {@link module:core/editor/editor~Editor#locale} instance.
- */
- export default function bindTwoStepCaretToAttribute( { view, model, emitter, attribute, locale } ) {
- const twoStepCaretHandler = new TwoStepCaretHandler( model, emitter, attribute );
- const modelSelection = model.document.selection;
- // Listen to keyboard events and handle the caret movement according to the 2-step caret logic.
- //
- // Note: This listener has the "high+1" priority:
- // * "high" because of the filler logic implemented in the renderer which also engages on #keydown.
- // When the gravity is overridden the attributes of the (model) selection attributes are reset.
- // It may end up with the filler kicking in and breaking the selection.
- // * "+1" because we would like to avoid collisions with other features (like Widgets), which
- // take over the keydown events with the "high" priority. Two-step caret movement takes precedence
- // over Widgets in that matter.
- //
- // Find out more in https://github.com/ckeditor/ckeditor5-engine/issues/1301.
- emitter.listenTo( view.document, 'keydown', ( evt, data ) => {
- // This implementation works only for collapsed selection.
- if ( !modelSelection.isCollapsed ) {
- return;
- }
- // When user tries to expand the selection or jump over the whole word or to the beginning/end then
- // two-steps movement is not necessary.
- if ( data.shiftKey || data.altKey || data.ctrlKey ) {
- return;
- }
- const arrowRightPressed = data.keyCode == keyCodes.arrowright;
- const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
- // When neither left or right arrow has been pressed then do noting.
- if ( !arrowRightPressed && !arrowLeftPressed ) {
- return;
- }
- const position = modelSelection.getFirstPosition();
- const contentDirection = locale.contentLanguageDirection;
- let isMovementHandled;
- if ( ( contentDirection === 'ltr' && arrowRightPressed ) || ( contentDirection === 'rtl' && arrowLeftPressed ) ) {
- isMovementHandled = twoStepCaretHandler.handleForwardMovement( position, data );
- } else {
- isMovementHandled = twoStepCaretHandler.handleBackwardMovement( position, data );
- }
- // Stop the keydown event if the two-step caret movement handled it. Avoid collisions
- // with other features which may also take over the caret movement (e.g. Widget).
- if ( isMovementHandled ) {
- evt.stop();
- }
- }, { priority: priorities.get( 'high' ) + 1 } );
- }
- /**
- * This is a protected helper–class for {@link module:engine/utils/bindtwostepcarettoattribute}.
- * It handles the state of the 2-step caret movement for a single {@link module:engine/model/model~Model}
- * attribute upon the `keypress` in the {@link module:engine/view/view~View}.
- *
- * @protected
- */
- export class TwoStepCaretHandler {
- /*
- * Creates two step handler instance.
- *
- * @param {module:engine/model/model~Model} model Data model instance.
- * @param {module:utils/dom/emittermixin~Emitter} emitter The emitter to which this behavior should be added
- * (e.g. a plugin instance).
- * @param {String} attribute Attribute for which the behavior will be added.
- */
- constructor( model, emitter, attribute ) {
- /**
- * The model instance this class instance operates on.
- *
- * @readonly
- * @member {module:engine/model/model~Model#schema}
- */
- this.model = model;
- /**
- * The Attribute this class instance operates on.
- *
- * @readonly
- * @member {String}
- */
- this.attribute = attribute;
- /**
- * A reference to the document selection.
- *
- * @private
- * @member {module:engine/model/selection~Selection}
- */
- this._modelSelection = model.document.selection;
- /**
- * The current UID of the overridden gravity, as returned by
- * {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
- *
- * @private
- * @member {String}
- */
- this._overrideUid = null;
- /**
- * A flag indicating that the automatic gravity restoration for this attribute
- * should not happen upon the next
- * {@link module:engine/model/selection~Selection#event:change:range} event.
- *
- * @private
- * @member {String}
- */
- this._isNextGravityRestorationSkipped = false;
- // The automatic gravity restoration logic.
- emitter.listenTo( this._modelSelection, 'change:range', ( evt, data ) => {
- // Skipping the automatic restoration is needed if the selection should change
- // but the gravity must remain overridden afterwards. See the #handleBackwardMovement
- // to learn more.
- if ( this._isNextGravityRestorationSkipped ) {
- this._isNextGravityRestorationSkipped = false;
- return;
- }
- // Skip automatic restore when the gravity is not overridden — simply, there's nothing to restore
- // at this moment.
- if ( !this._isGravityOverridden ) {
- return;
- }
- // Skip automatic restore when the change is indirect AND the selection is at the attribute boundary.
- // It means that e.g. if the change was external (collaboration) and the user had their
- // selection around the link, its gravity should remain intact in this change:range event.
- if ( !data.directChange && isAtBoundary( this._modelSelection.getFirstPosition(), attribute ) ) {
- return;
- }
- this._restoreGravity();
- } );
- }
- /**
- * Updates the document selection and the view according to the two–step caret movement state
- * when moving **forwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
- *
- * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
- * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
- * @returns {Boolean} `true` when the handler prevented caret movement
- */
- handleForwardMovement( position, data ) {
- const attribute = this.attribute;
- // DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
- //
- // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
- //
- // or left the attribute
- //
- // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
- //
- // and the gravity will be restored automatically.
- if ( this._isGravityOverridden ) {
- return;
- }
- // DON'T ENGAGE 2-SCM when the selection is at the beginning of the block AND already has the
- // attribute:
- // * when the selection was initially set there using the mouse,
- // * when the editor has just started
- //
- // <paragraph><$text attribute>{}bar</$text>baz</paragraph>
- //
- if ( position.isAtStart && this._hasSelectionAttribute ) {
- return;
- }
- // ENGAGE 2-SCM when about to leave one attribute value and enter another:
- //
- // <paragraph><$text attribute="1">foo{}</$text><$text attribute="2">bar</$text></paragraph>
- //
- // but DON'T when already in between of them (no attribute selection):
- //
- // <paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
- //
- if ( isBetweenDifferentValues( position, attribute ) && this._hasSelectionAttribute ) {
- this._preventCaretMovement( data );
- this._removeSelectionAttribute();
- return true;
- }
- // ENGAGE 2-SCM when entering an attribute:
- //
- // <paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
- //
- if ( isAtStartBoundary( position, attribute ) ) {
- this._preventCaretMovement( data );
- this._overrideGravity();
- return true;
- }
- // ENGAGE 2-SCM when leaving an attribute:
- //
- // <paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
- //
- if ( isAtEndBoundary( position, attribute ) && this._hasSelectionAttribute ) {
- this._preventCaretMovement( data );
- this._overrideGravity();
- return true;
- }
- }
- /**
- * Updates the document selection and the view according to the two–step caret movement state
- * when moving **backwards**. Executed upon `keypress` in the {@link module:engine/view/view~View}.
- *
- * @param {module:engine/model/position~Position} position The model position at the moment of the key press.
- * @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
- * @returns {Boolean} `true` when the handler prevented caret movement
- */
- handleBackwardMovement( position, data ) {
- const attribute = this.attribute;
- // When the gravity is already overridden...
- if ( this._isGravityOverridden ) {
- // ENGAGE 2-SCM & REMOVE SELECTION ATTRIBUTE
- // when about to leave one attribute value and enter another:
- //
- // <paragraph><$text attribute="1">foo</$text><$text attribute="2">{}bar</$text></paragraph>
- //
- // but DON'T when already in between of them (no attribute selection):
- //
- // <paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
- //
- if ( isBetweenDifferentValues( position, attribute ) && this._hasSelectionAttribute ) {
- this._preventCaretMovement( data );
- this._restoreGravity();
- this._removeSelectionAttribute();
- return true;
- }
- // ENGAGE 2-SCM when at any boundary of the attribute:
- //
- // <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
- // <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
- //
- else {
- this._preventCaretMovement( data );
- this._restoreGravity();
- // REMOVE SELECTION ATRIBUTE at the beginning of the block.
- // It's like restoring gravity but towards a non-existent content when
- // the gravity is overridden:
- //
- // <paragraph><$text attribute>{}bar</$text></paragraph>
- //
- // becomes:
- //
- // <paragraph>{}<$text attribute>bar</$text></paragraph>
- //
- if ( position.isAtStart ) {
- this._removeSelectionAttribute();
- }
- return true;
- }
- } else {
- // ENGAGE 2-SCM when between two different attribute values but selection has no attribute:
- //
- // <paragraph><$text attribute="1">foo</$text>{}<$text attribute="2">bar</$text></paragraph>
- //
- if ( isBetweenDifferentValues( position, attribute ) && !this._hasSelectionAttribute ) {
- this._preventCaretMovement( data );
- this._setSelectionAttributeFromTheNodeBefore( position );
- return true;
- }
- // End of block boundary cases:
- //
- // <paragraph><$text attribute>bar{}</$text></paragraph>
- // <paragraph><$text attribute>bar</$text>{}</paragraph>
- //
- if ( position.isAtEnd && isAtEndBoundary( position, attribute ) ) {
- // DON'T ENGAGE 2-SCM if the selection has the attribute already.
- // This is a common selection if set using the mouse.
- //
- // <paragraph><$text attribute>bar{}</$text></paragraph>
- //
- if ( this._hasSelectionAttribute ) {
- // DON'T ENGAGE 2-SCM if the attribute at the end of the block which has length == 1.
- // Make sure the selection will not the attribute after it moves backwards.
- //
- // <paragraph>foo<$text attribute>b{}</$text></paragraph>
- //
- if ( isStepAfterTheAttributeBoundary( position, attribute ) ) {
- // Skip the automatic gravity restore upon the next selection#change:range event.
- // If not skipped, it would automatically restore the gravity, which should remain
- // overridden.
- this._skipNextAutomaticGravityRestoration();
- this._overrideGravity();
- // Don't return "true" here because we didn't call _preventCaretMovement.
- // Returning here will destabilize the filler logic, which also listens to
- // keydown (and the event would be stopped).
- }
- return;
- }
- // ENGAGE 2-SCM if the selection has no attribute. This may happen when the user
- // left the attribute using a FORWARD 2-SCM.
- //
- // <paragraph><$text attribute>bar</$text>{}</paragraph>
- //
- else {
- this._preventCaretMovement( data );
- this._setSelectionAttributeFromTheNodeBefore( position );
- return true;
- }
- }
- // REMOVE SELECTION ATRIBUTE when restoring gravity towards a non-existent content at the
- // beginning of the block.
- //
- // <paragraph>{}<$text attribute>bar</$text></paragraph>
- //
- if ( position.isAtStart ) {
- if ( this._hasSelectionAttribute ) {
- this._removeSelectionAttribute();
- this._preventCaretMovement( data );
- return true;
- }
- return;
- }
- // DON'T ENGAGE 2-SCM when about to enter of leave an attribute.
- // We need to check if the caret is a one position before the attribute boundary:
- //
- // <paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
- // <paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
- //
- if ( isStepAfterTheAttributeBoundary( position, attribute ) ) {
- // Skip the automatic gravity restore upon the next selection#change:range event.
- // If not skipped, it would automatically restore the gravity, which should remain
- // overridden.
- this._skipNextAutomaticGravityRestoration();
- this._overrideGravity();
- // Don't return "true" here because we didn't call _preventCaretMovement.
- // Returning here will destabilize the filler logic, which also listens to
- // keydown (and the event would be stopped).
- }
- }
- }
- /**
- * `true` when the gravity is overridden for the {@link #attribute}.
- *
- * @readonly
- * @private
- * @type {Boolean}
- */
- get _isGravityOverridden() {
- return !!this._overrideUid;
- }
- /**
- * `true` when the {@link module:engine/model/selection~Selection} has the {@link #attribute}.
- *
- * @readonly
- * @private
- * @type {Boolean}
- */
- get _hasSelectionAttribute() {
- return this._modelSelection.hasAttribute( this.attribute );
- }
- /**
- * Overrides the gravity using the {@link module:engine/model/writer~Writer model writer}
- * and stores the information about this fact in the {@link #_overrideUid}.
- *
- * A shorthand for {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
- *
- * @private
- */
- _overrideGravity() {
- this._overrideUid = this.model.change( writer => writer.overrideSelectionGravity() );
- }
- /**
- * Restores the gravity using the {@link module:engine/model/writer~Writer model writer}.
- *
- * A shorthand for {@link module:engine/model/writer~Writer#restoreSelectionGravity}.
- *
- * @private
- */
- _restoreGravity() {
- this.model.change( writer => {
- writer.restoreSelectionGravity( this._overrideUid );
- this._overrideUid = null;
- } );
- }
- /**
- * Prevents the caret movement in the view by calling `preventDefault` on the event data.
- *
- * @private
- */
- _preventCaretMovement( data ) {
- data.preventDefault();
- }
- /**
- * Removes the {@link #attribute} from the selection using using the
- * {@link module:engine/model/writer~Writer model writer}.
- *
- * @private
- */
- _removeSelectionAttribute() {
- this.model.change( writer => {
- writer.removeSelectionAttribute( this.attribute );
- } );
- }
- /**
- * Applies the {@link #attribute} to the current selection using using the
- * value from the node before the current position. Uses
- * the {@link module:engine/model/writer~Writer model writer}.
- *
- * @private
- * @param {module:engine/model/position~Position} position
- */
- _setSelectionAttributeFromTheNodeBefore( position ) {
- const attribute = this.attribute;
- this.model.change( writer => {
- writer.setSelectionAttribute( this.attribute, position.nodeBefore.getAttribute( attribute ) );
- } );
- }
- /**
- * Skips the next automatic selection gravity restoration upon the
- * {@link module:engine/model/selection~Selection#event:change:range} event.
- *
- * See {@link #_isNextGravityRestorationSkipped}.
- *
- * @private
- */
- _skipNextAutomaticGravityRestoration() {
- this._isNextGravityRestorationSkipped = true;
- }
- }
- // @param {module:engine/model/position~Position} position
- // @param {String} attribute
- // @returns {Boolean} `true` when position between the nodes sticks to the bound of text with given attribute.
- function isAtBoundary( position, attribute ) {
- return isAtStartBoundary( position, attribute ) || isAtEndBoundary( position, attribute );
- }
- // @param {module:engine/model/position~Position} position
- // @param {String} attribute
- function isAtStartBoundary( position, attribute ) {
- const { nodeBefore, nodeAfter } = position;
- const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
- const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
- return isAttrAfter && ( !isAttrBefore || nodeBefore.getAttribute( attribute ) !== nodeAfter.getAttribute( attribute ) );
- }
- // @param {module:engine/model/position~Position} position
- // @param {String} attribute
- function isAtEndBoundary( position, attribute ) {
- const { nodeBefore, nodeAfter } = position;
- const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
- const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
- return isAttrBefore && ( !isAttrAfter || nodeBefore.getAttribute( attribute ) !== nodeAfter.getAttribute( attribute ) );
- }
- // @param {module:engine/model/position~Position} position
- // @param {String} attribute
- function isBetweenDifferentValues( position, attribute ) {
- const { nodeBefore, nodeAfter } = position;
- const isAttrBefore = nodeBefore ? nodeBefore.hasAttribute( attribute ) : false;
- const isAttrAfter = nodeAfter ? nodeAfter.hasAttribute( attribute ) : false;
- if ( !isAttrAfter || !isAttrBefore ) {
- return;
- }
- return nodeAfter.getAttribute( attribute ) !== nodeBefore.getAttribute( attribute );
- }
- // @param {module:engine/model/position~Position} position
- // @param {String} attribute
- function isStepAfterTheAttributeBoundary( position, attribute ) {
- return isAtBoundary( position.getShiftedBy( -1 ), attribute );
- }
|