This repository has been archived by the owner on Nov 11, 2020. It is now read-only.
forked from ianstormtaylor/slate
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathandroid.js
624 lines (544 loc) · 19.3 KB
/
android.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
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
import Debug from 'debug'
import getWindow from 'get-window'
import pick from 'lodash/pick'
import { ANDROID_API_VERSION } from 'slate-dev-environment'
import fixSelectionInZeroWidthBlock from '../utils/fix-selection-in-zero-width-block'
import getSelectionFromDom from '../utils/get-selection-from-dom'
import setSelectionFromDom from '../utils/set-selection-from-dom'
import setTextFromDomNode from '../utils/set-text-from-dom-node'
import isInputDataEnter from '../utils/is-input-data-enter'
import isInputDataLastChar from '../utils/is-input-data-last-char'
import DomSnapshot from '../utils/dom-snapshot'
import Executor from '../utils/executor'
const debug = Debug('slate:android')
debug.reconcile = Debug('slate:reconcile')
debug('ANDROID_API_VERSION', { ANDROID_API_VERSION })
/**
* Define variables related to composition state.
*/
const NONE = 0
const COMPOSING = 1
function AndroidPlugin() {
/**
* The current state of composition.
*
* @type {NONE|COMPOSING|WAITING}
*/
let status = NONE
/**
* The set of nodes that we need to process when we next reconcile.
* Usually this is soon after the `onCompositionEnd` event.
*
* @type {Set} set containing Node objects
*/
const nodes = new window.Set()
/**
* Keep a snapshot after a composition end for API 26/27. If a `beforeInput`
* gets called with data that ends in an ENTER then we need to use this
* snapshot to revert the DOM so that React doesn't get out of sync with the
* DOM. We also need to cancel the `reconcile` operation as it interferes in
* certain scenarios like hitting 'enter' at the end of a word.
*
* @type {DomSnapshot} [compositionEndSnapshot]
*/
let compositionEndSnapshot = null
/**
* When there is a `compositionEnd` we ened to reconcile Slate's Document
* with the DOM. The `reconciler` is an instance of `Executor` that does
* this for us. It is created on every `compositionEnd` and executes on the
* next `requestAnimationFrame`. The `Executor` can be cancelled and resumed
* which some methods do.
*
* @type {Executor}
*/
let reconciler = null
/**
* A snapshot that gets taken when there is a `keydown` event in API26/27.
* If an `input` gets called with `inputType` of `deleteContentBackward`
* we need to undo the delete that Android does to keep React in sync with
* the DOM.
*
* @type {DomSnapshot}
*/
let keyDownSnapshot = null
/**
* The deleter is an instace of `Executor` that will execute a delete
* operation on the next `requestAnimationFrame`. It has to wait because
* we need Android to finish all of its DOM operations to do with deletion
* before we revert them to a Snapshot. After reverting, we then execute
* Slate's version of delete.
*
* @type {Executor}
*/
let deleter = null
/**
* Because Slate implements its own event handler for `beforeInput` in
* addition to React's version, we actually get two. If we cancel the
* first native version, the React one will still fire. We set this to
* `true` if we don't want that to happen. Remember that when we prevent it,
* we need to tell React to `preventDefault` so the event doesn't continue
* through React's event system.
*
* type {Boolean}
*/
let preventNextBeforeInput = false
/**
* When a composition ends, in some API versions we may need to know what we
* have learned so far about the composition and what we want to do because of
* some actions that may come later.
*
* For example in API 26/27, if we get a `beforeInput` that tells us that the
* input was a `.`, then we want the reconcile to happen even if there are
* `onInput:delete` events that follow. In this case, we would set
* `compositionEndAction` to `period`. During the `onInput` we would check if
* the `compositionEndAction` says `period` and if so we would not start the
* `delete` action.
*
* @type {(String|null)}
*/
let compositionEndAction = null
/**
* Looks at the `nodes` we have collected, usually the things we have edited
* during the course of a composition, and then updates Slate's internal
* Document based on the text values in these DOM nodes and also updates
* Slate's Selection based on the current cursor position in the Editor.
*
* @param {Window} window
* @param {Editor} editor
* @param {String} options.from - where reconcile was called from for debug
*/
function reconcile(window, editor, { from }) {
debug.reconcile({ from })
const domSelection = window.getSelection()
nodes.forEach(node => {
setTextFromDomNode(window, editor, node)
})
setSelectionFromDom(window, editor, domSelection)
nodes.clear()
}
/**
* On before input.
*
* Check `components/content` because some versions of Android attach a
* native `beforeinput` event on the Editor. In this case, you might need
* to distinguish whether the event coming through is the native or React
* version of the event. Also, if you cancel the native version that does
* not necessarily mean that the React version is cancelled.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onBeforeInput(event, editor, next) {
const isNative = !event.nativeEvent
debug('onBeforeInput', {
isNative,
event,
status,
e: pick(event, ['data', 'inputType', 'isComposing', 'nativeEvent']),
})
const window = getWindow(event.target)
if (preventNextBeforeInput) {
event.preventDefault()
preventNextBeforeInput = false
return
}
switch (ANDROID_API_VERSION) {
case 25:
// prevent onBeforeInput to allow selecting an alternate suggest to
// work.
break
case 26:
case 27:
if (deleter) {
deleter.cancel()
reconciler.resume()
}
// This analyses Android's native `beforeInput` which Slate adds
// on in the `Content` component. It only fires if the cursor is at
// the end of a block. Otherwise, the code below checks.
if (isNative) {
if (
event.inputType === 'insertParagraph' ||
event.inputType === 'insertLineBreak'
) {
debug('onBeforeInput:enter:native', {})
const domSelection = window.getSelection()
const selection = getSelectionFromDom(window, editor, domSelection)
preventNextBeforeInput = true
event.preventDefault()
editor.moveTo(selection.anchor.key, selection.anchor.offset)
editor.splitBlock()
}
} else {
if (isInputDataLastChar(event.data, ['.'])) {
debug('onBeforeInput:period')
reconciler.cancel()
compositionEndAction = 'period'
return
}
// This looks at the beforeInput event's data property and sees if it
// ends in a linefeed which is character code 10. This appears to be
// the only way to detect that enter has been pressed except at end
// of line where it doesn't work.
const isEnter = isInputDataEnter(event.data)
if (isEnter) {
if (reconciler) reconciler.cancel()
window.requestAnimationFrame(() => {
debug('onBeforeInput:enter:react', {})
compositionEndSnapshot.apply(editor)
editor.splitBlock()
})
}
}
break
case 28:
// If a `beforeInput` event fires after an `input:deleteContentBackward`
// event, it appears to be a good indicator that it is some sort of
// special combined Android event. If this is the case, then we don't
// want to have a deletion to happen, we just want to wait until Android
// has done its thing and then at the end we just want to reconcile.
if (deleter) {
deleter.cancel()
reconciler.resume()
}
break
default:
if (status !== COMPOSING) next()
}
}
/**
* On Composition end. By default, when a `compositionEnd` event happens,
* we start a reconciler. The reconciler will update Slate's Document using
* the DOM as the source of truth. In some cases, the reconciler needs to
* be cancelled and can also be resumed. For example, when a delete
* immediately followed a `compositionEnd`, we don't want to reconcile.
* Instead, we want the `delete` to take precedence.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onCompositionEnd(event, editor, next) {
debug('onCompositionEnd', { event })
const window = getWindow(event.target)
const domSelection = window.getSelection()
const { anchorNode } = domSelection
switch (ANDROID_API_VERSION) {
case 26:
case 27:
compositionEndSnapshot = new DomSnapshot(window, editor)
// fixes a bug in Android API 26 & 27 where a `compositionEnd` is triggered
// without the corresponding `compositionStart` event when clicking a
// suggestion.
//
// If we don't add this, the `onBeforeInput` is triggered and passes
// through to the `before` plugin.
status = COMPOSING
break
}
compositionEndAction = 'reconcile'
nodes.add(anchorNode)
reconciler = new Executor(window, () => {
status = NONE
reconcile(window, editor, { from: 'onCompositionEnd:reconciler' })
compositionEndAction = null
})
}
/**
* On composition start.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onCompositionStart(event, editor, next) {
debug('onCompositionStart', { event })
status = COMPOSING
nodes.clear()
}
/**
* On composition update.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onCompositionUpdate(event, editor, next) {
debug('onCompositionUpdate', { event })
}
/**
* On input.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onInput(event, editor, next) {
debug('onInput', {
event,
status,
e: pick(event, ['data', 'nativeEvent', 'inputType', 'isComposing']),
})
switch (ANDROID_API_VERSION) {
case 24:
case 25:
break
case 26:
case 27:
case 28:
const { nativeEvent } = event
if (ANDROID_API_VERSION === 28) {
// NOTE API 28:
// When a user hits space and then backspace in `middle` we end up
// with `midle`.
//
// This is because when the user hits space, the composition is not
// ended because `compositionEnd` is not called yet. When backspace is
// hit, the `compositionEnd` is called. We need to revert the DOM but
// the reconciler has not had a chance to run from the
// `compositionEnd` because it is set to run on the next
// `requestAnimationFrame`. When the backspace is carried out on the
// Slate Value, Slate doesn't know about the space yet so the
// backspace is carried out without the space cuasing us to lose a
// character.
//
// This fix forces Android to reconcile immediately after hitting
// the space.
//
// NOTE API 27:
// It is confirmed that this bug does not present itself on API27.
// A space fires a `compositionEnd` (as well as other events including
// an input that is a delete) so the reconciliation happens.
//
if (
nativeEvent.inputType === 'insertText' &&
nativeEvent.data === ' '
) {
if (reconciler) reconciler.cancel()
if (deleter) deleter.cancel()
reconcile(window, editor, { from: 'onInput:space' })
return
}
}
if (ANDROID_API_VERSION === 26 || ANDROID_API_VERSION === 27) {
if (compositionEndAction === 'period') {
debug('onInput:period:abort')
// This means that there was a `beforeInput` that indicated the
// period was pressed. When a period is pressed, you get a bunch
// of delete actions mixed in. We want to ignore those. At this
// point, we add the current node to the list of things we need to
// resolve at the next compositionEnd. We know that a new
// composition will start right after this event so it is safe to
// do this.
const { anchorNode } = window.getSelection()
nodes.add(anchorNode)
return
}
}
if (nativeEvent.inputType === 'deleteContentBackward') {
debug('onInput:delete', { keyDownSnapshot })
const window = getWindow(event.target)
if (reconciler) reconciler.cancel()
if (deleter) deleter.cancel()
deleter = new Executor(
window,
() => {
debug('onInput:delete:callback', { keyDownSnapshot })
keyDownSnapshot.apply(editor)
editor.deleteBackward()
deleter = null
},
{
onCancel() {
deleter = null
},
}
)
return
}
if (status === COMPOSING) {
const { anchorNode } = window.getSelection()
nodes.add(anchorNode)
return
}
// Some keys like '.' are input without compositions. This happens
// in API28. It might be happening in API 27 as well. Check by typing
// `It me. No.` On a blank line.
if (ANDROID_API_VERSION === 28) {
debug('onInput:fallback')
const { anchorNode } = window.getSelection()
nodes.add(anchorNode)
window.requestAnimationFrame(() => {
debug('onInput:fallback:callback')
reconcile(window, editor, { from: 'onInput:fallback' })
})
return
}
break
default:
if (status === COMPOSING) return
next()
}
}
/**
* On key down.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onKeyDown(event, editor, next) {
debug('onKeyDown', {
event,
status,
e: pick(event, [
'char',
'charCode',
'code',
'key',
'keyCode',
'keyIdentifier',
'keyLocation',
'location',
'nativeEvent',
'which',
]),
})
const window = getWindow(event.target)
switch (ANDROID_API_VERSION) {
// 1. We want to allow enter keydown to allows go through
// 2. We want to deny keydown, I think, when it fires before the composition
// or something. Need to remember what it was.
case 25:
// in API25 prevent other keys to fix clicking a word and then
// selecting an alternate suggestion.
//
// NOTE:
// The `setSelectionFromDom` is to allow hitting `Enter` to work
// because the selection needs to be in the right place; however,
// for now we've removed the cancelling of `onSelect` and everything
// appears to be working. Not sure why we removed `onSelect` though
// in API25.
if (event.key === 'Enter') {
// const window = getWindow(event.target)
// const selection = window.getSelection()
// setSelectionFromDom(window, editor, selection)
next()
}
break
case 26:
case 27:
if (event.key === 'Enter') {
debug('onKeyDown:enter', {})
if (deleter) {
// If a `deleter` exists which means there was an onInput with
// `deleteContentBackward` that hasn't fired yet, then we know
// this is one of the cases where we have to revert to before
// the split.
deleter.cancel()
event.preventDefault()
window.requestAnimationFrame(() => {
debug('onKeyDown:enter:callback')
compositionEndSnapshot.apply(editor)
editor.splitBlock()
})
} else {
event.preventDefault()
// If there is no deleter, all we have to do is prevent the
// action before applying or splitBlock. In this scenario, we
// have to grab the selection from the DOM.
const domSelection = window.getSelection()
const selection = getSelectionFromDom(window, editor, domSelection)
editor.moveTo(selection.anchor.key, selection.anchor.offset)
editor.splitBlock()
}
return
}
// We need to take a snapshot of the current selection and the
// element before when the user hits the backspace key. This is because
// we only know if the user hit backspace if the `onInput` event that
// follows has an `inputType` of `deleteContentBackward`. At that time
// it's too late to stop the event.
keyDownSnapshot = new DomSnapshot(window, editor, {
before: true,
})
// If we let 'Enter' through it breaks handling of hitting
// enter at the beginning of a word so we need to stop it.
break
case 28:
{
if (event.key === 'Enter') {
debug('onKeyDown:enter')
event.preventDefault()
if (reconciler) reconciler.cancel()
if (deleter) deleter.cancel()
window.requestAnimationFrame(() => {
reconcile(window, editor, { from: 'onKeyDown:enter' })
editor.splitBlock()
})
return
}
// We need to take a snapshot of the current selection and the
// element before when the user hits the backspace key. This is because
// we only know if the user hit backspace if the `onInput` event that
// follows has an `inputType` of `deleteContentBackward`. At that time
// it's too late to stop the event.
keyDownSnapshot = new DomSnapshot(window, editor, {
before: true,
})
debug('onKeyDown:snapshot', { keyDownSnapshot })
}
// If we let 'Enter' through it breaks handling of hitting
// enter at the beginning of a word so we need to stop it.
break
default:
if (status !== COMPOSING) {
next()
}
}
}
/**
* On select.
*
* @param {Event} event
* @param {Editor} editor
* @param {Function} next
*/
function onSelect(event, editor, next) {
debug('onSelect', { event, status })
switch (ANDROID_API_VERSION) {
// We don't want to have the selection move around in an onSelect because
// it happens after we press `enter` in the same transaction. This
// causes the cursor position to not be properly placed.
case 26:
case 27:
case 28:
const window = getWindow(event.target)
fixSelectionInZeroWidthBlock(window)
break
default:
break
}
}
/**
* Return the plugin.
*
* @type {Object}
*/
return {
onBeforeInput,
onCompositionEnd,
onCompositionStart,
onCompositionUpdate,
onInput,
onKeyDown,
onSelect,
}
}
/**
* Export.
*
* @type {Function}
*/
export default AndroidPlugin