1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-09-02 11:42:53 +02:00

refactor decorations to use selections (#1221)

* refactor decorations to use selections

* update docs

* cleanup

* add Selection.createList

* fix tests

* fix for nested blocks

* fix lint

* actually merge

* revert small change

* add state.decorations, with search example
This commit is contained in:
Ian Storm Taylor
2017-10-13 12:04:22 -07:00
committed by GitHub
parent 65ab5681d9
commit e53cee3942
32 changed files with 881 additions and 468 deletions

View File

@@ -103,24 +103,25 @@ Slate schemas are built up of a set of rules. Each of the properties will add ce
The `match` property is the only required property of a rule. It determines which objects the rule applies to. The `match` property is the only required property of a rule. It determines which objects the rule applies to.
### `decorate` ### `decorate`
`Function decorate(text: Node, object: Node) => List<Characters>` `Function decorate(node: Node) => List<Selection>|Array<Object>`
```js ```js
{ {
decorate: (text, node) => { decorate: (node) => {
let { characters } = text const text = node.getFirstText()
let first = characters.get(0)
let { marks } = first return [{
let mark = Mark.create({ type: 'bold' }) anchorKey: text.key,
marks = marks.add(mark) anchorOffset: 0,
first = first.merge({ marks }) focusKey: text.key,
characters = characters.set(0, first) focusOffset: 1,
return characters marks: [{ type: 'bold' }]
}]
} }
} }
``` ```
The `decorate` property allows you define a function that will apply extra marks to all of the ranges of text inside a node. It is called with a [`Text`](./text.md) node and the matched node. It should return a list of characters with the desired marks, which will then be added to the text before rendering. The `decorate` property allows you define a function that will apply extra marks to ranges of text inside a node. It is called with a [`Node`](./node.md). It should return a list of [`Selection`](./selection.md) objects with the desired marks, which will then be added to the text before rendering.
### `normalize` ### `normalize`
`Function normalize(change: Change, object: Node, failure: Any) => Change` `Function normalize(change: Change, object: Node, failure: Any) => Change`

View File

@@ -1,13 +1,13 @@
import { Editor } from 'slate-react' import { Editor } from 'slate-react'
import { Mark, State } from 'slate' import { State } from 'slate'
import Prism from 'prismjs' import Prism from 'prismjs'
import React from 'react' import React from 'react'
import initialState from './state.json' import initialState from './state.json'
/** /**
* Define a code block component. * Define our code components.
* *
* @param {Object} props * @param {Object} props
* @return {Element} * @return {Element}
@@ -40,43 +40,69 @@ function CodeBlock(props) {
) )
} }
function CodeBlockLine(props) {
return (
<div {...props.attributes}>{props.children}</div>
)
}
/** /**
* Define a Prism.js decorator for code blocks. * Define a Prism.js decorator for code blocks.
* *
* @param {Text} text
* @param {Block} block * @param {Block} block
* @return {Array}
*/ */
function codeBlockDecorator(text, block) { function codeBlockDecorator(block) {
const characters = text.characters.asMutable()
const language = block.data.get('language') const language = block.data.get('language')
const string = text.text const texts = block.getTexts().toArray()
const string = texts.map(t => t.text).join('\n')
const grammar = Prism.languages[language] const grammar = Prism.languages[language]
const tokens = Prism.tokenize(string, grammar) const tokens = Prism.tokenize(string, grammar)
let offset = 0 const decorations = []
let startText = texts.shift()
let endText = startText
let startOffset = 0
let endOffset = 0
let start = 0
for (const token of tokens) { for (const token of tokens) {
if (typeof token == 'string') { startText = endText
offset += token.length startOffset = endOffset
continue
const content = typeof token == 'string' ? token : token.content
const newlines = content.split('\n').length - 1
const length = content.length - newlines
const end = start + length
let available = startText.text.length - startOffset
let remaining = length
endOffset = startOffset + remaining
while (available < remaining) {
endText = texts.shift()
remaining = length - available
available = endText.text.length
endOffset = remaining
} }
const length = offset + token.content.length if (typeof token != 'string') {
const type = `highlight-${token.type}` const range = {
const mark = Mark.create({ type }) anchorKey: startText.key,
anchorOffset: startOffset,
for (let i = offset; i < length; i++) { focusKey: endText.key,
let char = characters.get(i) focusOffset: endOffset,
let { marks } = char marks: [{ type: `highlight-${token.type}` }],
marks = marks.add(mark)
char = char.set('marks', marks)
characters.set(i, char)
} }
offset = length decorations.push(range)
} }
return characters.asImmutable() start = end
}
return decorations
} }
/** /**
@@ -90,7 +116,10 @@ const schema = {
code: { code: {
render: CodeBlock, render: CodeBlock,
decorate: codeBlockDecorator, decorate: codeBlockDecorator,
} },
code_line: {
render: CodeBlockLine,
},
}, },
marks: { marks: {
'highlight-comment': { 'highlight-comment': {

View File

@@ -21,12 +21,172 @@
"data": { "data": {
"language": "js" "language": "js"
}, },
"nodes": [
{
"kind": "block",
"type": "code_line",
"nodes": [ "nodes": [
{ {
"kind": "text", "kind": "text",
"ranges": [ "ranges": [
{ {
"text": "// A simple FizzBuzz implementation.\nfor (var i = 1; i <= 100; i++) {\n if (i % 15 == 0) {\n console.log('Fizz Buzz');\n } else if (i % 5 == 0) {\n console.log('Buzz');\n } else if (i % 3 == 0) {\n console.log('Fizz');\n } else {\n console.log(i);\n }\n}" "text": "// A simple FizzBuzz implementation."
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "for (var i = 1; i <= 100; i++) {"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " if (i % 15 == 0) {"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " console.log('Fizz Buzz');"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " } else if (i % 5 == 0) {"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " console.log('Buzz');"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " } else if (i % 3 == 0) {"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " console.log('Fizz');"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " } else {"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " console.log(i);"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": " }"
}
]
}
]
},
{
"kind": "block",
"type": "code_line",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "}"
}
]
} }
] ]
} }

View File

@@ -61,6 +61,7 @@ td {
} }
input { input {
box-sizing: border-box;
font-size: .85em; font-size: .85em;
width: 100%; width: 100%;
padding: .5em; padding: .5em;
@@ -173,12 +174,29 @@ input:focus {
} }
.toolbar-menu { .toolbar-menu {
padding: 1px 0 17px 18px; position: relative;
padding: 1px 18px 17px;
margin: 0 -20px; margin: 0 -20px;
border-bottom: 2px solid #eee; border-bottom: 2px solid #eee;
margin-bottom: 20px; margin-bottom: 20px;
} }
.toolbar-menu .search {
position: relative;
}
.toolbar-menu .search-icon {
position: absolute;
top: 0.5em;
left: 0.5em;
color: #ccc;
}
.toolbar-menu .search-box {
padding-left: 2em;
width: 100%;
}
.hover-menu { .hover-menu {
padding: 8px 7px 6px; padding: 8px 7px 6px;
position: absolute; position: absolute;

View File

@@ -20,6 +20,7 @@ import Plugins from './plugins'
import RTL from './rtl' 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 Tables from './tables' import Tables from './tables'
import DevHugeDocument from './dev/huge-document' import DevHugeDocument from './dev/huge-document'
@@ -54,6 +55,7 @@ const EXAMPLES = [
['Code Highlighting', CodeHighlighting, '/code-highlighting'], ['Code Highlighting', CodeHighlighting, '/code-highlighting'],
['Tables', Tables, '/tables'], ['Tables', Tables, '/tables'],
['Paste HTML', PasteHtml, '/paste-html'], ['Paste HTML', PasteHtml, '/paste-html'],
['Search Highlighting', SearchHighlighting, '/search-highlighting'],
['Read-only', ReadOnly, '/read-only'], ['Read-only', ReadOnly, '/read-only'],
['RTL', RTL, '/rtl'], ['RTL', RTL, '/rtl'],
['Plugins', Plugins, '/plugins'], ['Plugins', Plugins, '/plugins'],

View File

@@ -0,0 +1,8 @@
# Rich Text Example
![](../../docs/images/rich-text-example.png)
This example shows you can add a very different concepts together: key commands, toolbars, and custom formatting, to get the functionality you'd expect from a rich text editor. Of course this is just the beginning, you can layer in whatever other behaviors you want!
Check out the [Examples readme](..) to see how to run it!

View File

@@ -0,0 +1,149 @@
import { Editor } from 'slate-react'
import { State } from 'slate'
import React from 'react'
import initialState from './state.json'
/**
* Define a schema.
*
* @type {Object}
*/
const schema = {
marks: {
highlight: {
backgroundColor: '#ffeeba'
}
}
}
/**
* The rich text example.
*
* @type {Component}
*/
class SearchHighlighting extends React.Component {
/**
* Deserialize the initial editor state.
*
* @type {Object}
*/
state = {
state: State.fromJSON(initialState),
}
/**
* On change, save the new `state`.
*
* @param {Change} change
*/
onChange = ({ state }) => {
this.setState({ state })
}
/**
* On input change, update the decorations.
*
* @param {Event} e
*/
onInputChange = (e) => {
const { state } = this.state
const string = e.target.value
const texts = state.document.getTexts()
const decorations = []
texts.forEach((node) => {
const { key, text } = node
const parts = text.split(string)
let offset = 0
parts.forEach((part, i) => {
if (i != 0) {
decorations.push({
anchorKey: key,
anchorOffset: offset - string.length,
focusKey: key,
focusOffset: offset,
marks: [{ type: 'highlight' }],
})
}
offset = offset + part.length + string.length
})
})
const change = state.change().setState({ decorations })
this.onChange(change)
}
/**
* Render.
*
* @return {Element}
*/
render() {
return (
<div>
{this.renderToolbar()}
{this.renderEditor()}
</div>
)
}
/**
* Render the toolbar.
*
* @return {Element}
*/
renderToolbar = () => {
return (
<div className="menu toolbar-menu">
<div className="search">
<span className="search-icon material-icons">search</span>
<input
className="search-box"
type="search"
placeholder="Search the text..."
onChange={this.onInputChange}
/>
</div>
</div>
)
}
/**
* Render the Slate editor.
*
* @return {Element}
*/
renderEditor = () => {
return (
<div className="editor">
<Editor
state={this.state.state}
onChange={this.onChange}
schema={schema}
placeholder={'Enter some rich text...'}
spellCheck
/>
</div>
)
}
}
/**
* Export.
*/
export default SearchHighlighting

View File

@@ -0,0 +1,34 @@
{
"document": {
"nodes": [
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "This is editable text that you can search. As you search, it looks for matching strings of text, and adds \"decoration\" marks to them in realtime."
}
]
}
]
},
{
"kind": "block",
"type": "paragraph",
"nodes": [
{
"kind": "text",
"ranges": [
{
"text": "Try it out for yourself by typing in the search box above!"
}
]
}
]
}
]
}
}

View File

@@ -13,6 +13,7 @@
"keycode": "^2.1.2", "keycode": "^2.1.2",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"react-portal": "^3.1.0", "react-portal": "^3.1.0",
"react-immutable-proptypes": "^2.1.0",
"selection-is-backward": "^1.0.0", "selection-is-backward": "^1.0.0",
"slate-base64-serializer": "^0.1.11", "slate-base64-serializer": "^0.1.11",
"slate-dev-logger": "^0.1.12", "slate-dev-logger": "^0.1.12",

View File

@@ -12,13 +12,13 @@ import TRANSFER_TYPES from '../constants/transfer-types'
import Node from './node' import Node from './node'
import extendSelection from '../utils/extend-selection' import extendSelection from '../utils/extend-selection'
import findClosestNode from '../utils/find-closest-node' import findClosestNode from '../utils/find-closest-node'
import getCaretPosition from '../utils/get-caret-position' import findDropPoint from '../utils/find-drop-point'
import findNativePoint from '../utils/find-native-point'
import findPoint from '../utils/find-point'
import getHtmlFromNativePaste from '../utils/get-html-from-native-paste' import getHtmlFromNativePaste from '../utils/get-html-from-native-paste'
import getPoint from '../utils/get-point'
import getDropPoint from '../utils/get-drop-point'
import getTransferData from '../utils/get-transfer-data' import getTransferData from '../utils/get-transfer-data'
import setTransferData from '../utils/set-transfer-data'
import scrollToSelection from '../utils/scroll-to-selection' import scrollToSelection from '../utils/scroll-to-selection'
import setTransferData from '../utils/set-transfer-data'
import { IS_FIREFOX, IS_MAC, IS_IE } from '../constants/environment' import { IS_FIREFOX, IS_MAC, IS_IE } from '../constants/environment'
/** /**
@@ -121,7 +121,7 @@ class Content extends React.Component {
*/ */
updateSelection = () => { updateSelection = () => {
const { editor, state } = this.props const { state } = this.props
const { selection } = state const { selection } = state
const window = getWindow(this.element) const window = getWindow(this.element)
const native = window.getSelection() const native = window.getSelection()
@@ -144,10 +144,8 @@ class Content extends React.Component {
// Otherwise, figure out which DOM nodes should be selected... // Otherwise, figure out which DOM nodes should be selected...
const { anchorKey, anchorOffset, focusKey, focusOffset, isCollapsed } = selection const { anchorKey, anchorOffset, focusKey, focusOffset, isCollapsed } = selection
const anchor = getCaretPosition(anchorKey, anchorOffset, state, editor, this.element) const anchor = findNativePoint(anchorKey, anchorOffset)
const focus = isCollapsed const focus = isCollapsed ? anchor : findNativePoint(focusKey, focusOffset)
? anchor
: getCaretPosition(focusKey, focusOffset, state, editor, this.element)
// If they are already selected, do nothing. // If they are already selected, do nothing.
if ( if (
@@ -432,12 +430,11 @@ class Content extends React.Component {
if (this.props.readOnly) return if (this.props.readOnly) return
const { editor, state } = this.props const { state } = this.props
const { nativeEvent } = event const { nativeEvent } = event
const { dataTransfer } = nativeEvent const { dataTransfer } = nativeEvent
const data = getTransferData(dataTransfer) const data = getTransferData(dataTransfer)
const point = getDropPoint(event, state, editor) const point = findDropPoint(event, state)
if (!point) return if (!point) return
// Add drop-specific information to the data. // Add drop-specific information to the data.
@@ -484,26 +481,33 @@ class Content extends React.Component {
// Get the selection point. // Get the selection point.
const native = window.getSelection() const native = window.getSelection()
const { anchorNode, anchorOffset } = native const { anchorNode, anchorOffset } = native
const point = getPoint(anchorNode, anchorOffset, state, editor) const point = findPoint(anchorNode, anchorOffset, state)
if (!point) return if (!point) return
// Get the range in question. // Get the text node and range in question.
const { key, index, start, end } = point
const { document, selection } = state const { document, selection } = state
const schema = editor.getSchema() const node = document.getDescendant(point.key)
const decorators = document.getDescendantDecorators(key, schema) const ranges = node.getRanges()
const node = document.getDescendant(key) let start = 0
const block = document.getClosestBlock(node.key) let end = 0
const ranges = node.getRanges(decorators)
const lastText = block.getLastText() const range = ranges.find((r) => {
end += r.text.length
if (end >= point.offset) return true
start = end
})
// Get the text information. // Get the text information.
const { text } = range
let { textContent } = anchorNode let { textContent } = anchorNode
const block = document.getClosestBlock(node.key)
const lastText = block.getLastText()
const lastRange = ranges.last()
const lastChar = textContent.charAt(textContent.length - 1) const lastChar = textContent.charAt(textContent.length - 1)
const isLastText = node == lastText const isLastText = node == lastText
const isLastRange = index == ranges.size - 1 const isLastRange = range == lastRange
// If we're dealing with the last leaf, and the DOM text ends in a new line, // COMPAT: If this is the last range, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account // 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. // for browsers collapsing a single trailing new lines, so remove it.
if (isLastText && isLastRange && lastChar == '\n') { if (isLastText && isLastRange && lastChar == '\n') {
@@ -511,26 +515,20 @@ class Content extends React.Component {
} }
// If the text is no different, abort. // If the text is no different, abort.
const range = ranges.get(index)
const { text, marks } = range
if (textContent == text) return if (textContent == text) return
// Determine what the selection should be after changing the text. // Determine what the selection should be after changing the text.
const delta = textContent.length - text.length const delta = textContent.length - text.length
const after = selection.collapseToEnd().move(delta) const corrected = selection.collapseToEnd().move(delta)
const entire = selection.moveAnchorTo(point.key, start).moveFocusTo(point.key, end)
// Change the current state to have the text replaced. // Change the current state to have the range's text replaced.
editor.change((change) => { editor.change((change) => {
change change
.select({ .select(entire)
anchorKey: key,
anchorOffset: start,
focusKey: key,
focusOffset: end
})
.delete() .delete()
.insertText(textContent, marks) .insertText(textContent, range.marks)
.select(after) .select(corrected)
}) })
} }
@@ -677,7 +675,7 @@ class Content extends React.Component {
if (!this.isInEditor(event.target)) return if (!this.isInEditor(event.target)) return
const window = getWindow(event.target) const window = getWindow(event.target)
const { state, editor } = this.props const { state } = this.props
const { document, selection } = state const { document, selection } = state
const native = window.getSelection() const native = window.getSelection()
const data = {} const data = {}
@@ -690,8 +688,8 @@ class Content extends React.Component {
// Otherwise, determine the Slate selection from the native one. // Otherwise, determine the Slate selection from the native one.
else { else {
const { anchorNode, anchorOffset, focusNode, focusOffset } = native const { anchorNode, anchorOffset, focusNode, focusOffset } = native
const anchor = getPoint(anchorNode, anchorOffset, state, editor) const anchor = findPoint(anchorNode, anchorOffset, state)
const focus = getPoint(focusNode, focusOffset, state, editor) const focus = findPoint(focusNode, focusOffset, state)
if (!anchor || !focus) return if (!anchor || !focus) return
// There are situations where a select event will fire with a new native // There are situations where a select event will fire with a new native
@@ -872,11 +870,14 @@ class Content extends React.Component {
renderNode = (child, isSelected) => { renderNode = (child, isSelected) => {
const { editor, readOnly, schema, state } = this.props const { editor, readOnly, schema, state } = this.props
const { document } = state const { document, decorations } = state
let decs = document.getDecorations(schema)
if (decorations) decs = decorations.concat(decs)
return ( return (
<Node <Node
block={null} block={null}
editor={editor} editor={editor}
decorations={decs}
isSelected={isSelected} isSelected={isSelected}
key={child.key} key={child.key}
node={child} node={child}

View File

@@ -100,6 +100,37 @@ class Leaf extends React.Component {
) )
} }
/**
* Render all of the leaf's mark components.
*
* @param {Object} props
* @return {Element}
*/
renderMarks(props) {
const { marks, schema, node, offset, text, state, editor } = props
const children = this.renderText(props)
return marks.reduce((memo, mark) => {
const Component = mark.getComponent(schema)
if (!Component) return memo
return (
<Component
editor={editor}
mark={mark}
marks={marks}
node={node}
offset={offset}
schema={schema}
state={state}
text={text}
>
{memo}
</Component>
)
}, children)
}
/** /**
* Render the text content of the leaf, accounting for browsers. * Render the text content of the leaf, accounting for browsers.
* *
@@ -136,37 +167,6 @@ class Leaf extends React.Component {
return text return text
} }
/**
* Render all of the leaf's mark components.
*
* @param {Object} props
* @return {Element}
*/
renderMarks(props) {
const { marks, schema, node, offset, text, state, editor } = props
const children = this.renderText(props)
return marks.reduce((memo, mark) => {
const Component = mark.getComponent(schema)
if (!Component) return memo
return (
<Component
editor={editor}
mark={mark}
marks={marks}
node={node}
offset={offset}
schema={schema}
state={state}
text={text}
>
{memo}
</Component>
)
}, children)
}
} }
/** /**

View File

@@ -1,6 +1,7 @@
import Base64 from 'slate-base64-serializer' import Base64 from 'slate-base64-serializer'
import Debug from 'debug' import Debug from 'debug'
import ImmutableTypes from 'react-immutable-proptypes'
import React from 'react' import React from 'react'
import SlateTypes from 'slate-prop-types' import SlateTypes from 'slate-prop-types'
import logger from 'slate-dev-logger' import logger from 'slate-dev-logger'
@@ -35,6 +36,7 @@ class Node extends React.Component {
static propTypes = { static propTypes = {
block: SlateTypes.block, block: SlateTypes.block,
decorations: ImmutableTypes.list.isRequired,
editor: Types.object.isRequired, editor: Types.object.isRequired,
isSelected: Types.bool.isRequired, isSelected: Types.bool.isRequired,
node: SlateTypes.node.isRequired, node: SlateTypes.node.isRequired,
@@ -136,6 +138,9 @@ class Node extends React.Component {
// need to be rendered again. // need to be rendered again.
if (n.isSelected || p.isSelected) return true if (n.isSelected || p.isSelected) return true
// If the decorations have changed, update.
if (!n.decorations.equals(p.decorations)) return true
// Otherwise, don't update. // Otherwise, don't update.
return false return false
} }
@@ -225,11 +230,13 @@ class Node extends React.Component {
*/ */
renderNode = (child, isSelected) => { renderNode = (child, isSelected) => {
const { block, editor, node, readOnly, schema, state } = this.props const { block, decorations, editor, node, readOnly, schema, state } = this.props
const Component = child.kind === 'text' ? Text : Node const Component = child.kind === 'text' ? Text : Node
const decs = decorations.concat(node.getDecorations(schema))
return ( return (
<Component <Component
block={node.kind == 'block' ? node : block} block={node.kind == 'block' ? node : block}
decorations={decs}
editor={editor} editor={editor}
isSelected={isSelected} isSelected={isSelected}
key={child.key} key={child.key}

View File

@@ -1,5 +1,6 @@
import Debug from 'debug' import Debug from 'debug'
import ImmutableTypes from 'react-immutable-proptypes'
import React from 'react' import React from 'react'
import SlateTypes from 'slate-prop-types' import SlateTypes from 'slate-prop-types'
import Types from 'prop-types' import Types from 'prop-types'
@@ -24,6 +25,7 @@ class Text extends React.Component {
static propTypes = { static propTypes = {
block: SlateTypes.block, block: SlateTypes.block,
decorations: ImmutableTypes.list.isRequired,
editor: Types.object.isRequired, editor: Types.object.isRequired,
node: SlateTypes.node.isRequired, node: SlateTypes.node.isRequired,
parent: SlateTypes.node.isRequired, parent: SlateTypes.node.isRequired,
@@ -63,16 +65,6 @@ class Text extends React.Component {
// for simplicity we just let them through. // for simplicity we just let them through.
if (n.node != p.node) return true if (n.node != p.node) return true
// Re-render if the current decorations have changed, even if the content of
// the text node itself hasn't.
if (n.schema.hasDecorators) {
const nDecorators = n.state.document.getDescendantDecorators(n.node.key, n.schema)
const pDecorators = p.state.document.getDescendantDecorators(p.node.key, p.schema)
const nRanges = n.node.getRanges(nDecorators)
const pRanges = p.node.getRanges(pDecorators)
if (!nRanges.equals(pRanges)) return true
}
// If the node parent is a block node, and it was the last child of the // If the node parent is a block node, and it was the last child of the
// block, re-render to cleanup extra `<br/>` or `\n`. // block, re-render to cleanup extra `<br/>` or `\n`.
if (n.parent.kind == 'block') { if (n.parent.kind == 'block') {
@@ -81,6 +73,9 @@ class Text extends React.Component {
if (p.node == pLast && n.node != nLast) return true if (p.node == pLast && n.node != nLast) return true
} }
// Re-render if the current decorations have changed.
if (!n.decorations.equals(p.decorations)) return true
// Otherwise, don't update. // Otherwise, don't update.
return false return false
} }
@@ -95,10 +90,19 @@ class Text extends React.Component {
const { props } = this const { props } = this
this.debug('render', { props }) this.debug('render', { props })
const { node, schema, state } = props const { decorations, node, state } = props
const { document } = state const { document } = state
const decorators = schema.hasDecorators ? document.getDescendantDecorators(node.key, schema) : [] const { key } = node
const ranges = node.getRanges(decorators)
const decs = decorations.filter((d) => {
const { startKey, endKey } = d
if (startKey == key || endKey == key) return true
const startsBefore = document.areDescendantsSorted(startKey, key)
const endsAfter = document.areDescendantsSorted(key, endKey)
return startsBefore && endsAfter
})
const ranges = node.getRanges(decs)
let offset = 0 let offset = 0
const leaves = ranges.map((range, i) => { const leaves = ranges.map((range, i) => {
@@ -108,7 +112,7 @@ class Text extends React.Component {
}) })
return ( return (
<span data-key={node.key}> <span data-key={key}>
{leaves} {leaves}
</span> </span>
) )

View File

@@ -8,8 +8,8 @@ import { Block, Inline, coreSchema } from 'slate'
import Content from '../components/content' import Content from '../components/content'
import Placeholder from '../components/placeholder' import Placeholder from '../components/placeholder'
import getPoint from '../utils/get-point'
import findDOMNode from '../utils/find-dom-node' import findDOMNode from '../utils/find-dom-node'
import findPoint from '../utils/find-point'
import { IS_CHROME, IS_MAC, IS_SAFARI } from '../constants/environment' import { IS_CHROME, IS_MAC, IS_SAFARI } from '../constants/environment'
/** /**
@@ -64,10 +64,9 @@ function Plugin(options = {}) {
* @param {Event} e * @param {Event} e
* @param {Object} data * @param {Object} data
* @param {Change} change * @param {Change} change
* @param {Editor} editor
*/ */
function onBeforeInput(e, data, change, editor) { function onBeforeInput(e, data, change) {
debug('onBeforeInput', { data }) debug('onBeforeInput', { data })
e.preventDefault() e.preventDefault()
@@ -82,8 +81,8 @@ function Plugin(options = {}) {
// the selection has gotten out of sync, and adjust it if so. (03/18/2017) // the selection has gotten out of sync, and adjust it if so. (03/18/2017)
const window = getWindow(e.target) const window = getWindow(e.target)
const native = window.getSelection() const native = window.getSelection()
const a = getPoint(native.anchorNode, native.anchorOffset, state, editor) const a = findPoint(native.anchorNode, native.anchorOffset, state)
const f = getPoint(native.focusNode, native.focusOffset, state, editor) const f = findPoint(native.focusNode, native.focusOffset, state)
const hasMismatch = a && f && ( const hasMismatch = a && f && (
anchorKey != a.key || anchorKey != a.key ||
anchorOffset != a.offset || anchorOffset != a.offset ||

View File

@@ -1,16 +1,22 @@
import { Node } from 'slate'
/** /**
* Find the DOM node for a `node`. * Find the DOM node for a `key`.
* *
* @param {Node} node * @param {String|Node} key
* @return {Element} * @return {Element}
*/ */
function findDOMNode(node) { function findDOMNode(key) {
const el = window.document.querySelector(`[data-key="${node.key}"]`) if (Node.isNode(key)) {
key = key.key
}
const el = window.document.querySelector(`[data-key="${key}"]`)
if (!el) { if (!el) {
throw new Error(`Unable to find a DOM node for "${node.key}". This is often because of forgetting to add \`props.attributes\` to a component returned from \`renderNode\`.`) throw new Error(`Unable to find a DOM node for "${key}". This is often because of forgetting to add \`props.attributes\` to a component returned from \`renderNode\`.`)
} }
return el return el

View File

@@ -2,18 +2,17 @@
import getWindow from 'get-window' import getWindow from 'get-window'
import findClosestNode from './find-closest-node' import findClosestNode from './find-closest-node'
import getPoint from './get-point' import findPoint from './find-point'
/** /**
* Get the target point for a drop event. * Find the target point for a drop `event`.
* *
* @param {Event} event * @param {Event} event
* @param {State} state * @param {State} state
* @param {Editor} editor
* @return {Object} * @return {Object}
*/ */
function getDropPoint(event, state, editor) { function findDropPoint(event, state) {
const { document } = state const { document } = state
const { nativeEvent, target } = event const { nativeEvent, target } = event
const { x, y } = nativeEvent const { x, y } = nativeEvent
@@ -48,7 +47,6 @@ function getDropPoint(event, state, editor) {
document.getNextSibling(nodeKey) document.getNextSibling(nodeKey)
const key = text.key const key = text.key
const offset = previous ? text.characters.size : 0 const offset = previous ? text.characters.size : 0
return { key, offset } return { key, offset }
} }
@@ -71,12 +69,10 @@ function getDropPoint(event, state, editor) {
const text = block.getLastText() const text = block.getLastText()
const { key } = text const { key } = text
const offset = 0 const offset = 0
return { key, offset } return { key, offset }
} }
const point = getPoint(n, o, state, editor) const point = findPoint(n, o, state)
return point return point
} }
@@ -86,4 +82,4 @@ function getDropPoint(event, state, editor) {
* @type {Function} * @type {Function}
*/ */
export default getDropPoint export default findDropPoint

View File

@@ -0,0 +1,45 @@
import getWindow from 'get-window'
import findDOMNode from './find-dom-node'
/**
* Find a native DOM selection point from a Slate `key` and `offset`.
*
* @param {Element} root
* @param {String} key
* @param {Number} offset
* @return {Object}
*/
function findNativePoint(key, offset) {
const el = findDOMNode(key)
if (!el) return null
const window = getWindow(el)
const iterator = window.document.createNodeIterator(el, NodeFilter.SHOW_TEXT)
let start = 0
let n
while (n = iterator.nextNode()) {
const { length } = n.textContent
const end = start + length
if (offset <= end) {
const o = offset - start
return { node: n, offset: o }
}
start = end
}
return null
}
/**
* Export.
*
* @type {Function}
*/
export default findNativePoint

View File

@@ -0,0 +1,84 @@
import getWindow from 'get-window'
import OffsetKey from './offset-key'
import normalizeNodeAndOffset from './normalize-node-and-offset'
import findClosestNode from './find-closest-node'
/**
* Constants.
*
* @type {String}
*/
const OFFSET_KEY_ATTRIBUTE = 'data-offset-key'
const RANGE_SELECTOR = `[${OFFSET_KEY_ATTRIBUTE}]`
const TEXT_SELECTOR = `[data-key]`
const VOID_SELECTOR = '[data-slate-void]'
/**
* Find a Slate point from a DOM selection's `nativeNode` and `nativeOffset`.
*
* @param {Element} nativeNode
* @param {Number} nativeOffset
* @param {State} state
* @return {Object}
*/
function findPoint(nativeNode, nativeOffset, state) {
const {
node: nearestNode,
offset: nearestOffset,
} = normalizeNodeAndOffset(nativeNode, nativeOffset)
const window = getWindow(nativeNode)
const { parentNode } = nearestNode
let rangeNode = findClosestNode(parentNode, RANGE_SELECTOR)
let offset
let node
// Calculate how far into the text node the `nearestNode` is, so that we can
// determine what the offset relative to the text node is.
if (rangeNode) {
const range = window.document.createRange()
const textNode = findClosestNode(rangeNode, TEXT_SELECTOR)
range.setStart(textNode, 0)
range.setEnd(nearestNode, nearestOffset)
node = textNode
offset = range.toString().length
}
// For void nodes, the element with the offset key will be a cousin, not an
// ancestor, so find it by going down from the nearest void parent.
else {
const voidNode = findClosestNode(parentNode, VOID_SELECTOR)
if (!voidNode) return null
rangeNode = voidNode.querySelector(RANGE_SELECTOR)
node = rangeNode
offset = node.textContent.length
}
// Get the string value of the offset key attribute.
const offsetKey = rangeNode.getAttribute(OFFSET_KEY_ATTRIBUTE)
if (!offsetKey) return null
const { key } = OffsetKey.parse(offsetKey)
// COMPAT: If someone is clicking from one Slate editor into another, the
// select event fires twice, once for the old editor's `element` first, and
// then afterwards for the correct `element`. (2017/03/03)
if (!state.document.hasDescendant(key)) return null
return {
key,
offset,
}
}
/**
* Export.
*
* @type {Function}
*/
export default findPoint

View File

@@ -1,46 +0,0 @@
import findDeepestNode from './find-deepest-node'
/**
* Get caret position from selection point.
*
* @param {String} key
* @param {Number} offset
* @param {State} state
* @param {Editor} editor
* @param {Element} el
* @return {Object}
*/
function getCaretPosition(key, offset, state, editor, el) {
const { document } = state
const text = document.getDescendant(key)
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(key, schema)
const ranges = text.getRanges(decorators)
let a = 0
let index
let off
ranges.forEach((range, i) => {
const { length } = range.text
a += length
if (a < offset) return
index = i
off = offset - (a - length)
return false
})
const span = el.querySelector(`[data-offset-key="${key}-${index}"]`)
const node = findDeepestNode(span)
return { node, offset: off }
}
/**
* Export.
*
* @type {Function}
*/
export default getCaretPosition

View File

@@ -1,41 +0,0 @@
import OffsetKey from './offset-key'
/**
* Get a point from a native selection's DOM `element` and `offset`.
*
* @param {Element} element
* @param {Number} offset
* @param {State} state
* @param {Editor} editor
* @return {Object}
*/
function getPoint(element, offset, state, editor) {
const { document } = state
const schema = editor.getSchema()
// If we can't find an offset key, we can't get a point.
const offsetKey = OffsetKey.findKey(element, offset)
if (!offsetKey) return null
// COMPAT: If someone is clicking from one Slate editor into another, the
// select event fires two, once for the old editor's `element` first, and
// then afterwards for the correct `element`. (2017/03/03)
const { key } = offsetKey
const node = document.getDescendant(key)
if (!node) return null
const decorators = document.getDescendantDecorators(key, schema)
const ranges = node.getRanges(decorators)
const point = OffsetKey.findPoint(offsetKey, ranges)
return point
}
/**
* Export.
*
* @type {Function}
*/
export default getPoint

View File

@@ -1,7 +1,4 @@
import normalizeNodeAndOffset from './normalize-node-and-offset'
import findClosestNode from './find-closest-node'
/** /**
* Offset key parser regex. * Offset key parser regex.
* *
@@ -10,117 +7,6 @@ import findClosestNode from './find-closest-node'
const PARSER = /^(\w+)(?:-(\d+))?$/ const PARSER = /^(\w+)(?:-(\d+))?$/
/**
* Offset key attribute name.
*
* @type {String}
*/
const ATTRIBUTE = 'data-offset-key'
/**
* Offset key attribute selector.
*
* @type {String}
*/
const SELECTOR = `[${ATTRIBUTE}]`
/**
* Void node selection.
*
* @type {String}
*/
const VOID_SELECTOR = '[data-slate-void]'
/**
* Find the start and end bounds from an `offsetKey` and `ranges`.
*
* @param {Number} index
* @param {List<Range>} ranges
* @return {Object}
*/
function findBounds(index, ranges) {
const range = ranges.get(index)
const start = ranges
.slice(0, index)
.reduce((memo, r) => {
return memo += r.text.length
}, 0)
return {
start,
end: start + range.text.length
}
}
/**
* From a DOM node, find the closest parent's offset key.
*
* @param {Element} rawNode
* @param {Number} rawOffset
* @return {Object}
*/
function findKey(rawNode, rawOffset) {
let { node, offset } = normalizeNodeAndOffset(rawNode, rawOffset)
const { parentNode } = node
// Find the closest parent with an offset key attribute.
let closest = findClosestNode(parentNode, SELECTOR)
// For void nodes, the element with the offset key will be a cousin, not an
// ancestor, so find it by going down from the nearest void parent.
if (!closest) {
const closestVoid = findClosestNode(parentNode, VOID_SELECTOR)
if (!closestVoid) return null
closest = closestVoid.querySelector(SELECTOR)
offset = closest.textContent.length
}
// Get the string value of the offset key attribute.
const offsetKey = closest.getAttribute(ATTRIBUTE)
// If we still didn't find an offset key, abort.
if (!offsetKey) return null
// Return the parsed the offset key.
const parsed = parse(offsetKey)
return {
key: parsed.key,
index: parsed.index,
offset
}
}
/**
* Find the selection point from an `offsetKey` and `ranges`.
*
* @param {Object} offsetKey
* @param {List<Range>} ranges
* @return {Object}
*/
function findPoint(offsetKey, ranges) {
let { key, index, offset } = offsetKey
const { start, end } = findBounds(index, ranges)
// Don't let the offset be outside of the start and end bounds.
offset = start + offset
offset = Math.max(offset, start)
offset = Math.min(offset, end)
return {
key,
index,
start,
end,
offset
}
}
/** /**
* Parse an offset key `string`. * Parse an offset key `string`.
* *
@@ -158,9 +44,6 @@ function stringify(object) {
*/ */
export default { export default {
findBounds,
findKey,
findPoint,
parse, parse,
stringify stringify
} }

View File

@@ -1,19 +1,19 @@
/** @jsx h */ /** @jsx h */
import h from '../../helpers/h' import h from '../../helpers/h'
import { Mark } from 'slate'
export const schema = { export const schema = {
nodes: { nodes: {
paragraph: { paragraph: {
decorate(text, block) { decorate(block) {
let { characters } = text const text = block.getFirstText()
let second = characters.get(1) return [{
const mark = Mark.create({ type: 'bold' }) anchorKey: text.key,
const marks = second.marks.add(mark) anchorOffset: 1,
second = second.merge({ marks }) focusKey: text.key,
characters = characters.set(1, second) focusOffset: 2,
return characters marks: [{ type: 'bold' }]
}]
} }
} }
}, },

View File

@@ -1,4 +1,6 @@
import State from '../models/state'
/** /**
* Changes. * Changes.
* *
@@ -8,20 +10,20 @@
const Changes = {} const Changes = {}
/** /**
* Set `properties` on the top-level state's data. * Set `properties` on the state.
* *
* @param {Change} change * @param {Change} change
* @param {Object} properties * @param {Object|State} properties
*/ */
Changes.setData = (change, properties) => { Changes.setState = (change, properties) => {
properties = State.createProperties(properties)
const { state } = change const { state } = change
const { data } = state
change.applyOperation({ change.applyOperation({
type: 'set_data', type: 'set_state',
properties, properties,
data, state,
}) })
} }

View File

@@ -165,19 +165,12 @@ class Node {
first = normalizeKey(first) first = normalizeKey(first)
second = normalizeKey(second) second = normalizeKey(second)
let sorted const keys = this.getKeysAsArray()
const firstIndex = keys.indexOf(first)
const secondIndex = keys.indexOf(second)
if (firstIndex == -1 || secondIndex == -1) return null
this.forEachDescendant((n) => { return firstIndex < secondIndex
if (n.key === first) {
sorted = true
return false
} else if (n.key === second) {
sorted = false
return false
}
})
return sorted
} }
/** /**
@@ -609,8 +602,8 @@ class Node {
* @return {Array} * @return {Array}
*/ */
getDecorators(schema) { getDecorations(schema) {
return schema.__getDecorators(this) return schema.__getDecorations(this)
} }
/** /**
@@ -674,32 +667,6 @@ class Node {
return descendant return descendant
} }
/**
* Get the decorators for a descendant by `key` given a `schema`.
*
* @param {String} key
* @param {Schema} schema
* @return {Array}
*/
getDescendantDecorators(key, schema) {
if (!schema.hasDecorators) {
return []
}
const descendant = this.assertDescendant(key)
let child = this.getFurthestAncestor(key)
let decorators = []
while (child != descendant) {
decorators = decorators.concat(child.getDecorators(schema))
child = child.getFurthestAncestor(key)
}
decorators = decorators.concat(descendant.getDecorators(schema))
return decorators
}
/** /**
* Get the first child text node. * Get the first child text node.
* *
@@ -958,18 +925,29 @@ class Node {
} }
/** /**
* Return a set of all keys in the node. * Return a set of all keys in the node as an array.
* *
* @return {Set<String>} * @return {Array<String>}
*/ */
getKeys() { getKeysAsArray() {
const keys = [] const keys = []
this.forEachDescendant((desc) => { this.forEachDescendant((desc) => {
keys.push(desc.key) keys.push(desc.key)
}) })
return keys
}
/**
* Return a set of all keys in the node.
*
* @return {Set<String>}
*/
getKeys() {
const keys = this.getKeysAsArray()
return new Set(keys) return new Set(keys)
} }
@@ -2102,6 +2080,7 @@ memoize(Node.prototype, [
'getInlines', 'getInlines',
'getInlinesAsArray', 'getInlinesAsArray',
'getKeys', 'getKeys',
'getKeysAsArray',
'getLastText', 'getLastText',
'getMarks', 'getMarks',
'getOrderedMarks', 'getOrderedMarks',
@@ -2135,11 +2114,10 @@ memoize(Node.prototype, [
'getClosestVoid', 'getClosestVoid',
'getCommonAncestor', 'getCommonAncestor',
'getComponent', 'getComponent',
'getDecorators', 'getDecorations',
'getDepth', 'getDepth',
'getDescendant', 'getDescendant',
'getDescendantAtPath', 'getDescendantAtPath',
'getDescendantDecorators',
'getFragmentAtRange', 'getFragmentAtRange',
'getFurthestBlock', 'getFurthestBlock',
'getFurthestInline', 'getFurthestInline',

View File

@@ -7,6 +7,7 @@ import typeOf from 'type-of'
import { Record } from 'immutable' import { Record } from 'immutable'
import MODEL_TYPES from '../constants/model-types' import MODEL_TYPES from '../constants/model-types'
import Selection from '../models/selection'
import isReactComponent from '../utils/is-react-component' import isReactComponent from '../utils/is-react-component'
/** /**
@@ -126,24 +127,33 @@ class Schema extends Record(DEFAULTS) {
} }
/** /**
* Return the decorators for an `object`. * Return the decorations for an `object`.
* *
* This method is private, because it should always be called on one of the * This method is private, because it should always be called on one of the
* often-changing immutable objects instead, since it will be memoized for * often-changing immutable objects instead, since it will be memoized for
* much better performance. * much better performance.
* *
* @param {Mixed} object * @param {Mixed} object
* @return {Array} * @return {List<Selection>}
*/ */
__getDecorators(object) { __getDecorations(object) {
return this.rules const array = []
.filter(rule => rule.decorate && rule.match(object))
.map((rule) => { this.rules.forEach((rule) => {
return (text) => { if (!rule.decorate) return
return rule.decorate(text, object) if (!rule.match(object)) return
}
const decorations = rule.decorate(object)
if (!decorations.length) return
decorations.forEach((dec) => {
array.push(dec)
}) })
})
const list = Selection.createList(array)
return list
} }
/** /**

View File

@@ -1,9 +1,10 @@
import isPlainObject from 'is-plain-object' import isPlainObject from 'is-plain-object'
import logger from 'slate-dev-logger' import logger from 'slate-dev-logger'
import { Record } from 'immutable' import { List, Record, Set } from 'immutable'
import MODEL_TYPES from '../constants/model-types' import MODEL_TYPES from '../constants/model-types'
import Mark from './mark'
/** /**
* Default properties. * Default properties.
@@ -48,6 +49,22 @@ class Selection extends Record(DEFAULTS) {
throw new Error(`\`Selection.create\` only accepts objects or selections, but you passed it: ${attrs}`) throw new Error(`\`Selection.create\` only accepts objects or selections, but you passed it: ${attrs}`)
} }
/**
* Create a list of `Selections` from a `value`.
*
* @param {Array<Selection|Object>|List<Selection|Object>} value
* @return {List<Selection>}
*/
static createList(value = []) {
if (List.isList(value) || Array.isArray(value)) {
const list = new List(value.map(Selection.create))
return list
}
throw new Error(`\`Selection.createList\` only accepts arrays or lists, but you passed it: ${value}`)
}
/** /**
* Create a dictionary of settable selection properties from `attrs`. * Create a dictionary of settable selection properties from `attrs`.
* *
@@ -108,7 +125,7 @@ class Selection extends Record(DEFAULTS) {
focusOffset, focusOffset,
isBackward, isBackward,
isFocused, isFocused,
marks, marks: marks == null ? null : new Set(marks.map(Mark.fromJSON)),
}) })
return selection return selection

View File

@@ -5,7 +5,7 @@ import { Record, Set, List, Map } from 'immutable'
import MODEL_TYPES from '../constants/model-types' import MODEL_TYPES from '../constants/model-types'
import SCHEMA from '../schemas/core' import SCHEMA from '../schemas/core'
import Change from './change' import Data from './data'
import Document from './document' import Document from './document'
import History from './history' import History from './history'
import Selection from './selection' import Selection from './selection'
@@ -21,6 +21,7 @@ const DEFAULTS = {
selection: Selection.create(), selection: Selection.create(),
history: History.create(), history: History.create(),
data: new Map(), data: new Map(),
decorations: null,
} }
/** /**
@@ -51,6 +52,31 @@ class State extends Record(DEFAULTS) {
throw new Error(`\`State.create\` only accepts objects or states, but you passed it: ${attrs}`) throw new Error(`\`State.create\` only accepts objects or states, but you passed it: ${attrs}`)
} }
/**
* Create a dictionary of settable state properties from `attrs`.
*
* @param {Object|State} attrs
* @return {Object}
*/
static createProperties(attrs = {}) {
if (State.isState(attrs)) {
return {
data: attrs.data,
decorations: attrs.decorations,
}
}
if (isPlainObject(attrs)) {
const props = {}
if ('data' in attrs) props.data = Data.create(attrs.data)
if ('decorations' in attrs) props.decorations = Selection.createList(attrs.decorations)
return props
}
throw new Error(`\`State.createProperties\` only accepts objects or states, but you passed it: ${attrs}`)
}
/** /**
* Create a `State` from a JSON `object`. * Create a `State` from a JSON `object`.
* *
@@ -549,6 +575,7 @@ class State extends Record(DEFAULTS) {
*/ */
change(attrs = {}) { change(attrs = {}) {
const Change = require('./change').default
return new Change({ ...attrs, state: this }) return new Change({ ...attrs, state: this })
} }
@@ -572,11 +599,20 @@ class State extends Record(DEFAULTS) {
toJSON(options = {}) { toJSON(options = {}) {
const object = { const object = {
kind: this.kind,
data: this.data.toJSON(), data: this.data.toJSON(),
document: this.document.toJSON(options), document: this.document.toJSON(options),
kind: this.kind,
history: this.history.toJSON(),
selection: this.selection.toJSON(), selection: this.selection.toJSON(),
decorations: this.decorations ? this.decorations.toArray().map(d => d.toJSON()) : null,
history: this.history.toJSON(),
}
if (!options.preserveData) {
delete object.data
}
if (!options.preserveDecorations) {
delete object.decorations
} }
if (!options.preserveHistory) { if (!options.preserveHistory) {
@@ -587,10 +623,6 @@ class State extends Record(DEFAULTS) {
delete object.selection delete object.selection
} }
if (!options.preserveStateData) {
delete object.data
}
if (options.preserveSelection && !options.preserveKeys) { if (options.preserveSelection && !options.preserveKeys) {
const { document, selection } = this const { document, selection } = this
object.selection.anchorPath = selection.isSet ? document.getPath(selection.anchorKey) : null object.selection.anchorPath = selection.isSet ? document.getPath(selection.anchorKey) : null

View File

@@ -1,7 +1,7 @@
import isPlainObject from 'is-plain-object' import isPlainObject from 'is-plain-object'
import logger from 'slate-dev-logger' import logger from 'slate-dev-logger'
import { List, Record, OrderedSet, is } from 'immutable' import { List, OrderedSet, Record, Set, is } from 'immutable'
import Character from './character' import Character from './character'
import Mark from './mark' import Mark from './mark'
@@ -193,11 +193,25 @@ class Text extends Record(DEFAULTS) {
*/ */
addMark(index, length, mark) { addMark(index, length, mark) {
const marks = new Set([mark])
return this.addMarks(index, length, marks)
}
/**
* Add a `set` of marks at `index` and `length`.
*
* @param {Number} index
* @param {Number} length
* @param {Set<Mark>} set
* @return {Text}
*/
addMarks(index, length, set) {
const characters = this.characters.map((char, i) => { const characters = this.characters.map((char, i) => {
if (i < index) return char if (i < index) return char
if (i >= index + length) return char if (i >= index + length) return char
let { marks } = char let { marks } = char
marks = marks.add(mark) marks = marks.union(set)
char = char.set('marks', marks) char = char.set('marks', marks)
return char return char
}) })
@@ -206,24 +220,29 @@ class Text extends Record(DEFAULTS) {
} }
/** /**
* Derive a set of decorated characters with `decorators`. * Derive a set of decorated characters with `decorations`.
* *
* @param {Array} decorators * @param {List<Decoration>} decorations
* @return {List<Character>} * @return {List<Character>}
*/ */
getDecorations(decorators) { getDecoratedCharacters(decorations) {
const node = this let node = this
let { characters } = node const { key, characters } = node
// PERF: Exit early if there are no characters to be decorated.
if (characters.size == 0) return characters if (characters.size == 0) return characters
for (let i = 0; i < decorators.length; i++) { decorations.forEach((range) => {
const decorator = decorators[i] const { startKey, endKey, startOffset, endOffset, marks } = range
const decorateds = decorator(node) const hasStart = startKey == key
characters = characters.merge(decorateds) const hasEnd = endKey == key
} const index = hasStart ? startOffset : 0
const length = hasEnd ? endOffset - index : characters.size
node = node.addMarks(index, length, marks)
})
return characters return node.characters
} }
/** /**
@@ -233,8 +252,8 @@ class Text extends Record(DEFAULTS) {
* @return {Array} * @return {Array}
*/ */
getDecorators(schema) { getDecorations(schema) {
return schema.__getDecorators(this) return schema.__getDecorations(this)
} }
/** /**
@@ -291,12 +310,12 @@ class Text extends Record(DEFAULTS) {
/** /**
* Derive the ranges for a list of `characters`. * Derive the ranges for a list of `characters`.
* *
* @param {Array|Void} decorators (optional) * @param {Array|Void} decorations (optional)
* @return {List<Range>} * @return {List<Range>}
*/ */
getRanges(decorators = []) { getRanges(decorations = []) {
const characters = this.getDecorations(decorators) const characters = this.getDecoratedCharacters(decorations)
let ranges = [] let ranges = []
// PERF: cache previous values for faster lookup. // PERF: cache previous values for faster lookup.
@@ -513,8 +532,8 @@ memoize(Text.prototype, [
}) })
memoize(Text.prototype, [ memoize(Text.prototype, [
'getDecoratedCharacters',
'getDecorations', 'getDecorations',
'getDecorators',
'getMarksAtIndex', 'getMarksAtIndex',
'getRanges', 'getRanges',
'validate' 'validate'

View File

@@ -310,23 +310,6 @@ const APPLIERS = {
return state return state
}, },
/**
* Set `data` on `state`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
set_data(state, operation) {
const { properties } = operation
let { data } = state
data = data.merge(properties)
state = state.set('data', data)
return state
},
/** /**
* Set `properties` on mark on text at `offset` and `length` in node by `path`. * Set `properties` on mark on text at `offset` and `length` in node by `path`.
* *
@@ -359,15 +342,13 @@ const APPLIERS = {
let { document } = state let { document } = state
let node = document.assertPath(path) let node = document.assertPath(path)
// Warn when trying to overwite a node's children. if ('nodes' in properties) {
if (properties.nodes && properties.nodes != node.nodes) { logger.warn('Updating a Node\'s `nodes` property via `setNode()` is not allowed. Use the appropriate insertion and removal methods instead. The operation in question was:', operation)
logger.warn('Updating a Node\'s `nodes` property via `setNode()` is not allowed. Use the appropriate insertion and removal operations instead. The opeartion in question was:', operation)
delete properties.nodes delete properties.nodes
} }
// Warn when trying to change a node's key. if ('key' in properties) {
if (properties.key && properties.key != node.key) { logger.warn('Updating a Node\'s `key` property via `setNode()` is not allowed. There should be no reason to do this. The operation in question was:', operation)
logger.warn('Updating a Node\'s `key` property via `setNode()` is not allowed. There should be no reason to do this. The opeartion in question was:', operation)
delete properties.key delete properties.key
} }
@@ -413,6 +394,36 @@ const APPLIERS = {
return state return state
}, },
/**
* Set `properties` on `state`.
*
* @param {State} state
* @param {Object} operation
* @return {State}
*/
set_state(state, operation) {
const { properties } = operation
if ('document' in properties) {
logger.warn('Updating `state.document` property via `setState()` is not allowed. Use the appropriate document updating methods instead. The operation in question was:', operation)
delete properties.document
}
if ('selection' in properties) {
logger.warn('Updating `state.selection` property via `setState()` is not allowed. Use the appropriate selection updating methods instead. The operation in question was:', operation)
delete properties.selection
}
if ('history' in properties) {
logger.warn('Updating `state.history` property via `setState()` is not allowed. Use the appropriate history updating methods instead. The operation in question was:', operation)
delete properties.history
}
state = state.merge(properties)
return state
},
/** /**
* Split a node by `path` at `offset`. * Split a node by `path` at `offset`.
* *

View File

@@ -3,7 +3,7 @@
import h from '../../../helpers/h' import h from '../../../helpers/h'
export default function (change) { export default function (change) {
change.setData({ thing: 'value' }) change.setState({ data: { thing: 'value' }})
} }
export const input = ( export const input = (

View File

@@ -42,5 +42,5 @@ export const output = {
} }
export const options = { export const options = {
preserveStateData: true, preserveData: true,
} }

View File

@@ -5016,6 +5016,10 @@ react-frame-component@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-1.1.1.tgz#05b7f5689a2d373f25baf0c9adb0e59d78103388" resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-1.1.1.tgz#05b7f5689a2d373f25baf0c9adb0e59d78103388"
react-immutable-proptypes@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"
react-portal@^3.1.0: react-portal@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899" resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899"