/**
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md.
*/
/**
* @module engine/utils/bindtwostepcarettoattribute
*/
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
/**
* This helper enabled the two-step caret (phantom) movement behavior for the given {@link module:engine/model/model~Model}
* attribute on arrow right (→) and left (←) 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.
*
* # 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>
*
* →
*
* foo<$text a="true">{}bar$text>
*
* * When disabled:
*
* foo{}<$text a="true">bar$text>
*
* →
*
* foo<$text a="true">b{}ar$text>
*
*
* ## "Leaving" an attribute:
*
* * When enabled:
*
* <$text a="true">bar{}$text>baz
*
* →
*
* <$text a="true">bar$text>{}baz
*
* * When disabled:
*
* <$text a="true">bar{}$text>baz
*
* →
*
* <$text a="true">bar$text>b{}az
*
* # Backward movement
*
* * When enabled:
*
* <$text a="true">bar$text>{}baz
*
* ←
*
* <$text a="true">bar{}$text>baz
*
* * When disabled:
*
* <$text a="true">bar$text>{}baz
*
* ←
*
* <$text a="true">ba{}r$text>b{}az
*
* @param {module:engine/view/view~View} view View controller 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 this behavior will be added.
*/
export default function bindTwoStepCaretToAttribute( view, model, emitter, attribute ) {
const twoStepCaretHandler = new TwoStepCaretHandler( model, emitter, attribute );
const modelSelection = model.document.selection;
// Listen to keyboard events and handle cursor before the move.
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();
if ( arrowRightPressed ) {
twoStepCaretHandler.handleForwardMovement( position, data );
} else {
twoStepCaretHandler.handleBackwardMovement( position, data );
}
} );
}
/**
* This is a private 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}.
*
* @private
*/
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.
*/
handleForwardMovement( position, data ) {
const attribute = this.attribute;
// DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we just entered
//
// foo{}<$text attribute>bar$text>baz
//
// or left the attribute
//
// foo<$text attribute>bar$text>{}baz
//
// and the gravity will be restored automatically.
if ( this._isGravityOverridden ) {
return;
}
// DON'T ENGAGE 2-SCM when the selection is at the beginning of an attribute AND already has it:
// * when the selection was initially set there using the mouse,
// * when the editor has just started
//
// <$text attribute>{}bar$text>baz
//
if ( position.isAtStart && this._hasAttribute ) {
return;
}
// ENGAGE 2-SCM when about to leave one attribute value and enter another:
//
// <$text attribute="1">foo{}$text><$text attribute="2">bar$text>
//
// but DON'T when already in between of them (no attribute selection):
//
// <$text attribute="1">foo$text>{}<$text attribute="2">bar$text>
//
if ( isBetweenDifferentValues( position, attribute ) && this._hasAttribute ) {
this._preventCaretMovement( data );
this._removeSelectionAttribute();
} else {
// ENGAGE 2-SCM when entering an attribute:
//
// foo{}<$text attribute>bar$text>baz
//
if ( isAtStartBoundary( position, attribute ) ) {
this._preventCaretMovement( data );
this._overrideGravity();
return;
}
// ENGAGE 2-SCM when leaving an attribute:
//
// foo<$text attribute>bar{}$text>baz
//
if ( isAtEndBoundary( position, attribute ) && this._hasAttribute ) {
this._preventCaretMovement( data );
this._overrideGravity();
}
}
}
/**
* 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.
*/
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:
//
// <$text attribute="1">foo$text><$text attribute="2">{}bar$text>
//
// but DON'T when already in between of them (no attribute selection):
//
// <$text attribute="1">foo$text>{}<$text attribute="2">bar$text>
//
if ( isBetweenDifferentValues( position, attribute ) && this._hasAttribute ) {
this._preventCaretMovement( data );
this._restoreGravity();
this._removeSelectionAttribute();
}
// ENGAGE 2-SCM when at any boundary of the attribute:
//
// foo<$text attribute>bar$text>{}baz
// foo<$text attribute>{}bar$text>baz
//
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:
//
// <$text attribute>{}bar$text>
//
// becomes:
//
// {}<$text attribute>bar$text>
//
if ( position.isAtStart ) {
this._removeSelectionAttribute();
}
}
} else {
// ENGAGE 2-SCM when between two different attribute values but selection has no attribute:
//
// <$text attribute="1">foo$text>{}<$text attribute="2">bar$text>
//
if ( isBetweenDifferentValues( position, attribute ) && !this._hasAttribute ) {
this._preventCaretMovement( data );
this._setSelectionAttributeFromTheNodeBefore( position );
return;
}
// DON'T ENGAGE 2-SCM if gravity is already overridden. It means that we have already entered
//
// <$text attribute>bar{}$text>
//
if ( position.isAtEnd && isAtBoundary( position, attribute ) ) {
if ( this._hasAttribute ) {
return;
} else {
this._preventCaretMovement( data );
this._setSelectionAttributeFromTheNodeBefore( position );
return;
}
}
// REMOVE SELECTION ATRIBUTE when restoring gravity towards a non-existent content at the
// beginning of the block.
//
// {}<$text attribute>bar$text>
//
if ( position.isAtStart && isAtBoundary( position, attribute ) ) {
if ( this._hasAttribute ) {
this._removeSelectionAttribute();
return;
}
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:
//
// foo<$text attribute>b{}ar$text>baz
// foo<$text attribute>bar$text>b{}az
//
if ( isAtBoundary( position.getShiftedBy( -1 ), 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();
}
}
}
/**
* `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 _hasAttribute() {
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 prevNode = position.nodeBefore;
const nextNode = position.nodeAfter;
const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
if ( ( !isAttrInPrev && isAttrInNext ) || isBetweenDifferentValues( position, attribute ) ) {
return true;
}
return false;
}
// @param {module:engine/model/position~Position} position
// @param {String} attribute
function isAtEndBoundary( position, attribute ) {
const prevNode = position.nodeBefore;
const nextNode = position.nodeAfter;
const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
if ( ( isAttrInPrev && !isAttrInNext ) || isBetweenDifferentValues( position, attribute ) ) {
return true;
}
return false;
}
// @param {module:engine/model/position~Position} position
// @param {String} attribute
function isBetweenDifferentValues( position, attribute ) {
const prevNode = position.nodeBefore;
const nextNode = position.nodeAfter;
const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
if ( !isAttrInPrev || !isAttrInNext ) {
return;
}
return nextNode.getAttribute( attribute ) !== prevNode.getAttribute( attribute );
}