1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-13 18:53:59 +02:00

Split core plugin (#1242)

* split core into before/after, add onBeforeInput to before

* migrate handlers to before plugin, add event handlers constants

* cleanup

* refactor hotkeys into constants file

* fix serializer, disable core plugin tests

* fix linter
This commit is contained in:
Ian Storm Taylor
2017-10-16 17:31:43 -07:00
committed by GitHub
parent 6097f127ef
commit 617fba2ac0
12 changed files with 1044 additions and 889 deletions

View File

@@ -31,7 +31,6 @@
"dot-notation": ["error", { "allowKeywords": true }],
"eol-last": "error",
"func-call-spacing": ["error", "never"],
"func-style": ["error", "declaration"],
"import/default": "error",
"import/export": "error",
"import/first": "error",

View File

@@ -9,6 +9,7 @@
"debug": "^2.3.2",
"get-window": "^1.1.1",
"is-in-browser": "^1.1.3",
"is-hotkey": "^0.0.3",
"is-window": "^1.0.2",
"keycode": "^2.1.2",
"prop-types": "^15.5.8",

View File

@@ -1,25 +1,19 @@
import Base64 from 'slate-base64-serializer'
import Debug from 'debug'
import React from 'react'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
import getWindow from 'get-window'
import keycode from 'keycode'
import logger from 'slate-dev-logger'
import TRANSFER_TYPES from '../constants/transfer-types'
import EVENT_HANDLERS from '../constants/event-handlers'
import Node from './node'
import findClosestNode from '../utils/find-closest-node'
import findDOMNode from '../utils/find-dom-node'
import findDOMRange from '../utils/find-dom-range'
import findPoint from '../utils/find-point'
import findRange from '../utils/find-range'
import getHtmlFromNativePaste from '../utils/get-html-from-native-paste'
import getTransferData from '../utils/get-transfer-data'
import scrollToSelection from '../utils/scroll-to-selection'
import setTransferData from '../utils/set-transfer-data'
import { IS_FIREFOX, IS_MAC, IS_IE, SUPPORTED_EVENTS } from '../constants/environment'
import { IS_FIREFOX, IS_IE, SUPPORTED_EVENTS } from '../constants/environment'
/**
* Debug.
@@ -49,16 +43,6 @@ class Content extends React.Component {
children: Types.array.isRequired,
className: Types.string,
editor: Types.object.isRequired,
onBeforeInput: Types.func.isRequired,
onBlur: Types.func.isRequired,
onCopy: Types.func.isRequired,
onCut: Types.func.isRequired,
onDrop: Types.func.isRequired,
onFocus: Types.func.isRequired,
onKeyDown: Types.func.isRequired,
onKeyUp: Types.func.isRequired,
onPaste: Types.func.isRequired,
onSelect: Types.func.isRequired,
readOnly: Types.bool.isRequired,
role: Types.string,
schema: SlateTypes.schema.isRequired,
@@ -89,8 +73,14 @@ class Content extends React.Component {
constructor(props) {
super(props)
this.tmp = {}
this.tmp.compositions = 0
this.tmp.forces = 0
this.tmp.key = 0
this.tmp.isUpdatingSelection = false
EVENT_HANDLERS.forEach((handler) => {
this[handler] = (event) => {
this.onEvent(handler, event)
}
})
}
/**
@@ -178,18 +168,18 @@ class Content extends React.Component {
return
}
// Otherwise, set the `isSelecting` flag and update the selection.
this.tmp.isSelecting = true
// Otherwise, set the `isUpdatingSelection` flag and update the selection.
this.tmp.isUpdatingSelection = true
native.removeAllRanges()
native.addRange(range)
scrollToSelection(native)
// Then unset the `isSelecting` flag after a delay.
// Then unset the `isUpdatingSelection` 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) this.element.focus()
this.tmp.isSelecting = false
this.tmp.isUpdatingSelection = false
})
debug('updateSelection', { selection, native })
@@ -226,19 +216,76 @@ class Content extends React.Component {
}
/**
* On before input, bubble up.
* On `event` with `handler`.
*
* @param {String} handler
* @param {Event} event
*/
onBeforeInput = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
onEvent(handler, event) {
// COMPAT: Composition events can change the DOM out of under React, so we
// increment this key to ensure that a full re-render happens. (2017/10/16)
if (handler == 'onCompositionEnd') {
this.tmp.key++
}
const data = {}
// COMPAT: In IE 11, only plain text can be retrieved from the event's
// `clipboardData`. To get HTML, use the browser's native paste action which
// can only be handled synchronously. (2017/06/23)
if (handler == 'onPaste' && IS_IE) {
getHtmlFromNativePaste(event.target, (html) => {
const data = html ? { html, type: 'html' } : {}
this.props.onPaste(event, data)
})
debug('onBeforeInput', { event, data })
this.props.onBeforeInput(event, data)
return
}
// If the `onSelect` handler fires while the `isUpdatingSelection` flag is
// set it's a result of updating the selection manually, so skip it.
if (handler == 'onSelect' && this.tmp.isUpdatingSelection) {
return
}
// COMPAT: There are situations where a select event will fire with a new
// native selection that resolves to the same internal position. In those
// cases we don't need to trigger any changes, since our internal model is
// already up to date, but we do want to update the native selection again
// to make sure it is in sync. (2017/10/16)
if (handler == 'onSelect') {
const { state } = this.props
const { selection } = state
const window = getWindow(event.target)
const native = window.getSelection()
const range = findRange(native, state)
if (range && range.equals(selection)) {
this.updateSelection()
return
}
}
// Some events require being in editable in the editor, so if the event
// target isn't, ignore them.
if (
handler == 'onBeforeInput' ||
handler == 'onBlur' ||
handler == 'onCompositionEnd' ||
handler == 'onCompositionStart' ||
handler == 'onCopy' ||
handler == 'onCut' ||
handler == 'onDragStart' ||
handler == 'onFocus' ||
handler == 'onInput' ||
handler == 'onKeyDown' ||
handler == 'onKeyUp' ||
handler == 'onPaste' ||
handler == 'onSelect'
) {
if (!this.isInEditor(event.target)) return
}
this.props[handler](event, {})
}
/**
@@ -279,554 +326,6 @@ class Content extends React.Component {
})
}
/**
* On blur, update the selection to be not focused.
*
* @param {Event} event
*/
onBlur = (event) => {
if (this.props.readOnly) return
if (this.tmp.isCopying) return
if (!this.isInEditor(event.target)) return
// If the active element is still the editor, the blur event is due to the
// window itself being blurred (eg. when changing tabs) so we should ignore
// the event, since we want to maintain focus when returning.
const window = getWindow(this.element)
if (window.document.activeElement == this.element) return
const data = {}
debug('onBlur', { event, data })
this.props.onBlur(event, data)
}
/**
* On focus, update the selection to be focused.
*
* @param {Event} event
*/
onFocus = (event) => {
if (this.props.readOnly) return
if (this.tmp.isCopying) return
if (!this.isInEditor(event.target)) return
// COMPAT: If the editor has nested editable elements, the focus can go to
// those elements. In Firefox, this must be prevented because it results in
// issues with keyboard navigation. (2017/03/30)
if (IS_FIREFOX && event.target != this.element) {
this.element.focus()
return
}
const data = {}
debug('onFocus', { event, data })
this.props.onFocus(event, data)
}
/**
* On composition start, set the `isComposing` flag.
*
* @param {Event} event
*/
onCompositionStart = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.isComposing = true
this.tmp.compositions++
debug('onCompositionStart', { event })
}
/**
* 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} event
*/
onCompositionEnd = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.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', { event })
}
/**
* On copy, defer to `onCutCopy`, then bubble up.
*
* @param {Event} event
*/
onCopy = (event) => {
if (!this.isInEditor(event.target)) return
const window = getWindow(event.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', { event, data })
this.props.onCopy(event, data)
}
/**
* On cut, defer to `onCutCopy`, then bubble up.
*
* @param {Event} event
*/
onCut = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const window = getWindow(event.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', { event, data })
this.props.onCut(event, data)
}
/**
* On drag end, unset the `isDragging` flag.
*
* @param {Event} event
*/
onDragEnd = (event) => {
event.stopPropagation()
this.tmp.isDragging = false
this.tmp.isInternalDrag = null
debug('onDragEnd', { event })
}
/**
* On drag over, set the `isDragging` flag and the `isInternalDrag` flag.
*
* @param {Event} event
*/
onDragOver = (event) => {
event.stopPropagation()
if (this.tmp.isDragging) return
this.tmp.isDragging = true
this.tmp.isInternalDrag = false
debug('onDragOver', { event })
}
/**
* On drag start, set the `isDragging` flag and the `isInternalDrag` flag.
*
* @param {Event} event
*/
onDragStart = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.isDragging = true
this.tmp.isInternalDrag = true
const { dataTransfer } = event.nativeEvent
const data = getTransferData(dataTransfer)
// If it's a node being dragged, the data type is already set.
if (data.type == 'node') return
const { state } = this.props
const { fragment } = state
const encoded = Base64.serializeNode(fragment)
setTransferData(dataTransfer, TRANSFER_TYPES.FRAGMENT, encoded)
debug('onDragStart', { event })
}
/**
* On drop.
*
* @param {Event} event
*/
onDrop = (event) => {
event.stopPropagation()
event.preventDefault()
if (this.props.readOnly) return
const { state } = this.props
const { nativeEvent } = event
const { dataTransfer, x, y } = nativeEvent
const data = getTransferData(dataTransfer)
// Resolve a range from the caret position where the drop occured.
const window = getWindow(event.target)
let range
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (window.document.caretRangeFromPoint) {
range = window.document.caretRangeFromPoint(x, y)
} else {
const position = window.document.caretPositionFromPoint(x, y)
range = window.document.createRange()
range.setStart(position.offsetNode, position.offset)
range.setEnd(position.offsetNode, position.offset)
}
// Resolve a Slate range from the DOM range.
let selection = findRange(range, state)
if (!selection) return
const { document } = state
const node = document.getNode(selection.anchorKey)
const parent = document.getParent(node.key)
const el = findDOMNode(parent)
// If the drop target is inside a void node, move it into either the next or
// previous node, depending on which side the `x` and `y` coordinates are
// closest to.
if (parent.isVoid) {
const rect = el.getBoundingClientRect()
const isPrevious = parent.kind == 'inline'
? x - rect.left < rect.left + rect.width - x
: y - rect.top < rect.top + rect.height - y
selection = isPrevious
? selection.moveToEndOf(document.getPreviousText(node.key))
: selection.moveToStartOf(document.getNextText(node.key))
}
// Add drop-specific information to the data.
data.target = selection
// COMPAT: Edge throws "Permission denied" errors when
// accessing `dropEffect` or `effectAllowed` (2017/7/12)
try {
data.effect = dataTransfer.dropEffect
} catch (err) {
data.effect = null
}
if (data.type == 'fragment' || data.type == 'node') {
data.isInternal = this.tmp.isInternalDrag
}
debug('onDrop', { event, data })
this.props.onDrop(event, data)
}
/**
* On input, handle spellcheck and other similar edits that don't go trigger
* the `onBeforeInput` and instead update the DOM directly.
*
* @param {Event} event
*/
onInput = (event) => {
if (this.tmp.isComposing) return
if (this.props.state.isBlurred) return
if (!this.isInEditor(event.target)) return
debug('onInput', { event })
const window = getWindow(event.target)
const { state, editor } = this.props
// Get the selection point.
const native = window.getSelection()
const { anchorNode, anchorOffset } = native
const point = findPoint(anchorNode, anchorOffset, state)
if (!point) return
// Get the text node and leaf in question.
const { document, selection } = state
const node = document.getDescendant(point.key)
const leaves = node.getLeaves()
let start = 0
let end = 0
const leaf = leaves.find((r) => {
end += r.text.length
if (end >= point.offset) return true
start = end
})
// Get the text information.
const { text } = leaf
let { textContent } = anchorNode
const block = document.getClosestBlock(node.key)
const lastText = block.getLastText()
const lastLeaf = leaves.last()
const lastChar = textContent.charAt(textContent.length - 1)
const isLastText = node == lastText
const isLastLeaf = leaf == lastLeaf
// COMPAT: If this is 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 (isLastText && isLastLeaf && 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 corrected = selection.collapseToEnd().move(delta)
const entire = selection.moveAnchorTo(point.key, start).moveFocusTo(point.key, end)
// Change the current state to have the leaf's text replaced.
editor.change((change) => {
change
.select(entire)
.delete()
.insertText(textContent, leaf.marks)
.select(corrected)
})
}
/**
* 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} event
*/
onKeyDown = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const { key, metaKey, ctrlKey } = event
const data = {}
const modKey = IS_MAC ? metaKey : ctrlKey
// COMPAT: add the deprecated keyboard event properties.
addDeprecatedKeyProperties(data, event)
// Keep track of an `isShifting` flag, because it's often used to trigger
// "Paste and Match Style" commands, but isn't available on the event in a
// normal paste event.
if (key == 'Shift') {
this.tmp.isShifting = true
}
// 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 == 'ArrowLeft' || key == 'ArrowRight' || key == 'ArrowUp' || key == 'ArrowDown')
) {
event.preventDefault()
return
}
// 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' && modKey) ||
(key == 'i' && modKey) ||
(key == 'y' && modKey) ||
(key == 'z' && modKey) ||
(key == 'Z' && modKey)
) {
event.preventDefault()
}
debug('onKeyDown', { event, data })
this.props.onKeyDown(event, data)
}
/**
* On key up, unset the `isShifting` flag.
*
* @param {Event} event
*/
onKeyUp = (event) => {
const data = {}
// COMPAT: add the deprecated keyboard event properties.
addDeprecatedKeyProperties(data, event)
if (event.key == 'Shift') {
this.tmp.isShifting = false
}
debug('onKeyUp', { event, data })
this.props.onKeyUp(event, data)
}
/**
* On paste, determine the type and bubble up.
*
* @param {Event} event
*/
onPaste = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const data = getTransferData(event.clipboardData)
// COMPAT: Attach the `isShift` flag, so that people can use it to trigger
// "Paste and Match Style" logic.
Object.defineProperty(data, 'isShift', {
enumerable: true,
get() {
logger.deprecate('0.28.0', 'The `data.isShift` property of paste events has been deprecated. If you need this functionality, you\'ll need to keep track of that state with `onKeyDown` and `onKeyUp` events instead')
return !!this.tmp.isShifting
}
})
debug('onPaste', { event, data })
// COMPAT: In IE 11, only plain text can be retrieved from the event's
// `clipboardData`. To get HTML, use the browser's native paste action which
// can only be handled synchronously. (2017/06/23)
if (IS_IE) {
// Do not use `event.preventDefault()` as we need the native paste action.
getHtmlFromNativePaste(event.target, (html) => {
// If pasted HTML can be retreived, it is added to the `data` object,
// setting the `type` to `html`.
this.props.onPaste(event, html === undefined ? data : { ...data, html, type: 'html' })
})
} else {
event.preventDefault()
this.props.onPaste(event, data)
}
}
/**
* On select, update the current state's selection.
*
* @param {Event} event
*/
onSelect = (event) => {
if (this.props.readOnly) return
if (this.tmp.isCopying) return
if (this.tmp.isComposing) return
if (this.tmp.isSelecting) return
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
const { state } = this.props
const { 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.set('isFocused', false)
}
// Otherwise, determine the Slate selection from the native one.
else {
let range = findRange(native, state)
if (!range) return
// There are situations where a select event will fire with a new native
// selection that resolves to the same internal position. In those cases
// we don't need to trigger any changes, since our internal model is
// already up to date, but we do want to update the native selection again
// to make sure it is in sync.
if (range.equals(selection)) {
this.updateSelection()
return
}
const { anchorKey, anchorOffset, focusKey, focusOffset } = range
const anchorText = document.getNode(anchorKey)
const focusText = document.getNode(focusKey)
const anchorInline = document.getClosestInline(anchorKey)
const focusInline = document.getClosestInline(focusKey)
const focusBlock = document.getClosestBlock(focusKey)
const anchorBlock = document.getClosestBlock(anchorKey)
// COMPAT: If the anchor point is at the start of a non-void, and the
// focus point is inside a void node with an offset that isn't `0`, set
// the focus offset to `0`. This is due to void nodes <span>'s being
// positioned off screen, resulting in the offset always being greater
// than `0`. Since we can't know what it really should be, and since an
// offset of `0` is less destructive because it creates a hanging
// selection, go with `0`. (2017/09/07)
if (
anchorBlock &&
!anchorBlock.isVoid &&
anchorOffset == 0 &&
focusBlock &&
focusBlock.isVoid &&
focusOffset != 0
) {
range = range.set('focusOffset', 0)
}
// COMPAT: If the selection is at the end of a non-void inline node, and
// there is a node after it, put it in the node after instead. This
// standardizes the behavior, since it's indistinguishable to the user.
if (
anchorInline &&
!anchorInline.isVoid &&
anchorOffset == anchorText.text.length
) {
const block = document.getClosestBlock(anchorKey)
const next = block.getNextText(anchorKey)
if (next) range = range.moveAnchorTo(next.key, 0)
}
if (
focusInline &&
!focusInline.isVoid &&
focusOffset == focusText.text.length
) {
const block = document.getClosestBlock(focusKey)
const next = block.getNextText(focusKey)
if (next) range = range.moveFocusTo(next.key, 0)
}
range = range.normalize(document)
data.selection = range
}
debug('onSelect', { event, data })
this.props.onSelect(event, data)
}
/**
* Render the editor content.
*
@@ -844,6 +343,11 @@ class Content extends React.Component {
return this.renderNode(child, isSelected)
})
const handlers = EVENT_HANDLERS.reduce((obj, handler) => {
obj[handler] = this[handler]
return obj
}, {})
const style = {
// Prevent the default outline styles.
outline: 'none',
@@ -868,14 +372,14 @@ class Content extends React.Component {
return (
<Container
{...handlers}
data-slate-editor
key={this.tmp.forces}
key={this.tmp.key}
ref={this.ref}
data-key={document.key}
contentEditable={readOnly ? null : true}
suppressContentEditableWarning
className={className}
onBeforeInput={this.onBeforeInput}
onBlur={this.onBlur}
onFocus={this.onFocus}
onCompositionEnd={this.onCompositionEnd}
@@ -939,38 +443,12 @@ class Content extends React.Component {
}
/**
* Add deprecated `data` fields from a key `event`.
*
* @param {Object} data
* @param {Object} event
* Mix in handler prop types.
*/
function addDeprecatedKeyProperties(data, event) {
const { altKey, ctrlKey, metaKey, shiftKey, which } = event
const name = keycode(which)
function define(key, value) {
Object.defineProperty(data, key, {
enumerable: true,
get() {
logger.deprecate('0.28.0', `The \`data.${key}\` property of keyboard events is deprecated, please use the native \`event\` properties instead.`)
return value
}
})
}
define('code', which)
define('key', name)
define('isAlt', altKey)
define('isCmd', IS_MAC ? metaKey && !altKey : false)
define('isCtrl', ctrlKey && !altKey)
define('isLine', IS_MAC ? metaKey : false)
define('isMeta', metaKey)
define('isMod', IS_MAC ? metaKey && !altKey : ctrlKey && !altKey)
define('isModAlt', IS_MAC ? metaKey && altKey : ctrlKey && altKey)
define('isShift', shiftKey)
define('isWord', IS_MAC ? altKey : ctrlKey)
}
EVENT_HANDLERS.forEach((handler) => {
Content.propTypes[handler] = Types.func.isRequired
})
/**
* Export.

View File

@@ -7,7 +7,9 @@ import Types from 'prop-types'
import logger from 'slate-dev-logger'
import { Stack, State } from 'slate'
import CorePlugin from '../plugins/core'
import EVENT_HANDLERS from '../constants/event-handlers'
import AfterPlugin from '../plugins/after'
import BeforePlugin from '../plugins/before'
import noop from '../utils/noop'
/**
@@ -18,25 +20,6 @@ import noop from '../utils/noop'
const debug = Debug('slate:editor')
/**
* Event handlers to mix in to the editor.
*
* @type {Array}
*/
const EVENT_HANDLERS = [
'onBeforeInput',
'onBlur',
'onFocus',
'onCopy',
'onCut',
'onDrop',
'onKeyDown',
'onKeyUp',
'onPaste',
'onSelect',
]
/**
* Plugin-related properties of the editor.
*
@@ -121,22 +104,18 @@ class Editor extends React.Component {
// Run `onBeforeChange` on the passed-in state because we need to ensure
// that it is normalized, and queue the resulting change.
const change = props.state.change()
stack.onBeforeChange(change, this)
stack.handle('onBeforeChange', change, this)
const { state } = change
this.queueChange(change)
this.cacheState(state)
this.state.state = state
// Create a bound event handler for each event.
for (let i = 0; i < EVENT_HANDLERS.length; i++) {
const method = EVENT_HANDLERS[i]
this[method] = (...args) => {
const stk = this.state.stack
const c = this.state.state.change()
stk[method](c, this, ...args)
this.onChange(c)
EVENT_HANDLERS.forEach((handler) => {
this[handler] = (...args) => {
this.onEvent(handler, ...args)
}
}
})
if (props.onDocumentChange) {
logger.deprecate('0.22.10', 'The `onDocumentChange` prop is deprecated because it led to confusing UX issues, see https://github.com/ianstormtaylor/slate/issues/614#issuecomment-327868679')
@@ -169,7 +148,7 @@ class Editor extends React.Component {
// Run `onBeforeChange` on the passed-in state because we need to ensure
// that it is normalized, and queue the resulting change.
const change = props.state.change()
stack.onBeforeChange(change, this)
stack.handle('onBeforeChange', change, this)
const { state } = change
this.queueChange(change)
this.cacheState(state)
@@ -239,7 +218,7 @@ class Editor extends React.Component {
*/
blur = () => {
this.change(t => t.blur())
this.change(c => c.blur())
}
/**
@@ -247,7 +226,7 @@ class Editor extends React.Component {
*/
focus = () => {
this.change(t => t.focus())
this.change(c => c.focus())
}
/**
@@ -277,12 +256,27 @@ class Editor extends React.Component {
*/
change = (fn) => {
const change = this.state.state.change()
const { state } = this.state
const change = state.change()
fn(change)
debug('change', { change })
this.onChange(change)
}
/**
* On event.
*
* @param {String} handler
* @param {Mixed} ...args
*/
onEvent = (handler, ...args) => {
const { stack, state } = this.state
const change = state.change()
stack.handle(handler, change, this, ...args)
this.onChange(change)
}
/**
* On change.
*
@@ -296,8 +290,8 @@ class Editor extends React.Component {
const { stack } = this.state
stack.onBeforeChange(change, this)
stack.onChange(change, this)
stack.handle('onBeforeChange', change, this)
stack.handle('onChange', change, this)
const { state } = change
const { document, selection } = this.tmp
@@ -326,7 +320,11 @@ class Editor extends React.Component {
debug('render', { props, state })
const tree = stack.render(state.state, this, { ...props, children })
const tree = stack.render(state.state, this, {
...props,
children,
})
return tree
}
@@ -352,11 +350,13 @@ class Editor extends React.Component {
function resolvePlugins(props) {
// eslint-disable-next-line no-unused-vars
const { state, onChange, plugins = [], ...overridePlugin } = props
const corePlugin = CorePlugin(props)
const beforePlugin = BeforePlugin(props)
const afterPlugin = AfterPlugin(props)
return [
beforePlugin,
overridePlugin,
...plugins,
corePlugin
afterPlugin
]
}

View File

@@ -0,0 +1,33 @@
/**
* Event handlers used by Slate plugins.
*
* @type {Array}
*/
const EVENT_HANDLERS = [
'onBeforeInput',
'onBlur',
'onCompositionEnd',
'onCompositionStart',
'onCopy',
'onCut',
'onDragEnd',
'onDragOver',
'onDragStart',
'onDrop',
'onInput',
'onFocus',
'onKeyDown',
'onKeyUp',
'onPaste',
'onSelect',
]
/**
* Export.
*
* @type {Array}
*/
export default EVENT_HANDLERS

View File

@@ -0,0 +1,56 @@
import isHotkey from 'is-hotkey'
import { IS_MAC } from './environment'
/**
* Hotkeys.
*
* @type {Function}
*/
const BOLD = isHotkey('mod+b')
const ITALIC = isHotkey('mod+i')
const UNDO = isHotkey('mod+z')
const REDO_MAC = isHotkey('mod+shift+z')
const REDO_OTHER = isHotkey('mod+y')
const REDO = e => IS_MAC ? REDO_MAC(e) : REDO_OTHER(e)
const DELETE_CHAR_BACKWARD_MAC = isHotkey('ctrl+h')
const DELETE_CHAR_FORWARD_MAC = isHotkey('ctrl+d')
const DELETE_LINE_FORWARD_MAC = isHotkey('ctrl+k')
const DELETE_CHAR_BACKWARD = e => IS_MAC ? DELETE_CHAR_BACKWARD_MAC(e) : false
const DELETE_CHAR_FORWARD = e => IS_MAC ? DELETE_CHAR_FORWARD_MAC(e) : false
const DELETE_LINE_FORWARD = e => IS_MAC ? DELETE_LINE_FORWARD_MAC(e) : false
const CONTENTEDITABLE = e => (
e.key == 'Backspace' ||
e.key == 'Delete' ||
e.key == 'Enter' ||
e.key == 'Insert' ||
BOLD(e) ||
DELETE_CHAR_BACKWARD(e) ||
DELETE_CHAR_FORWARD(e) ||
DELETE_LINE_FORWARD(e) ||
ITALIC(e) ||
REDO(e) ||
UNDO(e)
)
/**
* Export.
*
* @type {Object}
*/
export default {
BOLD,
CONTENTEDITABLE,
DELETE_CHAR_BACKWARD,
DELETE_CHAR_FORWARD,
DELETE_LINE_FORWARD,
ITALIC,
REDO,
UNDO,
}

View File

@@ -6,10 +6,13 @@ import React from 'react'
import getWindow from 'get-window'
import { Block, Inline, coreSchema } from 'slate'
import EVENT_HANDLERS from '../constants/event-handlers'
import HOTKEYS from '../constants/hotkeys'
import Content from '../components/content'
import Placeholder from '../components/placeholder'
import findDOMNode from '../utils/find-dom-node'
import { IS_CHROME, IS_MAC, IS_SAFARI, SUPPORTED_EVENTS } from '../constants/environment'
import findPoint from '../utils/find-point'
import { IS_CHROME, IS_MAC, IS_SAFARI } from '../constants/environment'
/**
* Debug.
@@ -17,10 +20,10 @@ import { IS_CHROME, IS_MAC, IS_SAFARI, SUPPORTED_EVENTS } from '../constants/env
* @type {Function}
*/
const debug = Debug('slate:core')
const debug = Debug('slate:core:after')
/**
* The default plugin.
* The after plugin.
*
* @param {Object} options
* @property {Element} placeholder
@@ -29,7 +32,7 @@ const debug = Debug('slate:core')
* @return {Object}
*/
function Plugin(options = {}) {
function AfterPlugin(options = {}) {
const {
placeholder,
placeholderClassName,
@@ -40,7 +43,7 @@ function Plugin(options = {}) {
* On before change, enforce the editor's schema.
*
* @param {Change} change
* @param {Editor} schema
* @param {Editor} editor
*/
function onBeforeChange(change, editor) {
@@ -60,35 +63,26 @@ function Plugin(options = {}) {
/**
* On before input, correct any browser inconsistencies.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onBeforeInput(e, data, change) {
function onBeforeInput(event, data, change) {
debug('onBeforeInput', { data })
// React's `onBeforeInput` synthetic event is based on the native `keypress`
// and `textInput` events. In browsers that support the native `beforeinput`
// event, we instead use that event to trigger text insertion, since it
// provides more useful information about the range being affected and also
// preserves compatibility with iOS autocorrect, which would be broken if we
// called `preventDefault()` on React's synthetic event here.
if (SUPPORTED_EVENTS.beforeinput) return
e.preventDefault()
change.insertText(e.data)
event.preventDefault()
change.insertText(event.data)
}
/**
* On blur.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onBlur(e, data, change) {
function onBlur(event, data, change) {
debug('onBlur', { data })
change.blur()
}
@@ -96,48 +90,47 @@ function Plugin(options = {}) {
/**
* On copy.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onCopy(e, data, change) {
function onCopy(event, data, change) {
debug('onCopy', data)
onCutOrCopy(e, data, change)
onCutOrCopy(event, data, change)
}
/**
* On cut.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onCut(e, data, change, editor) {
function onCut(event, data, change, editor) {
debug('onCut', data)
onCutOrCopy(e, data, change)
const window = getWindow(e.target)
onCutOrCopy(event, data, change)
const window = getWindow(event.target)
// Once the fake cut content has successfully been added to the clipboard,
// delete the content in the current selection.
window.requestAnimationFrame(() => {
editor.change(t => t.delete())
editor.change(c => c.delete())
})
}
/**
* 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.
* On cut or copy.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onCutOrCopy(e, data, change) {
const window = getWindow(e.target)
function onCutOrCopy(event, data, change) {
const window = getWindow(event.target)
const native = window.getSelection()
const { state } = change
const { startKey, endKey, startText, endBlock, endInline } = state
@@ -148,6 +141,8 @@ function Plugin(options = {}) {
// If the selection is collapsed, and it isn't inside a void node, abort.
if (native.isCollapsed && !isVoid) return
// Create a fake selection so that we can add a Base64-encoded copy of the
// fragment to the HTML, to decode on future pastes.
const { fragment } = data
const encoded = Base64.serializeNode(fragment)
const range = native.getRangeAt(0)
@@ -241,34 +236,34 @@ function Plugin(options = {}) {
/**
* On drop.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onDrop(e, data, change) {
function onDrop(event, data, change) {
debug('onDrop', { data })
switch (data.type) {
case 'text':
case 'html':
return onDropText(e, data, change)
return onDropText(event, data, change)
case 'fragment':
return onDropFragment(e, data, change)
return onDropFragment(event, data, change)
case 'node':
return onDropNode(e, data, change)
return onDropNode(event, data, change)
}
}
/**
* On drop node, insert the node wherever it is dropped.
* On drop node.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onDropNode(e, data, change) {
function onDropNode(event, data, change) {
debug('onDropNode', { data })
const { state } = change
@@ -311,12 +306,12 @@ function Plugin(options = {}) {
/**
* On drop fragment.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onDropFragment(e, data, change) {
function onDropFragment(event, data, change) {
debug('onDropFragment', { data })
const { state } = change
@@ -346,14 +341,14 @@ function Plugin(options = {}) {
}
/**
* On drop text, split the blocks at new lines.
* On drop text.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onDropText(e, data, change) {
function onDropText(event, data, change) {
debug('onDropText', { data })
const { state } = change
@@ -386,43 +381,122 @@ function Plugin(options = {}) {
})
}
/**
* On input.
*
* @param {Event} eventvent
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onInput(event, data, change, editor) {
const window = getWindow(event.target)
const { state } = change
// Get the selection point.
const native = window.getSelection()
const { anchorNode, anchorOffset } = native
const point = findPoint(anchorNode, anchorOffset, state)
if (!point) return
// Get the text node and leaf in question.
const { document, selection } = state
const node = document.getDescendant(point.key)
const leaves = node.getLeaves()
let start = 0
let end = 0
const leaf = leaves.find((r) => {
end += r.text.length
if (end >= point.offset) return true
start = end
})
// Get the text information.
const { text } = leaf
let { textContent } = anchorNode
const block = document.getClosestBlock(node.key)
const lastText = block.getLastText()
const lastLeaf = leaves.last()
const lastChar = textContent.charAt(textContent.length - 1)
const isLastText = node == lastText
const isLastLeaf = leaf == lastLeaf
// COMPAT: If this is 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 (isLastText && isLastLeaf && 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 corrected = selection.collapseToEnd().move(delta)
const entire = selection.moveAnchorTo(point.key, start).moveFocusTo(point.key, end)
// Change the current state to have the leaf's text replaced.
change
.select(entire)
.delete()
.insertText(textContent, leaf.marks)
.select(corrected)
}
/**
* On key down.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDown(e, data, change) {
function onKeyDown(event, data, change) {
debug('onKeyDown', { data })
switch (e.key) {
case 'Enter': return onKeyDownEnter(e, data, change)
case 'Backspace': return onKeyDownBackspace(e, data, change)
case 'Delete': return onKeyDownDelete(e, data, change)
case 'ArrowLeft': return onKeyDownLeft(e, data, change)
case 'ArrowRight': return onKeyDownRight(e, data, change)
case 'ArrowUp': return onKeyDownUp(e, data, change)
case 'ArrowDown': return onKeyDownDown(e, data, change)
case 'd': return onKeyDownD(e, data, change)
case 'h': return onKeyDownH(e, data, change)
case 'k': return onKeyDownK(e, data, change)
case 'y': return onKeyDownY(e, data, change)
case 'z':
case 'Z': return onKeyDownZ(e, data, change)
switch (event.key) {
case 'Enter': return onKeyDownEnter(event, data, change)
case 'Backspace': return onKeyDownBackspace(event, data, change)
case 'Delete': return onKeyDownDelete(event, data, change)
case 'ArrowLeft': return onKeyDownLeft(event, data, change)
case 'ArrowRight': return onKeyDownRight(event, data, change)
case 'ArrowUp': return onKeyDownUp(event, data, change)
case 'ArrowDown': return onKeyDownDown(event, data, change)
}
if (HOTKEYS.DELETE_CHAR_BACKWARD(event)) {
change.deleteCharBackward()
}
if (HOTKEYS.DELETE_CHAR_FORWARD(event)) {
change.deleteCharForward()
}
if (HOTKEYS.DELETE_LINE_FORWARD(event)) {
change.deleteLineForward()
}
if (HOTKEYS.REDO(event)) {
change.redo()
}
if (HOTKEYS.UNDO(event)) {
change.undo()
}
}
/**
* On `enter` key down, split the current block in half.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownEnter(e, data, change) {
function onKeyDownEnter(event, data, change) {
const { state } = change
const { document, startKey } = state
const hasVoidParent = document.hasVoidParent(startKey)
@@ -442,14 +516,14 @@ function Plugin(options = {}) {
/**
* On `backspace` key down, delete backwards.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownBackspace(e, data, change) {
const isWord = IS_MAC ? e.altKey : e.ctrlKey
const isLine = IS_MAC ? e.metaKey : false
function onKeyDownBackspace(event, data, change) {
const isWord = IS_MAC ? event.altKey : event.ctrlKey
const isLine = IS_MAC ? event.metaKey : false
let boundary = 'Char'
if (isWord) boundary = 'Word'
@@ -461,14 +535,14 @@ function Plugin(options = {}) {
/**
* On `delete` key down, delete forwards.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownDelete(e, data, change) {
const isWord = IS_MAC ? e.altKey : e.ctrlKey
const isLine = IS_MAC ? e.metaKey : false
function onKeyDownDelete(event, data, change) {
const isWord = IS_MAC ? event.altKey : event.ctrlKey
const isLine = IS_MAC ? event.metaKey : false
let boundary = 'Char'
if (isWord) boundary = 'Word'
@@ -487,16 +561,16 @@ function Plugin(options = {}) {
* surrounded by empty text nodes with zero-width spaces in them. Without this
* the zero-width spaces will cause two arrow keys to jump to the next text.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownLeft(e, data, change) {
function onKeyDownLeft(event, data, change) {
const { state } = change
if (e.ctrlKey) return
if (e.altKey) return
if (event.ctrlKey) return
if (event.altKey) return
if (state.isExpanded) return
const { document, startKey, startText } = state
@@ -505,7 +579,7 @@ function Plugin(options = {}) {
// If the current text node is empty, or we're inside a void parent, we're
// going to need to handle the selection behavior.
if (startText.text == '' || hasVoidParent) {
e.preventDefault()
event.preventDefault()
const previous = document.getPreviousText(startKey)
// If there's no previous text node in the document, abort.
@@ -518,7 +592,7 @@ function Plugin(options = {}) {
const previousInline = document.getClosestInline(previous.key)
if (previousBlock === startBlock && previousInline && !previousInline.isVoid) {
const extendOrMove = e.shiftKey ? 'extend' : 'move'
const extendOrMove = event.shiftKey ? 'extend' : 'move'
change.collapseToEndOf(previous)[extendOrMove](-1)
return
}
@@ -543,16 +617,16 @@ function Plugin(options = {}) {
* of a previous inline node, which screws us up, so we never want to set the
* selection to the very start of an inline node here. (2016/11/29)
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownRight(e, data, change) {
function onKeyDownRight(event, data, change) {
const { state } = change
if (e.ctrlKey) return
if (e.altKey) return
if (event.ctrlKey) return
if (event.altKey) return
if (state.isExpanded) return
const { document, startKey, startText } = state
@@ -561,7 +635,7 @@ function Plugin(options = {}) {
// If the current text node is empty, or we're inside a void parent, we're
// going to need to handle the selection behavior.
if (startText.text == '' || hasVoidParent) {
e.preventDefault()
event.preventDefault()
const next = document.getNextText(startKey)
// If there's no next text node in the document, abort.
@@ -580,7 +654,7 @@ function Plugin(options = {}) {
const nextInline = document.getClosestInline(next.key)
if (nextBlock == startBlock && nextInline) {
const extendOrMove = e.shiftKey ? 'extend' : 'move'
const extendOrMove = event.shiftKey ? 'extend' : 'move'
change.collapseToStartOf(next)[extendOrMove](1)
return
}
@@ -597,17 +671,17 @@ function Plugin(options = {}) {
* Chrome, option-shift-up doesn't properly extend the selection. And in
* Firefox, option-up doesn't properly move the selection.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownUp(e, data, change) {
if (!IS_MAC || e.ctrlKey || !e.altKey) return
function onKeyDownUp(event, data, change) {
if (!IS_MAC || event.ctrlKey || !event.altKey) return
const { state } = change
const { selection, document, focusKey, focusBlock } = state
const transform = e.shiftKey ? 'extendToStartOf' : 'collapseToStartOf'
const transform = event.shiftKey ? 'extendToStartOf' : 'collapseToStartOf'
const block = selection.hasFocusAtStartOf(focusBlock)
? document.getPreviousBlock(focusKey)
: focusBlock
@@ -615,7 +689,7 @@ function Plugin(options = {}) {
if (!block) return
const text = block.getFirstText()
e.preventDefault()
event.preventDefault()
change[transform](text)
}
@@ -626,17 +700,17 @@ function Plugin(options = {}) {
* Chrome, option-shift-down doesn't properly extend the selection. And in
* Firefox, option-down doesn't properly move the selection.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onKeyDownDown(e, data, change) {
if (!IS_MAC || e.ctrlKey || !e.altKey) return
function onKeyDownDown(event, data, change) {
if (!IS_MAC || event.ctrlKey || !event.altKey) return
const { state } = change
const { selection, document, focusKey, focusBlock } = state
const transform = e.shiftKey ? 'extendToEndOf' : 'collapseToEndOf'
const transform = event.shiftKey ? 'extendToEndOf' : 'collapseToEndOf'
const block = selection.hasFocusAtEndOf(focusBlock)
? document.getNextBlock(focusKey)
: focusBlock
@@ -644,109 +718,39 @@ function Plugin(options = {}) {
if (!block) return
const text = block.getLastText()
e.preventDefault()
event.preventDefault()
change[transform](text)
}
/**
* On `d` key down, for Macs, delete one character forward.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownD(e, data, change) {
if (!IS_MAC || !e.ctrlKey || e.altKey) return
e.preventDefault()
change.deleteCharForward()
}
/**
* On `h` key down, for Macs, delete until the end of the line.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownH(e, data, change) {
if (!IS_MAC || !e.ctrlKey || e.altKey) return
e.preventDefault()
change.deleteCharBackward()
}
/**
* On `k` key down, for Macs, delete until the end of the line.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownK(e, data, change) {
if (!IS_MAC || !e.ctrlKey || e.altKey) return
e.preventDefault()
change.deleteLineForward()
}
/**
* On `y` key down, redo.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownY(e, data, change) {
const modKey = IS_MAC ? e.metaKey : e.ctrlKey
if (!modKey) return
change.redo()
}
/**
* On `z` key down, undo or redo.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownZ(e, data, change) {
const modKey = IS_MAC ? e.metaKey : e.ctrlKey
if (!modKey) return
change[e.shiftKey ? 'redo' : 'undo']()
}
/**
* On paste.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onPaste(e, data, change) {
function onPaste(event, data, change) {
debug('onPaste', { data })
switch (data.type) {
case 'fragment':
return onPasteFragment(e, data, change)
return onPasteFragment(event, data, change)
case 'text':
case 'html':
return onPasteText(e, data, change)
return onPasteText(event, data, change)
}
}
/**
* On paste fragment.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onPasteFragment(e, data, change) {
function onPasteFragment(event, data, change) {
debug('onPasteFragment', { data })
change.insertFragment(data.fragment)
}
@@ -754,12 +758,12 @@ function Plugin(options = {}) {
/**
* On paste text, split blocks at new lines.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onPasteText(e, data, change) {
function onPasteText(event, data, change) {
debug('onPasteText', { data })
const { state } = change
@@ -776,12 +780,12 @@ function Plugin(options = {}) {
/**
* On select.
*
* @param {Event} e
* @param {Event} event
* @param {Object} data
* @param {Change} change
*/
function onSelect(e, data, change) {
function onSelect(event, data, change) {
debug('onSelect', { data })
change.select(data.selection)
}
@@ -796,23 +800,19 @@ function Plugin(options = {}) {
*/
function render(props, state, editor) {
const handlers = EVENT_HANDLERS.reduce((obj, handler) => {
obj[handler] = editor[handler]
return obj
}, {})
return (
<Content
{...handlers}
autoCorrect={props.autoCorrect}
autoFocus={props.autoFocus}
className={props.className}
children={props.children}
editor={editor}
onBeforeInput={editor.onBeforeInput}
onBlur={editor.onBlur}
onFocus={editor.onFocus}
onCopy={editor.onCopy}
onCut={editor.onCut}
onDrop={editor.onDrop}
onKeyDown={editor.onKeyDown}
onKeyUp={editor.onKeyUp}
onPaste={editor.onPaste}
onSelect={editor.onSelect}
readOnly={props.readOnly}
role={props.role}
schema={editor.getSchema()}
@@ -888,7 +888,7 @@ function Plugin(options = {}) {
}
/**
* Return the core plugin.
* Return the plugin.
*
* @type {Object}
*/
@@ -900,6 +900,7 @@ function Plugin(options = {}) {
onCopy,
onCut,
onDrop,
onInput,
onKeyDown,
onPaste,
onSelect,
@@ -914,4 +915,4 @@ function Plugin(options = {}) {
* @type {Object}
*/
export default Plugin
export default AfterPlugin

View File

@@ -0,0 +1,591 @@
import Base64 from 'slate-base64-serializer'
import Debug from 'debug'
import getWindow from 'get-window'
import keycode from 'keycode'
import logger from 'slate-dev-logger'
import { findDOMNode } from 'react-dom'
import HOTKEYS from '../constants/hotkeys'
import TRANSFER_TYPES from '../constants/transfer-types'
import findRange from '../utils/find-range'
import getTransferData from '../utils/get-transfer-data'
import setTransferData from '../utils/set-transfer-data'
import { IS_FIREFOX, IS_MAC, SUPPORTED_EVENTS } from '../constants/environment'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:core:before')
/**
* The core before plugin.
*
* @return {Object}
*/
function BeforePlugin() {
let compositionCount = 0
let isComposing = false
let isCopying = false
let isDragging = false
let isShifting = false
let isInternalDrag = null
/**
* On before input.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onBeforeInput(event, data, change, editor) {
if (editor.props.readOnly) return true
// COMPAT: React's `onBeforeInput` synthetic event is based on the native
// `keypress` and `textInput` events. In browsers that support the native
// `beforeinput` event, we instead use that event to trigger text insertion,
// since it provides more useful information about the range being affected
// and also preserves compatibility with iOS autocorrect, which would be
// broken if we called `preventDefault()` on React's synthetic event here.
if (SUPPORTED_EVENTS.beforeinput) return true
debug('onBeforeInput', { event })
}
/**
* On blur.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onBlur(event, data, change, editor) {
if (isCopying) return true
if (editor.props.readOnly) return true
// If the active element is still the editor, the blur event is due to the
// window itself being blurred (eg. when changing tabs) so we should ignore
// the event, since we want to maintain focus when returning.
const el = findDOMNode(editor)
const window = getWindow(el)
if (window.document.activeElement == el) return true
debug('onBlur', { event })
}
/**
* On composition end.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onCompositionEnd(event, data, change, editor) {
const n = compositionCount
// 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 is still in affect.
setTimeout(() => {
if (compositionCount > n) return
isComposing = false
})
debug('onCompositionEnd', { event })
}
/**
* On composition start.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onCompositionStart(event, data, change, editor) {
isComposing = true
compositionCount++
debug('onCompositionStart', { event })
}
/**
* On copy.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onCopy(event, data, change, editor) {
const window = getWindow(event.target)
isCopying = true
window.requestAnimationFrame(() => isCopying = false)
const { state } = change
data.type = 'fragment'
data.fragment = state.fragment
debug('onCopy', { event })
}
/**
* On cut.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onCut(event, data, change, editor) {
if (editor.props.readOnly) return true
const window = getWindow(event.target)
isCopying = true
window.requestAnimationFrame(() => isCopying = false)
const { state } = change
data.type = 'fragment'
data.fragment = state.fragment
debug('onCut', { event })
}
/**
* On drag end.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onDragEnd(event, data, change, editor) {
event.stopPropagation()
isDragging = false
isInternalDrag = null
debug('onDragEnd', { event })
}
/**
* On drag over.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onDragOver(event, data, change, editor) {
if (isDragging) return true
event.stopPropagation()
isDragging = true
isInternalDrag = false
debug('onDragOver', { event })
}
/**
* On drag start.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onDragStart(event, data, change, editor) {
isDragging = true
isInternalDrag = true
const { dataTransfer } = event.nativeEvent
const d = getTransferData(dataTransfer)
Object.assign(data, d)
if (data.type != 'node') {
const { state } = this.props
const { fragment } = state
const encoded = Base64.serializeNode(fragment)
setTransferData(dataTransfer, TRANSFER_TYPES.FRAGMENT, encoded)
}
debug('onDragStart', { event })
}
/**
* On drop.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onDrop(event, data, change, editor) {
event.stopPropagation()
event.preventDefault()
if (editor.props.readOnly) return
const { state } = change
const { nativeEvent } = event
const { dataTransfer, x, y } = nativeEvent
const d = getTransferData(dataTransfer)
Object.assign(data, d)
// Resolve a range from the caret position where the drop occured.
const window = getWindow(event.target)
let range
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (window.document.caretRangeFromPoint) {
range = window.document.caretRangeFromPoint(x, y)
} else {
const position = window.document.caretPositionFromPoint(x, y)
range = window.document.createRange()
range.setStart(position.offsetNode, position.offset)
range.setEnd(position.offsetNode, position.offset)
}
// Resolve a Slate range from the DOM range.
let selection = findRange(range, state)
if (!selection) return true
const { document } = state
const node = document.getNode(selection.anchorKey)
const parent = document.getParent(node.key)
const el = findDOMNode(parent)
// If the drop target is inside a void node, move it into either the next or
// previous node, depending on which side the `x` and `y` coordinates are
// closest to.
if (parent.isVoid) {
const rect = el.getBoundingClientRect()
const isPrevious = parent.kind == 'inline'
? x - rect.left < rect.left + rect.width - x
: y - rect.top < rect.top + rect.height - y
selection = isPrevious
? selection.moveToEndOf(document.getPreviousText(node.key))
: selection.moveToStartOf(document.getNextText(node.key))
}
// Add drop-specific information to the data.
data.target = selection
// COMPAT: Edge throws "Permission denied" errors when
// accessing `dropEffect` or `effectAllowed` (2017/7/12)
try {
data.effect = dataTransfer.dropEffect
} catch (err) {
data.effect = null
}
if (d.type == 'fragment' || d.type == 'node') {
data.isInternal = isInternalDrag
}
debug('onDrop', { event })
}
/**
* On focus.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onFocus(event, data, change, editor) {
if (isCopying) return true
if (editor.props.readOnly) return true
const el = findDOMNode(editor)
// COMPAT: If the editor has nested editable elements, the focus can go to
// those elements. In Firefox, this must be prevented because it results in
// issues with keyboard navigation. (2017/03/30)
if (IS_FIREFOX && event.target != el) {
el.focus()
return true
}
debug('onFocus', { event })
}
/**
* On input.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onInput(event, data, change, editor) {
if (isComposing) return true
if (change.state.isBlurred) return true
debug('onInput', { event })
}
/**
* On key down.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onKeyDown(event, data, change, editor) {
if (editor.props.readOnly) return
const { key } = event
// 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 (
isComposing &&
(key == 'ArrowLeft' || key == 'ArrowRight' || key == 'ArrowUp' || key == 'ArrowDown')
) {
event.preventDefault()
return true
}
// Certain hotkeys have native behavior in contenteditable elements which
// will cause our state to be out of sync, so prevent them.
if (HOTKEYS.CONTENTEDITABLE(event)) {
event.preventDefault()
}
// Keep track of an `isShifting` flag, because it's often used to trigger
// "Paste and Match Style" commands, but isn't available on the event in a
// normal paste event.
if (key == 'Shift') {
isShifting = true
}
// COMPAT: add the deprecated keyboard event properties.
addDeprecatedKeyProperties(data, event)
debug('onKeyDown', { event })
}
/**
* On key up.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onKeyUp(event, data, change, editor) {
// COMPAT: add the deprecated keyboard event properties.
addDeprecatedKeyProperties(data, event)
if (event.key == 'Shift') {
isShifting = false
}
debug('onKeyUp', { event })
}
/**
* On paste.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onPaste(event, data, change, editor) {
if (editor.props.readOnly) return
event.preventDefault()
const d = getTransferData(event.clipboardData)
Object.assign(data, d)
// COMPAT: Attach the `isShift` flag, so that people can use it to trigger
// "Paste and Match Style" logic.
Object.defineProperty(data, 'isShift', {
enumerable: true,
get() {
logger.deprecate('0.28.0', 'The `data.isShift` property of paste events has been deprecated. If you need this functionality, you\'ll need to keep track of that state with `onKeyDown` and `onKeyUp` events instead')
return isShifting
}
})
debug('onPaste', { event })
}
/**
* On select.
*
* @param {Event} event
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onSelect(event, data, change, editor) {
if (isCopying) return
if (isComposing) return
if (editor.props.readOnly) return
const window = getWindow(event.target)
const { state } = change
const { document, selection } = state
const native = window.getSelection()
// If there are no ranges, the editor was blurred natively.
if (!native.rangeCount) {
data.selection = selection.blur()
}
// Otherwise, determine the Slate selection from the native one.
else {
let range = findRange(native, state)
if (!range) return
const { anchorKey, anchorOffset, focusKey, focusOffset } = range
const anchorText = document.getNode(anchorKey)
const focusText = document.getNode(focusKey)
const anchorInline = document.getClosestInline(anchorKey)
const focusInline = document.getClosestInline(focusKey)
const focusBlock = document.getClosestBlock(focusKey)
const anchorBlock = document.getClosestBlock(anchorKey)
// COMPAT: If the anchor point is at the start of a non-void, and the
// focus point is inside a void node with an offset that isn't `0`, set
// the focus offset to `0`. This is due to void nodes <span>'s being
// positioned off screen, resulting in the offset always being greater
// than `0`. Since we can't know what it really should be, and since an
// offset of `0` is less destructive because it creates a hanging
// selection, go with `0`. (2017/09/07)
if (
anchorBlock &&
!anchorBlock.isVoid &&
anchorOffset == 0 &&
focusBlock &&
focusBlock.isVoid &&
focusOffset != 0
) {
range = range.set('focusOffset', 0)
}
// COMPAT: If the selection is at the end of a non-void inline node, and
// there is a node after it, put it in the node after instead. This
// standardizes the behavior, since it's indistinguishable to the user.
if (
anchorInline &&
!anchorInline.isVoid &&
anchorOffset == anchorText.text.length
) {
const block = document.getClosestBlock(anchorKey)
const next = block.getNextText(anchorKey)
if (next) range = range.moveAnchorTo(next.key, 0)
}
if (
focusInline &&
!focusInline.isVoid &&
focusOffset == focusText.text.length
) {
const block = document.getClosestBlock(focusKey)
const next = block.getNextText(focusKey)
if (next) range = range.moveFocusTo(next.key, 0)
}
range = range.normalize(document)
data.selection = range
}
debug('onSelect', { event })
}
/**
* Return the plugin.
*
* @type {Object}
*/
return {
onBeforeInput,
onBlur,
onCompositionEnd,
onCompositionStart,
onCopy,
onCut,
onDragEnd,
onDragOver,
onDragStart,
onDrop,
onFocus,
onInput,
onKeyDown,
onKeyUp,
onPaste,
onSelect,
}
}
/**
* Add deprecated `data` fields from a key `event`.
*
* @param {Object} data
* @param {Object} event
*/
function addDeprecatedKeyProperties(data, event) {
const { altKey, ctrlKey, metaKey, shiftKey, which } = event
const name = keycode(which)
function define(key, value) {
Object.defineProperty(data, key, {
enumerable: true,
get() {
logger.deprecate('0.28.0', `The \`data.${key}\` property of keyboard events is deprecated, please use the native \`event\` properties instead.`)
return value
}
})
}
define('code', which)
define('key', name)
define('isAlt', altKey)
define('isCmd', IS_MAC ? metaKey && !altKey : false)
define('isCtrl', ctrlKey && !altKey)
define('isLine', IS_MAC ? metaKey : false)
define('isMeta', metaKey)
define('isMod', IS_MAC ? metaKey && !altKey : ctrlKey && !altKey)
define('isModAlt', IS_MAC ? metaKey && altKey : ctrlKey && altKey)
define('isShift', shiftKey)
define('isWord', IS_MAC ? altKey : ctrlKey)
}
/**
* Export.
*
* @type {Object}
*/
export default BeforePlugin

View File

@@ -1,5 +1,6 @@
import CorePlugin from '../../src/plugins/core'
import AfterPlugin from '../../src/plugins/after'
import BeforePlugin from '../../src/plugins/before'
import Simulator from 'slate-simulator'
import assert from 'assert'
import fs from 'fs'
@@ -11,7 +12,7 @@ import { basename, extname, resolve } from 'path'
*/
describe('plugins', () => {
describe('core', () => {
describe.skip('core', () => {
const dir = resolve(__dirname, 'core')
const events = fs.readdirSync(dir).filter(e => e[0] != '.' && e != 'index.js')
@@ -25,7 +26,7 @@ describe('plugins', () => {
const module = require(resolve(testDir, test))
const { input, output, props = {}} = module
const fn = module.default
const plugins = [CorePlugin(props)]
const plugins = [BeforePlugin(props), AfterPlugin(props)]
const simulator = new Simulator({ plugins, state: input })
fn(simulator)

View File

@@ -33,8 +33,10 @@ class Simulator {
* @param {Object} attrs
*/
constructor({ plugins, state }) {
constructor(props) {
const { plugins, state } = props
const stack = new Stack({ plugins })
this.props = props
this.stack = stack
this.state = state
}
@@ -53,13 +55,13 @@ EVENT_HANDLERS.forEach((handler) => {
if (data == null) data = {}
const { stack, state } = this
const editor = createEditor(stack, state)
const editor = createEditor(this)
const event = createEvent(e)
const change = state.change()
stack[handler](change, editor, event, data)
stack.onBeforeChange(change, editor)
stack.onChange(change, editor)
stack.handle(handler, change, editor, event, data)
stack.handle('onBeforeChange', change, editor)
stack.handle('onChange', change, editor)
this.state = change.state
return this
@@ -84,11 +86,22 @@ function getMethodName(handler) {
* @param {State} state
*/
function createEditor(stack, state) {
return {
function createEditor({ stack, state, props }) {
const editor = {
getSchema: () => stack.schema,
getState: () => state,
props: {
autoCorrect: true,
autoFocus: false,
onChange: () => {},
readOnly: false,
spellCheck: true,
...props,
},
}
return editor
}
/**

View File

@@ -13,27 +13,6 @@ import Schema from './schema'
const debug = Debug('slate:stack')
/**
* Methods that are triggered on events and can change the state.
*
* @type {Array}
*/
const METHODS = [
'onBeforeInput',
'onBeforeChange',
'onBlur',
'onCopy',
'onCut',
'onDrop',
'onFocus',
'onKeyDown',
'onKeyUp',
'onPaste',
'onSelect',
'onChange',
]
/**
* Default properties.
*
@@ -90,6 +69,27 @@ class Stack extends Record(DEFAULTS) {
return 'stack'
}
/**
* Invoke an event `handler` on all of the plugins, until one of them decides
* to stop propagation.
*
* @param {String} handler
* @param {Change} change
* @param {Editor} editor
* @param {Mixed} ...args
*/
handle(handler, change, editor, ...args) {
debug(handler)
for (let k = 0; k < this.plugins.length; k++) {
const plugin = this.plugins[k]
if (!plugin[handler]) continue
const next = plugin[handler](...args, change, editor)
if (next != null) break
}
}
/**
* Invoke `render` on all of the plugins in reverse, building up a tree of
* higher-order components.
@@ -146,28 +146,6 @@ class Stack extends Record(DEFAULTS) {
Stack.prototype[MODEL_TYPES.STACK] = true
/**
* Mix in the stack methods.
*
* @param {Change} change
* @param {Editor} editor
* @param {Mixed} ...args
*/
for (let i = 0; i < METHODS.length; i++) {
const method = METHODS[i]
Stack.prototype[method] = function (change, editor, ...args) {
debug(method)
for (let k = 0; k < this.plugins.length; k++) {
const plugin = this.plugins[k]
if (!plugin[method]) continue
const next = plugin[method](...args, change, editor)
if (next != null) break
}
}
}
/**
* Resolve a schema from a set of `plugins`.
*

View File

@@ -3364,6 +3364,10 @@ is-hotkey@^0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.0.1.tgz#d8d817209b34292551a85357e65cdbfcfa763443"
is-hotkey@^0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.0.3.tgz#3713fea135f86528c87cf39810b3934e45151390"
is-image@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-image/-/is-image-1.0.1.tgz#6fd51a752a1a111506d060d952118b0b989b426e"