mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-02-24 17:23:07 +01:00
720 lines
18 KiB
JavaScript
720 lines
18 KiB
JavaScript
|
|
import Base64 from '../serializers/base-64'
|
|
import Debug from 'debug'
|
|
import Node from './node'
|
|
import OffsetKey from '../utils/offset-key'
|
|
import React from 'react'
|
|
import Selection from '../models/selection'
|
|
import Transfer from '../utils/transfer'
|
|
import TYPES from '../constants/types'
|
|
import getWindow from 'get-window'
|
|
import includes from 'lodash/includes'
|
|
import keycode from 'keycode'
|
|
import { IS_FIREFOX, IS_MAC } from '../constants/environment'
|
|
|
|
/**
|
|
* Debug.
|
|
*
|
|
* @type {Function}
|
|
*/
|
|
|
|
const debug = Debug('slate:content')
|
|
|
|
/**
|
|
* Noop.
|
|
*
|
|
* @type {Function}
|
|
*/
|
|
|
|
function noop() {}
|
|
|
|
/**
|
|
* Content.
|
|
*
|
|
* @type {Component}
|
|
*/
|
|
|
|
class Content extends React.Component {
|
|
|
|
/**
|
|
* Property types.
|
|
*
|
|
* @type {Object}
|
|
*/
|
|
|
|
static propTypes = {
|
|
className: React.PropTypes.string,
|
|
editor: React.PropTypes.object.isRequired,
|
|
onBeforeInput: React.PropTypes.func.isRequired,
|
|
onBlur: React.PropTypes.func.isRequired,
|
|
onChange: React.PropTypes.func.isRequired,
|
|
onCopy: React.PropTypes.func.isRequired,
|
|
onCut: React.PropTypes.func.isRequired,
|
|
onDrop: React.PropTypes.func.isRequired,
|
|
onKeyDown: React.PropTypes.func.isRequired,
|
|
onPaste: React.PropTypes.func.isRequired,
|
|
onSelect: React.PropTypes.func.isRequired,
|
|
readOnly: React.PropTypes.bool.isRequired,
|
|
schema: React.PropTypes.object,
|
|
spellCheck: React.PropTypes.bool.isRequired,
|
|
state: React.PropTypes.object.isRequired,
|
|
style: React.PropTypes.object
|
|
};
|
|
|
|
/**
|
|
* Default properties.
|
|
*
|
|
* @type {Object}
|
|
*/
|
|
|
|
static defaultProps = {
|
|
style: {}
|
|
};
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param {Object} props
|
|
*/
|
|
|
|
constructor(props) {
|
|
super(props)
|
|
this.tmp = {}
|
|
this.tmp.compositions = 0
|
|
this.forces = 0
|
|
}
|
|
|
|
/**
|
|
* Should the component update?
|
|
*
|
|
* @param {Object} props
|
|
* @param {Object} state
|
|
* @return {Boolean} shouldUpdate
|
|
*/
|
|
|
|
shouldComponentUpdate = (props, state) => {
|
|
// If the state has been transformed natively, never re-render, or else we
|
|
// will end up duplicating content.
|
|
if (props.state.isNative) return false
|
|
|
|
return (
|
|
props.className != this.props.className ||
|
|
props.readOnly != this.props.readOnly ||
|
|
props.schema != this.props.schema ||
|
|
props.spellCheck != this.props.spellCheck ||
|
|
props.state != this.props.state ||
|
|
props.style != this.props.style
|
|
)
|
|
}
|
|
|
|
/**
|
|
* While rendering, set the `isRendering` flag.
|
|
*
|
|
* @param {Object} props
|
|
* @param {Object} state
|
|
*/
|
|
|
|
componentWillUpdate = (props, state) => {
|
|
this.tmp.isRendering = true
|
|
}
|
|
|
|
/**
|
|
* When finished rendering, move the `isRendering` flag on next tick.
|
|
*
|
|
* @param {Object} props
|
|
* @param {Object} state
|
|
*/
|
|
|
|
componentDidUpdate = (props, state) => {
|
|
setTimeout(() => {
|
|
this.tmp.isRendering = false
|
|
}, 1)
|
|
}
|
|
|
|
/**
|
|
* Get a point from a native selection's DOM `element` and `offset`.
|
|
*
|
|
* @param {Element} element
|
|
* @param {Number} offset
|
|
* @return {Object}
|
|
*/
|
|
|
|
getPoint(element, offset) {
|
|
const { state, editor } = this.props
|
|
const { document } = state
|
|
const schema = editor.getSchema()
|
|
const offsetKey = OffsetKey.findKey(element, offset)
|
|
const { key } = offsetKey
|
|
const node = document.getDescendant(key)
|
|
const decorators = document.getDescendantDecorators(key, schema)
|
|
const ranges = node.getRanges(decorators)
|
|
const point = OffsetKey.findPoint(offsetKey, ranges)
|
|
return point
|
|
}
|
|
|
|
/**
|
|
* On before input, bubble up.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onBeforeInput = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (isNonEditable(e)) return
|
|
|
|
const data = {}
|
|
|
|
debug('onBeforeInput', data)
|
|
this.props.onBeforeInput(e, data)
|
|
}
|
|
|
|
/**
|
|
* On blur, update the selection to be not focused.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onBlur = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (this.tmp.isCopying) return
|
|
if (isNonEditable(e)) return
|
|
|
|
const data = {}
|
|
|
|
debug('onBlur', data)
|
|
this.props.onBlur(e, data)
|
|
}
|
|
|
|
/**
|
|
* On change, bubble up.
|
|
*
|
|
* @param {State} state
|
|
*/
|
|
|
|
onChange = (state) => {
|
|
debug('onChange', state)
|
|
this.props.onChange(state)
|
|
}
|
|
|
|
/**
|
|
* On composition start, set the `isComposing` flag.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onCompositionStart = (e) => {
|
|
if (isNonEditable(e)) return
|
|
|
|
this.tmp.isComposing = true
|
|
this.tmp.compositions++
|
|
|
|
debug('onCompositionStart')
|
|
}
|
|
|
|
/**
|
|
* On composition end, remove the `isComposing` flag on the next tick. Also
|
|
* increment the `forces` key, which will force the contenteditable element
|
|
* to completely re-render, since IME puts React in an unreconcilable state.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onCompositionEnd = (e) => {
|
|
if (isNonEditable(e)) return
|
|
|
|
this.forces++
|
|
const count = this.tmp.compositions
|
|
|
|
// The `count` check here ensures that if another composition starts
|
|
// before the timeout has closed out this one, we will abort unsetting the
|
|
// `isComposing` flag, since a composition in still in affect.
|
|
setTimeout(() => {
|
|
if (this.tmp.compositions > count) return
|
|
this.tmp.isComposing = false
|
|
})
|
|
|
|
debug('onCompositionEnd')
|
|
}
|
|
|
|
/**
|
|
* On copy, defer to `onCutCopy`, then bubble up.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onCopy = (e) => {
|
|
if (isNonEditable(e)) return
|
|
const window = getWindow(e.target)
|
|
|
|
this.tmp.isCopying = true
|
|
window.requestAnimationFrame(() => {
|
|
this.tmp.isCopying = false
|
|
})
|
|
|
|
const { state } = this.props
|
|
const data = {}
|
|
data.type = 'fragment'
|
|
data.fragment = state.fragment
|
|
|
|
debug('onCopy', data)
|
|
this.props.onCopy(e, data)
|
|
}
|
|
|
|
/**
|
|
* On cut, defer to `onCutCopy`, then bubble up.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onCut = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (isNonEditable(e)) return
|
|
const window = getWindow(e.target)
|
|
|
|
this.tmp.isCopying = true
|
|
window.requestAnimationFrame(() => {
|
|
this.tmp.isCopying = false
|
|
})
|
|
|
|
const { state } = this.props
|
|
const data = {}
|
|
data.type = 'fragment'
|
|
data.fragment = state.fragment
|
|
|
|
debug('onCut', data)
|
|
this.props.onCut(e, data)
|
|
}
|
|
|
|
/**
|
|
* On drag end, unset the `isDragging` flag.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onDragEnd = (e) => {
|
|
if (isNonEditable(e)) return
|
|
|
|
this.tmp.isDragging = false
|
|
this.tmp.isInternalDrag = null
|
|
|
|
debug('onDragEnd')
|
|
}
|
|
|
|
/**
|
|
* On drag over, set the `isDragging` flag and the `isInternalDrag` flag.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onDragOver = (e) => {
|
|
if (isNonEditable(e)) return
|
|
|
|
const { dataTransfer } = e.nativeEvent
|
|
const transfer = new Transfer(dataTransfer)
|
|
|
|
// Prevent default when nodes are dragged to allow dropping.
|
|
if (transfer.getType() == 'node') {
|
|
e.preventDefault()
|
|
}
|
|
|
|
if (this.tmp.isDragging) return
|
|
this.tmp.isDragging = true
|
|
this.tmp.isInternalDrag = false
|
|
|
|
debug('onDragOver')
|
|
}
|
|
|
|
/**
|
|
* On drag start, set the `isDragging` flag and the `isInternalDrag` flag.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onDragStart = (e) => {
|
|
if (isNonEditable(e)) return
|
|
|
|
this.tmp.isDragging = true
|
|
this.tmp.isInternalDrag = true
|
|
const { dataTransfer } = e.nativeEvent
|
|
const transfer = new Transfer(dataTransfer)
|
|
|
|
// If it's a node being dragged, the data type is already set.
|
|
if (transfer.getType() == 'node') return
|
|
|
|
const { state } = this.props
|
|
const { fragment } = state
|
|
const encoded = Base64.serializeNode(fragment)
|
|
dataTransfer.setData(TYPES.FRAGMENT, encoded)
|
|
|
|
debug('onDragStart')
|
|
}
|
|
|
|
/**
|
|
* On drop.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onDrop = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (isNonEditable(e)) return
|
|
|
|
e.preventDefault()
|
|
|
|
const window = getWindow(e.target)
|
|
const { state } = this.props
|
|
const { selection } = state
|
|
const { dataTransfer, x, y } = e.nativeEvent
|
|
const transfer = new Transfer(dataTransfer)
|
|
const data = transfer.getData()
|
|
|
|
// Resolve the point where the drop occured.
|
|
let range
|
|
|
|
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
|
|
if (window.document.caretRangeFromPoint) {
|
|
range = window.document.caretRangeFromPoint(x, y)
|
|
} else {
|
|
range = window.document.createRange()
|
|
range.setStart(e.nativeEvent.rangeParent, e.nativeEvent.rangeOffset)
|
|
}
|
|
|
|
const startNode = range.startContainer
|
|
const startOffset = range.startOffset
|
|
const point = this.getPoint(startNode, startOffset)
|
|
const target = Selection.create({
|
|
anchorKey: point.key,
|
|
anchorOffset: point.offset,
|
|
focusKey: point.key,
|
|
focusOffset: point.offset,
|
|
isFocused: true
|
|
})
|
|
|
|
// If the target is inside a void node, abort.
|
|
if (state.document.hasVoidParent(point.key)) return
|
|
|
|
// Add drop-specific information to the data.
|
|
data.target = target
|
|
data.effect = dataTransfer.dropEffect
|
|
|
|
if (data.type == 'fragment' || data.type == 'node') {
|
|
data.isInternal = this.tmp.isInternalDrag
|
|
}
|
|
|
|
debug('onDrop', data)
|
|
this.props.onDrop(e, data)
|
|
}
|
|
|
|
/**
|
|
* On input, handle spellcheck and other similar edits that don't go trigger
|
|
* the `onBeforeInput` and instead update the DOM directly.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onInput = (e) => {
|
|
if (this.tmp.isRendering) return
|
|
if (this.tmp.isComposing) return
|
|
if (isNonEditable(e)) return
|
|
debug('onInput')
|
|
|
|
const window = getWindow(e.target)
|
|
|
|
// Get the selection point.
|
|
const native = window.getSelection()
|
|
const { anchorNode, anchorOffset, focusOffset } = native
|
|
const point = this.getPoint(anchorNode, anchorOffset)
|
|
const { key, index, start, end } = point
|
|
|
|
// Get the range in question.
|
|
const { state, editor } = this.props
|
|
const { document, selection } = state
|
|
const schema = editor.getSchema()
|
|
const decorators = document.getDescendantDecorators(key, schema)
|
|
const node = document.getDescendant(key)
|
|
const ranges = node.getRanges(decorators)
|
|
const range = ranges.get(index)
|
|
|
|
// Get the text information.
|
|
const isLast = index == ranges.size - 1
|
|
const { text, marks } = range
|
|
let { textContent } = anchorNode
|
|
const lastChar = textContent.charAt(textContent.length - 1)
|
|
|
|
// If we're dealing with the last leaf, and the DOM text ends in a new line,
|
|
// we will have added another new line in <Leaf>'s render method to account
|
|
// for browsers collapsing a single trailing new lines, so remove it.
|
|
if (isLast && lastChar == '\n') {
|
|
textContent = textContent.slice(0, -1)
|
|
}
|
|
|
|
// If the text is no different, abort.
|
|
if (textContent == text) return
|
|
|
|
// Determine what the selection should be after changing the text.
|
|
const delta = textContent.length - text.length
|
|
const after = selection.collapseToEnd().moveForward(delta)
|
|
|
|
// Create an updated state with the text replaced.
|
|
const next = state
|
|
.transform()
|
|
.moveTo({
|
|
anchorKey: key,
|
|
anchorOffset: start,
|
|
focusKey: key,
|
|
focusOffset: end
|
|
})
|
|
.delete()
|
|
.insertText(textContent, marks)
|
|
.moveTo(after)
|
|
.apply()
|
|
|
|
// Change the current state.
|
|
this.onChange(next)
|
|
}
|
|
|
|
/**
|
|
* On key down, prevent the default behavior of certain commands that will
|
|
* leave the editor in an out-of-sync state, then bubble up.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onKeyDown = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (isNonEditable(e)) return
|
|
|
|
const key = keycode(e.which)
|
|
const data = {}
|
|
|
|
// When composing, these characters commit the composition but also move the
|
|
// selection before we're able to handle it, so prevent their default,
|
|
// selection-moving behavior.
|
|
if (
|
|
this.tmp.isComposing &&
|
|
(key == 'left' || key == 'right' || key == 'up' || key == 'down')
|
|
) {
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
|
|
// Add helpful properties for handling hotkeys to the data object.
|
|
data.code = e.which
|
|
data.key = key
|
|
data.isAlt = e.altKey
|
|
data.isCmd = IS_MAC ? e.metaKey && !e.altKey : false
|
|
data.isCtrl = e.ctrlKey && !e.altKey
|
|
data.isLine = IS_MAC ? e.metaKey : false
|
|
data.isMeta = e.metaKey
|
|
data.isMod = IS_MAC ? e.metaKey && !e.altKey : e.ctrlKey && !e.altKey
|
|
data.isModAlt = IS_MAC ? e.metaKey && e.altKey : e.ctrlKey && e.altKey
|
|
data.isShift = e.shiftKey
|
|
data.isWord = IS_MAC ? e.altKey : e.ctrlKey
|
|
|
|
// These key commands have native behavior in contenteditable elements which
|
|
// will cause our state to be out of sync, so prevent them.
|
|
if (
|
|
(key == 'enter') ||
|
|
(key == 'backspace') ||
|
|
(key == 'delete') ||
|
|
(key == 'b' && data.isMod) ||
|
|
(key == 'i' && data.isMod) ||
|
|
(key == 'y' && data.isMod) ||
|
|
(key == 'z' && data.isMod)
|
|
) {
|
|
e.preventDefault()
|
|
}
|
|
|
|
debug('onKeyDown', data)
|
|
this.props.onKeyDown(e, data)
|
|
}
|
|
|
|
/**
|
|
* On paste, determine the type and bubble up.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onPaste = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (isNonEditable(e)) return
|
|
|
|
e.preventDefault()
|
|
const transfer = new Transfer(e.clipboardData)
|
|
const data = transfer.getData()
|
|
|
|
debug('onPaste', data)
|
|
this.props.onPaste(e, data)
|
|
}
|
|
|
|
/**
|
|
* On select, update the current state's selection.
|
|
*
|
|
* @param {Event} e
|
|
*/
|
|
|
|
onSelect = (e) => {
|
|
if (this.props.readOnly) return
|
|
if (this.tmp.isRendering) return
|
|
if (this.tmp.isCopying) return
|
|
if (this.tmp.isComposing) return
|
|
if (isNonEditable(e)) return
|
|
|
|
const window = getWindow(e.target)
|
|
const { state } = this.props
|
|
let { document, selection } = state
|
|
const native = window.getSelection()
|
|
const data = {}
|
|
|
|
// If there are no ranges, the editor was blurred natively.
|
|
if (!native.rangeCount) {
|
|
data.selection = selection.merge({ isFocused: false })
|
|
data.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)
|
|
|
|
// There are valid situations where a select event will fire when we're
|
|
// already at that position (for example when entering a character), since
|
|
// our `insertText` transform already updates the selection. In those
|
|
// cases we can safely ignore the event.
|
|
if (
|
|
anchor.key == selection.anchorKey &&
|
|
anchor.offset == selection.anchorOffset &&
|
|
focus.key == selection.focusKey &&
|
|
focus.offset == selection.focusOffset &&
|
|
selection.isFocused
|
|
) {
|
|
return
|
|
}
|
|
|
|
const properties = {
|
|
anchorKey: anchor.key,
|
|
anchorOffset: anchor.offset,
|
|
focusKey: focus.key,
|
|
focusOffset: focus.offset,
|
|
isFocused: true,
|
|
isBackward: null
|
|
}
|
|
|
|
data.selection = selection
|
|
.merge(properties)
|
|
.normalize(document)
|
|
}
|
|
|
|
debug('onSelect', { data, selection: data.selection.toJS() })
|
|
this.props.onSelect(e, data)
|
|
}
|
|
|
|
/**
|
|
* Render the editor content.
|
|
*
|
|
* @return {Element} element
|
|
*/
|
|
|
|
render = () => {
|
|
debug('render')
|
|
|
|
const { className, readOnly, state } = this.props
|
|
const { document } = state
|
|
const children = document.nodes
|
|
.map(node => this.renderNode(node))
|
|
.toArray()
|
|
|
|
let style = {
|
|
// Prevent the default outline styles.
|
|
outline: 'none',
|
|
// Preserve adjacent whitespace and new lines.
|
|
whiteSpace: 'pre-wrap',
|
|
// Allow words to break if they are too long.
|
|
wordWrap: 'break-word',
|
|
// COMPAT: In iOS, a formatting menu with bold, italic and underline
|
|
// buttons is shown which causes our internal state to get out of sync in
|
|
// weird ways. This hides that. (2016/06/21)
|
|
...(readOnly ? {} : { WebkitUserModify: 'read-write-plaintext-only' }),
|
|
// Allow for passed-in styles to override anything.
|
|
...this.props.style,
|
|
}
|
|
|
|
// COMPAT: In Firefox, spellchecking can remove entire wrapping elements
|
|
// including inline ones like `<a>`, which is jarring for the user but also
|
|
// causes the DOM to get into an irreconilable state.
|
|
const spellCheck = IS_FIREFOX ? false : this.props.spellCheck
|
|
|
|
return (
|
|
<div
|
|
key={this.forces}
|
|
contentEditable={!readOnly}
|
|
suppressContentEditableWarning
|
|
className={className}
|
|
onBeforeInput={this.onBeforeInput}
|
|
onBlur={this.onBlur}
|
|
onCompositionEnd={this.onCompositionEnd}
|
|
onCompositionStart={this.onCompositionStart}
|
|
onCopy={this.onCopy}
|
|
onCut={this.onCut}
|
|
onDragEnd={this.onDragEnd}
|
|
onDragOver={this.onDragOver}
|
|
onDragStart={this.onDragStart}
|
|
onDrop={this.onDrop}
|
|
onInput={this.onInput}
|
|
onKeyDown={this.onKeyDown}
|
|
onKeyUp={noop}
|
|
onPaste={this.onPaste}
|
|
onSelect={this.onSelect}
|
|
spellCheck={spellCheck}
|
|
style={style}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Render a `node`.
|
|
*
|
|
* @param {Node} node
|
|
* @return {Element} element
|
|
*/
|
|
|
|
renderNode = (node) => {
|
|
const { editor, schema, state } = this.props
|
|
|
|
return (
|
|
<Node
|
|
key={node.key}
|
|
node={node}
|
|
schema={schema}
|
|
state={state}
|
|
editor={editor}
|
|
/>
|
|
)
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Check if an `event` is being fired from inside a non-contentediable child
|
|
* element, in which case we'll want to ignore it.
|
|
*
|
|
* @param {Event} event
|
|
* @return {Boolean}
|
|
*/
|
|
|
|
function isNonEditable(event) {
|
|
const { target, currentTarget } = event
|
|
const nonEditable = target.closest('[contenteditable="false"]')
|
|
const isContained = currentTarget.contains(nonEditable)
|
|
return isContained
|
|
}
|
|
|
|
/**
|
|
* Export.
|
|
*/
|
|
|
|
export default Content
|