1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-02-24 17:23:07 +01:00
slate/lib/plugins/core.js

720 lines
16 KiB
JavaScript
Raw Normal View History

2016-06-15 12:07:12 -07:00
import Base64 from '../serializers/base-64'
import Character from '../models/character'
import Debug from 'debug'
2016-07-11 18:36:45 -07:00
import Placeholder from '../components/placeholder'
2016-06-17 19:57:37 -07:00
import React from 'react'
import String from '../utils/string'
import getWindow from 'get-window'
2016-06-15 12:07:12 -07:00
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:core')
2016-07-06 14:48:40 -07:00
/**
2016-07-11 18:36:45 -07:00
* The default plugin.
2016-07-06 20:19:19 -07:00
*
2016-07-11 18:36:45 -07:00
* @param {Object} options
* @property {Element} placeholder
* @property {String} placeholderClassName
* @property {Object} placeholderStyle
2016-07-11 18:36:45 -07:00
* @return {Object}
2016-07-06 14:48:40 -07:00
*/
2016-07-11 18:36:45 -07:00
function Plugin(options = {}) {
const {
placeholder,
placeholderClassName,
placeholderStyle,
} = options
2016-08-12 11:33:48 -07:00
/**
* On before change, enforce the editor's schema.
*
* @param {State} state
* @param {Editor} schema
* @return {State}
*/
function onBeforeChange(state, editor) {
if (state.isNative) return state
const schema = editor.getSchema()
return state.normalize(schema)
2016-08-12 11:33:48 -07:00
}
2016-07-27 12:54:04 -07:00
/**
* On before input, see if we can let the browser continue with it's native
* input behavior, to avoid a re-render for performance.
*
* @param {Event} e
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @param {Editor} editor
* @return {State}
*/
function onBeforeInput(e, data, state, editor) {
const { document, startKey, startOffset, startText } = state
2016-07-27 12:54:04 -07:00
// Determine what the characters would be if natively inserted.
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(startKey, schema)
const prevChars = startText.getDecorations(decorators)
const prevChar = prevChars.get(startOffset - 1)
const char = Character.create({
text: e.data,
marks: prevChar && prevChar.marks
})
const chars = prevChars
2016-07-27 12:54:04 -07:00
.slice(0, startOffset)
.push(char)
.concat(prevChars.slice(startOffset))
2016-07-27 12:54:04 -07:00
// Determine what the characters should be, if not natively inserted.
let next = state
.transform()
.insertText(e.data)
.apply()
const nextText = next.startText
const nextChars = nextText.getDecorations(decorators)
2016-07-27 12:54:04 -07:00
// 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
// natively inserted characters would be the same as the non-native.
const isNative = (
state.isCollapsed &&
state.startText.text != '' &&
state.cursorMarks == null &&
chars.equals(nextChars)
)
// Add the `isNative` flag directly, so we don't have to re-transform.
if (isNative) {
next = next.merge({ isNative })
2016-07-11 18:36:45 -07:00
}
2016-06-30 14:37:29 -07:00
2016-07-27 12:54:04 -07:00
// If not native, prevent default so that the DOM remains untouched.
if (!isNative) e.preventDefault()
debug('onBeforeInput', { data, isNative })
2016-07-27 12:54:04 -07:00
return next
}
2016-07-27 13:07:52 -07:00
/**
* On blur.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onBlur(e, data, state) {
const isNative = true
debug('onBlur', { data, isNative })
2016-07-27 13:07:52 -07:00
return state
.transform()
.blur()
.apply({ isNative })
2016-07-27 13:07:52 -07:00
}
/**
* On copy.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onCopy(e, data, state) {
debug('onCopy', data)
onCutOrCopy(e, data, state)
}
/**
* On cut.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @param {Editor} editor
* @return {State}
*/
function onCut(e, data, state, editor) {
debug('onCut', data)
onCutOrCopy(e, data, state)
const window = getWindow(e.target)
// Once the fake cut content has successfully been added to the clipboard,
// delete the content in the current selection.
window.requestAnimationFrame(() => {
const next = editor
.getState()
.transform()
.delete()
.apply()
editor.onChange(next)
})
}
/**
* On cut or copy, create a fake selection so that we can add a Base 64
* encoded copy of the fragment to the HTML, to decode on future pastes.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onCutOrCopy(e, data, state) {
const window = getWindow(e.target)
const native = window.getSelection()
if (!native.rangeCount) return
const { fragment } = data
const encoded = Base64.serializeNode(fragment)
// Wrap the first character of the selection in a span that has the encoded
// fragment attached as an attribute, so it will show up in the copied HTML.
const range = native.getRangeAt(0)
const contents = range.cloneContents()
const wrapper = window.document.createElement('span')
const text = contents.childNodes[0]
const char = text.textContent.slice(0, 1)
const first = window.document.createTextNode(char)
const rest = text.textContent.slice(1)
text.textContent = rest
wrapper.appendChild(first)
wrapper.setAttribute('data-slate-fragment', encoded)
contents.insertBefore(wrapper, text)
// Add the phony content to the DOM, and select it, so it will be copied.
const body = window.document.querySelector('body')
const div = window.document.createElement('div')
div.setAttribute('contenteditable', true)
div.style.position = 'absolute'
div.style.left = '-9999px'
div.appendChild(contents)
body.appendChild(div)
// COMPAT: In Firefox, trying to use the terser `native.selectAllChildren`
// throws an error, so we use the older `range` equivalent. (2016/06/21)
const r = window.document.createRange()
r.selectNodeContents(div)
native.removeAllRanges()
native.addRange(r)
// Revert to the previous selection right after copying.
window.requestAnimationFrame(() => {
body.removeChild(div)
native.removeAllRanges()
native.addRange(range)
})
}
2016-07-27 12:54:04 -07:00
/**
* On drop.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onDrop(e, data, state) {
debug('onDrop', { data })
2016-07-27 12:54:04 -07:00
switch (data.type) {
case 'text':
case 'html':
return onDropText(e, data, state)
case 'fragment':
return onDropFragment(e, data, state)
2016-06-15 12:07:12 -07:00
}
2016-07-11 18:36:45 -07:00
}
2016-06-17 19:57:37 -07:00
2016-06-24 12:06:59 -07:00
/**
2016-07-27 12:54:04 -07:00
* On drop fragment.
2016-06-24 12:06:59 -07:00
*
2016-07-27 12:54:04 -07:00
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
2016-06-24 12:06:59 -07:00
*/
2016-07-27 12:54:04 -07:00
function onDropFragment(e, data, state) {
debug('onDropFragment', { data })
2016-07-27 12:54:04 -07:00
const { selection } = state
let { fragment, target, isInternal } = data
// If the drag is internal and the target is after the selection, it
// needs to account for the selection's content being deleted.
if (
isInternal &&
selection.endKey == target.endKey &&
selection.endOffset < target.endOffset
) {
target = target.moveBackward(selection.startKey == selection.endKey
? selection.endOffset - selection.startOffset
: selection.endOffset)
2016-07-11 18:36:45 -07:00
}
2016-07-27 12:54:04 -07:00
let transform = state.transform()
if (isInternal) transform = transform.delete()
return transform
.moveTo(target)
.insertFragment(fragment)
.apply()
2016-07-11 18:36:45 -07:00
}
2016-06-24 12:06:59 -07:00
2016-06-17 19:57:37 -07:00
/**
2016-07-27 12:54:04 -07:00
* On drop text, split the blocks at new lines.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
2016-06-17 19:57:37 -07:00
*/
2016-07-27 12:54:04 -07:00
function onDropText(e, data, state) {
debug('onDropText', { data })
2016-07-27 12:54:04 -07:00
const { text, target } = data
let transform = state
.transform()
.moveTo(target)
text
.split('\n')
.forEach((line, i) => {
if (i > 0) transform = transform.splitBlock()
transform = transform.insertText(line)
})
2016-07-11 18:36:45 -07:00
2016-07-27 12:54:04 -07:00
return transform.apply()
}
/**
* On key down.
*
* @param {Event} e
2016-07-27 14:30:09 -07:00
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @return {State}
*/
2016-07-27 14:30:09 -07:00
function onKeyDown(e, data, state) {
debug('onKeyDown', { data })
2016-07-27 14:30:09 -07:00
switch (data.key) {
case 'enter': return onKeyDownEnter(e, data, state)
case 'backspace': return onKeyDownBackspace(e, data, state)
case 'delete': return onKeyDownDelete(e, data, state)
case 'y': return onKeyDownY(e, data, state)
case 'z': return onKeyDownZ(e, data, state)
2016-07-27 12:54:04 -07:00
}
}
/**
* On `enter` key down, split the current block in half.
*
* @param {Event} e
2016-07-27 14:30:09 -07:00
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @return {State}
*/
2016-07-27 14:30:09 -07:00
function onKeyDownEnter(e, data, state) {
debug('onKeyDownEnter', { data })
2016-07-27 12:54:04 -07:00
const { document, startKey, startBlock } = state
// For void blocks, we don't want to split. Instead we just move to the
// start of the next text node if one exists.
if (startBlock && startBlock.isVoid) {
const text = document.getNextText(startKey)
if (!text) return
return state
.transform()
2016-07-27 12:54:04 -07:00
.collapseToStartOf(text)
.apply()
2016-07-27 12:54:04 -07:00
}
2016-07-27 12:54:04 -07:00
return state
.transform()
.splitBlock()
.apply()
}
/**
* On `backspace` key down, delete backwards.
*
* @param {Event} e
2016-07-27 14:30:09 -07:00
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @return {State}
*/
2016-07-27 14:30:09 -07:00
function onKeyDownBackspace(e, data, state) {
debug('onKeyDownBackspace', { data })
2016-07-27 12:54:04 -07:00
// If expanded, delete regularly.
if (state.isExpanded) {
return state
.transform()
2016-07-27 12:54:04 -07:00
.delete()
.apply()
2016-07-11 18:36:45 -07:00
}
2016-07-27 12:54:04 -07:00
const { startOffset, startBlock } = state
const text = startBlock.text
let n
// Determine how far backwards to delete.
2016-07-27 14:30:09 -07:00
if (data.isWord) {
2016-07-27 12:54:04 -07:00
n = String.getWordOffsetBackward(text, startOffset)
2016-07-27 14:30:09 -07:00
}
else if (data.isLine) {
2016-07-27 12:54:04 -07:00
n = startOffset
2016-07-27 14:30:09 -07:00
}
else {
2016-07-27 12:54:04 -07:00
n = String.getCharOffsetBackward(text, startOffset)
}
return state
.transform()
.deleteBackward(n)
.apply()
}
/**
* On `delete` key down, delete forwards.
*
* @param {Event} e
2016-07-27 14:30:09 -07:00
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @return {State}
*/
2016-07-27 14:30:09 -07:00
function onKeyDownDelete(e, data, state) {
debug('onKeyDownDelete', { data })
2016-07-27 12:54:04 -07:00
// If expanded, delete regularly.
if (state.isExpanded) {
return state
.transform()
.delete()
.apply()
}
const { startOffset, startBlock } = state
const text = startBlock.text
let n
// Determine how far forwards to delete.
2016-07-27 14:30:09 -07:00
if (data.isWord) {
2016-07-27 12:54:04 -07:00
n = String.getWordOffsetForward(text, startOffset)
2016-07-27 14:30:09 -07:00
}
else if (data.isLine) {
2016-07-27 12:54:04 -07:00
n = text.length - startOffset
2016-07-27 14:30:09 -07:00
}
else {
2016-07-27 12:54:04 -07:00
n = String.getCharOffsetForward(text, startOffset)
}
return state
.transform()
.deleteForward(n)
.apply()
}
/**
* On `y` key down, redo.
*
* @param {Event} e
2016-07-27 14:30:09 -07:00
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @return {State}
*/
2016-07-27 14:30:09 -07:00
function onKeyDownY(e, data, state) {
if (!data.isMod) return
debug('onKeyDownY', { data })
2016-07-27 12:54:04 -07:00
return state
.transform()
.redo()
}
/**
* On `z` key down, undo or redo.
*
* @param {Event} e
2016-07-27 14:30:09 -07:00
* @param {Object} data
2016-07-27 12:54:04 -07:00
* @param {State} state
* @return {State}
*/
2016-07-27 14:30:09 -07:00
function onKeyDownZ(e, data, state) {
if (!data.isMod) return
debug('onKeyDownZ', { data })
2016-07-27 12:54:04 -07:00
return state
.transform()
2016-07-27 14:30:09 -07:00
[data.isShift ? 'redo' : 'undo']()
2016-07-27 12:54:04 -07:00
}
/**
* On paste.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onPaste(e, data, state) {
debug('onPaste', { data })
2016-07-27 12:54:04 -07:00
switch (data.type) {
2016-07-27 14:34:11 -07:00
case 'fragment':
return onPasteFragment(e, data, state)
2016-07-27 12:54:04 -07:00
case 'text':
case 'html':
return onPasteText(e, data, state)
}
}
2016-07-27 14:34:11 -07:00
/**
* On paste fragment.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onPasteFragment(e, data, state) {
debug('onPasteFragment', { data })
2016-07-27 14:34:11 -07:00
return state
.transform()
.insertFragment(data.fragment)
.apply()
}
2016-07-27 12:54:04 -07:00
/**
* On paste text, split blocks at new lines.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onPasteText(e, data, state) {
debug('onPasteText', { data })
2016-07-27 12:54:04 -07:00
let transform = state.transform()
data.text
.split('\n')
.forEach((line, i) => {
if (i > 0) transform = transform.splitBlock()
transform = transform.insertText(line)
})
return transform.apply()
}
/**
* On select.
*
* @param {Event} e
* @param {Object} data
* @param {State} state
* @return {State}
*/
function onSelect(e, data, state) {
const { selection } = data
debug('onSelect', { data, selection: selection.toJS() })
2016-07-27 12:54:04 -07:00
return state
.transform()
.moveTo(selection)
.focus()
.apply()
2016-07-27 12:54:04 -07:00
}
/**
2016-08-14 15:51:07 -07:00
* A default schema rule to render block nodes.
*
* @type {Object}
*/
const BLOCK_RENDER_RULE = {
match: (node) => {
return node.kind == 'block'
},
render: (props) => {
return (
<div {...props.attributes} style={{ position: 'relative' }}>
{props.children}
{placeholder
? <Placeholder
className={placeholderClassName}
node={props.node}
parent={props.state.document}
state={props.state}
style={placeholderStyle}
>
{placeholder}
</Placeholder>
: null}
</div>
)
}
}
/**
* A default schema rule to render inline nodes.
*
* @type {Object}
*/
const INLINE_RENDER_RULE = {
match: (node) => {
return node.kind == 'inline'
},
render: (props) => {
return (
<span {...props.attributes} style={{ position: 'relative' }}>
{props.children}
</span>
)
}
}
/**
* A default schema rule to only allow block nodes in documents.
*
* @type {Object}
*/
const DOCUMENT_CHILDREN_RULE = {
match: (node) => {
return node.kind == 'document'
},
validate: (document) => {
const { nodes } = document
const invalids = nodes.filter(n => n.kind != 'block')
return invalids.size ? invalids : null
},
2016-08-14 18:25:12 -07:00
normalize: (transform, document, invalids) => {
2016-08-14 15:51:07 -07:00
return invalids.reduce((t, n) => t.removeNodeByKey(n.key), transform)
}
}
/**
* A default schema rule to only allow block, inline and text nodes in blocks.
*
* @type {Object}
*/
const BLOCK_CHILDREN_RULE = {
match: (node) => {
return node.kind == 'block'
},
validate: (block) => {
const { nodes } = block
const invalids = nodes.filter(n => n.kind != 'block' && n.kind != 'inline' && n.kind != 'text')
return invalids.size ? invalids : null
},
2016-08-14 18:25:12 -07:00
normalize: (transform, block, invalids) => {
2016-08-14 15:51:07 -07:00
return invalids.reduce((t, n) => t.removeNodeByKey(n.key), transform)
}
}
/**
* A default schema rule to only allow inline and text nodes in inlines.
2016-07-27 12:54:04 -07:00
*
2016-08-14 15:51:07 -07:00
* @type {Object}
2016-07-27 12:54:04 -07:00
*/
2016-08-14 15:51:07 -07:00
const INLINE_CHILDREN_RULE = {
match: (object) => {
return object.kind == 'inline'
},
validate: (inline) => {
const { nodes } = inline
const invalids = nodes.filter(n => n.kind != 'inline' && n.kind != 'text')
return invalids.size ? invalids : null
},
2016-08-14 18:25:12 -07:00
normalize: (transform, inline, invalids) => {
2016-08-14 15:51:07 -07:00
return invalids.reduce((t, n) => t.removeNodeByKey(n.key), transform)
}
}
/**
* The default schema.
*
* @type {Object}
*/
const schema = {
rules: [
BLOCK_RENDER_RULE,
INLINE_RENDER_RULE,
DOCUMENT_CHILDREN_RULE,
BLOCK_CHILDREN_RULE,
INLINE_CHILDREN_RULE,
]
2016-07-27 12:54:04 -07:00
}
/**
* Return the core plugin.
*/
return {
2016-08-12 11:33:48 -07:00
onBeforeChange,
2016-07-27 12:54:04 -07:00
onBeforeInput,
onBlur,
onCopy,
onCut,
2016-07-27 12:54:04 -07:00
onDrop,
onKeyDown,
onPaste,
onSelect,
2016-08-14 15:51:07 -07:00
schema,
2016-06-15 12:07:12 -07:00
}
}
2016-07-11 18:36:45 -07:00
/**
* Export.
*/
export default Plugin