mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-13 18:53:59 +02:00
Android 8 and 9 Support (#2565)
* Allow the dev server to work for non localhost host * Refactored set-selection-from-dom into utils as prep for Android support * Show debug onInput at start if triggered * Added and refactored to use set-text-from-dom-node with improved set selection after input * Remove unnecessary console.log in set-text-from-dom-node * Fixes to pass linter * Adds basic composition to Android API27 including fixing one bug where compositionStart does not fire * Fix some of the enter handling in API 27 and 28 * Add fixes for API 25 * Add debug for slate:update instead of separate render and updateSelection * Add API 26 fix for ignoring all but Enter in onKeyDown * Fix enter on Android 26 and 27 * Revert onSelect bug. Editor API 26 and 27 stable-ish * Fix enter at beginning and end of word in API 26 and 27 * Fix enter handling at end of line API 26 and 27 * Fix reversion of enter bug when not at end of line * Rename enter to linefeed which is more accurate * Fix backspace on Android 27 and 28 * Fix enter at end of line then backspace then enter bug in API 26 and 27 * Refactor to simplify reading code * Refactor to use executor and fix the suggestion problem * Fix multi point edit in API 27/28 * Update Android documentation on enter handling * Fix enter in API 26/27 and document 4 different enter cases * Refactor partial into SlateSnapshot * Complete SlateSnapshot refactor * Remove unnecessary plugin comments * Add smoke tests * Rename smoke tests to composition in exmaples * Fix API28 split join and insertion * Fix space then backspace in middle of word bug in API 28 * Add text for middle word space and backspace bug * Add note that the space backspace bug does not exist on API 27 * Fix 'It me. No.' bug in API 26/27 * Fix comments * Update comments to fit Slate style guide * Move a debug statement * Fix zero-width selection placement bug. * Fix 'it is' then enter in middle of 'it' bug * Partial fix of enter, backspace, enter in word * Add and fix comments. Fix selection in zero-width for API26-27 * Fix linting * Fix documentation * Remove snapback from packages * Remove snapback from yarn.lock * Rename SlateSnapshot to DomSnapshot * Remove guard on DomSnapshot apply method * Remove debug plugin from plugins/dom * Remove unnecessary comment in content.js componentDidUpdate * Remove closest and add function into dom-snapshot directly * Remove unused DebugPlugin * Move Android detection related code into slate-dev-environment * Capitalize to Number in JSDoc * Add API version and Input Events Level 2 for Android * Add input events level 2 for android without matching api version * Fix line failures
This commit is contained in:
@@ -29,6 +29,7 @@ import RTL from './rtl'
|
|||||||
import ReadOnly from './read-only'
|
import ReadOnly from './read-only'
|
||||||
import RichText from './rich-text'
|
import RichText from './rich-text'
|
||||||
import SearchHighlighting from './search-highlighting'
|
import SearchHighlighting from './search-highlighting'
|
||||||
|
import Composition from './composition'
|
||||||
import InputTester from './input-tester'
|
import InputTester from './input-tester'
|
||||||
import SyncingOperations from './syncing-operations'
|
import SyncingOperations from './syncing-operations'
|
||||||
import Tables from './tables'
|
import Tables from './tables'
|
||||||
@@ -43,6 +44,7 @@ import Mentions from './mentions'
|
|||||||
const EXAMPLES = [
|
const EXAMPLES = [
|
||||||
['Check Lists', CheckLists, '/check-lists'],
|
['Check Lists', CheckLists, '/check-lists'],
|
||||||
['Code Highlighting', CodeHighlighting, '/code-highlighting'],
|
['Code Highlighting', CodeHighlighting, '/code-highlighting'],
|
||||||
|
['Composition', Composition, '/composition/:subpage?'],
|
||||||
['Embeds', Embeds, '/embeds'],
|
['Embeds', Embeds, '/embeds'],
|
||||||
['Emojis', Emojis, '/emojis'],
|
['Emojis', Emojis, '/emojis'],
|
||||||
['Forced Layout', ForcedLayout, '/forced-layout'],
|
['Forced Layout', ForcedLayout, '/forced-layout'],
|
||||||
@@ -262,11 +264,13 @@ export default class App extends React.Component {
|
|||||||
<Switch>
|
<Switch>
|
||||||
{EXAMPLES.map(([name, Component, path]) => (
|
{EXAMPLES.map(([name, Component, path]) => (
|
||||||
<Route key={path} path={path}>
|
<Route key={path} path={path}>
|
||||||
|
{({ match }) => (
|
||||||
<div>
|
<div>
|
||||||
<ExampleContent>
|
<ExampleContent>
|
||||||
<Component />
|
<Component params={match.params} />
|
||||||
</ExampleContent>
|
</ExampleContent>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
))}
|
))}
|
||||||
<Redirect from="/" to="/rich-text" />
|
<Redirect from="/" to="/rich-text" />
|
||||||
|
401
examples/composition/index.js
Normal file
401
examples/composition/index.js
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
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
|
15
examples/composition/insert.js
Normal file
15
examples/composition/insert.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
23
examples/composition/special.js
Normal file
23
examples/composition/special.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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(''),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
18
examples/composition/split-join.js
Normal file
18
examples/composition/split-join.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
15
examples/composition/util.js
Normal file
15
examples/composition/util.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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' }] }
|
||||||
|
}
|
119
examples/composition/value.json
Normal file
119
examples/composition/value.json
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"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!"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@@ -95,8 +95,10 @@
|
|||||||
"bootstrap": "lerna bootstrap && yarn build",
|
"bootstrap": "lerna bootstrap && yarn build",
|
||||||
"build": "rollup --config ./support/rollup/config.js",
|
"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: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",
|
"clean": "lerna run clean && rm -rf ./node_modules ./dist ./build",
|
||||||
"gh-pages": "gh-pages --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": "yarn lint:eslint && yarn lint:prettier",
|
||||||
"lint:eslint": "eslint benchmark packages/*/src packages/*/test examples/*/*.js examples/dev/*/*.js",
|
"lint:eslint": "eslint benchmark packages/*/src packages/*/test examples/*/*.js examples/dev/*/*.js",
|
||||||
"lint:prettier": "prettier --list-different '**/*.{md,json,css}'",
|
"lint:prettier": "prettier --list-different '**/*.{md,json,css}'",
|
||||||
|
@@ -4,7 +4,11 @@ import Types from 'prop-types'
|
|||||||
import getWindow from 'get-window'
|
import getWindow from 'get-window'
|
||||||
import warning from 'tiny-warning'
|
import warning from 'tiny-warning'
|
||||||
import throttle from 'lodash/throttle'
|
import throttle from 'lodash/throttle'
|
||||||
import { IS_FIREFOX, HAS_INPUT_EVENTS_LEVEL_2 } from 'slate-dev-environment'
|
import {
|
||||||
|
IS_ANDROID,
|
||||||
|
IS_FIREFOX,
|
||||||
|
HAS_INPUT_EVENTS_LEVEL_2,
|
||||||
|
} from 'slate-dev-environment'
|
||||||
|
|
||||||
import EVENT_HANDLERS from '../constants/event-handlers'
|
import EVENT_HANDLERS from '../constants/event-handlers'
|
||||||
import Node from './node'
|
import Node from './node'
|
||||||
@@ -24,6 +28,15 @@ const FIREFOX_NODE_TYPE_ACCESS_ERROR = /Permission denied to access property "no
|
|||||||
|
|
||||||
const debug = Debug('slate:content')
|
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.
|
* Content.
|
||||||
*
|
*
|
||||||
@@ -133,6 +146,7 @@ class Content extends React.Component {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate() {
|
||||||
|
debug.update('componentDidUpdate')
|
||||||
this.updateSelection()
|
this.updateSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +162,7 @@ class Content extends React.Component {
|
|||||||
const window = getWindow(this.element)
|
const window = getWindow(this.element)
|
||||||
const native = window.getSelection()
|
const native = window.getSelection()
|
||||||
const { activeElement } = window.document
|
const { activeElement } = window.document
|
||||||
|
debug.update('updateSelection', { selection: selection.toJSON() })
|
||||||
|
|
||||||
// COMPAT: In Firefox, there's a but where `getSelection` can return `null`.
|
// COMPAT: In Firefox, there's a but where `getSelection` can return `null`.
|
||||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=827585 (2018/11/07)
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=827585 (2018/11/07)
|
||||||
@@ -261,6 +276,7 @@ class Content extends React.Component {
|
|||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
debug('updateSelection', { selection, native, activeElement })
|
debug('updateSelection', { selection, native, activeElement })
|
||||||
|
debug.update('updateSelection-applied', { selection })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +356,12 @@ class Content extends React.Component {
|
|||||||
// cases we don't need to trigger any changes, since our internal model is
|
// 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
|
// already up to date, but we do want to update the native selection again
|
||||||
// to make sure it is in sync. (2017/10/16)
|
// to make sure it is in sync. (2017/10/16)
|
||||||
if (handler === 'onSelect') {
|
//
|
||||||
|
// ANDROID: The updateSelection causes issues in Android when you are
|
||||||
|
// at the end of a block. 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') {
|
||||||
const { editor } = this.props
|
const { editor } = this.props
|
||||||
const { value } = editor
|
const { value } = editor
|
||||||
const { selection } = value
|
const { selection } = value
|
||||||
@@ -462,6 +483,12 @@ class Content extends React.Component {
|
|||||||
|
|
||||||
debug('render', { props })
|
debug('render', { props })
|
||||||
|
|
||||||
|
debug.update('render', {
|
||||||
|
text: value.document.text,
|
||||||
|
selection: value.selection.toJSON(),
|
||||||
|
value: value.toJSON(),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
{...handlers}
|
{...handlers}
|
||||||
|
219
packages/slate-react/src/plugins/ANDROID.md
Normal file
219
packages/slate-react/src/plugins/ANDROID.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 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 `` 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 `𐃁` 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"`.
|
624
packages/slate-react/src/plugins/android.js
Normal file
624
packages/slate-react/src/plugins/android.js
Normal file
@@ -0,0 +1,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
|
64
packages/slate-react/src/plugins/debug.js
Normal file
64
packages/slate-react/src/plugins/debug.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
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
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { IS_ANDROID } from 'slate-dev-environment'
|
||||||
|
import AndroidPlugin from './android'
|
||||||
import AfterPlugin from './after'
|
import AfterPlugin from './after'
|
||||||
import BeforePlugin from './before'
|
import BeforePlugin from './before'
|
||||||
|
|
||||||
@@ -10,9 +12,13 @@ import BeforePlugin from './before'
|
|||||||
|
|
||||||
function DOMPlugin(options = {}) {
|
function DOMPlugin(options = {}) {
|
||||||
const { plugins = [] } = 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()] : []
|
||||||
const beforePlugin = BeforePlugin()
|
const beforePlugin = BeforePlugin()
|
||||||
const afterPlugin = AfterPlugin()
|
const afterPlugin = AfterPlugin()
|
||||||
return [beforePlugin, ...plugins, afterPlugin]
|
return [...beforeBeforePlugins, beforePlugin, ...plugins, afterPlugin]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
49
packages/slate-react/src/utils/android-api-version.js
Normal file
49
packages/slate-react/src/utils/android-api-version.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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
|
67
packages/slate-react/src/utils/dom-snapshot.js
Normal file
67
packages/slate-react/src/utils/dom-snapshot.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import getSelectionFromDom from './get-selection-from-dom'
|
||||||
|
import ElementSnapshot from './element-snapshot'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
function closest(node, selector, win = window) {
|
||||||
|
if (node.nodeType === win.Node.TEXT_NODE) {
|
||||||
|
node = node.parentNode
|
||||||
|
}
|
||||||
|
return node.closest(selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DomSnapshot 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 DomSnapshot {
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
const { snapshot, selection } = this
|
||||||
|
snapshot.apply()
|
||||||
|
editor.moveTo(selection.anchor.key, selection.anchor.offset)
|
||||||
|
}
|
||||||
|
}
|
164
packages/slate-react/src/utils/element-snapshot.js
Normal file
164
packages/slate-react/src/utils/element-snapshot.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
96
packages/slate-react/src/utils/executor.js
Normal file
96
packages/slate-react/src/utils/executor.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
77
packages/slate-react/src/utils/get-selection-from-dom.js
Normal file
77
packages/slate-react/src/utils/get-selection-from-dom.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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
|
||||||
|
}
|
19
packages/slate-react/src/utils/is-input-data-enter.js
Normal file
19
packages/slate-react/src/utils/is-input-data-enter.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
17
packages/slate-react/src/utils/is-input-data-last-char.js
Normal file
17
packages/slate-react/src/utils/is-input-data-last-char.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
@@ -1,77 +1,14 @@
|
|||||||
import findRange from './find-range'
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
export default function setSelectionFromDOM(window, editor, domSelection) {
|
export default function setSelectionFromDOM(window, editor, domSelection) {
|
||||||
const { value } = editor
|
const selection = getSelectionFromDOM(window, editor, domSelection)
|
||||||
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)
|
editor.select(selection)
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,19 @@
|
|||||||
import findPoint from './find-point'
|
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) {
|
export default function setTextFromDomNode(window, editor, domNode) {
|
||||||
const point = findPoint(domNode, 0, editor)
|
const point = findPoint(domNode, 0, editor)
|
||||||
if (!point) return
|
if (!point) return
|
||||||
|
Reference in New Issue
Block a user