mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-04-21 13:51:59 +02:00
add placeholder
This commit is contained in:
parent
806ffc38df
commit
3d191dbf14
9
Makefile
9
Makefile
@ -34,7 +34,6 @@ dist: $(shell find ./lib) package.json
|
||||
--out-dir \
|
||||
./dist \
|
||||
./lib
|
||||
@ touch ./dist
|
||||
|
||||
# Build the examples.
|
||||
examples:
|
||||
@ -84,6 +83,14 @@ test-server:
|
||||
--fgrep "$(GREP)" \
|
||||
./test/server.js
|
||||
|
||||
# Watch the source.
|
||||
watch-dist: $(shell find ./lib) package.json
|
||||
@ $(babel) \
|
||||
--watch \
|
||||
--out-dir \
|
||||
./dist \
|
||||
./lib
|
||||
|
||||
# Watch the examples.
|
||||
watch-examples:
|
||||
@ $(watchify) \
|
||||
|
@ -66,6 +66,7 @@ class PlainText extends React.Component {
|
||||
render = () => {
|
||||
return (
|
||||
<Editor
|
||||
placeholder={'Enter some plain text...'}
|
||||
state={this.state.state}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
|
@ -1,5 +1,5 @@
|
||||
|
||||
import { Editor, Mark, Raw, Utils } from '../..'
|
||||
import { Editor, Mark, Placeholder, Raw, Utils } from '../..'
|
||||
import React from 'react'
|
||||
import initialState from './state.json'
|
||||
import keycode from 'keycode'
|
||||
@ -11,13 +11,12 @@ import keycode from 'keycode'
|
||||
*/
|
||||
|
||||
const NODES = {
|
||||
'block-quote': props => <blockquote>{props.children}</blockquote>,
|
||||
'bulleted-list': props => <ul>{props.chidlren}</ul>,
|
||||
'heading-one': props => <h1>{props.children}</h1>,
|
||||
'heading-two': props => <h2>{props.children}</h2>,
|
||||
'list-item': props => <li>{props.chidlren}</li>,
|
||||
'numbered-list': props => <ol>{props.children}</ol>,
|
||||
'paragraph': props => <p>{props.children}</p>
|
||||
'block-quote': props => <blockquote {...props.attributes}>{props.children}</blockquote>,
|
||||
'bulleted-list': props => <ul {...props.attributes}>{props.chidlren}</ul>,
|
||||
'heading-one': props => <h1 {...props.attributes}>{props.children}</h1>,
|
||||
'heading-two': props => <h2 {...props.attributes}>{props.children}</h2>,
|
||||
'list-item': props => <li {...props.attributes}>{props.chidlren}</li>,
|
||||
'numbered-list': props => <ol {...props.attributes}>{props.children}</ol>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -107,6 +106,7 @@ class RichText extends React.Component {
|
||||
return (
|
||||
<div className="editor">
|
||||
<Editor
|
||||
placeholder={'Enter some rich text...'}
|
||||
state={this.state.state}
|
||||
renderNode={this.renderNode}
|
||||
renderMark={this.renderMark}
|
||||
|
@ -387,8 +387,13 @@ class Content extends React.Component {
|
||||
.map(child => this.renderNode(child))
|
||||
.toArray()
|
||||
|
||||
const attributes = {
|
||||
'data-key': node.key
|
||||
}
|
||||
|
||||
const element = (
|
||||
<Component
|
||||
attributes={attributes}
|
||||
key={node.key}
|
||||
editor={editor}
|
||||
node={node}
|
||||
|
@ -1,8 +1,8 @@
|
||||
|
||||
import Content from './content'
|
||||
import CorePlugin from '../plugins/core'
|
||||
import React from 'react'
|
||||
import State from '../models/state'
|
||||
import corePlugin from '../plugins/core'
|
||||
|
||||
/**
|
||||
* Editor.
|
||||
@ -190,7 +190,6 @@ class Editor extends React.Component {
|
||||
if (!plugin.renderNode) continue
|
||||
const component = plugin.renderNode(node, this.state.state, this)
|
||||
if (component) return component
|
||||
throw new Error(`No renderer found for node with type "${node.type}".`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -206,7 +205,6 @@ class Editor extends React.Component {
|
||||
if (!plugin.renderMark) continue
|
||||
const style = plugin.renderMark(mark, this.state.state, this)
|
||||
if (style) return style
|
||||
throw new Error(`No renderer found for mark with type "${mark.type}".`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,6 +224,7 @@ class Editor extends React.Component {
|
||||
|
||||
resolvePlugins = (props) => {
|
||||
const { onChange, plugins, ...editorPlugin } = props
|
||||
const corePlugin = CorePlugin(props)
|
||||
return [
|
||||
editorPlugin,
|
||||
...plugins,
|
||||
|
108
lib/components/placeholder.js
Normal file
108
lib/components/placeholder.js
Normal file
@ -0,0 +1,108 @@
|
||||
|
||||
import Portal from 'react-portal'
|
||||
import React from 'react'
|
||||
import findDOMNode from '../utils/find-dom-node'
|
||||
|
||||
/**
|
||||
* Placeholder.
|
||||
*/
|
||||
|
||||
class Placeholder extends React.Component {
|
||||
|
||||
/**
|
||||
* Properties.
|
||||
*/
|
||||
|
||||
static propTypes = {
|
||||
children: React.PropTypes.any.isRequired,
|
||||
className: React.PropTypes.string,
|
||||
node: React.PropTypes.object.isRequired,
|
||||
parent: React.PropTypes.object.isRequired,
|
||||
state: React.PropTypes.object.isRequired,
|
||||
style: React.PropTypes.object
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onlyFirstChild: false,
|
||||
style: {
|
||||
opacity: '0.333'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Should the component update?
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} state
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
shouldComponentUpdate = (props, state) => {
|
||||
return (
|
||||
props.children != this.props.children ||
|
||||
props.className != this.props.className ||
|
||||
props.parent != this.props.parent ||
|
||||
props.node != this.props.node ||
|
||||
props.style != this.props.style
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the placeholder visible?
|
||||
*
|
||||
* @return {Boolean}
|
||||
*/
|
||||
|
||||
isVisible = () => {
|
||||
const { onlyFirstChild, node, parent } = this.props
|
||||
if (node.text) return false
|
||||
if (parent.nodes.size > 1) return false
|
||||
|
||||
const isFirst = parent.nodes.first() === node
|
||||
if (isFirst) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* On open, update the placeholder element's position.
|
||||
*
|
||||
* @param {Element} portal
|
||||
*/
|
||||
|
||||
onOpen = (portal) => {
|
||||
const { node } = this.props
|
||||
const el = portal.firstChild
|
||||
const nodeEl = findDOMNode(node)
|
||||
const rect = nodeEl.getBoundingClientRect()
|
||||
el.style.pointerEvents = 'none'
|
||||
el.style.position = 'absolute'
|
||||
el.style.top = `${rect.top}px`
|
||||
el.style.left = `${rect.left}px`
|
||||
el.style.width = `${rect.width}px`
|
||||
el.style.height = `${rect.height}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render.
|
||||
*
|
||||
* @return {Element} element
|
||||
*/
|
||||
|
||||
render = () => {
|
||||
const { children, className, style } = this.props
|
||||
const isOpen = this.isVisible()
|
||||
return (
|
||||
<Portal isOpened={isOpen} onOpen={this.onOpen}>
|
||||
<span className={className} style={style}>{children}</span>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default Placeholder
|
@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import Editor from './components/editor'
|
||||
import Placeholder from './components/placeholder'
|
||||
|
||||
/**
|
||||
* Models.
|
||||
@ -31,8 +32,12 @@ import Raw from './serializers/raw'
|
||||
*/
|
||||
|
||||
import Key from './utils/key'
|
||||
import findDOMNode from './utils/find-dom-node'
|
||||
|
||||
const Utils = { Key }
|
||||
const Utils = {
|
||||
Key,
|
||||
findDOMNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
@ -47,6 +52,7 @@ export {
|
||||
Html,
|
||||
Inline,
|
||||
Mark,
|
||||
Placeholder,
|
||||
Raw,
|
||||
Selection,
|
||||
State,
|
||||
@ -63,6 +69,7 @@ export default {
|
||||
Html,
|
||||
Inline,
|
||||
Mark,
|
||||
Placeholder,
|
||||
Raw,
|
||||
Selection,
|
||||
State,
|
||||
|
@ -485,7 +485,7 @@ const Node = {
|
||||
if (range.isCollapsed && startOffset == 0) {
|
||||
const text = this.getDescendant(startKey)
|
||||
const previous = this.getPreviousText(startKey)
|
||||
if (!previous) return marks
|
||||
if (!previous || !previous.length) return marks
|
||||
const char = previous.characters.get(previous.length - 1)
|
||||
return char.marks
|
||||
}
|
||||
|
@ -1,204 +1,258 @@
|
||||
|
||||
import Key from '../utils/key'
|
||||
import Placeholder from '../components/placeholder'
|
||||
import React from 'react'
|
||||
import keycode from 'keycode'
|
||||
import { IS_WINDOWS, IS_MAC } from '../utils/environment'
|
||||
|
||||
/**
|
||||
* Default block renderer.
|
||||
* The default plugin.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @return {Element} element
|
||||
* @param {Object} options
|
||||
* @return {Object}
|
||||
*/
|
||||
|
||||
function DEFAULT_BLOCK(props) {
|
||||
return <div>{props.children}</div>
|
||||
function Plugin(options = {}) {
|
||||
const { placeholder } = options
|
||||
|
||||
/**
|
||||
* Define a default block renderer.
|
||||
*
|
||||
* @type {Component}
|
||||
*/
|
||||
|
||||
class DEFAULT_BLOCK extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
attributes: React.PropTypes.object.isRequired,
|
||||
children: React.PropTypes.any.isRequired,
|
||||
node: React.PropTypes.object.isRequired,
|
||||
state: React.PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
render = () => {
|
||||
const { attributes, children } = this.props
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{this.renderPlaceholder()}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderPlaceholder = () => {
|
||||
if (!placeholder) return null
|
||||
const { node, state } = this.props
|
||||
|
||||
return (
|
||||
<Placeholder parent={state.document} node={node} state={state}>
|
||||
{placeholder}
|
||||
</Placeholder>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a default inline renderer.
|
||||
*
|
||||
* @type {Component}
|
||||
*/
|
||||
|
||||
class DEFAULT_INLINE extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
attributes: React.PropTypes.object.isRequired,
|
||||
children: React.PropTypes.any.isRequired,
|
||||
node: React.PropTypes.object.isRequired,
|
||||
state: React.PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
render = () => {
|
||||
const { attributes, children } = this.props
|
||||
return <span {...attributes}>{children}</span>
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a default mark renderer.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
|
||||
const DEFAULT_MARK = {}
|
||||
|
||||
/**
|
||||
* Return the plugin.
|
||||
*/
|
||||
|
||||
return {
|
||||
|
||||
/**
|
||||
* The core `onBeforeInput` handler.
|
||||
*
|
||||
* If the current selection is expanded, we have to re-render.
|
||||
*
|
||||
* If the next state resolves a new list of decorations for any of its text
|
||||
* nodes, we have to re-render.
|
||||
*
|
||||
* Otherwise, we can allow the default, native text insertion, avoiding a
|
||||
* re-render for improved performance.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {State} state
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onBeforeInput(e, state, editor) {
|
||||
const transform = state.transform().insertText(e.data)
|
||||
const synthetic = transform.apply()
|
||||
const resolved = editor.resolveState(synthetic)
|
||||
|
||||
const isSynthenic = (
|
||||
state.isExpanded ||
|
||||
!resolved.equals(synthetic)
|
||||
)
|
||||
|
||||
if (isSynthenic) e.preventDefault()
|
||||
|
||||
return isSynthenic
|
||||
? synthetic
|
||||
: transform.apply({ isNative: true })
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `onKeyDown` handler.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {State} state
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onKeyDown(e, state, editor) {
|
||||
const key = keycode(e.which)
|
||||
const transform = state.transform()
|
||||
|
||||
switch (key) {
|
||||
case 'enter': {
|
||||
return transform.splitBlock().apply()
|
||||
}
|
||||
|
||||
case 'backspace': {
|
||||
return Key.isWord(e)
|
||||
? transform.backspaceWord().apply()
|
||||
: transform.deleteBackward().apply()
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
return Key.isWord(e)
|
||||
? transform.deleteWord().apply()
|
||||
: transform.deleteForward().apply()
|
||||
}
|
||||
|
||||
case 'up': {
|
||||
if (state.isExpanded) return
|
||||
const first = state.blocks.first()
|
||||
if (!first || !first.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToEndOfPreviousBlock().apply()
|
||||
}
|
||||
|
||||
case 'down': {
|
||||
if (state.isExpanded) return
|
||||
const first = state.blocks.first()
|
||||
if (!first || !first.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToStartOfNextBlock().apply()
|
||||
}
|
||||
|
||||
case 'left': {
|
||||
if (state.isExpanded) return
|
||||
const node = state.blocks.first() || state.inlines.first()
|
||||
if (!node || !node.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToEndOfPreviousText().apply()
|
||||
}
|
||||
|
||||
case 'right': {
|
||||
if (state.isExpanded) return
|
||||
const node = state.blocks.first() || state.inlines.first()
|
||||
if (!node || !node.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToStartOfNextText().apply()
|
||||
}
|
||||
|
||||
case 'y': {
|
||||
if (!Key.isWindowsCommand(e)) return
|
||||
return transform.redo()
|
||||
}
|
||||
|
||||
case 'z': {
|
||||
if (!Key.isCommand(e)) return
|
||||
return IS_MAC && Key.isShift(e)
|
||||
? transform.redo()
|
||||
: transform.undo()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `onPaste` handler, which treats everything as plain text.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {Object} paste
|
||||
* @param {State} state
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onPaste(e, paste, state, editor) {
|
||||
if (paste.type == 'files') return
|
||||
|
||||
let transform = state.transform()
|
||||
|
||||
paste.text
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
if (i > 0) transform = transform.splitBlock()
|
||||
transform = transform.insertText(line)
|
||||
})
|
||||
|
||||
return transform.apply()
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `node` renderer, which uses plain `<div>` or `<span>` depending on
|
||||
* what kind of node it is.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @return {Component} component
|
||||
*/
|
||||
|
||||
renderNode(node) {
|
||||
return node.kind == 'block'
|
||||
? DEFAULT_BLOCK
|
||||
: DEFAULT_INLINE
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `mark` renderer, with no styles.
|
||||
*
|
||||
* @param {Mark} mark
|
||||
* @return {Object} style
|
||||
*/
|
||||
|
||||
renderMark(mark) {
|
||||
return DEFAULT_MARK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default inline renderer.
|
||||
*
|
||||
* @param {Object} props
|
||||
* @return {Element} element
|
||||
*/
|
||||
|
||||
function DEFAULT_INLINE(props) {
|
||||
return <span>{props.children}</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mark renderer.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
|
||||
const DEFAULT_MARK = {}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default {
|
||||
|
||||
/**
|
||||
* The core `onBeforeInput` handler.
|
||||
*
|
||||
* If the current selection is expanded, we have to re-render.
|
||||
*
|
||||
* If the next state resolves a new list of decorations for any of its text
|
||||
* nodes, we have to re-render.
|
||||
*
|
||||
* Otherwise, we can allow the default, native text insertion, avoiding a
|
||||
* re-render for improved performance.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {State} state
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onBeforeInput(e, state, editor) {
|
||||
const transform = state.transform().insertText(e.data)
|
||||
const synthetic = transform.apply()
|
||||
const resolved = editor.resolveState(synthetic)
|
||||
|
||||
const isSynthenic = (
|
||||
state.isExpanded ||
|
||||
!resolved.equals(synthetic)
|
||||
)
|
||||
|
||||
if (isSynthenic) e.preventDefault()
|
||||
|
||||
return isSynthenic
|
||||
? synthetic
|
||||
: transform.apply({ isNative: true })
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `onKeyDown` handler.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {State} state
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onKeyDown(e, state, editor) {
|
||||
const key = keycode(e.which)
|
||||
const transform = state.transform()
|
||||
|
||||
switch (key) {
|
||||
case 'enter': {
|
||||
return transform.splitBlock().apply()
|
||||
}
|
||||
|
||||
case 'backspace': {
|
||||
return Key.isWord(e)
|
||||
? transform.backspaceWord().apply()
|
||||
: transform.deleteBackward().apply()
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
return Key.isWord(e)
|
||||
? transform.deleteWord().apply()
|
||||
: transform.deleteForward().apply()
|
||||
}
|
||||
|
||||
case 'up': {
|
||||
if (state.isExpanded) return
|
||||
const first = state.blocks.first()
|
||||
if (!first || !first.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToEndOfPreviousBlock().apply()
|
||||
}
|
||||
|
||||
case 'down': {
|
||||
if (state.isExpanded) return
|
||||
const first = state.blocks.first()
|
||||
if (!first || !first.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToStartOfNextBlock().apply()
|
||||
}
|
||||
|
||||
case 'left': {
|
||||
if (state.isExpanded) return
|
||||
const node = state.blocks.first() || state.inlines.first()
|
||||
if (!node || !node.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToEndOfPreviousText().apply()
|
||||
}
|
||||
|
||||
case 'right': {
|
||||
if (state.isExpanded) return
|
||||
const node = state.blocks.first() || state.inlines.first()
|
||||
if (!node || !node.isVoid) return
|
||||
e.preventDefault()
|
||||
return transform.moveToStartOfNextText().apply()
|
||||
}
|
||||
|
||||
case 'y': {
|
||||
if (!Key.isWindowsCommand(e)) return
|
||||
return transform.redo()
|
||||
}
|
||||
|
||||
case 'z': {
|
||||
if (!Key.isCommand(e)) return
|
||||
return IS_MAC && Key.isShift(e)
|
||||
? transform.redo()
|
||||
: transform.undo()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `onPaste` handler, which treats everything as plain text.
|
||||
*
|
||||
* @param {Event} e
|
||||
* @param {Object} paste
|
||||
* @param {State} state
|
||||
* @param {Editor} editor
|
||||
* @return {State or Null} newState
|
||||
*/
|
||||
|
||||
onPaste(e, paste, state, editor) {
|
||||
if (paste.type == 'files') return
|
||||
|
||||
let transform = state.transform()
|
||||
|
||||
paste.text
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
if (i > 0) transform = transform.splitBlock()
|
||||
transform = transform.insertText(line)
|
||||
})
|
||||
|
||||
return transform.apply()
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `node` renderer, which uses plain `<div>` or `<span>` depending on
|
||||
* what kind of node it is.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @return {Component} component
|
||||
*/
|
||||
|
||||
renderNode(node) {
|
||||
return node.kind == 'block'
|
||||
? DEFAULT_BLOCK
|
||||
: DEFAULT_INLINE
|
||||
},
|
||||
|
||||
/**
|
||||
* The core `mark` renderer, with no styles.
|
||||
*
|
||||
* @param {Mark} mark
|
||||
* @return {Object} style
|
||||
*/
|
||||
|
||||
renderMark(mark) {
|
||||
return DEFAULT_MARK
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Plugin
|
||||
|
17
lib/utils/find-dom-node.js
Normal file
17
lib/utils/find-dom-node.js
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
/**
|
||||
* Find the DOM node for a `node`.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @return {Element} el
|
||||
*/
|
||||
|
||||
function findDOMNode(node) {
|
||||
return window.document.querySelector(`[data-key="${node.key}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*/
|
||||
|
||||
export default findDOMNode
|
Loading…
x
Reference in New Issue
Block a user