1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-26 16:44:22 +02:00

fix to greatly improve performance, and void selections in void nodes

This commit is contained in:
Ian Storm Taylor
2016-07-26 16:58:42 -07:00
parent 665c50e76f
commit 3448dac17b
12 changed files with 321 additions and 206 deletions

View File

@@ -44,6 +44,7 @@
"no-array-constructor": "error",
"no-class-assign": "error",
"no-const-assign": "error",
"no-console": "warn",
"no-debugger": "warn",
"no-dupe-args": "error",
"no-dupe-class-members": "error",

View File

@@ -129,12 +129,11 @@ class CodeHighlighting extends React.Component {
* Render decorations on `text` nodes inside code blocks.
*
* @param {Text} text
* @param {Block} block
* @return {Characters}
*/
renderDecorations = (text, state) => {
const { document } = state
const block = document.getClosestBlock(text)
renderDecorations = (text, block) => {
if (block.type != 'code') return text.characters
let characters = text.characters.asMutable()

View File

@@ -42,6 +42,7 @@ class Content extends React.Component {
onPaste: React.PropTypes.func.isRequired,
onSelect: React.PropTypes.func.isRequired,
readOnly: React.PropTypes.bool.isRequired,
renderDecorations: React.PropTypes.func.isRequired,
renderMark: React.PropTypes.func.isRequired,
renderNode: React.PropTypes.func.isRequired,
spellCheck: React.PropTypes.bool.isRequired,
@@ -114,6 +115,36 @@ class Content extends React.Component {
})
}
/**
* Get a point from a native selection's DOM `element` and `offset`.
*
* @param {Element} element
* @param {Number} offset
* @return {Object}
*/
getPoint(element, offset) {
const offsetKey = OffsetKey.findKey(element, offset)
const ranges = this.getRanges(offsetKey.key)
const point = OffsetKey.findPoint(offsetKey, ranges)
return point
}
/**
* Get the ranges for a text node by `key`.
*
* @param {String} key
* @return {List}
*/
getRanges(key) {
const { state, renderDecorations } = this.props
const node = state.document.getDescendant(key)
const block = state.document.getClosestBlock(node)
const ranges = node.getDecoratedRanges(block, renderDecorations)
return ranges
}
/**
* On before input, bubble up.
*
@@ -333,7 +364,7 @@ class Content extends React.Component {
if (this.props.readOnly) return
e.preventDefault()
const { state } = this.props
const { state, renderDecorations } = this.props
const { selection } = state
const data = e.nativeEvent.dataTransfer
const drop = {}
@@ -355,7 +386,7 @@ class Content extends React.Component {
const startNode = range.startContainer
const startOffset = range.startOffset
const point = OffsetKey.findPoint(startNode, startOffset, state)
const point = this.getPoint(startNode, startOffset)
const target = Selection.create({
anchorKey: point.key,
anchorOffset: point.offset,
@@ -418,16 +449,16 @@ class Content extends React.Component {
*/
onInput = (e) => {
let { state } = this.props
let { state, renderDecorations } = this.props
const { selection } = state
const native = window.getSelection()
const { anchorNode, anchorOffset, focusOffset } = native
let { textContent } = anchorNode
const offsetKey = OffsetKey.findKey(anchorNode)
const { key, index } = OffsetKey.parse(offsetKey)
const { start, end } = OffsetKey.findBounds(key, index, state)
const range = OffsetKey.findRange(anchorNode, state)
const point = this.getPoint(anchorNode, anchorOffset)
const { key, index, start, end } = point
const ranges = this.getRanges(key)
const range = ranges.get(index)
const { text, marks } = range
let { textContent } = anchorNode
// COMPAT: If the DOM text ends in a new line, we will have added one to
// account for browsers collapsing a single one, so remove it.
@@ -564,7 +595,52 @@ class Content extends React.Component {
if (this.tmp.isCopying) return
if (this.tmp.isComposing) return
this.props.onSelect(e)
const { state, renderDecorations } = this.props
let { document, selection } = state
const native = window.getSelection()
const select = {}
// If there are no ranges, the editor was blurred natively.
if (!native.rangeCount) {
select.selection = selection.merge({ isFocused: false })
select.isNative = true
}
// Otherwise, determine the Slate selection from the native one.
else {
const { anchorNode, anchorOffset, focusNode, focusOffset } = native
const anchor = this.getPoint(anchorNode, anchorOffset)
const focus = this.getPoint(focusNode, focusOffset)
// COMPAT: In Firefox, and potentially other browsers, sometimes a select
// event will fire that resolves to the same location as the current
// selection, so we can ignore it.
if (
anchor.key == selection.anchorKey &&
anchor.offset == selection.anchorOffset &&
focus.key == selection.focusKey &&
focus.offset == selection.focusOffset
) {
return
}
// If the native selection is inside text nodes, we can trust the native
// state and not need to re-render.
select.isNative = (
anchorNode.nodeType == 3 &&
focusNode.nodeType == 3
)
select.selection = selection.merge({
anchorKey: anchor.key,
anchorOffset: anchor.offset,
focusKey: focus.key,
focusOffset: focus.offset,
isFocused: true
})
}
this.props.onSelect(e, select)
}
/**
@@ -637,15 +713,16 @@ class Content extends React.Component {
*/
renderNode = (node) => {
const { editor, renderMark, renderNode, state } = this.props
const { editor, renderDecorations, renderMark, renderNode, state } = this.props
return (
<Node
key={node.key}
node={node}
state={state}
editor={editor}
renderNode={renderNode}
renderDecorations={renderDecorations}
renderMark={renderMark}
renderNode={renderNode}
/>
)
}

View File

@@ -62,7 +62,7 @@ class Editor extends React.Component {
this.tmp = {}
this.state = {}
this.state.plugins = this.resolvePlugins(props)
this.state.state = this.resolveState(props.state)
this.state.state = props.state
}
/**
@@ -72,13 +72,11 @@ class Editor extends React.Component {
*/
componentWillReceiveProps = (props) => {
this.state.state = props.state
if (props.plugins != this.props.plugins) {
this.setState({ plugins: this.resolvePlugins(props) })
}
if (props.state != this.props.state) {
this.setState({ state: this.resolveState(props.state) })
}
}
/**
@@ -232,6 +230,7 @@ class Editor extends React.Component {
onPaste={this.onPaste}
onSelect={this.onSelect}
readOnly={this.props.readOnly}
renderDecorations={this.renderDecorations}
renderMark={this.renderMark}
renderNode={this.renderNode}
spellCheck={this.props.spellCheck}
@@ -242,18 +241,21 @@ class Editor extends React.Component {
}
/**
* Render a `node`, cascading through the plugins.
* Render the decorations for a `text`, cascading through the plugins.
*
* @param {Node} node
* @return {Element} element
* @param {Block} text
* @param {Block} block
* @return {Object} style
*/
renderNode = (node) => {
renderDecorations = (text, block) => {
for (const plugin of this.state.plugins) {
if (!plugin.renderNode) continue
const component = plugin.renderNode(node, this.state.state, this)
if (component) return component
if (!plugin.renderDecorations) continue
const style = plugin.renderDecorations(text, block, this.state.state, this)
if (style) return style
}
return text.characters
}
/**
@@ -274,6 +276,21 @@ class Editor extends React.Component {
return {}
}
/**
* Render a `node`, cascading through the plugins.
*
* @param {Node} node
* @return {Element} element
*/
renderNode = (node) => {
for (const plugin of this.state.plugins) {
if (!plugin.renderNode) continue
const component = plugin.renderNode(node, this.state.state, this)
if (component) return component
}
}
/**
* Resolve the editor's current plugins from `props` when they change.
*
@@ -298,34 +315,6 @@ class Editor extends React.Component {
]
}
/**
* Resolve the editor's current state from `props` when they change.
*
* This is where we handle decorating the text nodes with the decorator
* functions, so that they are always accounted for when rendering.
*
* @param {State} state
* @return {State} state
*/
resolveState = (state) => {
const { plugins } = this.state
let { document } = state
document = document.decorateTexts((text) => {
for (const plugin of plugins) {
if (!plugin.renderDecorations) continue
const characters = plugin.renderDecorations(text, state, this)
if (characters) return characters
}
return text.characters
})
state = state.merge({ document })
return state
}
}
/**

View File

@@ -17,6 +17,7 @@ class Leaf extends React.Component {
index: React.PropTypes.number.isRequired,
marks: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired,
ranges: React.PropTypes.object.isRequired,
renderMark: React.PropTypes.func.isRequired,
state: React.PropTypes.object.isRequired,
text: React.PropTypes.string.isRequired
@@ -54,7 +55,7 @@ class Leaf extends React.Component {
return true
}
const { start, end } = OffsetKey.findBounds(node.key, index, state)
const { start, end } = OffsetKey.findBounds(index, props.ranges)
return selection.hasEdgeBetween(node, start, end)
}
@@ -67,15 +68,15 @@ class Leaf extends React.Component {
}
updateSelection() {
const { state } = this.props
const { state, ranges } = this.props
const { selection } = state
// If the selection is not focused we have nothing to do.
if (!selection.isFocused) return
// If the selection is blurred we have nothing to do.
if (selection.isBlurred) return
const { anchorOffset, focusOffset } = selection
const { node, index } = this.props
const { start, end } = OffsetKey.findBounds(node.key, index, state)
const { start, end } = OffsetKey.findBounds(index, ranges)
// If neither matches, the selection doesn't start or end here, so exit.
const hasAnchor = selection.hasAnchorBetween(node, start, end)

View File

@@ -15,6 +15,7 @@ class Node extends React.Component {
static propTypes = {
editor: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired,
renderDecorations: React.PropTypes.func.isRequired,
renderMark: React.PropTypes.func.isRequired,
renderNode: React.PropTypes.func.isRequired,
state: React.PropTypes.object.isRequired
@@ -65,15 +66,16 @@ class Node extends React.Component {
*/
renderNode = (node) => {
const { editor, renderMark, renderNode, state } = this.props
const { editor, renderDecorations, renderMark, renderNode, state } = this.props
return (
<Node
key={node.key}
node={node}
state={state}
editor={editor}
renderNode={renderNode}
renderDecorations={renderDecorations}
renderMark={renderMark}
renderNode={renderNode}
/>
)
}
@@ -118,12 +120,13 @@ class Node extends React.Component {
*/
renderText = () => {
const { node, editor, renderMark, state } = this.props
const { node, editor, renderDecorations, renderMark, state } = this.props
return (
<Text
key={node.key}
editor={editor}
node={node}
renderDecorations={renderDecorations}
renderMark={renderMark}
state={state}
/>

View File

@@ -16,6 +16,7 @@ class Text extends React.Component {
static propTypes = {
editor: React.PropTypes.object.isRequired,
node: React.PropTypes.object.isRequired,
renderDecorations: React.PropTypes.func.isRequired,
renderMark: React.PropTypes.func.isRequired,
state: React.PropTypes.object.isRequired
};
@@ -30,9 +31,8 @@ class Text extends React.Component {
shouldComponentUpdate(props, state) {
return (
props.state.selection.hasEdgeIn(props.node) ||
props.node.decorations != this.props.node.decorations ||
props.node.characters != this.props.node.characters
props.node != this.props.node ||
props.state.selection.hasEdgeIn(props.node)
)
}
@@ -58,28 +58,30 @@ class Text extends React.Component {
*/
renderLeaves() {
const { node } = this.props
const ranges = node.getDecoratedRanges()
const { node, state, renderDecorations } = this.props
const block = state.document.getClosestBlock(node)
const ranges = node.getDecoratedRanges(block, renderDecorations)
return ranges.map((range, i, original) => {
const previous = original.slice(0, i)
const offset = previous.size
? previous.map(r => r.text).join('').length
: 0
return this.renderLeaf(range, i, offset)
return this.renderLeaf(ranges, range, i, offset)
})
}
/**
* Render a single leaf node given a `range` and `offset`.
*
* @param {Object} range
* @param {List} ranges
* @param {Range} range
* @param {Number} index
* @param {Number} offset
* @return {Element} leaf
*/
renderLeaf(range, index, offset) {
renderLeaf(ranges, range, index, offset) {
const { node, renderMark, state } = this.props
const text = range.text
const marks = range.marks
@@ -92,6 +94,7 @@ class Text extends React.Component {
node={node}
text={text}
marks={marks}
ranges={ranges}
renderMark={renderMark}
/>
)

View File

@@ -8,10 +8,16 @@ import keycode from 'keycode'
/**
* Void.
*
* @type {Component}
*/
class Void extends React.Component {
/**
* Property types.
*/
static propTypes = {
children: React.PropTypes.any.isRequired,
className: React.PropTypes.string,
@@ -21,17 +27,53 @@ class Void extends React.Component {
style: React.PropTypes.object
};
/**
* Default properties.
*/
static defaultProps = {
style: {}
}
shouldComponentUpdate = (props) => {
/**
* Should the component update?
*
* @param {Object} props
* @param {Object} state
* @return {Boolean}
*/
shouldComponentUpdate = (props, state) => {
return (
props.node != this.props.node ||
props.state.selection.hasEdgeIn(props.node)
)
}
/**
* When one of the wrapper elements it clicked, select the void node.
*
* @param {Event} e
*/
onClick = (e) => {
e.preventDefault()
const { state, node, editor } = this.props
const next = state
.transform()
.moveToRangeOf(node)
.focus()
.apply()
editor.onChange(next)
}
/**
* Render.
*
* @return {Element}
*/
render = () => {
const { children, node, className, style } = this.props
const Tag = node.kind == 'block' ? 'div' : 'span'
@@ -43,7 +85,7 @@ class Void extends React.Component {
}
return (
<Tag contentEditable={false}>
<Tag contentEditable={false} onClick={this.onClick}>
<Tag
contentEditable
suppressContentEditableWarning
@@ -51,12 +93,21 @@ class Void extends React.Component {
style={styles}
>
{this.renderSpacer()}
<Tag contentEditable={false}>{children}</Tag>
<Tag contentEditable={false} onClick={this.onClick}>{children}</Tag>
</Tag>
</Tag>
)
}
/**
* Render a fake spacer leaf, which will catch the cursor when it the void
* node is navigated to with the arrow keys. Having this spacer there means
* the browser continues to manage the selection natively, so it keeps track
* of the right offset when moving across the block.
*
* @return {Element}
*/
renderSpacer = () => {
const style = {
position: 'absolute',
@@ -70,9 +121,16 @@ class Void extends React.Component {
)
}
/**
* Render a fake leaf.
*
* @return {Element}
*/
renderLeaf = () => {
const { node, state } = this.props
const child = node.getTexts().first()
const ranges = child.getRanges()
const text = ''
const marks = Mark.createSet()
const index = 0
@@ -88,6 +146,7 @@ class Void extends React.Component {
key={offsetKey}
state={state}
node={child}
ranges={ranges}
index={index}
text={text}
marks={marks}
@@ -95,14 +154,16 @@ class Void extends React.Component {
)
}
/**
* Render a fake leaf mark.
*
* @return {Object}
*/
renderLeafMark = (mark) => {
return {}
}
renderLeafRefs = (el) => {
this.leaf = el
}
}
/**

View File

@@ -903,21 +903,23 @@ const Node = {
desc = desc.merge({ nodes })
}
if (desc.kind == 'text') {
if (desc.kind == 'text' && !removals.has(desc.key)) {
let next = node.getNextSibling(desc)
// ...that there are no adjacent text nodes.
while (next && next.kind == 'text') {
const characters = desc.characters.concat(next.characters)
desc = desc.merge({ characters })
removals = removals.add(next.key)
next = node.getNextSibling(next)
if (next && next.kind == 'text') {
while (next && next.kind == 'text') {
const characters = desc.characters.concat(next.characters)
desc = desc.merge({ characters })
removals = removals.add(next.key)
next = node.getNextSibling(next)
}
}
// ...that there are no extra empty text nodes.
if (desc.length == 0) {
else if (desc.length == 0) {
const parent = node.getParent(desc)
if (parent.nodes.size != 1) removals = removals.add(desc.key)
if (parent.nodes.size > 1) removals = removals.add(desc.key)
}
}

View File

@@ -102,44 +102,29 @@ class Text extends new Record(DEFAULTS) {
.join('')
}
/**
* Decorate the text node's characters with a `decorator` function.
*
* @param {Function} decorator
* @return {Text} text
*/
decorateCharacters(decorator) {
let { characters, cache } = this
if (characters == cache) return this
const decorations = this.getDecoratedCharacters(decorator)
if (decorations == characters) return this
return this.merge({
cache: characters,
decorations,
})
}
/**
* Get the decorated characters.
*
* @param {Block} block
* @param {Function} decorator
* @return {List} characters
*/
getDecoratedCharacters(decorator) {
return decorator(this)
getDecoratedCharacters(block, decorator) {
return decorator(this, block)
}
/**
* Get the decorated characters grouped by marks.
*
* @param {Block} block
* @param {Function} decorator
* @return {List} ranges
*/
getDecoratedRanges() {
return this.getRangesForCharacters(this.decorations || this.characters)
getDecoratedRanges(block, decorator) {
const decorations = this.getDecoratedCharacters(block, decorator)
return this.getRangesForCharacters(decorations)
}
/**

View File

@@ -87,26 +87,47 @@ function Plugin(options = {}) {
*/
onBeforeInput(e, state, editor) {
const transform = state.transform().insertText(e.data)
const synthetic = transform.apply()
const resolved = editor.resolveState(synthetic)
const { renderDecorations } = editor
const { startOffset, startText, startBlock } = state
// Determine what the characters would be if natively inserted.
const prev = startText.getDecoratedCharacters(startBlock, renderDecorations)
const char = prev.get(startOffset)
const chars = prev
.slice(0, startOffset)
.push(char.merge({ text: e.data }))
.concat(prev.slice(startOffset))
// Determine what the characters should be, if not natively inserted.
let next = state
.transform()
.insertText(e.data)
.apply()
const nextText = next.startText
const nextBlock = next.startBlock
const nextChars = nextText.getDecoratedCharacters(nextBlock, renderDecorations)
// We do not have to re-render if the current selection is collapsed, the
// current node is not empty, there are no marks on the cursor, and the
// new state has the same decorations as the current one.
// natively inserted characters would be the same as the non-native.
const isNative = (
state.isCollapsed &&
state.startText.text != '' &&
state.cursorMarks == null &&
resolved.equals(synthetic)
chars.equals(nextChars)
)
state = isNative
? transform.apply({ isNative })
: synthetic
// Add the `isNative` flag directly, so we don't have to re-transform.
if (isNative) {
next = next.merge({ isNative })
}
// If not native, prevent default so that the DOM remains untouched.
if (!isNative) e.preventDefault()
return state
// Return the new state.
return next
},
/**
@@ -303,56 +324,18 @@ function Plugin(options = {}) {
* The core `onSelect` handler.
*
* @param {Event} e
* @param {Object} select
* @param {State} state
* @param {Editor} editor
* @return {State or Null}
*/
onSelect(e, state, editor) {
let { document, selection } = state
const native = window.getSelection()
// If there are no ranges, the editor was blurred natively.
if (!native.rangeCount) {
return state
.transform()
.blur()
.apply({ isNative: true })
}
// Calculate the Slate-specific selection based on the native one.
const { anchorNode, anchorOffset, focusNode, focusOffset } = native
const anchor = OffsetKey.findPoint(anchorNode, anchorOffset, state)
const focus = OffsetKey.findPoint(focusNode, focusOffset, state)
// COMPAT: In Firefox, and potentially other browsers, sometimes a select
// event will fire that resolves to the same location as the current
// selection, so we can ignore it.
if (
anchor.key == selection.anchorKey &&
anchor.offset == selection.anchorOffset &&
focus.key == selection.focusKey &&
focus.offset == selection.focusOffset
) {
return
}
// If the native selection is inside text nodes, we can trust the native
// state and not need to re-render.
const isNative = (
anchorNode.nodeType == 3 &&
focusNode.nodeType == 3
)
onSelect(e, select, state, editor) {
const { selection, isNative } = select
return state
.transform()
.moveTo(selection)
.focus()
.moveTo({
anchorKey: anchor.key,
anchorOffset: anchor.offset,
focusKey: focus.key,
focusOffset: focus.offset
})
.apply({ isNative })
},

View File

@@ -13,17 +13,14 @@ const ATTRIBUTE = 'data-offset-key'
const SELECTOR = `[${ATTRIBUTE}]`
/**
* Find the start and end bounds from a node's `key` and `index`.
* Find the start and end bounds from an `offsetKey` and `ranges`.
*
* @param {String} key
* @param {Number} index
* @param {State} state
* @param {List} ranges
* @return {Object}
*/
function findBounds(key, index, state) {
const text = state.document.assertDescendant(key)
const ranges = text.getDecoratedRanges()
function findBounds(index, ranges) {
const range = ranges.get(index)
const start = ranges
.slice(0, index)
@@ -41,33 +38,26 @@ function findBounds(key, index, state) {
* From a `element`, find the closest parent's offset key.
*
* @param {Element} element
* @return {String or Null}
*/
function findKey(element) {
if (element.nodeType == 3) element = element.parentNode
const parent = element.closest(SELECTOR)
if (!parent) return null
return parent.getAttribute(ATTRIBUTE)
}
/**
* Find the selection point from an `element`, `offset`, and `state`.
*
* @param {Element} element
* @param {Offset} offset
* @param {State} state
* @param {Number} offset
* @return {Object}
*/
function findPoint(element, offset, state) {
let offsetKey = findKey(element)
function findKey(element, offset) {
if (element.nodeType == 3) element = element.parentNode
const parent = element.closest(SELECTOR)
const children = element.querySelectorAll(SELECTOR)
let offsetKey
// Get the key from a parent if one exists.
if (parent) {
offsetKey = parent.getAttribute(ATTRIBUTE)
}
// COMPAT: In Firefox, and potentially other browsers, when performing a
// "select all" action, a parent element is selected instead of the text. In
// this case, we need to select the proper inner text nodes. (2016/07/26)
if (!offsetKey) {
const children = element.querySelectorAll(SELECTOR)
else if (children.length) {
let child = children[0]
if (offset != 0) {
@@ -78,8 +68,44 @@ function findPoint(element, offset, state) {
offsetKey = child.getAttribute(ATTRIBUTE)
}
const { key, index } = parse(offsetKey)
const { start, end } = findBounds(key, index, state)
// Otherwise, for void node scenarios, a cousin element will be selected, and
// we need to select the first text node cousin we can find.
else {
while (element = element.parentNode) {
const cousin = element.querySelector(SELECTOR)
if (!cousin) continue
offsetKey = cousin.getAttribute(ATTRIBUTE)
offset = cousin.textContent.length
break
}
}
// If we still didn't find an offset key, error. This is a bug.
if (!offsetKey) {
throw new Error(`Unable to find offset key for ${element} with offset "${offset}".`)
}
// Parse 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} 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
@@ -88,27 +114,13 @@ function findPoint(element, offset, state) {
return {
key,
index,
start,
end,
offset
}
}
/**
* Find the range from an `element`.
*
* @param {Element} element
* @param {State} state
* @return {Range}
*/
function findRange(element, state) {
const offsetKey = findKey(element)
const { key, index } = parse(offsetKey)
const text = state.document.getDescendant(key)
const ranges = text.getDecoratedRanges()
const range = ranges.get(index)
return range
}
/**
* Parse an offset key `string`.
*
@@ -147,7 +159,6 @@ export default {
findBounds,
findKey,
findPoint,
findRange,
parse,
stringify
}