1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-07-31 20:40:19 +02:00

change selection updating logic to happen at the top-level, closes #662

This commit is contained in:
Ian Storm Taylor
2017-03-30 00:41:06 -04:00
parent fccca74b8f
commit 6198708086
6 changed files with 137 additions and 232 deletions

View File

@@ -41,13 +41,13 @@ class CheckListItem extends React.Component {
const checked = node.data.get('checked') const checked = node.data.get('checked')
return ( return (
<div {...attributes} className="check-list-item"> <div {...attributes} className="check-list-item">
<div contentEditable={false}> <span contentEditable={false}>
<input <input
type="checkbox" type="checkbox"
checked={checked} checked={checked}
onChange={this.onChange} onChange={this.onChange}
/> />
</div> </span>
{children} {children}
</div> </div>
) )

View File

@@ -29,9 +29,9 @@ const schema = {
nodes: { nodes: {
image: (props) => { image: (props) => {
const { node, state } = props const { node, state } = props
const isFocused = state.selection.hasEdgeIn(node) const active = state.isFocused && state.selection.hasEdgeIn(node)
const src = node.data.get('src') const src = node.data.get('src')
const className = isFocused ? 'active' : null const className = active ? 'active' : null
return ( return (
<img src={src} className={className} {...props.attributes} /> <img src={src} className={className} {...props.attributes} />
) )

View File

@@ -79,7 +79,7 @@ class Content extends React.Component {
super(props) super(props)
this.tmp = {} this.tmp = {}
this.tmp.compositions = 0 this.tmp.compositions = 0
this.forces = 0 this.tmp.forces = 0
} }
/** /**
@@ -110,10 +110,12 @@ class Content extends React.Component {
} }
/** /**
* On mount, if `autoFocus` is set, focus the editor. * On mount, update the selection, and focus the editor if `autoFocus` is set.
*/ */
componentDidMount = () => { componentDidMount = () => {
this.updateSelection()
if (this.props.autoFocus) { if (this.props.autoFocus) {
const el = ReactDOM.findDOMNode(this) const el = ReactDOM.findDOMNode(this)
el.focus() el.focus()
@@ -121,22 +123,99 @@ class Content extends React.Component {
} }
/** /**
* On update, if the state is blurred now, but was focused before, and the * On update, update the selection.
* DOM still has a node inside the editor selected, we need to blur it.
*
* @param {Object} prevProps
* @param {Object} prevState
*/ */
componentDidUpdate = (prevProps, prevState) => { componentDidUpdate = () => {
if (this.props.state.isBlurred && prevProps.state.isFocused) { this.updateSelection()
const el = ReactDOM.findDOMNode(this) }
const window = getWindow(el)
const native = window.getSelection() /**
* Update the native DOM selection to reflect the internal model.
*/
updateSelection = () => {
const { state } = this.props
const { selection } = state
const el = ReactDOM.findDOMNode(this)
const window = getWindow(el)
const native = window.getSelection()
// If both selections are blurred, do nothing.
if (!native.rangeCount && selection.isBlurred) return
// If the selection has been blurred, but hasn't been updated in the DOM,
// blur the DOM selection.
if (selection.isBlurred) {
if (!el.contains(native.anchorNode)) return if (!el.contains(native.anchorNode)) return
native.removeAllRanges() native.removeAllRanges()
el.blur() el.blur()
debug('updateSelection', { selection, native })
return
} }
// Otherwise, figure out which DOM nodes should be selected...
const { anchorText, focusText } = state
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection
const anchorRanges = anchorText.getRanges()
const focusRanges = focusText.getRanges()
let a = 0
let f = 0
let anchorIndex
let focusIndex
let anchorOff
let focusOff
anchorRanges.forEach((range, i, ranges) => {
const { length } = range.text
a += length
if (a < anchorOffset) return
anchorIndex = i
anchorOff = anchorOffset - (a - length)
return false
})
focusRanges.forEach((range, i, ranges) => {
const { length } = range.text
f += length
if (f < focusOffset) return
focusIndex = i
focusOff = focusOffset - (f - length)
return false
})
const anchorSpan = el.querySelector(`[data-offset-key="${anchorKey}-${anchorIndex}"]`)
const focusSpan = el.querySelector(`[data-offset-key="${focusKey}-${focusIndex}"]`)
const anchorEl = anchorSpan.firstChild
const focusEl = focusSpan.firstChild
// If they are already selected, do nothing.
if (
anchorEl == native.anchorNode &&
anchorOff == native.anchorOffset &&
focusEl == native.focusNode &&
focusOff == native.focusOffset
) {
return
}
// Otherwise, set the `isSelecting` flag and update the selection.
this.tmp.isSelecting = true
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(anchorEl, anchorOff)
native.addRange(range)
native.extend(focusEl, focusOff)
// Then unset the `isSelecting` flag after a delay.
setTimeout(() => {
// COMPAT: In Firefox, it's not enough to create a range, you also need to
// focus the contenteditable element too. (2016/11/16)
if (IS_FIREFOX) el.focus()
this.tmp.isSelecting = false
})
debug('updateSelection', { selection, native })
} }
/** /**
@@ -254,7 +333,7 @@ class Content extends React.Component {
onCompositionEnd = (event) => { onCompositionEnd = (event) => {
if (!this.isInContentEditable(event)) return if (!this.isInContentEditable(event)) return
this.forces++ this.tmp.forces++
const count = this.tmp.compositions const count = this.tmp.compositions
// The `count` check here ensures that if another composition starts // The `count` check here ensures that if another composition starts
@@ -619,6 +698,7 @@ class Content extends React.Component {
if (this.props.readOnly) return if (this.props.readOnly) return
if (this.tmp.isCopying) return if (this.tmp.isCopying) return
if (this.tmp.isComposing) return if (this.tmp.isComposing) return
if (this.tmp.isSelecting) return
if (!this.isInContentEditable(event)) return if (!this.isInContentEditable(event)) return
const window = getWindow(event.target) const window = getWindow(event.target)
@@ -640,10 +720,11 @@ class Content extends React.Component {
const focus = getPoint(focusNode, focusOffset, state, editor) const focus = getPoint(focusNode, focusOffset, state, editor)
if (!anchor || !focus) return if (!anchor || !focus) return
// There are valid situations where a select event will fire when we're // There are situations where a select event will fire with a new native
// already at that position (for example when entering a character), since // selection that resolves to the same internal position. In those cases
// our `insertText` transform already updates the selection. In those // we don't need to trigger any changes, since our internal model is
// cases we can safely ignore the event. // already up to date, but we do want to update the native selection again
// to make sure it is in sync.
if ( if (
anchor.key == selection.anchorKey && anchor.key == selection.anchorKey &&
anchor.offset == selection.anchorOffset && anchor.offset == selection.anchorOffset &&
@@ -651,6 +732,7 @@ class Content extends React.Component {
focus.offset == selection.focusOffset && focus.offset == selection.focusOffset &&
selection.isFocused selection.isFocused
) { ) {
this.updateSelection()
return return
} }
@@ -736,7 +818,7 @@ class Content extends React.Component {
return ( return (
<div <div
data-slate-editor data-slate-editor
key={this.forces} key={this.tmp.forces}
ref={this.ref} ref={this.ref}
contentEditable={!readOnly} contentEditable={!readOnly}
suppressContentEditableWarning suppressContentEditableWarning

View File

@@ -3,7 +3,6 @@ import Debug from 'debug'
import OffsetKey from '../utils/offset-key' import OffsetKey from '../utils/offset-key'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import getWindow from 'get-window'
import { IS_FIREFOX } from '../constants/environment' import { IS_FIREFOX } from '../constants/environment'
/** /**
@@ -88,123 +87,10 @@ class Leaf extends React.Component {
const text = this.renderText(props) const text = this.renderText(props)
if (el.textContent != text) return true if (el.textContent != text) return true
// If the selection was previously focused, and now it isn't, re-render so
// that the selection will be properly removed.
if (this.props.state.isFocused && props.state.isBlurred) {
const { index, node, ranges, state } = this.props
const { start, end } = OffsetKey.findBounds(index, ranges)
if (state.selection.hasEdgeBetween(node, start, end)) return true
}
// If the selection will be focused, only re-render if this leaf contains
// one or both of the selection's edges.
if (props.state.isFocused) {
const { index, node, ranges, state } = props
const { start, end } = OffsetKey.findBounds(index, ranges)
if (state.selection.hasEdgeBetween(node, start, end)) return true
}
// Otherwise, don't update. // Otherwise, don't update.
return false return false
} }
/**
* When the DOM updates, try updating the selection.
*/
componentDidMount() {
this.updateSelection()
}
componentDidUpdate() {
this.updateSelection()
}
/**
* Update the DOM selection if it's inside the leaf.
*/
updateSelection() {
const { state, ranges } = this.props
const { selection } = state
// If the selection is blurred we have nothing to do.
if (selection.isBlurred) return
const { node, index } = this.props
const { start, end } = OffsetKey.findBounds(index, ranges)
const anchorOffset = selection.anchorOffset - start
const focusOffset = selection.focusOffset - start
// If neither matches, the selection doesn't start or end here, so exit.
const hasAnchor = selection.hasAnchorBetween(node, start, end)
const hasFocus = selection.hasFocusBetween(node, start, end)
if (!hasAnchor && !hasFocus) return
// We have a selection to render, so prepare a few things...
const ref = ReactDOM.findDOMNode(this)
const el = findDeepestNode(ref)
const window = getWindow(el)
const native = window.getSelection()
const parent = ref.closest('[contenteditable]')
// COMPAT: In Firefox, it's not enough to create a range, you also need to
// focus the contenteditable element. (2016/11/16)
function focus() {
if (!IS_FIREFOX) return
if (parent) setTimeout(() => parent.focus())
}
// If both the start and end are here, set the selection all at once.
if (hasAnchor && hasFocus) {
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(el, anchorOffset)
native.addRange(range)
native.extend(el, focusOffset)
focus()
}
// Otherwise we need to set the selection across two different leaves.
// If the selection is forward, we can set things in sequence. In the
// first leaf to render, reset the selection and set the new start. And
// then in the second leaf to render, extend to the new end.
else if (selection.isForward) {
if (hasAnchor) {
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(el, anchorOffset)
native.addRange(range)
} else if (hasFocus) {
native.extend(el, focusOffset)
focus()
}
}
// Otherwise, if the selection is backward, we need to hack the order a bit.
// In the first leaf to render, set a phony start anchor to store the true
// end position. And then in the second leaf to render, set the start and
// extend the end to the stored value.
else if (hasFocus) {
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(el, focusOffset)
native.addRange(range)
}
else if (hasAnchor) {
const endNode = native.focusNode
const endOffset = native.focusOffset
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(el, anchorOffset)
native.addRange(range)
native.extend(endNode, endOffset)
focus()
}
this.debug('updateSelection', { selection })
}
/** /**
* Render the leaf. * Render the leaf.
* *
@@ -225,7 +111,6 @@ class Leaf extends React.Component {
// get out of sync, causing it to not realize the DOM needs updating. // get out of sync, causing it to not realize the DOM needs updating.
this.tmp.renders++ this.tmp.renders++
this.debug('render', { props }) this.debug('render', { props })
return ( return (

View File

@@ -89,80 +89,42 @@ class Node extends React.Component {
*/ */
shouldComponentUpdate = (nextProps) => { shouldComponentUpdate = (nextProps) => {
const { props } = this
const { Component } = this.state const { Component } = this.state
// If the node is rendered with a `Component` that has enabled suppression // If the `Component` has enabled suppression of update checking, always
// of update checking, always return true so that it can deal with update // return true so that it can deal with update checking itself.
// checking itself. if (Component && Component.suppressShouldComponentUpdate) return true
if (Component && Component.suppressShouldComponentUpdate) {
return true // If the `readOnly` status has changed, re-render in case there is any
// user-land logic that depends on it, like nested editable contents.
if (nextProps.readOnly != props.readOnly) return true
// If the node has changed, update. PERF: There are cases where it will have
// changed, but it's properties will be exactly the same (eg. copy-paste)
// which this won't catch. But that's rare and not a drag on performance, so
// for simplicity we just let them through.
if (nextProps.node != props.node) return true
// If the node is a block or inline, which can have custom renderers, we
// include an extra check to re-render if the node's focus changes, to make
// it simple for users to show a node's "selected" state.
if (props.node.kind != 'text') {
const hasEdgeIn = props.state.selection.hasEdgeIn(props.node)
const nextHasEdgeIn = nextProps.state.selection.hasEdgeIn(nextProps.node)
const hasFocus = props.state.isFocused || nextProps.state.isFocused
const hasEdge = hasEdgeIn || nextHasEdgeIn
if (hasFocus && hasEdge) return true
} }
// If the `readOnly` status has changed, we need to re-render in case there is // If the node is a text node, re-render if the current decorations have
// any user-land logic that depends on it, like nested editable contents. // changed, even if the content of the text node itself hasn't.
if (nextProps.readOnly !== this.props.readOnly) return true
// If the node has changed, update. PERF: There are certain cases where the
// node instance will have changed, but it's properties will be exactly the
// same (copy-paste, delete backwards, etc.) in which case this will not
// catch a potentially avoidable re-render. But those cases are rare enough
// that they aren't really a drag on performance, so for simplicity we just
// let them through.
if (nextProps.node != this.props.node) {
return true
}
const nextHasEdgeIn = nextProps.state.selection.hasEdgeIn(nextProps.node)
// If the selection is focused and is inside the node, we need to update so
// that the selection will be set by one of the <Leaf> components.
if (
nextProps.state.isFocused &&
nextHasEdgeIn
) {
return true
}
const hasEdgeIn = this.props.state.selection.hasEdgeIn(nextProps.node)
// If the selection is blurred but was previously focused (or vice versa) inside the node,
// we need to update to ensure the selection gets updated by re-rendering.
if (
this.props.state.isFocused != nextProps.state.isFocused &&
(
hasEdgeIn || nextHasEdgeIn
)
) {
return true
}
// For block and inline nodes, which can have custom renderers, we need to
// include another check for whether the previous selection had an edge in
// the node, to allow for intuitive selection-based rendering.
if (
this.props.node.kind != 'text' &&
hasEdgeIn != nextHasEdgeIn
) {
return true
}
// For text nodes, which can have custom decorations, we need to check to
// see if the block has changed, which has caused the decorations to change.
if (nextProps.node.kind == 'text' && nextProps.schema.hasDecorators) { if (nextProps.node.kind == 'text' && nextProps.schema.hasDecorators) {
const { node, schema, state } = nextProps const nextDecorators = nextProps.state.document.getDescendantDecorators(nextProps.node.key, nextProps.schema)
const decorators = props.state.document.getDescendantDecorators(props.node.key, props.schema)
const { document } = state const nextRanges = nextProps.node.getRanges(nextDecorators)
const decorators = document.getDescendantDecorators(node.key, schema) const ranges = props.node.getRanges(decorators)
const ranges = node.getRanges(decorators) if (!nextRanges.equals(ranges)) return true
const prevNode = this.props.node
const prevSchema = this.props.schema
const prevDocument = this.props.state.document
const prevDecorators = prevDocument.getDescendantDecorators(prevNode.key, prevSchema)
const prevRanges = prevNode.getRanges(prevDecorators)
if (!ranges.equals(prevRanges)) {
return true
}
} }
// Otherwise, don't update. // Otherwise, don't update.

View File

@@ -187,34 +187,11 @@ function Plugin(options = {}) {
*/ */
function onBlur(e, data, state) { function onBlur(e, data, state) {
const isNative = true debug('onBlur', { data })
debug('onBlur', { data, isNative })
return state return state
.transform() .transform()
.blur() .blur()
.apply({ isNative }) .apply()
}
/**
* On focus.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onFocus(e, data, state) {
const isNative = true
debug('onFocus', { data, isNative })
return state
.transform()
.focus()
.apply({ isNative })
} }
/** /**
@@ -975,7 +952,6 @@ function Plugin(options = {}) {
onBeforeChange, onBeforeChange,
onBeforeInput, onBeforeInput,
onBlur, onBlur,
onFocus,
onCopy, onCopy,
onCut, onCut,
onDrop, onDrop,