1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-11 01:33:58 +02:00

Merge branch 'master' of github.com:ianstormtaylor/slate

This commit is contained in:
Ian Storm Taylor
2019-05-21 13:24:02 -07:00
35 changed files with 979 additions and 280 deletions

View File

@@ -538,8 +538,8 @@ Wrap the given node in a [`Inline`](./inline.md) node that match `properties`. F
### `wrapNodeByKey/Path`
`wraNodeByKey(key: String, parent: Node) => Editor` <br/>
`wraNodeByPath(path: List, parent: Node) => Editor` <br/>
`wrapNodeByKey(key: String, parent: Node) => Editor` <br/>
`wrapNodeByPath(path: List, parent: Node) => Editor` <br/>
Wrap the node with the specified key with the parent [`Node`](./node.md). This will clear all children of the parent.

View File

@@ -11,6 +11,8 @@ A text node in a Slate [`Document`](./document.md). Text nodes are always the bo
```js
Text({
key: String,
text: String,
marks: Immutable.List<Mark>,
})
```
@@ -20,20 +22,24 @@ Text({
A unique identifier for the node.
### `text`
`String`
The text contents of this node.
### `marks`
`Immutable.List<Mark>,`
A list of marks for this node.
### `object`
`String`
An immutable string value of `'text'` for easily separating this node from [`Inline`](./inline.md) or [`Block`](./block.md) nodes.
## Computed Properties
### `text`
`String`
A concatenated string of all of the characters in the text node.
## Static Methods
### `Text.create`

View File

@@ -89,18 +89,18 @@ class App extends React.Component {
render() {
return (
// Pass in the `renderNode` prop...
// Pass in the `renderBlock` prop...
<Editor
value={this.state.value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
renderNode={this.renderNode}
renderBlock={this.renderBlock}
/>
)
}
// Add a `renderNode` method to render a `CodeNode` for code blocks.
renderNode = (props, editor, next) => {
// Add a `renderBlock` method to render a `CodeNode` for code blocks.
renderBlock = (props, editor, next) => {
switch (props.node.type) {
case 'code':
return <CodeNode {...props} />
@@ -148,12 +148,12 @@ class App extends React.Component {
value={this.state.value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
renderNode={this.renderNode}
renderBlock={this.renderBlock}
/>
)
}
renderNode = (props, editor, next) => {
renderBlock = (props, editor, next) => {
switch (props.node.type) {
case 'code':
return <CodeNode {...props} />
@@ -206,12 +206,12 @@ class App extends React.Component {
value={this.state.value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
renderNode={this.renderNode}
renderBlock={this.renderBlock}
/>
)
}
renderNode = (props, editor, next) => {
renderBlock = (props, editor, next) => {
switch (props.node.type) {
case 'code':
return <CodeNode {...props} />

View File

@@ -258,7 +258,7 @@ class App extends React.Component {
value={this.state.value}
onChange={this.onChange}
// Add the ability to render our nodes and marks...
renderNode={this.renderNode}
renderBlock={this.renderNode}
renderMark={this.renderMark}
/>
)

View File

@@ -27,6 +27,7 @@ import PlainText from './plain-text'
import Plugins from './plugins'
import RTL from './rtl'
import ReadOnly from './read-only'
import RestoreDOM from './restore-dom'
import RichText from './rich-text'
import SearchHighlighting from './search-highlighting'
import Composition from './composition'
@@ -63,6 +64,7 @@ const EXAMPLES = [
['Plain Text', PlainText, '/plain-text'],
['Plugins', Plugins, '/plugins'],
['Read-only', ReadOnly, '/read-only'],
['Restore DOM', RestoreDOM, '/restore-dom'],
['Rich Text', RichText, '/rich-text'],
['RTL', RTL, '/rtl'],
['Search Highlighting', SearchHighlighting, '/search-highlighting'],

View File

@@ -19,6 +19,52 @@ export const Button = React.forwardRef(
)
)
export const EditorValue = React.forwardRef(
({ className, value, ...props }, ref) => {
const textLines = value.document.nodes
.map(node => node.text)
.toArray()
.join('\n')
return (
<div
ref={ref}
{...props}
className={cx(
className,
css`
margin: 30px -20px 0;
`
)}
>
<div
className={css`
font-size: 14px;
padding: 5px 20px;
color: #404040;
border-top: 2px solid #eeeeee;
background: #f8f8f8;
`}
>
Slate's value as text
</div>
<div
className={css`
color: #404040;
font: 12px monospace;
white-space: pre-wrap;
padding: 10px 20px;
div {
margin: 0 0 0.5em;
}
`}
>
{textLines}
</div>
</div>
)
}
)
export const Icon = React.forwardRef(({ className, ...props }, ref) => (
<span
{...props}
@@ -34,6 +80,23 @@ export const Icon = React.forwardRef(({ className, ...props }, ref) => (
/>
))
export const Instruction = React.forwardRef(({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={cx(
className,
css`
white-space: pre-wrap;
margin: 0 -20px 10px;
padding: 10px 20px;
font-size: 14px;
background: #f8f8e8;
`
)}
/>
))
export const Menu = React.forwardRef(({ className, ...props }, ref) => (
<div
{...props}

View File

@@ -8,7 +8,7 @@ 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'
import { Button, EditorValue, Icon, Instruction, Toolbar } from '../components'
import { ANDROID_API_VERSION } from 'slate-dev-environment'
/**
@@ -25,23 +25,11 @@ const DEFAULT_NODE = 'paragraph'
* @type {Component}
*/
const Instruction = props => (
<div
{...props}
className={css`
white-space: pre-wrap;
margin: -1em -1em 1em;
padding: 0.5em;
background: #eee;
`}
/>
)
const Tabs = props => (
<div
{...props}
className={css`
margin-bottom: 0.5em;
margin: -10px -10px 0;
`}
/>
)
@@ -52,11 +40,13 @@ const Tab = ({ active, ...props }) => (
className={css`
display: inline-block;
text-decoration: none;
color: black;
background: ${active ? '#AAA' : '#DDD'};
padding: 0.25em 0.5em;
border-radius: 0.25em;
font-size: 14px;
color: ${active ? 'black' : '#808080'};
background: ${active ? '#f8f8e8' : '#fff'};
padding: 10px;
margin-right: 0.25em;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
`}
/>
)
@@ -73,45 +63,6 @@ const Version = props => (
/>
)
const EditorText = props => (
<div
{...props}
className={css`
color: #808080;
background: #f0f0f0;
font: 12px monospace;
white-space: pre-wrap;
margin: 1em -1em;
padding: 0.5em;
div {
margin: 0 0 0.5em;
}
`}
/>
)
const EditorTextCaption = props => (
<div
{...props}
className={css`
color: white;
background: #808080;
padding: 0.5em;
`}
/>
)
/**
* Extract lines of text from `Value`
*
* @return {String[]}
*/
function getTextLines(value) {
return value.document.nodes.map(node => node.text).toArray()
}
/**
* Subpages which are each a smoke test.
*
@@ -210,29 +161,23 @@ class RichTextExample extends React.Component {
render() {
const { text } = this.state
if (text == null) return <Redirect to="/composition/split-join" />
const textLines = getTextLines(this.state.value)
// const textLines = getTextLines(this.state.value)
return (
<div>
<Tabs>
{SUBPAGES.map(([name, Component, subpage]) => {
const active = subpage === this.props.params.subpage
return (
<Tab key={subpage} to={`/composition/${subpage}`} active={active}>
{name}
</Tab>
)
})}
<Version>
{ANDROID_API_VERSION ? `Android API ${ANDROID_API_VERSION}` : null}
</Version>
</Tabs>
<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>
)
})}
<Version>
{ANDROID_API_VERSION
? `Android API ${ANDROID_API_VERSION}`
: null}
</Version>
</Tabs>
<div>{this.state.text}</div>
</Instruction>
<Toolbar>
@@ -257,12 +202,7 @@ class RichTextExample extends React.Component {
renderBlock={this.renderBlock}
renderMark={this.renderMark}
/>
<EditorText>
<EditorTextCaption>Text in Slate's `Value`</EditorTextCaption>
{textLines.map((line, index) => (
<div key={index}>{line.length > 0 ? line : ' '}</div>
))}
</EditorText>
<EditorValue value={this.state.value} />
</div>
)
}

View File

@@ -1,23 +1,23 @@
import { p, bold } from './util'
import { p, text, 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(text('')),
p(
bold(
'Cursor to "mid|dle" then press space, backspace, space, backspace. Should say "middle".'
)
),
p('The middle word.'),
p(text('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(''),
p(text('')),
],
},
}

View File

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

View File

@@ -1,4 +1,4 @@
import { Editor, getEventRange, getEventTransfer } from 'slate-react'
import { Editor, getEventTransfer } from 'slate-react'
import { Block, Value } from 'slate'
import React from 'react'
@@ -181,7 +181,7 @@ class Images extends React.Component {
*/
onDropOrPaste = (event, editor, next) => {
const target = getEventRange(event, editor)
const target = editor.findEventRange(event)
if (!target && event.type === 'drop') return next()
const transfer = getEventTransfer(event)

View File

@@ -0,0 +1,224 @@
import { Editor } from 'slate-react'
import { Value } from 'slate'
import React from 'react'
import initialValue from './value.json'
import { Button, EditorValue, Icon, Instruction, Toolbar } from '../components'
/**
* The Restore DOM example.
*
* This shows the usage of the `restoreDOM` command to rebuild the editor from
* scratch causing all the nodes to force render even if there are no changes
* to the DOM.
*
* The `onClickHighlight` method changes the internal state but normally the
* change is not rendered because there is no change to Slate's internal
* `value`.
*
* RestoreDOM also blows away the old render which makes it safe if the DOM
* has been altered outside of React.
*
* @type {Component}
*/
class RestoreDOMExample extends React.Component {
/**
* Deserialize the initial editor value and set an initial highlight color.
*
* @type {Object}
*/
state = {
value: Value.fromJSON(initialValue),
bgcolor: '#ffeecc',
}
/**
* Store a reference to the `editor`.
*
* @param {Editor} editor
*/
ref = editor => {
this.editor = editor
}
/**
* Render.
*
* @return {Element}
*/
render() {
return (
<div>
<Instruction>
<ol>
<li>
Click a brush to change color in state. Use refresh button to
`restoreDOM` which renders changes.
</li>
<li>
Press `!` button to corrupt DOM by removing `bold`. Backspace from
start of `text` 5 times. Console will show error but Slate will
recover by restoring DOM.
</li>
</ol>
</Instruction>
<Toolbar>
{this.renderHighlightButton('#ffeecc')}
{this.renderHighlightButton('#ffffcc')}
{this.renderHighlightButton('#ccffcc')}
{this.renderCorruptButton()}
{this.renderRestoreButton()}
</Toolbar>
<Editor
spellCheck
autoFocus
placeholder="Enter some text..."
ref={this.ref}
value={this.state.value}
onChange={this.onChange}
renderBlock={this.renderBlock}
renderMark={this.renderMark}
/>
<EditorValue value={this.state.value} />
</div>
)
}
/**
* Render a highlight button
*
* @param {String} bgcolor
* @return {Element}
*/
renderHighlightButton = bgcolor => {
const isActive = this.state.bgcolor === bgcolor
return (
<Button
active={isActive}
onMouseDown={event => this.onClickHighlight(bgcolor)}
style={{ backgroundColor: bgcolor }}
>
<Icon>format_paint</Icon>
</Button>
)
}
/**
* Render restoreDOM button
*/
renderRestoreButton = () => {
const { editor } = this
function restoreDOM() {
editor.restoreDOM()
}
return (
<Button onMouseDown={restoreDOM}>
<Icon>refresh</Icon>
</Button>
)
}
/**
* Render a button to corrupt the DOM
*
*@return {Element}
*/
renderCorruptButton = () => {
/**
* Corrupt the DOM by forcibly deleting the first instance we can find
* of the `bold` text in the DOM.
*/
function corrupt() {
const boldEl = window.document.querySelector('[data-bold]')
const el = boldEl.closest('[data-slate-object="text"]')
el.parentNode.removeChild(el)
}
return (
<Button onMouseDown={corrupt}>
<Icon>error_outline</Icon>
</Button>
)
}
/**
* Highlight every block with a given background color
*
* @param {String} bgcolor
*/
onClickHighlight = bgcolor => {
this.setState({ bgcolor })
}
/**
* Render a Slate block.
*
* @param {Object} props
* @return {Element}
*/
renderBlock = (props, editor, next) => {
const { attributes, children, node } = props
const style = { backgroundColor: this.state.bgcolor }
switch (node.type) {
case 'paragraph':
return (
<p {...attributes} style={style}>
{children}
</p>
)
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':
// Added `data-bold` so we can find bold text with `querySelector`
return (
<strong {...attributes} data-bold>
{children}
</strong>
)
default:
return next()
}
}
/**
* On change, save the new `value`.
*
* @param {Editor} editor
*/
onChange = ({ value }) => {
this.setState({ value })
}
}
/**
* Export.
*/
export default RestoreDOMExample

View File

@@ -0,0 +1,65 @@
{
"object": "value",
"document": {
"object": "document",
"nodes": [
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"text": "First block with "
},
{
"object": "text",
"text": "bold",
"marks": [{ "type": "bold" }]
},
{
"object": "text",
"text": " text in it"
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"text": "Second block with "
},
{
"object": "text",
"text": "bold",
"marks": [{ "type": "bold" }]
},
{
"object": "text",
"text": " text in it"
}
]
},
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"text": "Third block with "
},
{
"object": "text",
"text": "bold",
"marks": [{ "type": "bold" }]
},
{
"object": "text",
"text": " text in it"
}
]
}
]
}
}

View File

@@ -53,6 +53,7 @@ class Content extends React.Component {
static propTypes = {
autoCorrect: Types.bool.isRequired,
className: Types.string,
contentKey: Types.number,
editor: Types.object.isRequired,
id: Types.string,
readOnly: Types.bool.isRequired,
@@ -74,6 +75,22 @@ class Content extends React.Component {
tagName: 'div',
}
/**
* An error boundary. If there is a render error, we increment `errorKey`
* which is part of the container `key` which forces a re-render from
* scratch.
*
* @param {Error} error
* @param {String} info
*/
componentDidCatch(error, info) {
debug('componentDidCatch', { error, info })
// The call to `setState` is required despite not setting a value.
// Without this call, React will not try to recreate the component tree.
this.setState({})
}
/**
* Temporary values.
*
@@ -486,6 +503,7 @@ class Content extends React.Component {
return (
<Container
key={this.props.contentKey}
{...handlers}
{...data}
ref={this.ref}

View File

@@ -79,7 +79,7 @@ class Editor extends React.Component {
* @type {Object}
*/
state = { value: this.props.defaultValue }
state = { value: this.props.defaultValue, contentKey: 0 }
/**
* Temporary values.
@@ -151,6 +151,7 @@ class Editor extends React.Component {
const { options, readOnly, value: valueFromProps } = this.props
const { value: valueFromState } = this.state
const value = valueFromProps || valueFromState
const { contentKey } = this.state
this.controller.setReadOnly(readOnly)
this.controller.setValue(value, options)
@@ -170,6 +171,7 @@ class Editor extends React.Component {
ref={this.tmp.contentRef}
autoCorrect={autoCorrect}
className={className}
contentKey={contentKey}
editor={this}
id={id}
onEvent={(handler, event) => this.run(handler, event)}

View File

@@ -201,10 +201,11 @@ Leaf.propTypes = {
const MemoizedLeaf = React.memo(Leaf, (prev, next) => {
return (
next.block === prev.block &&
next.index === prev.index &&
next.marks === prev.marks &&
next.parent === prev.parent &&
next.block === prev.block &&
next.text === prev.text &&
next.annotations.equals(prev.annotations) &&
next.decorations.equals(prev.decorations)
)

View File

@@ -1,4 +1,3 @@
import getSelectionFromDom from '../../utils/get-selection-from-dom'
import ElementSnapshot from './element-snapshot'
import SELECTORS from '../../constants/selectors'
@@ -51,7 +50,7 @@ export default class DomSnapshot {
}
this.snapshot = new ElementSnapshot(elements)
this.selection = getSelectionFromDom(window, editor, domSelection)
this.selection = editor.findSelection(domSelection)
}
/**

View File

@@ -5,7 +5,6 @@ import pick from 'lodash/pick'
import { ANDROID_API_VERSION } from 'slate-dev-environment'
import fixSelectionInZeroWidthBlock from './fix-selection-in-zero-width-block'
import getSelectionFromDom from '../../utils/get-selection-from-dom'
import setTextFromDomNode from '../../utils/set-text-from-dom-node'
import isInputDataEnter from './is-input-data-enter'
import isInputDataLastChar from './is-input-data-last-char'
import DomSnapshot from './dom-snapshot'
@@ -133,10 +132,10 @@ function AndroidPlugin() {
function reconcile(window, editor, { from }) {
debug.reconcile({ from })
const domSelection = window.getSelection()
const selection = getSelectionFromDom(window, editor, domSelection)
const selection = editor.findSelection(domSelection)
nodes.forEach(node => {
setTextFromDomNode(window, editor, node)
editor.reconcileDOMNode(node)
})
editor.select(selection)

View File

@@ -0,0 +1,111 @@
import Debug from 'debug'
import EVENT_HANDLERS from '../../constants/event-handlers'
import stringifyEvent from './stringify-event'
/**
* Constants
*/
const INTERVAL = 2000
/**
* Debug events function.
*
* @type {Function}
*/
const debug = Debug('slate:batch-events')
/**
* A plugin that sends short easy to digest debug info about each event to
* browser.
*
* @return {Object}
*/
function DebugBatchEventsPlugin() {
/**
* When the batch started
*
* @type {Date}
*/
let startDate = null
/**
* The timeoutId used to cancel the timeout
*
* @type {Any}
*/
let timeoutId = null
/**
* An array of events not yet dumped with `debug`
*
* @type {Array}
*/
const events = []
/**
* Send all events to debug
*
* Note: Formatted so it can easily be cut and pasted as text for analysis or
* documentation.
*/
function dumpEvents() {
debug(`\n${events.join('\n')}`)
events.length = 0
}
/**
* Push an event on to the Array of events for debugging in a batch
*
* @param {Event} event
*/
function pushEvent(event) {
if (events.length === 0) {
startDate = new Date()
}
const s = stringifyEvent(event)
const now = new Date()
events.push(`- ${now - startDate} - ${s}`)
clearTimeout(timeoutId)
timeoutId = setTimeout(dumpEvents, INTERVAL)
}
/**
* Plugin Object
*
* @type {Object}
*/
const plugin = {}
for (const eventName of EVENT_HANDLERS) {
plugin[eventName] = function(event, editor, next) {
pushEvent(event)
next()
}
}
/**
* Return the plugin.
*
* @type {Object}
*/
return plugin
}
/**
* Export.
*
* @type {Function}
*/
export default DebugBatchEventsPlugin

View File

@@ -0,0 +1,52 @@
import Debug from 'debug'
import EVENT_HANDLERS from '../../constants/event-handlers'
import stringifyEvent from './stringify-event'
/**
* Debug events function.
*
* @type {Function}
*/
const debug = Debug('slate:events')
/**
* A plugin that sends short easy to digest debug info about each event to
* browser.
*
* @return {Object}
*/
function DebugEventsPlugin() {
/**
* Plugin Object
*
* @type {Object}
*/
const plugin = {}
for (const eventName of EVENT_HANDLERS) {
plugin[eventName] = function(event, editor, next) {
const s = stringifyEvent(event)
debug(s)
next()
}
}
/**
* Return the plugin.
*
* @type {Object}
*/
return plugin
}
/**
* Export.
*
* @type {Function}
*/
export default DebugEventsPlugin

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

@@ -0,0 +1,21 @@
/**
* Takes a React Synthetic Event or a DOM Event and turns it into a String that
* is easy to log. It's succinct and keeps info to a bare minimum.
*
* @param {Event} event
*/
export default function stringifyEvent(event) {
const e = event.nativeEvent || event
switch (e.type) {
case 'keydown':
return `${e.type} ${JSON.stringify(e.key)}`
case 'input':
case 'beforeinput':
case 'textInput':
return `${e.type}:${e.inputType} ${JSON.stringify(e.data)}`
default:
return e.type
}
}

View File

@@ -8,7 +8,6 @@ import { IS_IOS, IS_IE, IS_EDGE } from 'slate-dev-environment'
import cloneFragment from '../../utils/clone-fragment'
import getEventTransfer from '../../utils/get-event-transfer'
import setEventTransfer from '../../utils/set-event-transfer'
import setTextFromDomNode from '../../utils/set-text-from-dom-node'
/**
* Debug.
@@ -432,7 +431,7 @@ function AfterPlugin(options = {}) {
}
const { anchorNode } = domSelection
setTextFromDomNode(window, editor, anchorNode)
editor.reconcileDOMNode(anchorNode)
next()
}
@@ -552,7 +551,7 @@ function AfterPlugin(options = {}) {
if (Hotkeys.isExtendBackward(event)) {
const startText = document.getNode(start.path)
const prevEntry = document.texts({
const [prevEntry] = document.texts({
path: start.path,
direction: 'backward',
})

View File

@@ -271,7 +271,7 @@ function BeforePlugin() {
// default, and calling `preventDefault` hides the cursor.
const node = editor.findNode(event.target)
if (editor.isVoid(node)) {
if (!node || editor.isVoid(node)) {
event.preventDefault()
}

View File

@@ -0,0 +1,72 @@
/**
* A set of commands for the React plugin.
*
* @return {Object}
*/
function CommandsPlugin() {
/**
* Takes a `node`, find the matching `domNode` and uses it to set the text
* in the `node`.
*
* @param {Editor} editor
* @param {Node} node
*/
function reconcileNode(editor, node) {
const { value } = editor
const { document, selection } = value
const path = document.getPath(node.key)
const domElement = editor.findDOMNode(path)
const block = document.getClosestBlock(path)
// Get text information
const { text } = node
let { textContent: domText } = domElement
const isLastNode = block.nodes.last() === node
const lastChar = domText.charAt(domText.length - 1)
// COMPAT: If this is the last leaf, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account
// for browsers collapsing a single trailing new lines, so remove it.
if (isLastNode && lastChar === '\n') {
domText = domText.slice(0, -1)
}
// If the text is no different, abort.
if (text === domText) return
let entire = selection.moveAnchorTo(path, 0).moveFocusTo(path, text.length)
entire = document.resolveRange(entire)
// Change the current value to have the leaf's text replaced.
editor.insertTextAtRange(entire, domText, node.marks)
return
}
/**
* Takes text from the `domNode` and uses it to set the text in the matching
* `node` in Slate.
*
* @param {Editor} editor
* @param {DOMNode} domNode
*/
function reconcileDOMNode(editor, domNode) {
const domElement = domNode.parentElement.closest('[data-key]')
const node = editor.findNode(domElement)
editor.reconcileNode(node)
}
return {
commands: {
reconcileNode,
reconcileDOMNode,
},
}
}
export default CommandsPlugin

View File

@@ -1,9 +1,14 @@
import Debug from 'debug'
import PlaceholderPlugin from 'slate-react-placeholder'
import EditorPropsPlugin from './editor-props'
import RenderingPlugin from './rendering'
import CommandsPlugin from './commands'
import QueriesPlugin from './queries'
import DOMPlugin from '../dom'
import RestoreDOMPlugin from './restore-dom'
import DebugEventsPlugin from '../debug/debug-events'
import DebugBatchEventsPlugin from '../debug/debug-batch-events'
/**
* A plugin that adds the React-specific rendering logic to the editor.
@@ -14,13 +19,20 @@ import DOMPlugin from '../dom'
function ReactPlugin(options = {}) {
const { placeholder = '', plugins = [] } = options
const debugEventsPlugin = Debug.enabled('slate:events')
? DebugEventsPlugin(options)
: null
const debugBatchEventsPlugin = Debug.enabled('slate:batch-events')
? DebugBatchEventsPlugin(options)
: null
const renderingPlugin = RenderingPlugin(options)
const commandsPlugin = CommandsPlugin(options)
const queriesPlugin = QueriesPlugin(options)
const editorPropsPlugin = EditorPropsPlugin(options)
const domPlugin = DOMPlugin({
plugins: [editorPropsPlugin, ...plugins],
})
const restoreDomPlugin = RestoreDOMPlugin()
const placeholderPlugin = PlaceholderPlugin({
placeholder,
when: (editor, node) =>
@@ -30,7 +42,16 @@ function ReactPlugin(options = {}) {
Array.from(node.texts()).length === 1,
})
return [domPlugin, placeholderPlugin, renderingPlugin, queriesPlugin]
return [
debugEventsPlugin,
debugBatchEventsPlugin,
domPlugin,
restoreDomPlugin,
placeholderPlugin,
renderingPlugin,
commandsPlugin,
queriesPlugin,
]
}
/**

View File

@@ -23,6 +23,10 @@ function QueriesPlugin() {
path = PathUtils.create(path)
const content = editor.tmp.contentRef.current
if (!content) {
return null
}
if (!path.size) {
return content.ref.current || null
}
@@ -177,13 +181,13 @@ function QueriesPlugin() {
: y - rect.top < rect.top + rect.height - y
const range = document.createRange()
const iterable = isPrevious ? 'previousTexts' : 'nextTexts'
const move = isPrevious ? 'moveToEndOfNode' : 'moveToStartOfNode'
const entry = document[iterable](path)
const entry = document[isPrevious ? 'getPreviousText' : 'getNextText'](
path
)
if (entry) {
const [n] = entry
return range[move](n)
return range[move](entry)
}
return null
@@ -230,13 +234,24 @@ function QueriesPlugin() {
function findPath(editor, element) {
const content = editor.tmp.contentRef.current
let nodeElement = element
if (element === content.ref.current) {
// If element does not have a key, it is likely a string or
// mark, return the closest parent Node that can be looked up.
if (!nodeElement.hasAttribute(DATA_ATTRS.KEY)) {
nodeElement = nodeElement.closest(SELECTORS.KEY)
}
if (!nodeElement || !nodeElement.getAttribute(DATA_ATTRS.KEY)) {
return null
}
if (nodeElement === content.ref.current) {
return PathUtils.create([])
}
const search = (instance, p) => {
if (element === instance) {
if (nodeElement === instance) {
return p
}
@@ -244,7 +259,7 @@ function QueriesPlugin() {
return null
}
if (element === instance.ref.current) {
if (nodeElement === instance.ref.current) {
return p
}
@@ -483,11 +498,14 @@ function QueriesPlugin() {
anchor.offset === anchorText.text.length
) {
const block = document.getClosestBlock(anchor.path)
const [next] = block.texts({ path: anchor.path })
const depth = document.getDepth(block.key)
const relativePath = PathUtils.drop(anchor.path, depth)
const [next] = block.texts({ path: relativePath })
if (next) {
const [, nextPath] = next
range = range.moveAnchorTo(nextPath, 0)
const absolutePath = anchor.path.slice(0, depth).concat(nextPath)
range = range.moveAnchorTo(absolutePath, 0)
}
}
@@ -497,11 +515,14 @@ function QueriesPlugin() {
focus.offset === focusText.text.length
) {
const block = document.getClosestBlock(focus.path)
const [next] = block.texts({ path: focus.path })
const depth = document.getDepth(block.key)
const relativePath = PathUtils.drop(focus.path, depth)
const [next] = block.texts({ path: relativePath })
if (next) {
const [, nextPath] = next
range = range.moveFocusTo(nextPath, 0)
const absolutePath = focus.path.slice(0, depth).concat(nextPath)
range = range.moveFocusTo(absolutePath, 0)
}
}

View File

@@ -0,0 +1,21 @@
function RestoreDOMPlugin() {
/**
* Makes sure that on the next Content `render` the DOM is restored.
* This gets us around issues where the DOM is in a different state than
* React's virtual DOM and would crash.
*
* @param {Editor} editor
*/
function restoreDOM(editor) {
editor.setState({ contentKey: editor.state.contentKey + 1 })
}
return {
commands: {
restoreDOM,
},
}
}
export default RestoreDOMPlugin

View File

@@ -1,4 +1,5 @@
import warning from 'tiny-warning'
import { PathUtils } from 'slate'
import findRange from './find-range'
@@ -59,11 +60,14 @@ export default function getSelectionFromDOM(window, editor, domSelection) {
anchor.offset === anchorText.text.length
) {
const block = document.getClosestBlock(anchor.path)
const [next] = block.texts({ path: anchor.path })
const depth = document.getDepth(block.key)
const relativePath = PathUtils.drop(anchor.path, depth)
const [next] = block.texts({ path: relativePath })
if (next) {
const [, nextPath] = next
range = range.moveAnchorTo(nextPath, 0)
const absolutePath = anchor.path.slice(0, depth).concat(nextPath)
range = range.moveAnchorTo(absolutePath, 0)
}
}
@@ -73,11 +77,14 @@ export default function getSelectionFromDOM(window, editor, domSelection) {
focus.offset === focusText.text.length
) {
const block = document.getClosestBlock(focus.path)
const [next] = block.texts({ path: focus.path })
const depth = document.getDepth(block.key)
const relativePath = PathUtils.drop(focus.path, depth)
const [next] = block.texts({ path: relativePath })
if (next) {
const [, nextPath] = next
range = range.moveFocusTo(nextPath, 0)
const absolutePath = focus.path.slice(0, depth).concat(nextPath)
range = range.moveFocusTo(absolutePath, 0)
}
}

View File

@@ -1,67 +0,0 @@
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
// Get the text node and leaf in question.
const { value } = editor
const { document, selection } = value
const node = document.getDescendant(point.path)
const block = document.getClosestBlock(point.path)
const leaves = node.getLeaves()
const lastText = block.getLastText()
const lastLeaf = leaves.last()
let start = 0
let end = 0
const leaf =
leaves.find(r => {
start = end
end += r.text.length
if (end > point.offset) return true
}) || lastLeaf
// Get the text information.
const { text } = leaf
let { textContent } = domNode
const isLastText = node === lastText
const isLastLeaf = leaf === lastLeaf
const lastChar = textContent.charAt(textContent.length - 1)
// COMPAT: If this is the last leaf, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account
// for browsers collapsing a single trailing new lines, so remove it.
if (isLastText && isLastLeaf && lastChar === '\n') {
textContent = textContent.slice(0, -1)
}
// If the text is no different, abort.
if (textContent === text) return
// Determine what the selection should be after changing the text.
// const delta = textContent.length - text.length
// const corrected = selection.moveToEnd().moveForward(delta)
let entire = selection
.moveAnchorTo(point.path, start)
.moveFocusTo(point.path, end)
entire = document.resolveRange(entire)
// Change the current value to have the leaf's text replaced.
editor.insertTextAtRange(entire, textContent, leaf.marks)
}

View File

@@ -114,7 +114,8 @@ Commands.deleteAtRange = (editor, range) => {
endOffset === 0 &&
isStartVoid === false &&
startKey === startBlock.getFirstText().key &&
endKey === endBlock.getFirstText().key
endKey === endBlock.getFirstText().key &&
startKey !== endKey
// If it's a hanging selection, nudge it back to end in the previous text.
if (isHanging && isEndVoid) {
@@ -661,15 +662,11 @@ Commands.insertBlockAtRange = (editor, range, block) => {
const startInline = document.getClosestInline(startKey)
const parent = document.getParent(startBlock.key)
const index = parent.nodes.indexOf(startBlock)
const insertionMode = getInsertionMode(editor, range)
if (editor.isVoid(startBlock)) {
const extra = start.isAtEndOfNode(startBlock) ? 1 : 0
editor.insertNodeByKey(parent.key, index + extra, block)
} else if (!startInline && startBlock.text === '') {
editor.insertNodeByKey(parent.key, index + 1, block)
} else if (start.isAtStartOfNode(startBlock)) {
if (insertionMode === 'before') {
editor.insertNodeByKey(parent.key, index, block)
} else if (start.isAtEndOfNode(startBlock)) {
} else if (insertionMode === 'behind') {
editor.insertNodeByKey(parent.key, index + 1, block)
} else {
if (startInline && editor.isVoid(startInline)) {
@@ -693,6 +690,34 @@ Commands.insertBlockAtRange = (editor, range, block) => {
}
}
/**
* Check if current block should be split or new block should be added before or behind it.
*
* @param {Editor} editor
* @param {Range} range
*/
const getInsertionMode = (editor, range) => {
const { value } = editor
const { document } = value
const { start } = range
const startKey = start.key
const startBlock = document.getClosestBlock(startKey)
const startInline = document.getClosestInline(startKey)
if (editor.isVoid(startBlock)) {
if (start.isAtEndOfNode(startBlock)) return 'behind'
else return 'before'
} else if (!startInline && startBlock.text === '') {
return 'behind'
} else if (start.isAtStartOfNode(startBlock)) {
return 'before'
} else if (start.isAtEndOfNode(startBlock)) {
return 'behind'
}
return 'split'
}
/**
* Insert a `fragment` at a `range`.
*
@@ -743,7 +768,12 @@ Commands.insertFragmentAtRange = (editor, range, fragment) => {
insertionNode === fragment &&
(firstChild.hasBlockChildren() || lastChild.hasBlockChildren())
) {
fragment.nodes.reverse().forEach(node => {
// check if reversal is necessary or not
const insertionMode = getInsertionMode(editor, range)
const nodes =
insertionMode === 'before' ? fragment.nodes : fragment.nodes.reverse()
nodes.forEach(node => {
editor.insertBlockAtRange(range, node)
})
return

View File

@@ -278,7 +278,19 @@ Commands.insertFragment = (editor, fragment) => {
if (newText && (lastInline || isInserting)) {
editor.moveToEndOfNode(newText)
} else if (newText) {
editor.moveToStartOfNode(newText).moveForward(lastBlock.text.length)
// The position within the last text node needs to be calculated. This is the length
// of all text nodes within the last block, but if the last block contains inline nodes,
// these have to be skipped.
const { nodes } = lastBlock
const lastIndex = nodes.findLastIndex(
node => node && node.object === 'inline'
)
const remainingTexts = nodes.takeLast(nodes.size - lastIndex - 1)
const remainingTextLength = remainingTexts.reduce(
(acc, val) => acc + val.text.length,
0
)
editor.moveToStartOfNode(newText).moveForward(remainingTextLength)
}
}

View File

@@ -0,0 +1,29 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function(editor) {
editor.delete()
}
export const input = (
<value>
<document>
<paragraph>
<cursor />
word
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
<cursor />
word
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,35 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function(editor) {
editor.insertFragment(
<document>
<paragraph>
<text>one</text>
<inline type="link">Some inline stuff</inline>
<text>two</text>
</paragraph>
</document>
)
}
export const input = (
<value>
<document>
<paragraph>
A<cursor />B
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>
Aone<inline type="link">Some inline stuff</inline>two<cursor />B
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,40 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function(editor) {
editor.insertFragment(
<document>
<quote>
<quote>one</quote>
<quote>two</quote>
</quote>
<paragraph>after quote</paragraph>
</document>
)
}
export const input = (
<value>
<document>
<paragraph>
word<cursor />
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<paragraph>word</paragraph>
<quote>
<quote>one</quote>
<quote>two</quote>
</quote>
<paragraph>
after quote<cursor />
</paragraph>
</document>
</value>
)

View File

@@ -0,0 +1,40 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function(editor) {
editor.insertFragment(
<document>
<quote>
<quote>one</quote>
<quote>two</quote>
</quote>
<paragraph>after quote</paragraph>
</document>
)
}
export const input = (
<value>
<document>
<paragraph>
<cursor />word
</paragraph>
</document>
</value>
)
export const output = (
<value>
<document>
<quote>
<quote>one</quote>
<quote>two</quote>
</quote>
<paragraph>
after quote<cursor />
</paragraph>
<paragraph>word</paragraph>
</document>
</value>
)