1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-31 19:01:54 +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.
### `decorate`
`Function decorate(text: Node, object: Node) => List<Characters>`
`Function decorate(node: Node) => List<Selection>|Array<Object>`
```js
{
decorate: (text, node) => {
let { characters } = text
let first = characters.get(0)
let { marks } = first
let mark = Mark.create({ type: 'bold' })
marks = marks.add(mark)
first = first.merge({ marks })
characters = characters.set(0, first)
return characters
decorate: (node) => {
const text = node.getFirstText()
return [{
anchorKey: text.key,
anchorOffset: 0,
focusKey: text.key,
focusOffset: 1,
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`
`Function normalize(change: Change, object: Node, failure: Any) => Change`

View File

@@ -1,13 +1,13 @@
import { Editor } from 'slate-react'
import { Mark, State } from 'slate'
import { State } from 'slate'
import Prism from 'prismjs'
import React from 'react'
import initialState from './state.json'
/**
* Define a code block component.
* Define our code components.
*
* @param {Object} props
* @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.
*
* @param {Text} text
* @param {Block} block
* @return {Array}
*/
function codeBlockDecorator(text, block) {
const characters = text.characters.asMutable()
function codeBlockDecorator(block) {
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 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) {
if (typeof token == 'string') {
offset += token.length
continue
startText = endText
startOffset = endOffset
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
const type = `highlight-${token.type}`
const mark = Mark.create({ type })
if (typeof token != 'string') {
const range = {
anchorKey: startText.key,
anchorOffset: startOffset,
focusKey: endText.key,
focusOffset: endOffset,
marks: [{ type: `highlight-${token.type}` }],
}
for (let i = offset; i < length; i++) {
let char = characters.get(i)
let { marks } = char
marks = marks.add(mark)
char = char.set('marks', marks)
characters.set(i, char)
decorations.push(range)
}
offset = length
start = end
}
return characters.asImmutable()
return decorations
}
/**
@@ -90,7 +116,10 @@ const schema = {
code: {
render: CodeBlock,
decorate: codeBlockDecorator,
}
},
code_line: {
render: CodeBlockLine,
},
},
marks: {
'highlight-comment': {

View File

@@ -23,10 +23,170 @@
},
"nodes": [
{
"kind": "text",
"ranges": [
"kind": "block",
"type": "code_line",
"nodes": [
{
"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}"
"kind": "text",
"ranges": [
{
"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 {
box-sizing: border-box;
font-size: .85em;
width: 100%;
padding: .5em;
@@ -173,12 +174,29 @@ input:focus {
}
.toolbar-menu {
padding: 1px 0 17px 18px;
position: relative;
padding: 1px 18px 17px;
margin: 0 -20px;
border-bottom: 2px solid #eee;
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 {
padding: 8px 7px 6px;
position: absolute;
@@ -236,4 +254,4 @@ input:focus {
padding: 12px;
background-color: #EBEBEB;
display: inline-block;
}
}

View File

@@ -20,6 +20,7 @@ import Plugins from './plugins'
import RTL from './rtl'
import ReadOnly from './read-only'
import RichText from './rich-text'
import SearchHighlighting from './search-highlighting'
import Tables from './tables'
import DevHugeDocument from './dev/huge-document'
@@ -54,6 +55,7 @@ const EXAMPLES = [
['Code Highlighting', CodeHighlighting, '/code-highlighting'],
['Tables', Tables, '/tables'],
['Paste HTML', PasteHtml, '/paste-html'],
['Search Highlighting', SearchHighlighting, '/search-highlighting'],
['Read-only', ReadOnly, '/read-only'],
['RTL', RTL, '/rtl'],
['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",
"prop-types": "^15.5.8",
"react-portal": "^3.1.0",
"react-immutable-proptypes": "^2.1.0",
"selection-is-backward": "^1.0.0",
"slate-base64-serializer": "^0.1.11",
"slate-dev-logger": "^0.1.12",

View File

@@ -12,13 +12,13 @@ import TRANSFER_TYPES from '../constants/transfer-types'
import Node from './node'
import extendSelection from '../utils/extend-selection'
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 getPoint from '../utils/get-point'
import getDropPoint from '../utils/get-drop-point'
import getTransferData from '../utils/get-transfer-data'
import setTransferData from '../utils/set-transfer-data'
import scrollToSelection from '../utils/scroll-to-selection'
import setTransferData from '../utils/set-transfer-data'
import { IS_FIREFOX, IS_MAC, IS_IE } from '../constants/environment'
/**
@@ -121,7 +121,7 @@ class Content extends React.Component {
*/
updateSelection = () => {
const { editor, state } = this.props
const { state } = this.props
const { selection } = state
const window = getWindow(this.element)
const native = window.getSelection()
@@ -144,10 +144,8 @@ class Content extends React.Component {
// Otherwise, figure out which DOM nodes should be selected...
const { anchorKey, anchorOffset, focusKey, focusOffset, isCollapsed } = selection
const anchor = getCaretPosition(anchorKey, anchorOffset, state, editor, this.element)
const focus = isCollapsed
? anchor
: getCaretPosition(focusKey, focusOffset, state, editor, this.element)
const anchor = findNativePoint(anchorKey, anchorOffset)
const focus = isCollapsed ? anchor : findNativePoint(focusKey, focusOffset)
// If they are already selected, do nothing.
if (
@@ -432,12 +430,11 @@ class Content extends React.Component {
if (this.props.readOnly) return
const { editor, state } = this.props
const { state } = this.props
const { nativeEvent } = event
const { dataTransfer } = nativeEvent
const data = getTransferData(dataTransfer)
const point = getDropPoint(event, state, editor)
const point = findDropPoint(event, state)
if (!point) return
// Add drop-specific information to the data.
@@ -484,26 +481,33 @@ class Content extends React.Component {
// Get the selection point.
const native = window.getSelection()
const { anchorNode, anchorOffset } = native
const point = getPoint(anchorNode, anchorOffset, state, editor)
const point = findPoint(anchorNode, anchorOffset, state)
if (!point) return
// Get the range in question.
const { key, index, start, end } = point
// Get the text node and range in question.
const { document, selection } = state
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(key, schema)
const node = document.getDescendant(key)
const block = document.getClosestBlock(node.key)
const ranges = node.getRanges(decorators)
const lastText = block.getLastText()
const node = document.getDescendant(point.key)
const ranges = node.getRanges()
let start = 0
let end = 0
const range = ranges.find((r) => {
end += r.text.length
if (end >= point.offset) return true
start = end
})
// Get the text information.
const { text } = range
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 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
// for browsers collapsing a single trailing new lines, so remove it.
if (isLastText && isLastRange && lastChar == '\n') {
@@ -511,26 +515,20 @@ class Content extends React.Component {
}
// If the text is no different, abort.
const range = ranges.get(index)
const { text, marks } = range
if (textContent == text) return
// Determine what the selection should be after changing the text.
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) => {
change
.select({
anchorKey: key,
anchorOffset: start,
focusKey: key,
focusOffset: end
})
.select(entire)
.delete()
.insertText(textContent, marks)
.select(after)
.insertText(textContent, range.marks)
.select(corrected)
})
}
@@ -677,7 +675,7 @@ class Content extends React.Component {
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
const { state, editor } = this.props
const { state } = this.props
const { document, selection } = state
const native = window.getSelection()
const data = {}
@@ -690,8 +688,8 @@ class Content extends React.Component {
// Otherwise, determine the Slate selection from the native one.
else {
const { anchorNode, anchorOffset, focusNode, focusOffset } = native
const anchor = getPoint(anchorNode, anchorOffset, state, editor)
const focus = getPoint(focusNode, focusOffset, state, editor)
const anchor = findPoint(anchorNode, anchorOffset, state)
const focus = findPoint(focusNode, focusOffset, state)
if (!anchor || !focus) return
// 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) => {
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 (
<Node
block={null}
editor={editor}
decorations={decs}
isSelected={isSelected}
key={child.key}
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.
*
@@ -136,37 +167,6 @@ class Leaf extends React.Component {
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 Debug from 'debug'
import ImmutableTypes from 'react-immutable-proptypes'
import React from 'react'
import SlateTypes from 'slate-prop-types'
import logger from 'slate-dev-logger'
@@ -35,6 +36,7 @@ class Node extends React.Component {
static propTypes = {
block: SlateTypes.block,
decorations: ImmutableTypes.list.isRequired,
editor: Types.object.isRequired,
isSelected: Types.bool.isRequired,
node: SlateTypes.node.isRequired,
@@ -136,6 +138,9 @@ class Node extends React.Component {
// need to be rendered again.
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.
return false
}
@@ -225,11 +230,13 @@ class Node extends React.Component {
*/
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 decs = decorations.concat(node.getDecorations(schema))
return (
<Component
block={node.kind == 'block' ? node : block}
decorations={decs}
editor={editor}
isSelected={isSelected}
key={child.key}

View File

@@ -1,5 +1,6 @@
import Debug from 'debug'
import ImmutableTypes from 'react-immutable-proptypes'
import React from 'react'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
@@ -24,6 +25,7 @@ class Text extends React.Component {
static propTypes = {
block: SlateTypes.block,
decorations: ImmutableTypes.list.isRequired,
editor: Types.object.isRequired,
node: SlateTypes.node.isRequired,
parent: SlateTypes.node.isRequired,
@@ -63,16 +65,6 @@ class Text extends React.Component {
// for simplicity we just let them through.
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
// block, re-render to cleanup extra `<br/>` or `\n`.
if (n.parent.kind == 'block') {
@@ -81,6 +73,9 @@ class Text extends React.Component {
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.
return false
}
@@ -95,10 +90,19 @@ class Text extends React.Component {
const { props } = this
this.debug('render', { props })
const { node, schema, state } = props
const { decorations, node, state } = props
const { document } = state
const decorators = schema.hasDecorators ? document.getDescendantDecorators(node.key, schema) : []
const ranges = node.getRanges(decorators)
const { key } = node
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
const leaves = ranges.map((range, i) => {
@@ -108,7 +112,7 @@ class Text extends React.Component {
})
return (
<span data-key={node.key}>
<span data-key={key}>
{leaves}
</span>
)

View File

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

View File

@@ -2,18 +2,17 @@
import getWindow from 'get-window'
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 {State} state
* @param {Editor} editor
* @return {Object}
*/
function getDropPoint(event, state, editor) {
function findDropPoint(event, state) {
const { document } = state
const { nativeEvent, target } = event
const { x, y } = nativeEvent
@@ -48,7 +47,6 @@ function getDropPoint(event, state, editor) {
document.getNextSibling(nodeKey)
const key = text.key
const offset = previous ? text.characters.size : 0
return { key, offset }
}
@@ -71,12 +69,10 @@ function getDropPoint(event, state, editor) {
const text = block.getLastText()
const { key } = text
const offset = 0
return { key, offset }
}
const point = getPoint(n, o, state, editor)
const point = findPoint(n, o, state)
return point
}
@@ -86,4 +82,4 @@ function getDropPoint(event, state, editor) {
* @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.
*
@@ -10,117 +7,6 @@ import findClosestNode from './find-closest-node'
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`.
*
@@ -158,9 +44,6 @@ function stringify(object) {
*/
export default {
findBounds,
findKey,
findPoint,
parse,
stringify
}

View File

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

View File

@@ -1,4 +1,6 @@
import State from '../models/state'
/**
* Changes.
*
@@ -8,20 +10,20 @@
const Changes = {}
/**
* Set `properties` on the top-level state's data.
* Set `properties` on the state.
*
* @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 { data } = state
change.applyOperation({
type: 'set_data',
type: 'set_state',
properties,
data,
state,
})
}

View File

@@ -165,19 +165,12 @@ class Node {
first = normalizeKey(first)
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) => {
if (n.key === first) {
sorted = true
return false
} else if (n.key === second) {
sorted = false
return false
}
})
return sorted
return firstIndex < secondIndex
}
/**
@@ -609,8 +602,8 @@ class Node {
* @return {Array}
*/
getDecorators(schema) {
return schema.__getDecorators(this)
getDecorations(schema) {
return schema.__getDecorations(this)
}
/**
@@ -674,32 +667,6 @@ class Node {
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.
*
@@ -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 = []
this.forEachDescendant((desc) => {
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)
}
@@ -2102,6 +2080,7 @@ memoize(Node.prototype, [
'getInlines',
'getInlinesAsArray',
'getKeys',
'getKeysAsArray',
'getLastText',
'getMarks',
'getOrderedMarks',
@@ -2135,11 +2114,10 @@ memoize(Node.prototype, [
'getClosestVoid',
'getCommonAncestor',
'getComponent',
'getDecorators',
'getDecorations',
'getDepth',
'getDescendant',
'getDescendantAtPath',
'getDescendantDecorators',
'getFragmentAtRange',
'getFurthestBlock',
'getFurthestInline',

View File

@@ -7,6 +7,7 @@ import typeOf from 'type-of'
import { Record } from 'immutable'
import MODEL_TYPES from '../constants/model-types'
import Selection from '../models/selection'
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
* often-changing immutable objects instead, since it will be memoized for
* much better performance.
*
* @param {Mixed} object
* @return {Array}
* @return {List<Selection>}
*/
__getDecorators(object) {
return this.rules
.filter(rule => rule.decorate && rule.match(object))
.map((rule) => {
return (text) => {
return rule.decorate(text, object)
}
__getDecorations(object) {
const array = []
this.rules.forEach((rule) => {
if (!rule.decorate) return
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 logger from 'slate-dev-logger'
import { Record } from 'immutable'
import { List, Record, Set } from 'immutable'
import MODEL_TYPES from '../constants/model-types'
import Mark from './mark'
/**
* 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}`)
}
/**
* 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`.
*
@@ -108,7 +125,7 @@ class Selection extends Record(DEFAULTS) {
focusOffset,
isBackward,
isFocused,
marks,
marks: marks == null ? null : new Set(marks.map(Mark.fromJSON)),
})
return selection

View File

@@ -5,7 +5,7 @@ import { Record, Set, List, Map } from 'immutable'
import MODEL_TYPES from '../constants/model-types'
import SCHEMA from '../schemas/core'
import Change from './change'
import Data from './data'
import Document from './document'
import History from './history'
import Selection from './selection'
@@ -21,6 +21,7 @@ const DEFAULTS = {
selection: Selection.create(),
history: History.create(),
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}`)
}
/**
* 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`.
*
@@ -549,6 +575,7 @@ class State extends Record(DEFAULTS) {
*/
change(attrs = {}) {
const Change = require('./change').default
return new Change({ ...attrs, state: this })
}
@@ -572,11 +599,20 @@ class State extends Record(DEFAULTS) {
toJSON(options = {}) {
const object = {
kind: this.kind,
data: this.data.toJSON(),
document: this.document.toJSON(options),
kind: this.kind,
history: this.history.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) {
@@ -587,10 +623,6 @@ class State extends Record(DEFAULTS) {
delete object.selection
}
if (!options.preserveStateData) {
delete object.data
}
if (options.preserveSelection && !options.preserveKeys) {
const { document, selection } = this
object.selection.anchorPath = selection.isSet ? document.getPath(selection.anchorKey) : null

View File

@@ -1,7 +1,7 @@
import isPlainObject from 'is-plain-object'
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 Mark from './mark'
@@ -193,11 +193,25 @@ class Text extends Record(DEFAULTS) {
*/
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) => {
if (i < index) return char
if (i >= index + length) return char
let { marks } = char
marks = marks.add(mark)
marks = marks.union(set)
char = char.set('marks', marks)
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>}
*/
getDecorations(decorators) {
const node = this
let { characters } = node
getDecoratedCharacters(decorations) {
let node = this
const { key, characters } = node
// PERF: Exit early if there are no characters to be decorated.
if (characters.size == 0) return characters
for (let i = 0; i < decorators.length; i++) {
const decorator = decorators[i]
const decorateds = decorator(node)
characters = characters.merge(decorateds)
}
decorations.forEach((range) => {
const { startKey, endKey, startOffset, endOffset, marks } = range
const hasStart = startKey == key
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}
*/
getDecorators(schema) {
return schema.__getDecorators(this)
getDecorations(schema) {
return schema.__getDecorations(this)
}
/**
@@ -291,12 +310,12 @@ class Text extends Record(DEFAULTS) {
/**
* Derive the ranges for a list of `characters`.
*
* @param {Array|Void} decorators (optional)
* @param {Array|Void} decorations (optional)
* @return {List<Range>}
*/
getRanges(decorators = []) {
const characters = this.getDecorations(decorators)
getRanges(decorations = []) {
const characters = this.getDecoratedCharacters(decorations)
let ranges = []
// PERF: cache previous values for faster lookup.
@@ -513,8 +532,8 @@ memoize(Text.prototype, [
})
memoize(Text.prototype, [
'getDecoratedCharacters',
'getDecorations',
'getDecorators',
'getMarksAtIndex',
'getRanges',
'validate'

View File

@@ -310,23 +310,6 @@ const APPLIERS = {
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`.
*
@@ -359,15 +342,13 @@ const APPLIERS = {
let { document } = state
let node = document.assertPath(path)
// Warn when trying to overwite a node's children.
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 operations instead. The opeartion in question was:', operation)
if ('nodes' in properties) {
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)
delete properties.nodes
}
// Warn when trying to change a node's key.
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 opeartion in question was:', operation)
if ('key' in properties) {
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)
delete properties.key
}
@@ -413,6 +394,36 @@ const APPLIERS = {
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`.
*

View File

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

View File

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

View File

@@ -5016,6 +5016,10 @@ react-frame-component@^1.1.1:
version "1.1.1"
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:
version "3.1.0"
resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.1.0.tgz#865c44fb72a1da106c649206936559ce891ee899"