/
twostepcaretmovement.js
481 lines (437 loc) · 15.3 KB
/
twostepcaretmovement.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
/**
* @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module typing/twostepcaretmovement
*/
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
import priorities from '@ckeditor/ckeditor5-utils/src/priorities';
/**
* This plugin enables the two-step caret (phantom) movement behavior for
* {@link module:typing/twostepcaretmovement~TwoStepCaretMovement#registerAttribute registered attributes}
* 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 plugin 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 plugin is enabled and registered 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
*
* # Multiple attributes
*
* * When enabled and many attributes starts or ends at the same position:
*
* <$text a="true" b="true">bar</$text>{}baz
*
* <kbd>←</kbd>
*
* <$text a="true" b="true">bar{}</$text>baz
*
* * When enabled and one procedes another:
*
* <$text a="true">bar</$text><$text b="true">{}bar</$text>
*
* <kbd>←</kbd>
*
* <$text a="true">bar{}</$text><$text b="true">bar</$text>
*
*/
export default class TwoStepCaretMovement extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'TwoStepCaretMovement';
}
/**
* @inheritDoc
*/
constructor( editor ) {
super( editor );
/**
* A set of attributes to handle.
*
* @protected
* @property {module:typing/twostepcaretmovement~TwoStepCaretMovement}
*/
this.attributes = new Set();
/**
* The current UID of the overridden gravity, as returned by
* {@link module:engine/model/writer~Writer#overrideSelectionGravity}.
*
* @private
* @member {String}
*/
this._overrideUid = null;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
const locale = editor.locale;
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.
this.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 contentDirection = locale.contentLanguageDirection;
let isMovementHandled = false;
if ( ( contentDirection === 'ltr' && arrowRightPressed ) || ( contentDirection === 'rtl' && arrowLeftPressed ) ) {
isMovementHandled = this._handleForwardMovement( data );
} else {
isMovementHandled = this._handleBackwardMovement( 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 === true ) {
evt.stop();
}
}, { priority: priorities.get( 'high' ) + 1 } );
/**
* A flag indicating that the automatic gravity restoration should not happen upon the next
* gravity restoration.
* {@link module:engine/model/selection~Selection#event:change:range} event.
*
* @private
* @member {String}
*/
this._isNextGravityRestorationSkipped = false;
// The automatic gravity restoration logic.
this.listenTo( 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 && isBetweenDifferentAttributes( modelSelection.getFirstPosition(), this.attributes ) ) {
return;
}
this._restoreGravity();
} );
}
/**
* Registers a given attribute for the two-step caret movement.
*
* @param {String} attribute Name of the attribute to handle.
*/
registerAttribute( attribute ) {
this.attributes.add( attribute );
}
/**
* 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}.
*
* @private
* @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
* @returns {Boolean} `true` when the handler prevented caret movement
*/
_handleForwardMovement( data ) {
const attributes = this.attributes;
const model = this.editor.model;
const selection = model.document.selection;
const position = selection.getFirstPosition();
// 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 false;
}
// 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 && hasAnyAttribute( selection, attributes ) ) {
return false;
}
// ENGAGE 2-SCM When at least one of the observed attributes changes its value (incl. starts, ends).
//
// <paragraph>foo<$text attribute>bar{}</$text>baz</paragraph>
// <paragraph>foo<$text attribute>bar{}</$text><$text otherAttribute>baz</$text></paragraph>
// <paragraph>foo<$text attribute=1>bar{}</$text><$text attribute=2>baz</$text></paragraph>
// <paragraph>foo{}<$text attribute>bar</$text>baz</paragraph>
//
if ( isBetweenDifferentAttributes( position, attributes ) ) {
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}.
*
* @private
* @param {module:engine/view/observer/domeventdata~DomEventData} data Data of the key press.
* @returns {Boolean} `true` when the handler prevented caret movement
*/
_handleBackwardMovement( data ) {
const attributes = this.attributes;
const model = this.editor.model;
const selection = model.document.selection;
const position = selection.getFirstPosition();
// When the gravity is already overridden (by this plugin), it means we are on the two-step position.
// Prevent the movement, restore the gravity and update selection attributes.
//
// <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>{}baz</$text></paragraph>
// <paragraph>foo<$text attribute>bar</$text><$text otherAttribute>{}baz</$text></paragraph>
// <paragraph>foo<$text attribute>{}bar</$text>baz</paragraph>
// <paragraph>foo<$text attribute>bar</$text>{}baz</paragraph>
//
if ( this._isGravityOverridden ) {
preventCaretMovement( data );
this._restoreGravity();
setSelectionAttributesFromTheNodeBefore( model, attributes, position );
return true;
} else {
// REMOVE SELECTION ATTRIBUTE when restoring gravity towards a non-existent content at the
// beginning of the block.
//
// <paragraph>{}<$text attribute>bar</$text></paragraph>
//
if ( position.isAtStart ) {
if ( hasAnyAttribute( selection, attributes ) ) {
preventCaretMovement( data );
setSelectionAttributesFromTheNodeBefore( model, attributes, position );
return true;
}
return false;
}
// When we are moving from natural gravity, to the position of the 2SCM, we need to override the gravity,
// and make sure it won't be restored. Unless it's at the end of the block and an observed attribute.
// We need to check if the caret is a one position before the attribute boundary:
//
// <paragraph>foo<$text attribute=1>bar</$text><$text attribute=2>b{}az</$text></paragraph>
// <paragraph>foo<$text attribute>bar</$text><$text otherAttribute>b{}az</$text></paragraph>
// <paragraph>foo<$text attribute>b{}ar</$text>baz</paragraph>
// <paragraph>foo<$text attribute>bar</$text>b{}az</paragraph>
//
if ( isStepAfterAnyAttributeBoundary( position, attributes ) ) {
// 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>
//
if (
position.isAtEnd &&
!hasAnyAttribute( selection, attributes ) &&
isBetweenDifferentAttributes( position, attributes )
) {
preventCaretMovement( data );
setSelectionAttributesFromTheNodeBefore( model, attributes, position );
return true;
}
// 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._isNextGravityRestorationSkipped = true;
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 false;
}
}
}
/**
* `true` when the gravity is overridden for the plugin.
*
* @readonly
* @private
* @type {Boolean}
*/
get _isGravityOverridden() {
return !!this._overrideUid;
}
/**
* 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.editor.model.change( writer => {
return 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.editor.model.change( writer => {
writer.restoreSelectionGravity( this._overrideUid );
this._overrideUid = null;
} );
}
}
// Checks whether the selection has any of given attributes.
//
// @param {module:engine/model/documentselection~DocumentSelection} selection
// @param {Iterable.<String>} attributes
function hasAnyAttribute( selection, attributes ) {
for ( const observedAttribute of attributes ) {
if ( selection.hasAttribute( observedAttribute ) ) {
return true;
}
}
return false;
}
// Applies the given attributes to the current selection using using the
// values from the node before the current position. Uses
// the {@link module:engine/model/writer~Writer model writer}.
//
// @param {module:engine/model/model~Model}
// @param {Iterable.<String>} attributess
// @param {module:engine/model/position~Position} position
function setSelectionAttributesFromTheNodeBefore( model, attributes, position ) {
const nodeBefore = position.nodeBefore;
model.change( writer => {
if ( nodeBefore ) {
writer.setSelectionAttribute( nodeBefore.getAttributes() );
} else {
writer.removeSelectionAttribute( attributes );
}
} );
}
// Prevents the caret movement in the view by calling `preventDefault` on the event data.
//
// @alias data.preventDefault
function preventCaretMovement( data ) {
data.preventDefault();
}
// Checks whether the step before `isBetweenDifferentAttributes()`.
//
// @param {module:engine/model/position~Position} position
// @param {String} attribute
function isStepAfterAnyAttributeBoundary( position, attributes ) {
const positionBefore = position.getShiftedBy( -1 );
return isBetweenDifferentAttributes( positionBefore, attributes );
}
// Checks whether the given position is between different values of given attributes.
//
// @param {module:engine/model/position~Position} position
// @param {Iterable.<String>} attributes
function isBetweenDifferentAttributes( position, attributes ) {
const { nodeBefore, nodeAfter } = position;
for ( const observedAttribute of attributes ) {
const attrBefore = nodeBefore ? nodeBefore.getAttribute( observedAttribute ) : undefined;
const attrAfter = nodeAfter ? nodeAfter.getAttribute( observedAttribute ) : undefined;
if ( attrAfter !== attrBefore ) {
return true;
}
}
return false;
}