1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-01 04:50:27 +02:00

Revert "Android 8.0, 8.1 and 9.0 Support (#2553)" (#2562)

This reverts commit 17cdeae858.
This commit is contained in:
Ian Storm Taylor
2019-01-28 19:18:03 -08:00
committed by GitHub
parent 17cdeae858
commit 391e2cba67
24 changed files with 84 additions and 2105 deletions

View File

@@ -29,7 +29,6 @@ import RTL from './rtl'
import ReadOnly from './read-only'
import RichText from './rich-text'
import SearchHighlighting from './search-highlighting'
import Composition from './composition'
import InputTester from './input-tester'
import SyncingOperations from './syncing-operations'
import Tables from './tables'
@@ -44,7 +43,6 @@ import Mentions from './mentions'
const EXAMPLES = [
['Check Lists', CheckLists, '/check-lists'],
['Code Highlighting', CodeHighlighting, '/code-highlighting'],
['Composition', Composition, '/composition/:subpage?'],
['Embeds', Embeds, '/embeds'],
['Emojis', Emojis, '/emojis'],
['Forced Layout', ForcedLayout, '/forced-layout'],
@@ -264,13 +262,11 @@ export default class App extends React.Component {
<Switch>
{EXAMPLES.map(([name, Component, path]) => (
<Route key={path} path={path}>
{({ match }) => (
<div>
<ExampleContent>
<Component params={match.params} />
</ExampleContent>
</div>
)}
<div>
<ExampleContent>
<Component />
</ExampleContent>
</div>
</Route>
))}
<Redirect from="/" to="/rich-text" />
@@ -294,7 +290,7 @@ export default class App extends React.Component {
</TabButton>
<Switch>
{EXAMPLES.map(([name, Component, path]) => (
<Route key={path} path={path}>
<Route key={path} exact path={path}>
<ExampleTitle>{name}</ExampleTitle>
</Route>
))}

View File

@@ -1,401 +0,0 @@
import { Editor } from 'slate-react'
import { Value } from 'slate'
import React from 'react'
import styled from 'react-emotion'
import { Link, Redirect } from 'react-router-dom'
import splitJoin from './split-join.js'
import insert from './insert.js'
import special from './special.js'
import { isKeyHotkey } from 'is-hotkey'
import { Button, Icon, Toolbar } from '../components'
/**
* Define the default node type.
*
* @type {String}
*/
const DEFAULT_NODE = 'paragraph'
/**
* Some styled components.
*
* @type {Component}
*/
const Instruction = styled('div')`
white-space: pre-wrap;
margin: -1em -1em 1em;
padding: 0.5em;
background: #eee;
`
const Tabs = styled('div')`
margin-bottom: 0.5em;
`
const TabLink = ({ active, ...props }) => <Link {...props} />
const Tab = styled(TabLink)`
display: inline-block;
text-decoration: none;
color: black;
background: ${p => (p.active ? '#AAA' : '#DDD')};
padding: 0.25em 0.5em;
border-radius: 0.25em;
margin-right: 0.25em;
`
/**
* Subpages which are each a smoke test.
*
* @type {Array}
*/
const SUBPAGES = [
['Split/Join', splitJoin, 'split-join'],
['Insertion', insert, 'insert'],
['Special', special, 'special'],
]
/**
* Define hotkey matchers.
*
* @type {Function}
*/
const isBoldHotkey = isKeyHotkey('mod+b')
const isItalicHotkey = isKeyHotkey('mod+i')
const isUnderlinedHotkey = isKeyHotkey('mod+u')
const isCodeHotkey = isKeyHotkey('mod+`')
/**
* The rich text example.
*
* @type {Component}
*/
class RichTextExample extends React.Component {
state = {}
/**
* Select and deserialize the initial editor value.
*
* @param {Object} nextProps
* @param {Object} prevState
* @return {Object}
*/
static getDerivedStateFromProps(nextProps, prevState) {
const { subpage } = nextProps.params
if (subpage === prevState.subpage) return null
const found = SUBPAGES.find(
([name, value, iSubpage]) => iSubpage === subpage
)
if (found == null) return {}
const { text, document } = found[1]
return {
subpage,
text,
value: Value.fromJSON({ document }),
}
}
/**
* Check if the current selection has a mark with `type` in it.
*
* @param {String} type
* @return {Boolean}
*/
hasMark = type => {
const { value } = this.state
return value.activeMarks.some(mark => mark.type == type)
}
/**
* Check if the any of the currently selected blocks are of `type`.
*
* @param {String} type
* @return {Boolean}
*/
hasBlock = type => {
const { value } = this.state
return value.blocks.some(node => node.type == type)
}
/**
* Store a reference to the `editor`.
*
* @param {Editor} editor
*/
ref = editor => {
this.editor = editor
}
/**
* Render.
*
* @return {Element}
*/
render() {
const { text } = this.state
if (text == null) return <Redirect to="/composition/split-join" />
return (
<div>
<Instruction>
<Tabs>
{SUBPAGES.map(([name, Component, subpage]) => {
const active = subpage === this.props.params.subpage
return (
<Tab
key={subpage}
to={`/composition/${subpage}`}
active={active}
>
{name}
</Tab>
)
})}
</Tabs>
<div>{this.state.text}</div>
</Instruction>
<Toolbar>
{this.renderMarkButton('bold', 'format_bold')}
{this.renderMarkButton('italic', 'format_italic')}
{this.renderMarkButton('underlined', 'format_underlined')}
{this.renderMarkButton('code', 'code')}
{this.renderBlockButton('heading-one', 'looks_one')}
{this.renderBlockButton('heading-two', 'looks_two')}
{this.renderBlockButton('block-quote', 'format_quote')}
{this.renderBlockButton('numbered-list', 'format_list_numbered')}
{this.renderBlockButton('bulleted-list', 'format_list_bulleted')}
</Toolbar>
<Editor
spellCheck
autoFocus
placeholder="Enter some rich text..."
ref={this.ref}
value={this.state.value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
renderNode={this.renderNode}
renderMark={this.renderMark}
/>
</div>
)
}
/**
* Render a mark-toggling toolbar button.
*
* @param {String} type
* @param {String} icon
* @return {Element}
*/
renderMarkButton = (type, icon) => {
const isActive = this.hasMark(type)
return (
<Button
active={isActive}
onMouseDown={event => this.onClickMark(event, type)}
>
<Icon>{icon}</Icon>
</Button>
)
}
/**
* Render a block-toggling toolbar button.
*
* @param {String} type
* @param {String} icon
* @return {Element}
*/
renderBlockButton = (type, icon) => {
let isActive = this.hasBlock(type)
if (['numbered-list', 'bulleted-list'].includes(type)) {
const { value: { document, blocks } } = this.state
if (blocks.size > 0) {
const parent = document.getParent(blocks.first().key)
isActive = this.hasBlock('list-item') && parent && parent.type === type
}
}
return (
<Button
active={isActive}
onMouseDown={event => this.onClickBlock(event, type)}
>
<Icon>{icon}</Icon>
</Button>
)
}
/**
* Render a Slate node.
*
* @param {Object} props
* @return {Element}
*/
renderNode = (props, editor, next) => {
const { attributes, children, node } = props
switch (node.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>
case 'heading-one':
return <h1 {...attributes}>{children}</h1>
case 'heading-two':
return <h2 {...attributes}>{children}</h2>
case 'list-item':
return <li {...attributes}>{children}</li>
case 'numbered-list':
return <ol {...attributes}>{children}</ol>
default:
return next()
}
}
/**
* Render a Slate mark.
*
* @param {Object} props
* @return {Element}
*/
renderMark = (props, editor, next) => {
const { children, mark, attributes } = props
switch (mark.type) {
case 'bold':
return <strong {...attributes}>{children}</strong>
case 'code':
return <code {...attributes}>{children}</code>
case 'italic':
return <em {...attributes}>{children}</em>
case 'underlined':
return <u {...attributes}>{children}</u>
default:
return next()
}
}
/**
* On change, save the new `value`.
*
* @param {Editor} editor
*/
onChange = ({ value }) => {
this.setState({ value })
}
/**
* On key down, if it's a formatting command toggle a mark.
*
* @param {Event} event
* @param {Editor} editor
* @return {Change}
*/
onKeyDown = (event, editor, next) => {
let mark
if (isBoldHotkey(event)) {
mark = 'bold'
} else if (isItalicHotkey(event)) {
mark = 'italic'
} else if (isUnderlinedHotkey(event)) {
mark = 'underlined'
} else if (isCodeHotkey(event)) {
mark = 'code'
} else {
return next()
}
event.preventDefault()
editor.toggleMark(mark)
}
/**
* When a mark button is clicked, toggle the current mark.
*
* @param {Event} event
* @param {String} type
*/
onClickMark = (event, type) => {
event.preventDefault()
this.editor.toggleMark(type)
}
/**
* When a block button is clicked, toggle the block type.
*
* @param {Event} event
* @param {String} type
*/
onClickBlock = (event, type) => {
event.preventDefault()
const { editor } = this
const { value } = editor
const { document } = value
// Handle everything but list buttons.
if (type != 'bulleted-list' && type != 'numbered-list') {
const isActive = this.hasBlock(type)
const isList = this.hasBlock('list-item')
if (isList) {
editor
.setBlocks(isActive ? DEFAULT_NODE : type)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
} else {
editor.setBlocks(isActive ? DEFAULT_NODE : type)
}
} else {
// Handle the extra wrapping required for list buttons.
const isList = this.hasBlock('list-item')
const isType = value.blocks.some(block => {
return !!document.getClosest(block.key, parent => parent.type == type)
})
if (isList && isType) {
editor
.setBlocks(DEFAULT_NODE)
.unwrapBlock('bulleted-list')
.unwrapBlock('numbered-list')
} else if (isList) {
editor
.unwrapBlock(
type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
)
.wrapBlock(type)
} else {
editor.setBlocks('list-item').wrapBlock(type)
}
}
}
}
/**
* Export.
*/
export default RichTextExample

View File

@@ -1,15 +0,0 @@
import { p, text, bold } from './util'
export default {
text: `Enter text below each line of instruction exactly including mis-spelling wasnt`,
document: {
nodes: [
p(bold('Tap on virtual keyboard: '), text('It wasnt me. No.')),
p(),
p(bold('Gesture write: '), text('Yes Sam, I am.')),
p(),
p(bold('If you have IME, write any two words with it')),
p(),
],
},
}

View File

@@ -1,23 +0,0 @@
import { p, bold } from './util'
export default {
text: `Follow the instructions on each line exactly`,
document: {
nodes: [
p(bold('Type "it is". cursor to "i|t" then hit enter.')),
p(''),
p(
bold(
'Cursor to "mid|dle" then press space, backspace, space, backspace. Should say "middle".'
)
),
p('The middle word.'),
p(
bold(
'Cursor in line below. Wait for caps on keyboard to show up. If not try again. Type "It me. No." and it should not mangle on the last period.'
)
),
p(''),
],
},
}

View File

@@ -1,18 +0,0 @@
import { p, text, bold } from './util'
export default {
text: `Hit enter x2 then backspace x2 before word "before", after "after", and in "middle" between two "d"s`,
document: {
nodes: [
p(
text('Before it before it '),
bold('before'),
text(' it middle it '),
bold('middle'),
text(' it after it '),
bold('after'),
text(' it after')
),
],
},
}

View File

@@ -1,15 +0,0 @@
export function p(...leaves) {
return {
object: 'block',
type: 'paragraph',
nodes: [{ object: 'text', leaves }],
}
}
export function text(textContent) {
return { text: textContent }
}
export function bold(textContent) {
return { text: textContent, marks: [{ type: 'bold' }] }
}

View File

@@ -1,119 +0,0 @@
{
"document": {
"nodes": [
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"leaves": [
{ "text": "Insert Text: ", "marks": [{ "type": "bold" }] },
{
"text":
"Type 'cat' before every word 'before' and after every word 'after' and in the middle of the word 'pion' so that it says 'pi cat on' using the virtual keyboard",
"marks": [{ "type": "italic" }]
}
]
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"leaves": [
{
"text": "Before there before is pion at after for after"
}
]
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"leaves": [
{ "text": "Handle Enter: ", "marks": [{ "type": "bold" }] },
{
"text":
"Hit Enter twice before every word 'before' and after every word 'after' and in the middle of the word 'split'",
"marks": [{ "type": "italic" }]
}
]
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"leaves": [
{
"text": "Before there before is split at after for after"
}
]
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"leaves": [
{
"text":
"Since it's rich text, you can do things like turn a selection of text "
},
{
"text": "bold",
"marks": [{ "type": "bold" }]
},
{
"text":
", or add a semantically rendered block quote in the middle of the page, like this:"
}
]
}
]
},
{
"object": "block",
"type": "block-quote",
"nodes": [
{
"object": "text",
"leaves": [
{
"text": "A wise quote."
}
]
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"leaves": [
{
"text": "Try it out for yourself!"
}
]
}
]
}
]
}
}

View File

@@ -93,10 +93,8 @@
"bootstrap": "lerna bootstrap && yarn build",
"build": "rollup --config ./support/rollup/config.js",
"build:production": "cross-env NODE_ENV=production rollup --config ./support/rollup/config.js && cross-env NODE_ENV=production webpack --config support/webpack/config.js",
"build:clean-fork": "rm ./build/CNAME",
"clean": "lerna run clean && rm -rf ./node_modules ./dist ./build",
"gh-pages": "gh-pages --dist ./build",
"gh-pages:fork": "npm-run-all build:production build:clean-fork gh-pages",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:eslint": "eslint benchmark packages/*/src packages/*/test examples/*/*.js examples/dev/*/*.js",
"lint:prettier": "prettier --list-different '**/*.{md,json,css}'",

View File

@@ -4,12 +4,7 @@ import Types from 'prop-types'
import getWindow from 'get-window'
import warning from 'tiny-warning'
import throttle from 'lodash/throttle'
import {
IS_ANDROID,
IS_FIREFOX,
HAS_INPUT_EVENTS_LEVEL_2,
} from 'slate-dev-environment'
import ANDROID_API_VERSION from '../utils/android-api-version'
import { IS_FIREFOX, HAS_INPUT_EVENTS_LEVEL_2 } from 'slate-dev-environment'
import EVENT_HANDLERS from '../constants/event-handlers'
import Node from './node'
@@ -29,15 +24,6 @@ const FIREFOX_NODE_TYPE_ACCESS_ERROR = /Permission denied to access property "no
const debug = Debug('slate:content')
/**
* Separate debug to easily see when the DOM has updated either by render or
* changing selection.
*
* @type {Function}
*/
debug.update = Debug('slate:update')
/**
* Content.
*
@@ -113,7 +99,7 @@ class Content extends React.Component {
// COMPAT: Restrict scope of `beforeinput` to clients that support the
// Input Events Level 2 spec, since they are preventable events.
if (HAS_INPUT_EVENTS_LEVEL_2 || ANDROID_API_VERSION === 28) {
if (HAS_INPUT_EVENTS_LEVEL_2) {
this.element.addEventListener('beforeinput', this.handlers.onBeforeInput)
}
@@ -134,7 +120,7 @@ class Content extends React.Component {
)
}
if (HAS_INPUT_EVENTS_LEVEL_2 || ANDROID_API_VERSION === 28) {
if (HAS_INPUT_EVENTS_LEVEL_2) {
this.element.removeEventListener(
'beforeinput',
this.handlers.onBeforeInput
@@ -147,14 +133,6 @@ class Content extends React.Component {
*/
componentDidUpdate() {
debug.update('componentDidUpdate')
// NOTE:
// Don't disable `updateSelection` on Android. Clicking a word and a
// suggestion breaks on API27. It does fix the crazy jumping cursor loop
// when doing an auto-suggest on a fully enclosed text with bold though.
// Most likely it still needs other fix issues though.
//
// if (IS_ANDROID) return
this.updateSelection()
}
@@ -170,7 +148,6 @@ class Content extends React.Component {
const window = getWindow(this.element)
const native = window.getSelection()
const { activeElement } = window.document
debug.update('updateSelection', { selection: selection.toJSON() })
// COMPAT: In Firefox, there's a but where `getSelection` can return `null`.
// https://bugzilla.mozilla.org/show_bug.cgi?id=827585 (2018/11/07)
@@ -283,7 +260,6 @@ class Content extends React.Component {
if (updated) {
debug('updateSelection', { selection, native, activeElement })
debug.update('updateSelection-applied', { selection })
}
}
@@ -363,12 +339,7 @@ class Content extends React.Component {
// cases we don't need to trigger any changes, since our internal model is
// already up to date, but we do want to update the native selection again
// to make sure it is in sync. (2017/10/16)
//
// ANDROID: The updateSelection causes issues in Android when you are
// at the end of a black. The selection ends up to the left of the inserted
// character instead of to the right. This behavior continues even if
// you enter more than one character. (2019/01/03)
if (!IS_ANDROID && handler == 'onSelect') {
if (handler == 'onSelect') {
const { editor } = this.props
const { value } = editor
const { selection } = value
@@ -490,12 +461,6 @@ class Content extends React.Component {
debug('render', { props })
debug.update('render', {
text: value.document.text,
selection: value.selection.toJSON(),
value: value.toJSON(),
})
return (
<Container
{...handlers}

View File

@@ -1,219 +0,0 @@
# Android
The following is a list of unexpected behaviors in Android
# Debugging
```
slate:android,slate:before,slate:after,slate:update,slate:reconcile
```
# API 25
### Backspace Handling
There appears to be no way to discern that backspace was pressed by looking at the events. There are two options (1) check the DOM and (2) look for a signature in the mutation.
For (1) we may be able to look at the current block and see if it disappears in the `input` event. If it no longer appears there, we know a `backspace` is likely what happened.
* Join previous paragraph
* keydown:Unidentified
* DOM change
* input:native
* mutation
* input:react
* keyup
# API 28
### Backspace Handling
* join previous paragraph
* compositionEnd
* keydown:Unidentified
* beforeinput:deleteContentBackward
* DOM change
* input:deleteContentBackward
## In the middle of a word, space then backspace
When you select in `edit|able` then press space and backspace we end up with `editble`.
When you hit `space` the composition hasn't ended.
## Two Words. One.
Type two words followed by a period. Then one word followed by a period.
The space after the second period is deleted. It does not happen if there is only one word followed by a period.
This text exhibits that issue when typed in a blank paragraph:
```
It me. No.
```
When we hit the period, here are the events:
* onCompositionEnd
* onKeyDown:Unidentified
* onBeforeInput:native:insertText "."
* onBeforeInput:synthetic:TextEvent data:"."
* onInput:insertText data:"."
* onSelect
* onKeyDown:Unidentified
* onBeforeInput:deleteContentBackward
* onInput:deleteContentBackward
# API 26/27
Although there are minor differences, API 26/27 behave similarly.
### It me. No. Failure with uppercase I.
Touch away from original selection position. Touch into a blank line. Wait for the keyboard to display uppercase letters. If it doesn't, this bug won't present itself.
Type `It me. No.` and upon hitting the final `.` you will end up with unexpected value which is usually removing the first `.` and putting the cursor behind it.
### Data in Input
In API 27, in certain situations, the `input` event returns data which lets us identify, for example, that a '.' was the last character typed. Other times, it does not provide this data.
If you start typing a line and the first character capitalizes itself, then you will not receive the data. If you start typing a line and the first character stays lower case, you will receive the data.
Because of this, `data` is not a reliable source of information even when it is available because in other scenarios it may not be.
### Backspace Handling
* Save the state using a snapshot during a `keydown` event as it may end up being a delete. The DOM is in a good before state at this time.
* Look at the `input` event to see if `event.nativeEvent.inputType` is equal to `deleteContentBackward`. If it is, then we are going to have to handle a delete but the DOM is already damaged.
* If we are handling a delete then:
* stop the `reconciler` which was started at `compositionEnd` because we don't need to reconcile anything as we will be reverting dom then deleting.
* start the `deleter` which will revert to the snapshot then execute the delete command within Slate after a `requestAnimationFrame` time.
* HOWEVER! if an `onBeforeInput` is called before the `deleter` handler is executed, we now know that it wasn't a delete after all and instead it was responding to a text change from a suggestion. In this case:
* cancel the `deleter`
* resume the `reconciler`
### Enter Handling
* Save the state using a snapshot during a `compositionEnd` event as it may end up being an `enter`. The DOM is in a good before state at this time.
* Look at the native version of the `beforeInput` event (two will fire a native and a React). Note: We manually forced Android to handle the native `beforeInput` by adding it to the `content` code.
* If the `event.nativeEvent.inputType` is equal to `insertParagraph` or `insertLineBreak` then we have determined that the user pressed enter at the end of a block (and only at the end of a block).
* If `enter is detected then:
* `preventDefault`
* set Slate's selection using the DOM
* call `splitBlock`
* Put some code in to make sure React's version of `beforeInput` doesn't fire by setting a variable. React's version will fire as it can't be cancelled from the native version even though we told it to stop.
* During React's version of `beforeInput`, if the `data` property which is a string ends in a linefeed (character code 10) then we know that it was an enter anywhere other than the end of block. At this point the DOM is already damaged.
* If we are handling an enter then:
* cancel the reconciler which was started from the `compositionEnd` event because we don't want reconciliation from the DOM to happen.
* wait until next animation frame
* revert to the last good state
* splitBlock using Slate
Events for different cases
* Start of word & Start of line
* compositionEnd
* keydown:Unidentified
* input:deleteContentBackward START DELETER
* keydown:Enter \*
* beforeInput:insertParagraph \*
* TOO LATE TO CANCEL
* Middle of word
* compositionEnd
* keydown:Unidentified
* input:deleteContentBackward START DELETER => SELF
* keydown:Unidentified
* beforeInput:CHR(10) at end \*
* TOO LATE TO CANCEL
* End of word
* compositionEnd
* keydown:Enter \*
* beforeInput:insertParagraph
* CANCELLABLE
* End of line
* keydown:Enter \*
* beforeInput:insertParagraph
* CANCELLABLE
Based on the previous cases:
* Use a snapshot if `input:deleteContentBackward` is detected before an Enter which is detected either by a `keydown:Enter` or a `beforeInput:insertParagraph` and we don't know which.
* Cancel the event if we detect a `keydown:Enter` without an immediately preceding `input:deleteContentBackward`.
### Enter at Start of Line
**TODO:**
* Go through all the steps in the Backspace handler. An enter at the beginning of a block looks exactly like a `delete` action at the beginning. The `reconciler` will be cancelled in the course of these events.
* A `keydown` event will fire with `event.key` === `Enter`. We need to set a variable `ENTER_START_OF_LINE` to `true`. Cancel the delete event and remove the reference.
* NOTE!!! Looks like splitting at other positions (not end of line) also provides an `Enter` and might be preferable to using the native `beforeInput` which we had to hack in!!! Try this!!!
* A `beforeinput` event will be called like in the `delete` code which usually cancels the `deleter` and resumes the `reconciler`. But since we removed the reference to the `deleter` neither of these methods are called.
# API 28
## DOM breaks when suggestion selected on text entirely within a `strong` tag
Appears similar to the bug in API 27.
## Can't hit Enter at begining of word API27 (probably 26 too)
WORKING ON THIS
## Can't split word with Enter (PARTIAL FIXED)
Move the cursor to `edit|able` where | is the cursor.
Hit `enter` on the virtual keyboard.
The `keydown` event does not indicate what key is being pressed so we don't know that we should be handling an enter. There are two opportunities:
1. The onBeforeInput event has a `data` property that contains the text immediately before the cursor and it includes `edit|` where the pipe indicates an enter.
2. We can look through the text at the end of a composition and simulate hitting enter maybe.
### Fixed for API 28
Allow enter to go through to the before plugin even during a compositiong and it works in API 28.
### Broken in API 27
# API 27
## Typing at end of line yields wrong cursor position (FIXED)
When you enter any text at the end of a block, the text gets entered in the wrong position.
### Fix
Fixed by ignoring the `updateSelection` code in `content.js` on the `onEvent` method if we are in Android. This doesn't ignore `updateSelection` altogether, only in that one place.
## Missing `onCompositionStart` (FIXED)
### Desciption
Insert a word using the virtual keyboard. Click outside the editor. Touch after the last letter in the word. This will display some suggestions. Click one. Selecting a suggestion will fire the `onCompositionEnd` but will not fire the corresponding `onCompositionStart` before it.
### Fix
Fixed by setting `isComposing` from the `onCompositionEnd` event until the `requestAnimationFrame` callback is executed.
## DOM breaks when suggestion selected on text entirely within a `strong` tag
Touch anywhere in the bold word "rich" in the example. Select an alternative recommendation and we get a failure.
Android is destroying the `strong` tag and replacing it with a `b` tag.
The problem does not present itself if the word is surrounding by spaces before the `strong` tag.
A possible fix may be to surround the word with a `ZERO WIDTH NO-BREAK SPACE` represented as `&#65279;` in HTML. It appears in React for empty paragraphs.#
## Other stuff
In API 28 and possibly other versions of Android, when you select inside an empty block, the block is not actually empty. It contains a `ZERO WIDTH NO-BREAK SPACE` which is `&#65729` or `\uFEFF`.
When the editor first starts, if you click immediately into an empty block, you will end up to the right of the zero-width space. Because of this, we don't get the all caps because I presume the editor only capitalizes the first characters and since the no break space is the first character it doesn't do this.
But also, as a side effect, you end up in a different editing mode which fires events differently. This breaks a bunch of things.
The fix (which I will be attempting) is to move the offset to `0` if we find ourselves in a block with the property `data-slate-zero-width="n"`.

View File

@@ -1,624 +0,0 @@
import Debug from 'debug'
import getWindow from 'get-window'
import pick from 'lodash/pick'
import API_VERSION from '../utils/android-api-version'
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 SlateSnapshot from '../utils/slate-snapshot'
import Executor from '../utils/executor'
const debug = Debug('slate:android')
debug.reconcile = Debug('slate:reconcile')
debug('API_VERSION', { 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 {SlateSnapshot} [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 {SlateSnapshot}
*/
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 (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 (API_VERSION) {
case 26:
case 27:
compositionEndSnapshot = new SlateSnapshot(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 (API_VERSION) {
case 24:
case 25:
break
case 26:
case 27:
case 28:
const { nativeEvent } = event
if (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 (API_VERSION === 26 || 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 (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 (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 SlateSnapshot(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 SlateSnapshot(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 (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

View File

@@ -1,64 +0,0 @@
import Debug from 'debug'
/**
* A plugin that adds the "before" browser-specific logic to the editor.
*
* @return {Object}
*/
function DebugPlugin(namespace) {
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug(namespace)
const events = [
'onBeforeInput',
'onBlur',
'onClick',
'onCompositionEnd',
'onCompositionStart',
'onCopy',
'onCut',
'onDragEnd',
'onDragEnter',
'onDragExit',
'onDragLeave',
'onDragOver',
'onDragStart',
'onDrop',
'onFocus',
'onInput',
'onKeyDown',
'onPaste',
'onSelect',
]
const plugin = {}
for (const eventName of events) {
plugin[eventName] = function(event, editor, next) {
debug(eventName, { event })
next()
}
}
/**
* Return the plugin.
*
* @type {Object}
*/
return plugin
}
/**
* Export.
*
* @type {Function}
*/
export default DebugPlugin

View File

@@ -1,6 +1,3 @@
import { IS_ANDROID } from 'slate-dev-environment'
import AndroidPlugin from './android'
import DebugPlugin from './debug'
import AfterPlugin from './after'
import BeforePlugin from './before'
@@ -13,15 +10,9 @@ import BeforePlugin from './before'
function DOMPlugin(options = {}) {
const { plugins = [] } = options
// Add Android specific handling separately before it gets to the other
// plugins because it is specific (other browser don't need it) and finicky
// (it has to come before other plugins to work).
const beforeBeforePlugins = IS_ANDROID
? [AndroidPlugin(), DebugPlugin('slate:debug')]
: []
const beforePlugin = BeforePlugin()
const afterPlugin = AfterPlugin()
return [...beforeBeforePlugins, beforePlugin, ...plugins, afterPlugin]
return [beforePlugin, ...plugins, afterPlugin]
}
/**

View File

@@ -1,49 +0,0 @@
import { IS_ANDROID } from 'slate-dev-environment'
/**
* Array of regular expression matchers and their API version
*
* @type {Array}
*/
const ANDROID_API_VERSIONS = [
[/^9([.]0|)/, 28],
[/^8[.]1/, 27],
[/^8([.]0|)/, 26],
[/^7[.]1/, 25],
[/^7([.]0|)/, 24],
[/^6([.]0|)/, 23],
[/^5[.]1/, 22],
[/^5([.]0|)/, 21],
[/^4[.]4/, 20],
]
/**
* get the Android API version from the userAgent
*
* @return {number} version
*/
function getApiVersion() {
if (!IS_ANDROID) return null
const { userAgent } = window.navigator
const matchData = userAgent.match(/Android\s([0-9\.]+)/)
if (matchData == null) return null
const versionString = matchData[1]
for (const tuple of ANDROID_API_VERSIONS) {
const [regex, version] = tuple
if (versionString.match(regex)) return version
}
return null
}
const API_VERSION = getApiVersion()
/**
* Export.
*
* type {number}
*/
export default API_VERSION

View File

@@ -1,16 +0,0 @@
/**
* Returns the closest element that matches the selector.
* Unlike the native `Element.closest` method, this doesn't require the
* starting node to be an Element.
*
* @param {Node} node to start at
* @param {String} css selector to match
* @return {Element} the closest matching element
*/
export default function closest(node, selector, win = window) {
if (node.nodeType === win.Node.TEXT_NODE) {
node = node.parentNode
}
return node.closest(selector)
}

View File

@@ -1,164 +0,0 @@
import getWindow from 'get-window'
/**
* Is the given node a text node?
*
* @param {node} node
* @param {Window} window
* @return {Boolean}
*/
function isTextNode(node, window) {
return node.nodeType === window.Node.TEXT_NODE
}
/**
* Takes a node and returns a snapshot of the node.
*
* @param {node} node
* @param {Window} window
* @return {object} element snapshot
*/
function getElementSnapshot(node, window) {
const snapshot = {}
snapshot.node = node
if (isTextNode(node, window)) {
snapshot.text = node.textContent
}
snapshot.children = Array.from(node.childNodes).map(childNode =>
getElementSnapshot(childNode, window)
)
return snapshot
}
/**
* Takes an array of elements and returns a snapshot
*
* @param {elements[]} elements
* @param {Window} window
* @return {object} snapshot
*/
function getSnapshot(elements, window) {
if (!elements.length) throw new Error(`elements must be an Array`)
const lastElement = elements[elements.length - 1]
const snapshot = {
elements: elements.map(element => getElementSnapshot(element, window)),
parent: lastElement.parentElement,
next: lastElement.nextElementSibling,
}
return snapshot
}
/**
* Takes an element snapshot and applies it to the element in the DOM.
* Basically, it fixes the DOM to the point in time that the snapshot was
* taken. This will put the DOM back in sync with React.
*
* @param {Object} snapshot
* @param {Window} window
*/
function applyElementSnapshot(snapshot, window) {
const el = snapshot.node
if (isTextNode(el, window)) {
// Update text if it is different
if (el.textContent !== snapshot.text) {
el.textContent = snapshot.text
}
}
snapshot.children.forEach(childSnapshot => {
applyElementSnapshot(childSnapshot, window)
el.appendChild(childSnapshot.node)
})
// remove children that shouldn't be there
const snapLength = snapshot.children.length
while (el.childNodes.length > snapLength) {
el.removeChild(el.childNodes[0])
}
// remove any clones from the DOM. This can happen when a block is split.
const { dataset } = el
if (!dataset) return // if there's no dataset, don't remove it
const key = dataset.key
if (!key) return // if there's no `data-key`, don't remove it
const dups = new window.Set(
Array.from(window.document.querySelectorAll(`[data-key='${key}']`))
)
dups.delete(el)
dups.forEach(dup => dup.parentElement.removeChild(dup))
}
/**
* Takes a snapshot and applies it to the DOM. Rearranges both the contents
* of the elements in the snapshot as well as putting the elements back into
* position relative to each other and also makes sure the last element is
* before the same element as it was when the snapshot was taken.
*
* @param {snapshot} snapshot
* @param {Window} window
*/
function applySnapshot(snapshot, window) {
const { elements, next, parent } = snapshot
elements.forEach(element => applyElementSnapshot(element, window))
const lastElement = elements[elements.length - 1].node
if (snapshot.next) {
parent.insertBefore(lastElement, next)
} else {
parent.appendChild(lastElement)
}
let prevElement = lastElement
for (let i = elements.length - 2; i >= 0; i--) {
const element = elements[i].node
parent.insertBefore(element, prevElement)
prevElement = element
}
}
/**
* A snapshot of one or more elements.
*/
export default class ElementSnapshot {
/**
* constructor
* @param {elements[]} elements - array of element to snapshot. Must be in order.
* @param {object} data - any arbitrary data you want to store with the snapshot
*/
constructor(elements, data) {
this.window = getWindow(elements[0])
this.snapshot = getSnapshot(elements, this.window)
this.data = data
}
/**
* apply the current snapshot to the DOM.
*/
apply() {
applySnapshot(this.snapshot, this.window)
}
/**
* get the data you passed into the constructor.
*
* @return {object} data
*/
getData() {
return this.data
}
}

View File

@@ -1,96 +0,0 @@
/**
* A function that does nothing
* @return {Function}
*/
function noop() {}
/**
* Creates an executor like a `resolver` or a `deleter` that handles
* delayed execution of a method using a `requestAnimationFrame` or `setTimeout`.
*
* Unlike a `requestAnimationFrame`, after a method is cancelled, it can be
* resumed. You can also optionally add a `timeout` after which time the
* executor is automatically cancelled.
*/
export default class Executor {
/**
* Executor
* @param {window} window
* @param {Function} fn - the function to execute when done
* @param {Object} options
*/
constructor(window, fn, options = {}) {
this.fn = fn
this.window = window
this.resume()
this.onCancel = options.onCancel
this.__setTimeout__(options.timeout)
}
__call__ = () => {
// I don't clear the timeout since it will be noop'ed anyways. Less code.
this.fn()
this.preventFurtherCalls() // Ensure you can only call the function once
}
/**
* Make sure that the function cannot be executed any more, even if other
* methods attempt to call `__call__`.
*/
preventFurtherCalls = () => {
this.fn = noop
}
/**
* Resume the executor's timer, usually after it has been cancelled.
*
* @param {Number} [ms] - how long to wait by default it is until next frame
*/
resume = ms => {
// in case resume is called more than once, we don't want old timers
// from executing because the `timeoutId` or `callbackId` is overwritten.
this.cancel()
if (ms) {
this.mode = 'timeout'
this.timeoutId = this.window.setTimeout(this.__call__, ms)
} else {
this.mode = 'animationFrame'
this.callbackId = this.window.requestAnimationFrame(this.__call__)
}
}
/**
* Cancel the executor from executing after the wait. This can be resumed
* with the `resume` method.
*/
cancel = () => {
if (this.mode === 'timeout') {
this.window.clearTimeout(this.timeoutId)
} else {
this.window.cancelAnimationFrame(this.callbackId)
}
if (this.onCancel) this.onCancel()
}
/**
* Sets a timeout after which this executor is automatically cancelled.
* @param {Number} ms
*/
__setTimeout__ = timeout => {
if (timeout == null) return
this.window.setTimeout(() => {
this.cancel()
this.preventFurtherCalls()
}, timeout)
}
}

View File

@@ -1,32 +0,0 @@
/**
* Fixes a selection within the DOM when the cursor is in Slate's special
* zero-width block. Slate handles empty blocks in a special manner and the
* cursor can end up either before or after the non-breaking space. This
* causes different behavior in Android and so we make sure the seleciton is
* always before the zero-width space.
*
* @param {Window} window
*/
export default function fixSelectionInZeroWidthBlock(window) {
const domSelection = window.getSelection()
const { anchorNode } = domSelection
const { dataset } = anchorNode.parentElement
const isZeroWidth = dataset ? dataset.slateZeroWidth === 'n' : false
// We are doing three checks to see if we need to move the cursor.
// Is this a zero-width slate span?
// Is the current cursor position not at the start of it?
// Is there more than one character (i.e. the zero-width space char) in here?
if (
isZeroWidth &&
anchorNode.textContent.length === 1 &&
domSelection.anchorOffset !== 0
) {
const range = window.document.createRange()
range.setStart(anchorNode, 0)
range.setEnd(anchorNode, 0)
domSelection.removeAllRanges()
domSelection.addRange(range)
}
}

View File

@@ -1,77 +0,0 @@
import findRange from './find-range'
export default function getSelectionFromDOM(window, editor, domSelection) {
const { value } = editor
const { document } = value
// If there are no ranges, the editor was blurred natively.
if (!domSelection.rangeCount) {
editor.blur()
return
}
// Otherwise, determine the Slate selection from the native one.
let range = findRange(domSelection, editor)
if (!range) {
return
}
const { anchor, focus } = range
const anchorText = document.getNode(anchor.key)
const focusText = document.getNode(focus.key)
const anchorInline = document.getClosestInline(anchor.key)
const focusInline = document.getClosestInline(focus.key)
const focusBlock = document.getClosestBlock(focus.key)
const anchorBlock = document.getClosestBlock(anchor.key)
// COMPAT: If the anchor point is at the start of a non-void, and the
// focus point is inside a void node with an offset that isn't `0`, set
// the focus offset to `0`. This is due to void nodes <span>'s being
// positioned off screen, resulting in the offset always being greater
// than `0`. Since we can't know what it really should be, and since an
// offset of `0` is less destructive because it creates a hanging
// selection, go with `0`. (2017/09/07)
if (
anchorBlock &&
!editor.isVoid(anchorBlock) &&
anchor.offset == 0 &&
focusBlock &&
editor.isVoid(focusBlock) &&
focus.offset != 0
) {
range = range.setFocus(focus.setOffset(0))
}
// COMPAT: If the selection is at the end of a non-void inline node, and
// there is a node after it, put it in the node after instead. This
// standardizes the behavior, since it's indistinguishable to the user.
if (
anchorInline &&
!editor.isVoid(anchorInline) &&
anchor.offset == anchorText.text.length
) {
const block = document.getClosestBlock(anchor.key)
const nextText = block.getNextText(anchor.key)
if (nextText) range = range.moveAnchorTo(nextText.key, 0)
}
if (
focusInline &&
!editor.isVoid(focusInline) &&
focus.offset == focusText.text.length
) {
const block = document.getClosestBlock(focus.key)
const nextText = block.getNextText(focus.key)
if (nextText) range = range.moveFocusTo(nextText.key, 0)
}
let selection = document.createSelection(range)
selection = selection.setIsFocused(true)
// Preserve active marks from the current selection.
// They will be cleared by `editor.select` if the selection actually moved.
selection = selection.set('marks', value.selection.marks)
return selection
}

View File

@@ -1,19 +0,0 @@
/**
* In Android API 26 and 27 we can tell if the input key was pressed by
* waiting for the `beforeInput` event and seeing that the last character
* of its `data` property is char code `10`.
*
* Note that at this point it is too late to prevent the event from affecting
* the DOM so we use other methods to clean the DOM up after we have detected
* the input.
*
* @param {String} data
* @return {Boolean}
*/
export default function isInputDataEnter(data) {
if (data == null) return false
const lastChar = data[data.length - 1]
const charCode = lastChar.charCodeAt(0)
return charCode === 10
}

View File

@@ -1,17 +0,0 @@
/**
* In Android sometimes the only way to tell what the user is trying to do
* is to look at an event's `data` property and see if the last characters
* matches a character. This method helps us make that determination.
*
* @param {String} data
* @param {[String]} chars
* @return {Boolean}
*/
export default function isInputDataLastChar(data, chars) {
if (!Array.isArray(chars))
throw new Error(`chars must be an array of one character strings`)
if (data == null) return false
const lastChar = data[data.length - 1]
return chars.includes(lastChar)
}

View File

@@ -1,14 +1,77 @@
import getSelectionFromDOM from './get-selection-from-dom'
/**
* Looks at the DOM and generates the equivalent Slate Selection.
*
* @param {Window} window
* @param {Editor} editor
* @param {Selection} domSelection - The DOM's selection Object
*/
import findRange from './find-range'
export default function setSelectionFromDOM(window, editor, domSelection) {
const selection = getSelectionFromDOM(window, editor, domSelection)
const { value } = editor
const { document } = value
// If there are no ranges, the editor was blurred natively.
if (!domSelection.rangeCount) {
editor.blur()
return
}
// Otherwise, determine the Slate selection from the native one.
let range = findRange(domSelection, editor)
if (!range) {
return
}
const { anchor, focus } = range
const anchorText = document.getNode(anchor.key)
const focusText = document.getNode(focus.key)
const anchorInline = document.getClosestInline(anchor.key)
const focusInline = document.getClosestInline(focus.key)
const focusBlock = document.getClosestBlock(focus.key)
const anchorBlock = document.getClosestBlock(anchor.key)
// COMPAT: If the anchor point is at the start of a non-void, and the
// focus point is inside a void node with an offset that isn't `0`, set
// the focus offset to `0`. This is due to void nodes <span>'s being
// positioned off screen, resulting in the offset always being greater
// than `0`. Since we can't know what it really should be, and since an
// offset of `0` is less destructive because it creates a hanging
// selection, go with `0`. (2017/09/07)
if (
anchorBlock &&
!editor.isVoid(anchorBlock) &&
anchor.offset == 0 &&
focusBlock &&
editor.isVoid(focusBlock) &&
focus.offset != 0
) {
range = range.setFocus(focus.setOffset(0))
}
// COMPAT: If the selection is at the end of a non-void inline node, and
// there is a node after it, put it in the node after instead. This
// standardizes the behavior, since it's indistinguishable to the user.
if (
anchorInline &&
!editor.isVoid(anchorInline) &&
anchor.offset == anchorText.text.length
) {
const block = document.getClosestBlock(anchor.key)
const nextText = block.getNextText(anchor.key)
if (nextText) range = range.moveAnchorTo(nextText.key, 0)
}
if (
focusInline &&
!editor.isVoid(focusInline) &&
focus.offset == focusText.text.length
) {
const block = document.getClosestBlock(focus.key)
const nextText = block.getNextText(focus.key)
if (nextText) range = range.moveFocusTo(nextText.key, 0)
}
let selection = document.createSelection(range)
selection = selection.setIsFocused(true)
// Preserve active marks from the current selection.
// They will be cleared by `editor.select` if the selection actually moved.
selection = selection.set('marks', value.selection.marks)
editor.select(selection)
}

View File

@@ -1,19 +1,5 @@
import findPoint from './find-point'
/**
* setTextFromDomNode lets us take a domNode and reconcile the text in the
* editor's Document such that it reflects the text in the DOM. This is the
* opposite of what the Editor usually does which takes the Editor's Document
* and React modifies the DOM to match. The purpose of this method is for
* composition changes where we don't know what changes the user made by
* looking at events. Instead we wait until the DOM is in a safe state, we
* read from it, and update the Editor's Document.
*
* @param {Window} window
* @param {Editor} editor
* @param {Node} domNode
*/
export default function setTextFromDomNode(window, editor, domNode) {
const point = findPoint(domNode, 0, editor)
if (!point) return

View File

@@ -1,52 +0,0 @@
import closest from './closest'
import getSelectionFromDom from './get-selection-from-dom'
import ElementSnapshot from './element-snapshot'
/**
* A SlateSnapshot remembers the state of elements at a given point in time
* and also remembers the state of the Editor at that time as well.
* The state can be applied to the DOM at a time in the future.
*/
export default class SlateSnapshot {
/**
* Constructor.
*
* @param {Window} window
* @param {Editor} editor
* @param {Boolean} options.before - should we remember the element before the one passed in
*/
constructor(window, editor, { before = false } = {}) {
const domSelection = window.getSelection()
const { anchorNode } = domSelection
const subrootEl = closest(anchorNode, '[data-slate-editor] > *')
const elements = [subrootEl]
// The before option is for when we need to take a snapshot of the current
// subroot and the element before when the user hits the backspace key.
if (before) {
const { previousElementSibling } = subrootEl
if (previousElementSibling) {
elements.unshift(previousElementSibling)
}
}
this.snapshot = new ElementSnapshot(elements)
this.selection = getSelectionFromDom(window, editor, domSelection)
}
/**
* Apply the snapshot to the DOM and set the selection in the Editor.
*
* @param {Editor} editor
*/
apply(editor) {
if (editor == null) throw new Error('editor is required')
const { snapshot, selection } = this
snapshot.apply()
editor.moveTo(selection.anchor.key, selection.anchor.offset)
}
}