Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit 88bb94c

Browse files
authored
Merge pull request #1300 from ckeditor/t/1289
Feature: Introduced two-step caret movement mechanism. Closes #1289.
2 parents 1e598fb + 5a801fe commit 88bb94c

File tree

9 files changed

+1495
-576
lines changed

9 files changed

+1495
-576
lines changed

src/model/documentselection.js

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ export default class DocumentSelection {
139139
return this._selection.isBackward;
140140
}
141141

142+
/**
143+
* Describes whether the gravity is overridden (using {@link module:engine/model/writer~Writer#overrideSelectionGravity}) or not.
144+
*
145+
* Note that the gravity remains overridden as long as will not be restored the same number of times as it was overridden.
146+
*
147+
* @readonly
148+
* @return {Boolean}
149+
*/
150+
get isGravityOverridden() {
151+
return this._selection.isGravityOverridden;
152+
}
153+
142154
/**
143155
* Used for the compatibility with the {@link module:engine/model/selection~Selection#isEqual} method.
144156
*
@@ -388,6 +400,34 @@ export default class DocumentSelection {
388400
return this._selection._getStoredAttributes();
389401
}
390402

403+
/**
404+
* Temporarily changes the gravity of the selection from left to right. The gravity defines from which direction
405+
* the selection inherits its attributes. If it's the default left gravity, the selection (after being moved by
406+
* the user) inherits attributes from its left hand side. This method allows to temporarily override this behavior
407+
* by forcing the gravity to the right.
408+
*
409+
* @see module:engine/model/writer~Writer#overrideSelectionGravity
410+
* @protected
411+
* @param {Boolean} [customRestore=false] When `true` then gravity won't be restored until
412+
* {@link ~DocumentSelection#_restoreGravity} will be called directly. When `false` then gravity is restored
413+
* after selection is moved by user.
414+
*/
415+
_overrideGravity( customRestore ) {
416+
this._selection.overrideGravity( customRestore );
417+
}
418+
419+
/**
420+
* Restores {@link ~DocumentSelection#_overrideGravity overridden gravity}.
421+
*
422+
* Note that gravity remains overridden as long as won't be restored the same number of times as was overridden.
423+
*
424+
* @see module:engine/model/writer~Writer#restoreSelectionGravity
425+
* @protected
426+
*/
427+
_restoreGravity() {
428+
this._selection.restoreGravity();
429+
}
430+
391431
/**
392432
* Generates and returns an attribute key for selection attributes store, basing on original attribute key.
393433
*
@@ -465,6 +505,13 @@ class LiveSelection extends Selection {
465505
// @member {Array} module:engine/model/liveselection~LiveSelection#_hasChangedRange
466506
this._hasChangedRange = false;
467507

508+
// Each overriding gravity increase the counter and each restoring decrease it.
509+
// Gravity is overridden when counter is greater than 0. This is to prevent conflicts when
510+
// gravity is overridden by more than one feature at the same time.
511+
// @private
512+
// @type {Number}
513+
this._overriddenGravityCounter = 0;
514+
468515
// Add events that will ensure selection correctness.
469516
this.on( 'change:range', () => {
470517
for ( const range of this.getRanges() ) {
@@ -550,6 +597,15 @@ class LiveSelection extends Selection {
550597
return this._ranges.length > 0;
551598
}
552599

600+
// When set to `true` then selection attributes on node before the caret won't be taken
601+
// into consideration while updating selection attributes.
602+
//
603+
// @protected
604+
// @type {Boolean}
605+
get isGravityOverridden() {
606+
return this._overriddenGravityCounter > 0;
607+
}
608+
553609
// Unbinds all events previously bound by live selection.
554610
destroy() {
555611
for ( let i = 0; i < this._ranges.length; i++ ) {
@@ -601,6 +657,31 @@ class LiveSelection extends Selection {
601657
}
602658
}
603659

660+
overrideGravity( customRestore ) {
661+
this._overriddenGravityCounter++;
662+
663+
if ( this._overriddenGravityCounter == 1 ) {
664+
if ( !customRestore ) {
665+
this.on( 'change:range', ( evt, data ) => {
666+
if ( data.directChange ) {
667+
this.restoreGravity();
668+
evt.off();
669+
}
670+
} );
671+
}
672+
673+
this._updateAttributes();
674+
}
675+
}
676+
677+
restoreGravity() {
678+
this._overriddenGravityCounter--;
679+
680+
if ( !this.isGravityOverridden ) {
681+
this._updateAttributes();
682+
}
683+
}
684+
604685
// Removes all attributes from the selection and sets attributes according to the surrounding nodes.
605686
_refreshAttributes() {
606687
this._updateAttributes( true );
@@ -851,16 +932,20 @@ class LiveSelection extends Selection {
851932
const nodeBefore = position.textNode ? position.textNode : position.nodeBefore;
852933
const nodeAfter = position.textNode ? position.textNode : position.nodeAfter;
853934

854-
// ...look at the node before caret and take attributes from it if it is a character node.
855-
attrs = getAttrsIfCharacter( nodeBefore );
935+
// When gravity is overridden then don't take node before into consideration.
936+
if ( !this.isGravityOverridden ) {
937+
// ...look at the node before caret and take attributes from it if it is a character node.
938+
attrs = getAttrsIfCharacter( nodeBefore );
939+
}
856940

857941
// 3. If not, look at the node after caret...
858942
if ( !attrs ) {
859943
attrs = getAttrsIfCharacter( nodeAfter );
860944
}
861945

862946
// 4. If not, try to find the first character on the left, that is in the same node.
863-
if ( !attrs ) {
947+
// When gravity is overridden then don't take node before into consideration.
948+
if ( !this.isGravityOverridden && !attrs ) {
864949
let node = nodeBefore;
865950

866951
while ( node && !attrs ) {

src/model/writer.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,46 @@ export default class Writer {
10161016
}
10171017
}
10181018

1019+
/**
1020+
* Temporarily changes the {@link module:engine/model/documentselection~DocumentSelection#isGravityOverridden gravity}
1021+
* of the selection from left to right.
1022+
*
1023+
* The gravity defines from which direction the selection inherits its attributes. If it's the default left gravity,
1024+
* then the selection (after being moved by the user) inherits attributes from its left-hand side.
1025+
* This method allows to temporarily override this behavior by forcing the gravity to the right.
1026+
*
1027+
* For the following model fragment:
1028+
*
1029+
* <$text bold="true" linkHref="url">bar[]</$text><$text bold="true">biz</$text>
1030+
*
1031+
* * Default gravity: selection will have the `bold` and `linkHref` attributes.
1032+
* * Overridden gravity: selection will have `bold` attribute.
1033+
*
1034+
* By default the selection's gravity is automatically restored just after a direct selection change (when user
1035+
* moved the caret) but you can pass `customRestore = true` in which case you will have to call
1036+
* {@link ~Writer#restoreSelectionGravity} manually.
1037+
*
1038+
* When the selection's gravity is overridden more than once without being restored in the meantime then it needs
1039+
* to be restored the same number of times. This is to prevent conflicts when
1040+
* more than one feature want to independently override and restore the selection's gravity.
1041+
*
1042+
* @param {Boolean} [customRestore=false] When `true` then gravity won't be restored until
1043+
* {@link ~Writer#restoreSelectionGravity} will be called directly. When `false` then gravity is restored
1044+
* after selection is moved by user.
1045+
*/
1046+
overrideSelectionGravity( customRestore ) {
1047+
this.model.document.selection._overrideGravity( customRestore );
1048+
}
1049+
1050+
/**
1051+
* Restores {@link ~Writer#overrideSelectionGravity} gravity to default.
1052+
*
1053+
* Note that the gravity remains overridden as long as will not be restored the same number of times as it was overridden.
1054+
*/
1055+
restoreSelectionGravity() {
1056+
this.model.document.selection._restoreGravity();
1057+
}
1058+
10191059
/**
10201060
* @private
10211061
* @param {String} key Key of the attribute to remove.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/**
2+
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md.
4+
*/
5+
6+
/**
7+
* @module engine/utils/bindtwostepcarettoattribute
8+
*/
9+
10+
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
11+
12+
/**
13+
* This helper adds two-step caret movement behavior for the given attribute.
14+
*
15+
* For example, when this behavior is enabled for the `linkHref` attribute (which converts to `<a>` element in the view)
16+
* and the caret is just before an `<a>` element (at a link boundary), then pressing
17+
* the right arrow key will move caret into that `<a>`element instead of moving it after the next character:
18+
*
19+
* * With two-step caret movement: `<p>foo{}<a>bar</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>{}bar</a>biz<p>`
20+
* * Without two-step caret movement: `<p>foo{}<a>bar</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>b{}ar</a>biz<p>`
21+
*
22+
* The same behavior will be changed fo "leaving" an attribute element:
23+
*
24+
* * With two-step caret movement: `<p>foo<a>bar{}</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>bar</a>{}biz<p>`
25+
* * Without two-step caret movement: `<p>foo<a>bar{}</a>biz<p>` + <kbd>→</kbd> => `<p>foo<a>bar</a>b{}iz<p>`
26+
*
27+
* And when moving left:
28+
*
29+
* * With two-step caret movement: `<p>foo<a>bar</a>b{}iz<p>` + <kbd>←</kbd> => `<p>foo<a>bar</a>{}biz<p>` +
30+
* <kbd>←</kbd> => `<p>foo<a>bar{}</a>biz<p>`
31+
* * Without two-step caret movement: `<p>foo<a>bar</a>b{}iz<p>` + <kbd>←</kbd> => `<p>foo<a>bar{}</a>biz<p>`
32+
*
33+
* @param {module:engine/view/view~View} view View controller instance.
34+
* @param {module:engine/model/model~Model} model Data model instance.
35+
* @param {module:utils/dom/emittermixin~Emitter} emitter The emitter to which this behavior should be added
36+
* (e.g. a plugin instance).
37+
* @param {String} attribute Attribute for which this behavior will be added.
38+
*/
39+
export default function bindTwoStepCaretToAttribute( view, model, emitter, attribute ) {
40+
const modelSelection = model.document.selection;
41+
42+
// Listen to keyboard events and handle cursor before the move.
43+
emitter.listenTo( view.document, 'keydown', ( evt, data ) => {
44+
const arrowRightPressed = data.keyCode == keyCodes.arrowright;
45+
const arrowLeftPressed = data.keyCode == keyCodes.arrowleft;
46+
47+
// When neither left or right arrow has been pressed then do noting.
48+
if ( !arrowRightPressed && !arrowLeftPressed ) {
49+
return;
50+
}
51+
52+
// This implementation works only for collapsed selection.
53+
if ( !modelSelection.isCollapsed ) {
54+
return;
55+
}
56+
57+
// When user tries to expand selection or jump over the whole word or to the beginning/end then
58+
// two-steps movement is not necessary.
59+
if ( data.shiftKey || data.altKey || data.ctrlKey ) {
60+
return;
61+
}
62+
63+
const position = modelSelection.getFirstPosition();
64+
65+
// Moving right.
66+
if ( arrowRightPressed ) {
67+
// If gravity is already overridden then do nothing.
68+
// It means that we already enter `foo<a>{}bar</a>biz` or left `foo<a>bar</a>{}biz` text with attribute
69+
// and gravity will be restored just after caret movement.
70+
if ( modelSelection.isGravityOverridden ) {
71+
return;
72+
}
73+
74+
// If caret sticks to the bound of Text with attribute it means that we are going to
75+
// enter `foo{}<a>bar</a>biz` or leave `foo<a>bar{}</a>biz` the text with attribute.
76+
if ( isAtAttributeBoundary( position.nodeAfter, position.nodeBefore, attribute ) ) {
77+
// So we need to prevent caret from being moved.
78+
data.preventDefault();
79+
// And override default selection gravity.
80+
model.change( writer => writer.overrideSelectionGravity() );
81+
}
82+
83+
// Moving left.
84+
} else {
85+
// If caret sticks to the bound of Text with attribute and gravity is already overridden it means that
86+
// we are going to enter `foo<a>bar</a>{}biz` or leave `foo<a>{}bar</a>biz` text with attribute.
87+
if ( modelSelection.isGravityOverridden && isAtAttributeBoundary( position.nodeBefore, position.nodeAfter, attribute ) ) {
88+
// So we need to prevent cater from being moved.
89+
data.preventDefault();
90+
// And restore the gravity.
91+
model.change( writer => writer.restoreSelectionGravity() );
92+
93+
return;
94+
}
95+
96+
// If we are here we need to check if caret is a one character before the text with attribute bound
97+
// `foo<a>bar</a>b{}iz` or `foo<a>b{}ar</a>biz`.
98+
const nextPosition = position.getShiftedBy( -1 );
99+
100+
// When position is the same it means that parent bound has been reached.
101+
if ( !nextPosition.isBefore( position ) ) {
102+
return;
103+
}
104+
105+
// When caret is going stick to the bound of Text with attribute after movement then we need to override
106+
// the gravity before the move. But we need to do it in a custom way otherwise `selection#change:range`
107+
// event following the overriding will restore the gravity.
108+
if ( isAtAttributeBoundary( nextPosition.nodeBefore, nextPosition.nodeAfter, attribute ) ) {
109+
model.change( writer => {
110+
let counter = 0;
111+
112+
// So let's override the gravity.
113+
writer.overrideSelectionGravity( true );
114+
115+
// But skip the following `change:range` event and restore the gravity on the next one.
116+
emitter.listenTo( modelSelection, 'change:range', ( evt, data ) => {
117+
if ( counter++ && data.directChange ) {
118+
writer.restoreSelectionGravity();
119+
evt.off();
120+
}
121+
} );
122+
} );
123+
}
124+
}
125+
} );
126+
}
127+
128+
// @param {module:engine/model/node~Node} nextNode Node before the position.
129+
// @param {module:engine/model/node~Node} prevNode Node after the position.
130+
// @param {String} attribute Attribute name.
131+
// @returns {Boolean} `true` when position between the nodes sticks to the bound of text with given attribute.
132+
function isAtAttributeBoundary( nextNode, prevNode, attribute ) {
133+
const isAttrInNext = nextNode ? nextNode.hasAttribute( attribute ) : false;
134+
const isAttrInPrev = prevNode ? prevNode.hasAttribute( attribute ) : false;
135+
136+
if ( isAttrInNext && isAttrInPrev && nextNode.getAttributeKeys( attribute ) !== prevNode.getAttribute( attribute ) ) {
137+
return true;
138+
}
139+
140+
return isAttrInNext && !isAttrInPrev || !isAttrInNext && isAttrInPrev;
141+
}

tests/manual/two-step-caret.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div id="editor">
2+
<p>Foo <u>bar</u> biz</p>
3+
<p>Foo <u>bar</u><i>biz</i> buz?</p>
4+
<p>Foo <b>bar</b> biz</p>
5+
</div>

tests/manual/two-step-caret.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
3+
* For licensing, see LICENSE.md.
4+
*/
5+
6+
/* global console, document */
7+
8+
import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
9+
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
10+
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
11+
import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
12+
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
13+
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
14+
15+
import bindTwoStepCaretToAttribute from '../../src/utils/bindtwostepcarettoattribute';
16+
17+
ClassicEditor
18+
.create( document.querySelector( '#editor' ), {
19+
plugins: [ Essentials, Paragraph, Underline, Bold, Italic ],
20+
toolbar: [ 'undo', 'redo', '|', 'bold', 'underline', 'italic' ]
21+
} )
22+
.then( editor => {
23+
const bold = editor.plugins.get( Italic );
24+
const underline = editor.plugins.get( Underline );
25+
26+
bindTwoStepCaretToAttribute( editor.editing.view, editor.model, bold, 'italic' );
27+
bindTwoStepCaretToAttribute( editor.editing.view, editor.model, underline, 'underline' );
28+
} )
29+
.catch( err => {
30+
console.error( err.stack );
31+
} );

0 commit comments

Comments
 (0)