1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-19 21:51:51 +02:00

change slate to be a monorepo using lerna (#1106)

* init lerna, move files into ./packages

* move test files into ./packages

* more moving around

* fill out package.json files

* fixing imports

* more fixing of imports, and horribleness

* convert examples, fix linting errors

* add documentation

* update docs

* get tests passing

* update travis.yml

* update travis.yml

* update travis.yml

* update test script

* update travis.yml

* update scripts

* try simplifying travis.yml

* ocd stuff

* remove slate-core-test-helpers package

* add package readmes

* update reference docs structure

* refactor slate-simulator into its own package

* add docs for new packages

* update docs

* separate benchmarks into packages, and refactor them
This commit is contained in:
Ian Storm Taylor
2017-09-11 18:11:45 -07:00
committed by GitHub
parent 4d73f19dc7
commit ace9f47930
687 changed files with 3337 additions and 15035 deletions

View File

@@ -0,0 +1,7 @@
benchmark
docs
examples
src
test
tmp
.babelrc

View File

@@ -0,0 +1,11 @@
# `slate-react`
This package contains the React-specific logic for Slate. It's separated further into a series of directories:
- [**Components**](./src/components) — containing the React components for rendering Slate editors.
- [**Constants**](./src/constants) — containing a few private constants modules.
- [**Plugins**](./src/plugins) — containing the React-specific plugins for Slate editors.
- [**Utils**](./src/utils) — containing a few private convenience modules.
Feel free to poke around in each of them to learn more!

View File

@@ -0,0 +1,34 @@
/* global suite, set, bench */
import fs from 'fs'
import { basename, extname, resolve } from 'path'
/**
* Benchmarks.
*/
const categoryDir = resolve(__dirname)
const categories = fs.readdirSync(categoryDir).filter(c => c[0] != '.' && c != 'index.js')
categories.forEach((category) => {
suite(category, () => {
set('iterations', 100)
set('mintime', 2000)
const benchmarkDir = resolve(categoryDir, category)
const benchmarks = fs.readdirSync(benchmarkDir).filter(b => b[0] != '.' && !!~b.indexOf('.js')).map(b => basename(b, extname(b)))
benchmarks.forEach((benchmark) => {
const dir = resolve(benchmarkDir, benchmark)
const module = require(dir)
const fn = module.default
let { input, before, after } = module
if (before) input = before(input)
bench(benchmark, () => {
fn(input)
if (after) after()
})
})
})
})

View File

@@ -0,0 +1,28 @@
/** @jsx h */
/* eslint-disable react/jsx-key */
import React from 'react'
import ReactDOM from 'react-dom/server'
import h from '../../test/helpers/h'
import { Editor } from '../..'
export default function (state) {
const el = React.createElement(Editor, { state })
ReactDOM.renderToStaticMarkup(el)
}
export const input = (
<state>
<document>
{Array.from(Array(10)).map(() => (
<quote>
<paragraph>
<paragraph>
This is editable <b>rich</b> text, <i>much</i> better than a textarea!
</paragraph>
</paragraph>
</quote>
))}
</document>
</state>
)

View File

@@ -0,0 +1,72 @@
{
"name": "slate-react",
"description": "A set of React components for building completely customizable rich-text editors.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"dependencies": {
"debug": "^2.3.2",
"get-window": "^1.1.1",
"is-in-browser": "^1.1.3",
"is-window": "^1.0.2",
"keycode": "^2.1.2",
"prop-types": "^15.5.8",
"react-portal": "^3.1.0",
"selection-is-backward": "^1.0.0",
"slate-base64-serializer": "^0.0.0",
"slate-plain-serializer": "^0.0.0",
"slate-prop-types": "^0.0.0",
"slate-logger": "^0.0.0"
},
"peerDependencies": {
"react": "^0.14.0 || ^15.0.0",
"react-dom": "^0.14.0 || ^15.0.0",
"slate": "^0.23.0"
},
"devDependencies": {
"babel-cli": "^6.10.1",
"browserify": "^13.0.1",
"mocha": "^2.5.3",
"slate": "^0.23.0",
"slate-hyperscript": "^0.0.0",
"slate-simulator": "^0.0.0",
"uglify-js": "^2.7.0"
},
"scripts": {
"build": "babel --out-dir ./lib ./src",
"build:max": "mkdir -p ./dist && NODE_ENV=production browserify ./src/index.js --transform babelify --transform envify --transform [ browserify-global-shim --global ] --standalone SlateReact > ./dist/slate-react.js",
"build:min": "mkdir -p ./dist && NODE_ENV=production browserify ./src/index.js --transform babelify --transform envify --transform [ browserify-global-shim --global ] --transform uglifyify --standalone SlateReact | uglifyjs > ./dist/slate-react.min.js",
"clean": "rm -rf ./dist ./lib ./node_modules",
"prepublish": "yarn run build:max && yarn run build:min",
"watch": "babel --watch --out-dir ./lib ./src --source-maps inline"
},
"browserify-global-shim": {
"react": "React",
"react-dom": "ReactDOM",
"react-dom/server": "ReactDOMServer",
"slate": "Slate"
},
"keywords": [
"canvas",
"contenteditable",
"doc",
"docs",
"document",
"edit",
"editor",
"html",
"immutable",
"markdown",
"medium",
"paper",
"react",
"rich",
"rich-text",
"richtext",
"slate",
"text",
"wysiwyg",
"wysiwym"
]
}

View File

@@ -0,0 +1,49 @@
This directory contains the React components that Slate renders. Here's what they all do:
- [Content](#content)
- [Editor](#editor)
- [Leaf](#leaf)
- [Placeholder](#placeholder)
- [Text](#text)
- [Void](#void)
#### Content
`Content` is rendered by the [`Editor`](#editor). Its goal is to encapsulate all of the `contenteditable` logic, so that the [`Editor`](#editor) doesn't have to be aware of it.
`Content` handles things attaching event listeners to the DOM and triggering updates based on events. However, it does not have any awareness of "plugins" as a concept, bubbling all of that logic up to the [`Editor`](#editor) itself.
You'll notice there are **no** `Block` or `Inline` components. That's because those rendering components are provided by the user, and rendered directly by the `Content` component. You can find the default renderers in the [`Core`](../plugins/core.js) plugin's logic.
#### Editor
The `Editor` is the highest-level component that you render from inside your application. Its goal is to present a very clean API for the user, and to encapsulate all of the plugin-level logic.
Many of the properties passed into the editor are combined to create a plugin of its own, that is given the highest priority. This makes overriding core logic super simple, without having to write a separate plugin.
#### Leaf
The `Leaf` component is the lowest-level component in the React tree. Its goal is to encapsulate the logic that works at the lowest level, on the actual strings of text in the DOM.
One `Leaf` component is rendered for each range of text with a unique set of [`Marks`](../models#mark). It handles both applying the mark styles to the text, and translating the current [`Selection`](../models#selection) into a real DOM selection, since it knows about the string offsets.
#### Placeholder
A `Placeholder` component is just a convenience for rendering placeholders on top of empty nodes. It's used in the core plugin's default block renderer, but is also exposed to provide the convenient API for custom blocks as well.
#### Text
A `Text` component is rendered for each [`Text`](../models#text) model in the document tree. This component handles grouping the characters of the text node into ranges that have the same set of [`Marks`](../models#mark), and then delegates rendering each range to...
#### Void
The `Void` component is a wrapper that gets rendered around [`Block`](../models#block) and [`Inline`](../models#inline) nodes that have `isVoid: true`. Its goal is to encapsule the logic needed to ensure that void nodes function as expected.
To achieve this, `Void` renders a few extra elements that are required to keep selections and keyboard shortcuts on void nodes functioning like you'd expect them two. It also ensures that everything inside the void node is not editable, so that it doesn't get the editor into an unknown state.

View File

@@ -0,0 +1,909 @@
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 { Selection } from 'slate'
import TRANSFER_TYPES from '../constants/transfer-types'
import Node from './node'
import extendSelection from '../utils/extend-selection'
import findClosestNode from '../utils/find-closest-node'
import getCaretPosition from '../utils/get-caret-position'
import getHtmlFromNativePaste from '../utils/get-html-from-native-paste'
import getPoint from '../utils/get-point'
import getTransferData from '../utils/get-transfer-data'
import setTransferData from '../utils/set-transfer-data'
import { IS_FIREFOX, IS_MAC, IS_IE } from '../constants/environment'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:content')
/**
* Content.
*
* @type {Component}
*/
class Content extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
autoCorrect: Types.bool.isRequired,
autoFocus: Types.bool.isRequired,
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,
spellCheck: Types.bool.isRequired,
state: SlateTypes.state.isRequired,
style: Types.object,
tabIndex: Types.number,
tagName: Types.string,
}
/**
* Default properties.
*
* @type {Object}
*/
static defaultProps = {
style: {},
tagName: 'div',
}
/**
* Constructor.
*
* @param {Object} props
*/
constructor(props) {
super(props)
this.tmp = {}
this.tmp.compositions = 0
this.tmp.forces = 0
}
/**
* When the editor first mounts in the DOM we need to:
*
* - Update the selection, in case it starts focused.
* - Focus the editor if `autoFocus` is set.
*/
componentDidMount = () => {
this.updateSelection()
if (this.props.autoFocus) {
this.element.focus()
}
}
/**
* On update, update the selection.
*/
componentDidUpdate = () => {
this.updateSelection()
}
/**
* Update the native DOM selection to reflect the internal model.
*/
updateSelection = () => {
const { editor, state } = this.props
const { selection } = state
const window = getWindow(this.element)
const native = window.getSelection()
// If both selections are blurred, do nothing.
if (!native.rangeCount && selection.isBlurred) return
// If the selection has been blurred, but is still inside the editor in the
// DOM, blur it manually.
if (selection.isBlurred) {
if (!this.isInEditor(native.anchorNode)) return
native.removeAllRanges()
this.element.blur()
debug('updateSelection', { selection, native })
return
}
// If the selection isn't set, do nothing.
if (selection.isUnset) return
// Otherwise, figure out which DOM nodes should be selected...
const { anchorKey, anchorOffset, focusKey, focusOffset, isCollapsed } = selection
const anchor = getCaretPosition(anchorKey, anchorOffset, state, editor, this.element)
const focus = isCollapsed
? anchor
: getCaretPosition(focusKey, focusOffset, state, editor, this.element)
// If they are already selected, do nothing.
if (
anchor.node == native.anchorNode &&
anchor.offset == native.anchorOffset &&
focus.node == native.focusNode &&
focus.offset == native.focusOffset
) {
return
}
// Otherwise, set the `isSelecting` flag and update the selection.
this.tmp.isSelecting = true
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(anchor.node, anchor.offset)
native.addRange(range)
if (!isCollapsed) extendSelection(native, focus.node, focus.offset)
// Then unset the `isSelecting` flag after a delay.
setTimeout(() => {
// COMPAT: In Firefox, it's not enough to create a range, you also need to
// focus the contenteditable element too. (2016/11/16)
if (IS_FIREFOX) this.element.focus()
this.tmp.isSelecting = false
})
debug('updateSelection', { selection, native })
}
/**
* The React ref method to set the root content element locally.
*
* @param {Element} element
*/
ref = (element) => {
this.element = element
}
/**
* Check if an event `target` is fired from within the contenteditable
* element. This should be false for edits happening in non-contenteditable
* children, such as void nodes and other nested Slate editors.
*
* @param {Element} target
* @return {Boolean}
*/
isInEditor = (target) => {
const { element } = this
// COMPAT: Text nodes don't have `isContentEditable` property. So, when
// `target` is a text node use its parent node for check.
const el = target.nodeType === 3 ? target.parentNode : target
return (
(el.isContentEditable) &&
(el === element || findClosestNode(el, '[data-slate-editor]') === element)
)
}
/**
* On before input, bubble up.
*
* @param {Event} event
*/
onBeforeInput = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const data = {}
debug('onBeforeInput', { event, data })
this.props.onBeforeInput(event, data)
}
/**
* 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) => {
if (!this.isInEditor(event.target)) return
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) => {
if (!this.isInEditor(event.target)) return
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.preventDefault()
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
const { state, editor } = this.props
const { nativeEvent } = event
const { dataTransfer, x, y } = nativeEvent
const data = getTransferData(dataTransfer)
// Resolve the point where the drop occured.
let range
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (window.document.caretRangeFromPoint) {
range = window.document.caretRangeFromPoint(x, y)
} else {
range = window.document.createRange()
range.setStart(nativeEvent.rangeParent, nativeEvent.rangeOffset)
}
const { startContainer, startOffset } = range
const point = getPoint(startContainer, startOffset, state, editor)
if (!point) return
const target = Selection.create({
anchorKey: point.key,
anchorOffset: point.offset,
focusKey: point.key,
focusOffset: point.offset,
isFocused: true
})
// Add drop-specific information to the data.
data.target = target
// 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 = getPoint(anchorNode, anchorOffset, state, editor)
if (!point) return
// Get the range in question.
const { key, index, start, end } = point
const { document, selection } = state
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(key, schema)
const node = document.getDescendant(key)
const block = document.getClosestBlock(node.key)
const ranges = node.getRanges(decorators)
const lastText = block.getLastText()
// Get the text information.
let { textContent } = anchorNode
const lastChar = textContent.charAt(textContent.length - 1)
const isLastText = node == lastText
const isLastRange = index == ranges.size - 1
// If we're dealing with the last leaf, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account
// for browsers collapsing a single trailing new lines, so remove it.
if (isLastText && isLastRange && lastChar == '\n') {
textContent = textContent.slice(0, -1)
}
// If the text is no different, abort.
const range = ranges.get(index)
const { text, marks } = range
if (textContent == text) return
// Determine what the selection should be after changing the text.
const delta = textContent.length - text.length
const after = selection.collapseToEnd().move(delta)
// Change the current state to have the text replaced.
editor.change((change) => {
change
.select({
anchorKey: key,
anchorOffset: start,
focusKey: key,
focusOffset: end
})
.delete()
.insertText(textContent, marks)
.select(after)
})
}
/**
* 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 { altKey, ctrlKey, metaKey, shiftKey, which } = event
const key = keycode(which)
const data = {}
// 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 == 'left' || key == 'right' || key == 'up' || key == 'down')
) {
event.preventDefault()
return
}
// Add helpful properties for handling hotkeys to the data object.
data.code = which
data.key = key
data.isAlt = altKey
data.isCmd = IS_MAC ? metaKey && !altKey : false
data.isCtrl = ctrlKey && !altKey
data.isLine = IS_MAC ? metaKey : false
data.isMeta = metaKey
data.isMod = IS_MAC ? metaKey && !altKey : ctrlKey && !altKey
data.isModAlt = IS_MAC ? metaKey && altKey : ctrlKey && altKey
data.isShift = shiftKey
data.isWord = IS_MAC ? altKey : ctrlKey
// These key commands have native behavior in contenteditable elements which
// will cause our state to be out of sync, so prevent them.
if (
(key == 'enter') ||
(key == 'backspace') ||
(key == 'delete') ||
(key == 'b' && data.isMod) ||
(key == 'i' && data.isMod) ||
(key == 'y' && data.isMod) ||
(key == 'z' && data.isMod)
) {
event.preventDefault()
}
debug('onKeyDown', { event, data })
this.props.onKeyDown(event, data)
}
/**
* On key up, unset the `isShifting` flag.
*
* @param {Event} event
*/
onKeyUp = (event) => {
const { altKey, ctrlKey, metaKey, shiftKey, which } = event
const key = keycode(which)
const data = {}
if (key == 'shift') {
this.tmp.isShifting = false
}
// Add helpful properties for handling hotkeys to the data object.
data.code = which
data.key = key
data.isAlt = altKey
data.isCmd = IS_MAC ? metaKey && !altKey : false
data.isCtrl = ctrlKey && !altKey
data.isLine = IS_MAC ? metaKey : false
data.isMeta = metaKey
data.isMod = IS_MAC ? metaKey && !altKey : ctrlKey && !altKey
data.isModAlt = IS_MAC ? metaKey && altKey : ctrlKey && altKey
data.isShift = shiftKey
data.isWord = IS_MAC ? altKey : ctrlKey
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)
// Attach the `isShift` flag, so that people can use it to trigger "Paste
// and Match Style" logic.
data.isShift = !!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, editor } = 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 {
const { anchorNode, anchorOffset, focusNode, focusOffset } = native
const anchor = getPoint(anchorNode, anchorOffset, state, editor)
const focus = getPoint(focusNode, focusOffset, state, editor)
if (!anchor || !focus) 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 (
anchor.key == selection.anchorKey &&
anchor.offset == selection.anchorOffset &&
focus.key == selection.focusKey &&
focus.offset == selection.focusOffset &&
selection.isFocused
) {
this.updateSelection()
return
}
const properties = {
anchorKey: anchor.key,
anchorOffset: anchor.offset,
focusKey: focus.key,
focusOffset: focus.offset,
isFocused: true,
isBackward: null
}
const anchorText = document.getNode(anchor.key)
const focusText = document.getNode(focus.key)
const anchorInline = document.getClosestInline(anchor.key)
const focusInline = document.getClosestInline(focus.key)
const focusBlock = document.getClosestBlock(focus.key)
const anchorBlock = document.getClosestBlock(anchor.key)
// 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 &&
anchor.offset == 0 &&
focusBlock &&
focusBlock.isVoid &&
focus.offset != 0
) {
properties.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 &&
anchor.offset == anchorText.text.length
) {
const block = document.getClosestBlock(anchor.key)
const next = block.getNextText(anchor.key)
if (next) {
properties.anchorKey = next.key
properties.anchorOffset = 0
}
}
if (
focusInline &&
!focusInline.isVoid &&
focus.offset == focusText.text.length
) {
const block = document.getClosestBlock(focus.key)
const next = block.getNextText(focus.key)
if (next) {
properties.focusKey = next.key
properties.focusOffset = 0
}
}
data.selection = selection
.merge(properties)
.normalize(document)
}
debug('onSelect', { event, data })
this.props.onSelect(event, data)
}
/**
* Render the editor content.
*
* @return {Element}
*/
render() {
const { props } = this
const { className, readOnly, state, tabIndex, role, tagName } = props
const Container = tagName
const { document, selection } = state
const indexes = document.getSelectionIndexes(selection, selection.isFocused)
const children = document.nodes.toArray().map((child, i) => {
const isSelected = !!indexes && indexes.start <= i && i < indexes.end
return this.renderNode(child, isSelected)
})
const style = {
// Prevent the default outline styles.
outline: 'none',
// Preserve adjacent whitespace and new lines.
whiteSpace: 'pre-wrap',
// Allow words to break if they are too long.
wordWrap: 'break-word',
// COMPAT: In iOS, a formatting menu with bold, italic and underline
// buttons is shown which causes our internal state to get out of sync in
// weird ways. This hides that. (2016/06/21)
...(readOnly ? {} : { WebkitUserModify: 'read-write-plaintext-only' }),
// Allow for passed-in styles to override anything.
...props.style,
}
// COMPAT: In Firefox, spellchecking can remove entire wrapping elements
// including inline ones like `<a>`, which is jarring for the user but also
// causes the DOM to get into an irreconcilable state. (2016/09/01)
const spellCheck = IS_FIREFOX ? false : props.spellCheck
debug('render', { props })
return (
<Container
data-slate-editor
key={this.tmp.forces}
ref={this.ref}
data-key={document.key}
contentEditable={!readOnly}
suppressContentEditableWarning
className={className}
onBeforeInput={this.onBeforeInput}
onBlur={this.onBlur}
onFocus={this.onFocus}
onCompositionEnd={this.onCompositionEnd}
onCompositionStart={this.onCompositionStart}
onCopy={this.onCopy}
onCut={this.onCut}
onDragEnd={this.onDragEnd}
onDragOver={this.onDragOver}
onDragStart={this.onDragStart}
onDrop={this.onDrop}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onPaste={this.onPaste}
onSelect={this.onSelect}
autoCorrect={props.autoCorrect}
spellCheck={spellCheck}
style={style}
role={readOnly ? null : (role || 'textbox')}
tabIndex={tabIndex}
// COMPAT: The Grammarly Chrome extension works by changing the DOM out
// from under `contenteditable` elements, which leads to weird behaviors
// so we have to disable it like this. (2017/04/24)
data-gramm={false}
>
{children}
{this.props.children}
</Container>
)
}
/**
* Render a `child` node of the document.
*
* @param {Node} child
* @param {Boolean} isSelected
* @return {Element}
*/
renderNode = (child, isSelected) => {
const { editor, readOnly, schema, state } = this.props
const { document } = state
return (
<Node
block={null}
editor={editor}
isSelected={isSelected}
key={child.key}
node={child}
parent={document}
readOnly={readOnly}
schema={schema}
state={state}
/>
)
}
}
/**
* Export.
*
* @type {Component}
*/
export default Content

View File

@@ -0,0 +1,316 @@
import Debug from 'debug'
import Portal from 'react-portal'
import React from 'react'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
import logger from 'slate-logger'
import { Stack, State } from 'slate'
import CorePlugin from '../plugins/core'
import noop from '../utils/noop'
/**
* Debug.
*
* @type {Function}
*/
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.
*
* @type {Array}
*/
const PLUGINS_PROPS = [
...EVENT_HANDLERS,
'placeholder',
'placeholderClassName',
'placeholderStyle',
'plugins',
'schema',
]
/**
* Editor.
*
* @type {Component}
*/
class Editor extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
autoCorrect: Types.bool,
autoFocus: Types.bool,
className: Types.string,
onBeforeChange: Types.func,
onChange: Types.func,
placeholder: Types.any,
placeholderClassName: Types.string,
placeholderStyle: Types.object,
plugins: Types.array,
readOnly: Types.bool,
role: Types.string,
schema: Types.object,
spellCheck: Types.bool,
state: SlateTypes.state.isRequired,
style: Types.object,
tabIndex: Types.number,
}
/**
* Default properties.
*
* @type {Object}
*/
static defaultProps = {
autoFocus: false,
autoCorrect: true,
onChange: noop,
plugins: [],
readOnly: false,
schema: {},
spellCheck: true,
}
/**
* When constructed, create a new `Stack` and run `onBeforeChange`.
*
* @param {Object} props
*/
constructor(props) {
super(props)
this.tmp = {}
this.state = {}
// Create a new `Stack`, omitting the `onChange` property since that has
// special significance on the editor itself.
const { state } = props
const plugins = resolvePlugins(props)
const stack = Stack.create({ plugins })
this.state.stack = stack
// Cache and set the state.
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 change = this.state.state.change()
stk[method](change, this, ...args)
stk.onBeforeChange(change, this)
stk.onChange(change, this)
this.onChange(change)
}
}
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')
}
if (props.onSelectionChange) {
logger.deprecate('0.22.10', 'The `onSelectionChange` prop is deprecated because it led to confusing UX issues, see https://github.com/ianstormtaylor/slate/issues/614#issuecomment-327868679')
}
}
/**
* When the `props` are updated, create a new `Stack` if necessary.
*
* @param {Object} props
*/
componentWillReceiveProps = (props) => {
const { state } = props
// If any plugin-related properties will change, create a new `Stack`.
for (let i = 0; i < PLUGINS_PROPS.length; i++) {
const prop = PLUGINS_PROPS[i]
if (props[prop] == this.props[prop]) continue
const plugins = resolvePlugins(props)
const stack = Stack.create({ plugins })
this.setState({ stack })
}
// Cache and save the state.
this.cacheState(state)
this.setState({ state })
}
/**
* Cache a `state` in memory to be able to compare against it later, for
* things like `onDocumentChange`.
*
* @param {State} state
*/
cacheState = (state) => {
this.tmp.document = state.document
this.tmp.selection = state.selection
}
/**
* Programmatically blur the editor.
*/
blur = () => {
this.change(t => t.blur())
}
/**
* Programmatically focus the editor.
*/
focus = () => {
this.change(t => t.focus())
}
/**
* Get the editor's current schema.
*
* @return {Schema}
*/
getSchema = () => {
return this.state.stack.schema
}
/**
* Get the editor's current state.
*
* @return {State}
*/
getState = () => {
return this.state.state
}
/**
* Perform a change `fn` on the editor's current state.
*
* @param {Function} fn
*/
change = (fn) => {
const change = this.state.state.change()
fn(change)
this.onChange(change)
}
/**
* On change.
*
* @param {Change} change
*/
onChange = (change) => {
if (State.isState(change)) {
throw new Error('As of slate@0.22.0 the `editor.onChange` method must be passed a `Change` object not a `State` object.')
}
const { onChange, onDocumentChange, onSelectionChange } = this.props
const { document, selection } = this.tmp
const { state } = change
if (state == this.state.state) return
onChange(change)
if (onDocumentChange && state.document != document) onDocumentChange(state.document, change)
if (onSelectionChange && state.selection != selection) onSelectionChange(state.selection, change)
}
/**
* Render the editor.
*
* @return {Element}
*/
render() {
const { props, state } = this
const { stack } = state
const children = stack
.renderPortal(state.state, this)
.map((child, i) => <Portal key={i} isOpened>{child}</Portal>)
debug('render', { props, state })
const tree = stack.render(state.state, this, { ...props, children })
return tree
}
}
/**
* Resolve an array of plugins from `props`.
*
* In addition to the plugins provided in `props.plugins`, this will create
* two other plugins:
*
* - A plugin made from the top-level `props` themselves, which are placed at
* the beginning of the stack. That way, you can add a `onKeyDown` handler,
* and it will override all of the existing plugins.
*
* - A "core" functionality plugin that handles the most basic events in
* Slate, like deleting characters, splitting blocks, etc.
*
* @param {Object} props
* @return {Array}
*/
function resolvePlugins(props) {
// eslint-disable-next-line no-unused-vars
const { state, onChange, plugins = [], ...overridePlugin } = props
const corePlugin = CorePlugin(props)
return [
overridePlugin,
...plugins,
corePlugin
]
}
/**
* Mix in the property types for the event handlers.
*/
for (let i = 0; i < EVENT_HANDLERS.length; i++) {
const property = EVENT_HANDLERS[i]
Editor.propTypes[property] = Types.func
}
/**
* Export.
*
* @type {Component}
*/
export default Editor

View File

@@ -0,0 +1,177 @@
import Debug from 'debug'
import React from 'react'
import Types from 'prop-types'
import SlateTypes from 'slate-prop-types'
import OffsetKey from '../utils/offset-key'
import { IS_FIREFOX } from '../constants/environment'
/**
* Debugger.
*
* @type {Function}
*/
const debug = Debug('slate:leaf')
/**
* Leaf.
*
* @type {Component}
*/
class Leaf extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
block: SlateTypes.block.isRequired,
editor: Types.object.isRequired,
index: Types.number.isRequired,
marks: SlateTypes.marks.isRequired,
node: SlateTypes.node.isRequired,
offset: Types.number.isRequired,
parent: SlateTypes.node.isRequired,
ranges: SlateTypes.ranges.isRequired,
schema: SlateTypes.schema.isRequired,
state: SlateTypes.state.isRequired,
text: Types.string.isRequired,
}
/**
* Debug.
*
* @param {String} message
* @param {Mixed} ...args
*/
debug = (message, ...args) => {
debug(message, `${this.props.node.key}-${this.props.index}`, ...args)
}
/**
* Should component update?
*
* @param {Object} props
* @return {Boolean}
*/
shouldComponentUpdate(props) {
// If any of the regular properties have changed, re-render.
if (
props.index != this.props.index ||
props.marks != this.props.marks ||
props.schema != this.props.schema ||
props.text != this.props.text
) {
return true
}
// Otherwise, don't update.
return false
}
/**
* Render the leaf.
*
* @return {Element}
*/
render() {
const { props } = this
const { node, index } = props
const offsetKey = OffsetKey.stringify({
key: node.key,
index
})
this.debug('render', { props })
return (
<span data-offset-key={offsetKey}>
{this.renderMarks(props)}
</span>
)
}
/**
* Render the text content of the leaf, accounting for browsers.
*
* @param {Object} props
* @return {Element}
*/
renderText(props) {
const { block, node, parent, text, index, ranges } = props
// COMPAT: If the text is empty and it's the only child, we need to render a
// <br/> to get the block to have the proper height.
if (text == '' && parent.kind == 'block' && parent.text == '') return <br />
// COMPAT: If the text is empty otherwise, it's because it's on the edge of
// an inline void node, so we render a zero-width space so that the
// selection can be inserted next to it still.
if (text == '') {
// COMPAT: In Chrome, zero-width space produces graphics glitches, so use
// hair space in place of it. (2017/02/12)
const space = IS_FIREFOX ? '\u200B' : '\u200A'
return <span data-slate-zero-width>{space}</span>
}
// COMPAT: Browsers will collapse trailing new lines at the end of blocks,
// so we need to add an extra trailing new lines to prevent that.
const lastText = block.getLastText()
const lastChar = text.charAt(text.length - 1)
const isLastText = node == lastText
const isLastRange = index == ranges.size - 1
if (isLastText && isLastRange && lastChar == '\n') return `${text}\n`
// Otherwise, just return the text.
return text
}
/**
* Render all of the leaf's mark components.
*
* @param {Object} props
* @return {Element}
*/
renderMarks(props) {
const { marks, schema, node, offset, text, state, editor } = props
const children = this.renderText(props)
return marks.reduce((memo, mark) => {
const Component = mark.getComponent(schema)
if (!Component) return memo
return (
<Component
editor={editor}
mark={mark}
marks={marks}
node={node}
offset={offset}
schema={schema}
state={state}
text={text}
>
{memo}
</Component>
)
}, children)
}
}
/**
* Export.
*
* @type {Component}
*/
export default Leaf

View File

@@ -0,0 +1,384 @@
import Base64 from 'slate-base64-serializer'
import Debug from 'debug'
import React from 'react'
import ReactDOM from 'react-dom'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
import getWindow from 'get-window'
import TRANSFER_TYPES from '../constants/transfer-types'
import Leaf from './leaf'
import Void from './void'
import scrollToSelection from '../utils/scroll-to-selection'
import setTransferData from '../utils/set-transfer-data'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:node')
/**
* Node.
*
* @type {Component}
*/
class Node extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
block: SlateTypes.block,
editor: Types.object.isRequired,
isSelected: Types.bool.isRequired,
node: SlateTypes.node.isRequired,
parent: SlateTypes.node.isRequired,
readOnly: Types.bool.isRequired,
schema: SlateTypes.schema.isRequired,
state: SlateTypes.state.isRequired,
}
/**
* Constructor.
*
* @param {Object} props
*/
constructor(props) {
super(props)
const { node, schema } = props
this.state = {}
this.state.Component = node.kind == 'text' ? null : node.getComponent(schema)
}
/**
* Debug.
*
* @param {String} message
* @param {Mixed} ...args
*/
debug = (message, ...args) => {
const { node } = this.props
const { key, kind, type } = node
const id = kind == 'text' ? `${key} (${kind})` : `${key} (${type})`
debug(message, `${id}`, ...args)
}
/**
* On receiving new props, update the `Component` renderer.
*
* @param {Object} props
*/
componentWillReceiveProps = (props) => {
if (props.node.kind == 'text') return
if (props.node == this.props.node) return
const Component = props.node.getComponent(props.schema)
this.setState({ Component })
}
/**
* Should the node update?
*
* @param {Object} nextProps
* @param {Object} state
* @return {Boolean}
*/
shouldComponentUpdate = (nextProps) => {
const { props } = this
const { Component } = this.state
const n = nextProps
const p = props
// If the `Component` has enabled suppression of update checking, always
// return true so that it can deal with update checking itself.
if (Component && Component.suppressShouldComponentUpdate) return true
// If the `readOnly` status has changed, re-render in case there is any
// user-land logic that depends on it, like nested editable contents.
if (n.readOnly != p.readOnly) return true
// If the node has changed, update. PERF: There are cases where it will have
// changed, but it's properties will be exactly the same (eg. copy-paste)
// which this won't catch. But that's rare and not a drag on performance, so
// for simplicity we just let them through.
if (n.node != p.node) return true
// If the node's selection state has changed, re-render in case there is any
// user-land logic depends on it to render.
if (n.isSelected != p.isSelected) return true
// If the node is a text node, re-render if the current decorations have
// changed, even if the content of the text node itself hasn't.
if (n.node.kind == 'text' && n.schema.hasDecorators) {
const nDecorators = n.state.document.getDescendantDecorators(n.node.key, n.schema)
const pDecorators = p.state.document.getDescendantDecorators(p.node.key, p.schema)
const nRanges = n.node.getRanges(nDecorators)
const pRanges = p.node.getRanges(pDecorators)
if (!nRanges.equals(pRanges)) return true
}
// If the node is a text node, and its parent is a block node, and it was
// the last child of the block, re-render to cleanup extra `<br/>` or `\n`.
if (n.node.kind == 'text' && n.parent.kind == 'block') {
const pLast = p.parent.nodes.last()
const nLast = n.parent.nodes.last()
if (p.node == pLast && n.node != nLast) return true
}
// Otherwise, don't update.
return false
}
/**
* On mount, update the scroll position.
*/
componentDidMount = () => {
this.updateScroll()
}
/**
* After update, update the scroll position if the node's content changed.
*
* @param {Object} prevProps
* @param {Object} prevState
*/
componentDidUpdate = (prevProps, prevState) => {
if (this.props.node != prevProps.node) this.updateScroll()
}
/**
* There is a corner case, that some nodes are unmounted right after they update
* Then, when the timer execute, it will throw the error
* `findDOMNode was called on an unmounted component`
* We should clear the timer from updateScroll here
*/
componentWillUnmount = () => {
clearTimeout(this.scrollTimer)
}
/**
* Update the scroll position after a change as occured if this is a leaf
* block and it has the selection's ending edge. This ensures that scrolling
* matches native `contenteditable` behavior even for cases where the edit is
* not applied natively, like when enter is pressed.
*/
updateScroll = () => {
const { node, state } = this.props
const { selection } = state
// If this isn't a block, or it's a wrapping block, abort.
if (node.kind != 'block') return
if (node.nodes.first().kind == 'block') return
// If the selection is blurred, or this block doesn't contain it, abort.
if (selection.isBlurred) return
if (!selection.hasEndIn(node)) return
// The native selection will be updated after componentDidMount or componentDidUpdate.
// Use setTimeout to queue scrolling to the last when the native selection has been updated to the correct value.
this.scrollTimer = setTimeout(() => {
const el = ReactDOM.findDOMNode(this)
const window = getWindow(el)
const native = window.getSelection()
scrollToSelection(native)
this.debug('updateScroll', el)
})
}
/**
* On drag start, add a serialized representation of the node to the data.
*
* @param {Event} e
*/
onDragStart = (e) => {
const { node } = this.props
// Only void node are draggable
if (!node.isVoid) {
return
}
const encoded = Base64.serializeNode(node, { preserveKeys: true })
const { dataTransfer } = e.nativeEvent
setTransferData(dataTransfer, TRANSFER_TYPES.NODE, encoded)
this.debug('onDragStart', e)
}
/**
* Render.
*
* @return {Element}
*/
render() {
const { props } = this
const { node } = this.props
this.debug('render', { props })
return node.kind == 'text'
? this.renderText()
: this.renderElement()
}
/**
* Render a `child` node.
*
* @param {Node} child
* @param {Boolean} isSelected
* @return {Element}
*/
renderNode = (child, isSelected) => {
const { block, editor, node, readOnly, schema, state } = this.props
return (
<Node
block={node.kind == 'block' ? node : block}
editor={editor}
isSelected={isSelected}
key={child.key}
node={child}
parent={node}
readOnly={readOnly}
schema={schema}
state={state}
/>
)
}
/**
* Render an element `node`.
*
* @return {Element}
*/
renderElement = () => {
const { editor, isSelected, node, parent, readOnly, state } = this.props
const { Component } = this.state
const { selection } = state
const indexes = node.getSelectionIndexes(selection, isSelected)
const children = node.nodes.toArray().map((child, i) => {
const isChildSelected = !!indexes && indexes.start <= i && i < indexes.end
return this.renderNode(child, isChildSelected)
})
// Attributes that the developer must to mix into the element in their
// custom node renderer component.
const attributes = {
'data-key': node.key,
'onDragStart': this.onDragStart
}
// If it's a block node with inline children, add the proper `dir` attribute
// for text direction.
if (node.kind == 'block' && node.nodes.first().kind != 'block') {
const direction = node.getTextDirection()
if (direction == 'rtl') attributes.dir = 'rtl'
}
const element = (
<Component
attributes={attributes}
editor={editor}
isSelected={isSelected}
key={node.key}
node={node}
parent={parent}
readOnly={readOnly}
state={state}
>
{children}
</Component>
)
return node.isVoid
? <Void {...this.props}>{element}</Void>
: element
}
/**
* Render a text node.
*
* @return {Element}
*/
renderText = () => {
const { node, schema, state } = this.props
const { document } = state
const decorators = schema.hasDecorators ? document.getDescendantDecorators(node.key, schema) : []
const ranges = node.getRanges(decorators)
let offset = 0
const leaves = ranges.map((range, i) => {
const leaf = this.renderLeaf(ranges, range, i, offset)
offset += range.text.length
return leaf
})
return (
<span data-key={node.key}>
{leaves}
</span>
)
}
/**
* Render a single leaf node given a `range` and `offset`.
*
* @param {List<Range>} ranges
* @param {Range} range
* @param {Number} index
* @param {Number} offset
* @return {Element} leaf
*/
renderLeaf = (ranges, range, index, offset) => {
const { block, node, parent, schema, state, editor } = this.props
const { text, marks } = range
return (
<Leaf
key={`${node.key}-${index}`}
block={block}
editor={editor}
index={index}
marks={marks}
node={node}
offset={offset}
parent={parent}
ranges={ranges}
schema={schema}
state={state}
text={text}
/>
)
}
}
/**
* Export.
*
* @type {Component}
*/
export default Node

View File

@@ -0,0 +1,125 @@
import React from 'react'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
/**
* Placeholder.
*
* @type {Component}
*/
class Placeholder extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
children: Types.any.isRequired,
className: Types.string,
firstOnly: Types.bool,
node: SlateTypes.node.isRequired,
parent: SlateTypes.node,
state: SlateTypes.state.isRequired,
style: Types.object,
}
/**
* Default properties.
*
* @type {Object}
*/
static defaultProps = {
firstOnly: true,
}
/**
* Should the placeholder update?
*
* @param {Object} props
* @param {Object} state
* @return {Boolean}
*/
shouldComponentUpdate = (props, state) => {
return (
props.children != this.props.children ||
props.className != this.props.className ||
props.firstOnly != this.props.firstOnly ||
props.parent != this.props.parent ||
props.node != this.props.node ||
props.style != this.props.style
)
}
/**
* Is the placeholder visible?
*
* @return {Boolean}
*/
isVisible = () => {
const { firstOnly, node, parent } = this.props
if (node.text) return false
if (firstOnly) {
if (parent.nodes.size > 1) return false
if (parent.nodes.first() === node) return true
return false
} else {
return true
}
}
/**
* Render.
*
* If the placeholder is a string, and no `className` or `style` has been
* passed, give it a default style of lowered opacity.
*
* @return {Element}
*/
render() {
const isVisible = this.isVisible()
if (!isVisible) return null
const { children, className } = this.props
let { style } = this.props
if (typeof children === 'string' && style == null && className == null) {
style = { opacity: '0.333' }
} else if (style == null) {
style = {}
}
const styles = {
position: 'absolute',
top: '0px',
right: '0px',
bottom: '0px',
left: '0px',
pointerEvents: 'none',
...style
}
return (
<span contentEditable={false} className={className} style={styles}>
{children}
</span>
)
}
}
/**
* Export.
*
* @type {Component}
*/
export default Placeholder

View File

@@ -0,0 +1,255 @@
import Debug from 'debug'
import React from 'react'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
import { Mark } from 'slate'
import Leaf from './leaf'
import OffsetKey from '../utils/offset-key'
import { IS_FIREFOX } from '../constants/environment'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:void')
/**
* Void.
*
* @type {Component}
*/
class Void extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
block: SlateTypes.block,
children: Types.any.isRequired,
editor: Types.object.isRequired,
node: SlateTypes.node.isRequired,
parent: SlateTypes.node.isRequired,
readOnly: Types.bool.isRequired,
schema: SlateTypes.schema.isRequired,
state: SlateTypes.state.isRequired,
}
/**
* State
*
* @type {Object}
*/
state = {
dragCounter: 0,
editable: false,
}
/**
* Debug.
*
* @param {String} message
* @param {Mixed} ...args
*/
debug = (message, ...args) => {
const { node } = this.props
const { key, type } = node
const id = `${key} (${type})`
debug(message, `${id}`, ...args)
}
/**
* When one of the wrapper elements it clicked, select the void node.
*
* @param {Event} event
*/
onClick = (event) => {
if (this.props.readOnly) return
this.debug('onClick', { event })
const { node, editor } = this.props
editor.change((change) => {
change
// COMPAT: In Chrome & Safari, selections that are at the zero offset of
// an inline node will be automatically replaced to be at the last
// offset of a previous inline node, which screws us up, so we always
// want to set it to the end of the node. (2016/11/29)
.collapseToEndOf(node)
.focus()
})
}
/**
* Increment counter, and temporarily switch node to editable to allow drop events
* Counter required as onDragLeave fires when hovering over child elements
*
* @param {Event} event
*/
onDragEnter = () => {
this.setState((prevState) => {
const dragCounter = prevState.dragCounter + 1
return { dragCounter, editable: undefined }
})
}
/**
* Decrement counter, and if counter 0, then no longer dragging over node
* and thus switch back to non-editable
*
* @param {Event} event
*/
onDragLeave = () => {
this.setState((prevState) => {
const dragCounter = prevState.dragCounter - 1
const editable = dragCounter === 0 ? false : undefined
return { dragCounter, editable }
})
}
/**
* If dropped item onto node, then reset state
*
* @param {Event} event
*/
onDrop = () => {
this.setState({ dragCounter: 0, editable: false })
}
/**
* Render.
*
* @return {Element}
*/
render() {
const { props } = this
const { children, node } = props
let Tag, style
// Make the outer wrapper relative, so the spacer can overlay it.
if (node.kind === 'block') {
Tag = 'div'
style = { position: 'relative' }
} else {
Tag = 'span'
}
this.debug('render', { props })
return (
<Tag
data-slate-void
style={style}
onClick={this.onClick}
onDragEnter={this.onDragEnter}
onDragLeave={this.onDragLeave}
onDrop={this.onDrop}
>
{this.renderSpacer()}
<Tag contentEditable={this.state.editable}>
{children}
</Tag>
</Tag>
)
}
/**
* Render a fake spacer leaf, which will catch the cursor when it the void
* node is navigated to with the arrow keys. Having this spacer there means
* the browser continues to manage the selection natively, so it keeps track
* of the right offset when moving across the block.
*
* @return {Element}
*/
renderSpacer = () => {
const { node } = this.props
let style
if (node.kind == 'block') {
style = IS_FIREFOX
? {
pointerEvents: 'none',
width: '0px',
height: '0px',
lineHeight: '0px',
visibility: 'hidden'
}
: {
position: 'absolute',
top: '0px',
left: '-9999px',
textIndent: '-9999px'
}
} else {
style = {
color: 'transparent'
}
}
return (
<span style={style}>{this.renderLeaf()}</span>
)
}
/**
* Render a fake leaf.
*
* @return {Element}
*/
renderLeaf = () => {
const { block, node, schema, state, editor } = this.props
const child = node.getFirstText()
const ranges = child.getRanges()
const text = ''
const offset = 0
const marks = Mark.createSet()
const index = 0
const offsetKey = OffsetKey.stringify({
key: child.key,
index
})
return (
<Leaf
key={offsetKey}
block={node.kind == 'block' ? node : block}
editor={editor}
index={index}
marks={marks}
node={child}
offset={offset}
parent={node}
ranges={ranges}
schema={schema}
state={state}
text={text}
/>
)
}
}
/**
* Export.
*
* @type {Component}
*/
export default Void

View File

@@ -0,0 +1,80 @@
import browser from 'is-in-browser'
/**
* Browser matching rules.
*
* @type {Array}
*/
const BROWSER_RULES = [
['edge', /Edge\/([0-9\._]+)/],
['chrome', /(?!Chrom.*OPR)Chrom(?:e|ium)\/([0-9\.]+)(:?\s|$)/],
['firefox', /Firefox\/([0-9\.]+)(?:\s|$)/],
['opera', /Opera\/([0-9\.]+)(?:\s|$)/],
['opera', /OPR\/([0-9\.]+)(:?\s|$)$/],
['ie', /Trident\/7\.0.*rv\:([0-9\.]+)\).*Gecko$/],
['ie', /MSIE\s([0-9\.]+);.*Trident\/[4-7].0/],
['ie', /MSIE\s(7\.0)/],
['android', /Android\s([0-9\.]+)/],
['safari', /Version\/([0-9\._]+).*Safari/],
]
/**
* Operating system matching rules.
*
* @type {Array}
*/
const OS_RULES = [
['macos', /mac os x/i],
['ios', /os ([\.\_\d]+) like mac os/i],
['android', /android/i],
['firefoxos', /mozilla\/[a-z\.\_\d]+ \((?:mobile)|(?:tablet)/i],
['windows', /windows\s*(?:nt)?\s*([\.\_\d]+)/i],
]
/**
* Define variables to store the result.
*/
let BROWSER
let OS
/**
* Run the matchers when in browser.
*/
if (browser) {
const { userAgent } = window.navigator
for (let i = 0; i < BROWSER_RULES.length; i++) {
const [ name, regexp ] = BROWSER_RULES[i]
if (regexp.test(userAgent)) {
BROWSER = name
break
}
}
for (let i = 0; i < OS_RULES.length; i++) {
const [ name, regexp ] = OS_RULES[i]
if (regexp.test(userAgent)) {
OS = name
break
}
}
}
/**
* Export.
*
* @type {Object}
*/
export const IS_CHROME = BROWSER === 'chrome'
export const IS_FIREFOX = BROWSER === 'firefox'
export const IS_SAFARI = BROWSER === 'safari'
export const IS_IE = BROWSER === 'ie'
export const IS_MAC = OS === 'macos'
export const IS_WINDOWS = OS === 'windows'

View File

@@ -0,0 +1,18 @@
/**
* Slate-specific data transfer types.
*
* @type {Object}
*/
const TYPES = {
FRAGMENT: 'application/x-slate-fragment',
NODE: 'application/x-slate-node',
}
/**
* Export.
*
* @type {Object}
*/
export default TYPES

View File

@@ -0,0 +1,31 @@
/**
* Components.
*/
import Editor from './components/editor'
import Placeholder from './components/placeholder'
/**
* Utils.
*/
import findDOMNode from './utils/find-dom-node'
/**
* Export.
*
* @type {Object}
*/
export {
Editor,
Placeholder,
findDOMNode,
}
export default {
Editor,
Placeholder,
findDOMNode,
}

View File

@@ -0,0 +1,2 @@
This directory contains the only plugin that ships with Slate by default, which controls all of the "core" logic. For example, it handles splitting apart paragraphs when `enter` is pressed, or inserting plain text content from the clipboard on paste.

View File

@@ -0,0 +1,900 @@
import Base64 from 'slate-base64-serializer'
import Debug from 'debug'
import Plain from 'slate-plain-serializer'
import React from 'react'
import getWindow from 'get-window'
import { Block, Inline } from 'slate'
import Content from '../components/content'
import Placeholder from '../components/placeholder'
import getPoint from '../utils/get-point'
import findDOMNode from '../utils/find-dom-node'
import { IS_CHROME, IS_MAC, IS_SAFARI } from '../constants/environment'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:core')
/**
* The default plugin.
*
* @param {Object} options
* @property {Element} placeholder
* @property {String} placeholderClassName
* @property {Object} placeholderStyle
* @return {Object}
*/
function Plugin(options = {}) {
const {
placeholder,
placeholderClassName,
placeholderStyle,
} = options
/**
* On before change, enforce the editor's schema.
*
* @param {Change} change
* @param {Editor} schema
*/
function onBeforeChange(change, editor) {
const { state } = change
const schema = editor.getSchema()
const prevState = editor.getState()
// PERF: Skip normalizing if the document hasn't changed, since the core
// schema only normalizes changes to the document, not selection.
if (prevState && state.document == prevState.document) return
change.normalize(schema)
debug('onBeforeChange')
}
/**
* On before input, correct any browser inconsistencies.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onBeforeInput(e, data, change, editor) {
debug('onBeforeInput', { data })
e.preventDefault()
const { state } = change
const { selection } = state
const { anchorKey, anchorOffset, focusKey, focusOffset } = selection
// COMPAT: In iOS, when using predictive text suggestions, the native
// selection will be changed to span the existing word, so that the word is
// replaced. But the `select` fires after the `beforeInput` event, even
// though the native selection is updated. So we need to manually check if
// the selection has gotten out of sync, and adjust it if so. (03/18/2017)
const window = getWindow(e.target)
const native = window.getSelection()
const a = getPoint(native.anchorNode, native.anchorOffset, state, editor)
const f = getPoint(native.focusNode, native.focusOffset, state, editor)
const hasMismatch = a && f && (
anchorKey != a.key ||
anchorOffset != a.offset ||
focusKey != f.key ||
focusOffset != f.offset
)
if (hasMismatch) {
change.select({
anchorKey: a.key,
anchorOffset: a.offset,
focusKey: f.key,
focusOffset: f.offset
})
}
change.insertText(e.data)
}
/**
* On blur.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onBlur(e, data, change) {
debug('onBlur', { data })
change.blur()
}
/**
* On copy.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onCopy(e, data, change) {
debug('onCopy', data)
onCutOrCopy(e, data, change)
}
/**
* On cut.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
* @param {Editor} editor
*/
function onCut(e, data, change, editor) {
debug('onCut', data)
onCutOrCopy(e, data, change)
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(() => {
editor.change(t => t.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.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onCutOrCopy(e, data, change) {
const window = getWindow(e.target)
const native = window.getSelection()
const { state } = change
const { endBlock, endInline } = state
const isVoidBlock = endBlock && endBlock.isVoid
const isVoidInline = endInline && endInline.isVoid
const isVoid = isVoidBlock || isVoidInline
// If the selection is collapsed, and it isn't inside a void node, abort.
if (native.isCollapsed && !isVoid) return
const { fragment } = data
const encoded = Base64.serializeNode(fragment)
const range = native.getRangeAt(0)
let contents = range.cloneContents()
let attach = contents.childNodes[0]
// If the end node is a void node, we need to move the end of the range from
// the void node's spacer span, to the end of the void node's content.
if (isVoid) {
const r = range.cloneRange()
const node = findDOMNode(isVoidBlock ? endBlock : endInline)
r.setEndAfter(node)
contents = r.cloneContents()
attach = contents.childNodes[contents.childNodes.length - 1].firstChild
}
// Remove any zero-width space spans from the cloned DOM so that they don't
// show up elsewhere when pasted.
const zws = [].slice.call(contents.querySelectorAll('[data-slate-zero-width]'))
zws.forEach(zw => zw.parentNode.removeChild(zw))
// COMPAT: In Chrome and Safari, if the last element in the selection to
// copy has `contenteditable="false"` the copy will fail, and nothing will
// be put in the clipboard. So we remove them all. (2017/05/04)
if (IS_CHROME || IS_SAFARI) {
const els = [].slice.call(contents.querySelectorAll('[contenteditable="false"]'))
els.forEach(el => el.removeAttribute('contenteditable'))
}
// Set a `data-slate-fragment` attribute on a non-empty node, so it shows up
// in the HTML, and can be used for intra-Slate pasting. If it's a text
// node, wrap it in a `<span>` so we have something to set an attribute on.
if (attach.nodeType == 3) {
const span = window.document.createElement('span')
span.appendChild(attach)
contents.appendChild(span)
attach = span
}
attach.setAttribute('data-slate-fragment', encoded)
// 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)
})
}
/**
* On drop.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onDrop(e, data, change) {
debug('onDrop', { data })
switch (data.type) {
case 'text':
case 'html':
return onDropText(e, data, change)
case 'fragment':
return onDropFragment(e, data, change)
case 'node':
return onDropNode(e, data, change)
}
}
/**
* On drop node, insert the node wherever it is dropped.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onDropNode(e, data, change) {
debug('onDropNode', { data })
const { state } = change
const { selection } = state
let { node, 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.move(selection.startKey == selection.endKey
? 0 - selection.endOffset + selection.startOffset
: 0 - selection.endOffset)
}
if (isInternal) {
change.delete()
}
if (Block.isBlock(node)) {
change
.select(target)
.insertBlock(node)
.removeNodeByKey(node.key)
}
if (Inline.isInline(node)) {
change
.select(target)
.insertInline(node)
.removeNodeByKey(node.key)
}
}
/**
* On drop fragment.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onDropFragment(e, data, change) {
debug('onDropFragment', { data })
const { state } = change
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.move(selection.startKey == selection.endKey
? 0 - selection.endOffset + selection.startOffset
: 0 - selection.endOffset)
}
if (isInternal) {
change.delete()
}
change
.select(target)
.insertFragment(fragment)
}
/**
* On drop text, split the blocks at new lines.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onDropText(e, data, change) {
debug('onDropText', { data })
const { state } = change
const { document } = state
const { text, target } = data
const { anchorKey } = target
change.select(target)
let hasVoidParent = document.hasVoidParent(anchorKey)
// Insert text into nearest text node
if (hasVoidParent) {
let node = document.getNode(anchorKey)
while (hasVoidParent) {
node = document.getNextText(node.key)
if (!node) break
hasVoidParent = document.hasVoidParent(node.key)
}
if (node) change.collapseToStartOf(node)
}
text
.split('\n')
.forEach((line, i) => {
if (i > 0) change.splitBlock()
change.insertText(line)
})
}
/**
* On key down.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDown(e, data, change) {
debug('onKeyDown', { data })
switch (data.key) {
case 'enter': return onKeyDownEnter(e, data, change)
case 'backspace': return onKeyDownBackspace(e, data, change)
case 'delete': return onKeyDownDelete(e, data, change)
case 'left': return onKeyDownLeft(e, data, change)
case 'right': return onKeyDownRight(e, data, change)
case 'up': return onKeyDownUp(e, data, change)
case 'down': 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': return onKeyDownZ(e, data, change)
}
}
/**
* On `enter` key down, split the current block in half.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownEnter(e, data, change) {
const { state } = change
const { document, startKey } = state
const hasVoidParent = document.hasVoidParent(startKey)
// For void nodes, we don't want to split. Instead we just move to the start
// of the next text node if one exists.
if (hasVoidParent) {
const text = document.getNextText(startKey)
if (!text) return
change.collapseToStartOf(text)
return
}
change.splitBlock()
}
/**
* On `backspace` key down, delete backwards.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownBackspace(e, data, change) {
let boundary = 'Char'
if (data.isWord) boundary = 'Word'
if (data.isLine) boundary = 'Line'
change[`delete${boundary}Backward`]()
}
/**
* On `delete` key down, delete forwards.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownDelete(e, data, change) {
let boundary = 'Char'
if (data.isWord) boundary = 'Word'
if (data.isLine) boundary = 'Line'
change[`delete${boundary}Forward`]()
}
/**
* On `left` key down, move backward.
*
* COMPAT: This is required to make navigating with the left arrow work when
* a void node is selected.
*
* COMPAT: This is also required to solve for the case where an inline node is
* 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 {Object} data
* @param {Change} change
*/
function onKeyDownLeft(e, data, change) {
const { state } = change
if (data.isCtrl) return
if (data.isAlt) return
if (state.isExpanded) return
const { document, startKey, startText } = state
const hasVoidParent = document.hasVoidParent(startKey)
// 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()
const previous = document.getPreviousText(startKey)
// If there's no previous text node in the document, abort.
if (!previous) return
// If the previous text is in the current block, and inside a non-void
// inline node, move one character into the inline node.
const { startBlock } = state
const previousBlock = document.getClosestBlock(previous.key)
const previousInline = document.getClosestInline(previous.key)
if (previousBlock === startBlock && previousInline && !previousInline.isVoid) {
const extendOrMove = data.isShift ? 'extend' : 'move'
change.collapseToEndOf(previous)[extendOrMove](-1)
return
}
// Otherwise, move to the end of the previous node.
change.collapseToEndOf(previous)
}
}
/**
* On `right` key down, move forward.
*
* COMPAT: This is required to make navigating with the right arrow work when
* a void node is selected.
*
* COMPAT: This is also required to solve for the case where an inline node is
* 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.
*
* COMPAT: In Chrome & Safari, selections that are at the zero offset of
* an inline node will be automatically replaced to be at the last offset
* 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 {Object} data
* @param {Change} change
*/
function onKeyDownRight(e, data, change) {
const { state } = change
if (data.isCtrl) return
if (data.isAlt) return
if (state.isExpanded) return
const { document, startKey, startText } = state
const hasVoidParent = document.hasVoidParent(startKey)
// 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()
const next = document.getNextText(startKey)
// If there's no next text node in the document, abort.
if (!next) return
// If the next text is inside a void node, move to the end of it.
if (document.hasVoidParent(next.key)) {
change.collapseToEndOf(next)
return
}
// If the next text is in the current block, and inside an inline node,
// move one character into the inline node.
const { startBlock } = state
const nextBlock = document.getClosestBlock(next.key)
const nextInline = document.getClosestInline(next.key)
if (nextBlock == startBlock && nextInline) {
const extendOrMove = data.isShift ? 'extend' : 'move'
change.collapseToStartOf(next)[extendOrMove](1)
return
}
// Otherwise, move to the start of the next text node.
change.collapseToStartOf(next)
}
}
/**
* On `up` key down, for Macs, move the selection to start of the block.
*
* COMPAT: Certain browsers don't handle the selection updates properly. In
* 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 {Object} data
* @param {Change} change
*/
function onKeyDownUp(e, data, change) {
if (!IS_MAC || data.isCtrl || !data.isAlt) return
const { state } = change
const { selection, document, focusKey, focusBlock } = state
const transform = data.isShift ? 'extendToStartOf' : 'collapseToStartOf'
const block = selection.hasFocusAtStartOf(focusBlock)
? document.getPreviousBlock(focusKey)
: focusBlock
if (!block) return
const text = block.getFirstText()
e.preventDefault()
change[transform](text)
}
/**
* On `down` key down, for Macs, move the selection to end of the block.
*
* COMPAT: Certain browsers don't handle the selection updates properly. In
* 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 {Object} data
* @param {Change} change
*/
function onKeyDownDown(e, data, change) {
if (!IS_MAC || data.isCtrl || !data.isAlt) return
const { state } = change
const { selection, document, focusKey, focusBlock } = state
const transform = data.isShift ? 'extendToEndOf' : 'collapseToEndOf'
const block = selection.hasFocusAtEndOf(focusBlock)
? document.getNextBlock(focusKey)
: focusBlock
if (!block) return
const text = block.getLastText()
e.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 || !data.isCtrl) 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 || !data.isCtrl) 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 || !data.isCtrl) return
e.preventDefault()
change.deleteLineForward()
}
/**
* On `y` key down, redo.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownY(e, data, change) {
if (!data.isMod) return
change.redo()
}
/**
* On `z` key down, undo or redo.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onKeyDownZ(e, data, change) {
if (!data.isMod) return
change[data.isShift ? 'redo' : 'undo']()
}
/**
* On paste.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onPaste(e, data, change) {
debug('onPaste', { data })
switch (data.type) {
case 'fragment':
return onPasteFragment(e, data, change)
case 'text':
case 'html':
return onPasteText(e, data, change)
}
}
/**
* On paste fragment.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onPasteFragment(e, data, change) {
debug('onPasteFragment', { data })
change.insertFragment(data.fragment)
}
/**
* On paste text, split blocks at new lines.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onPasteText(e, data, change) {
debug('onPasteText', { data })
const { state } = change
const { document, selection, startBlock } = state
if (startBlock.isVoid) return
const { text } = data
const defaultBlock = startBlock
const defaultMarks = document.getMarksAtRange(selection.collapseToStart())
const fragment = Plain.deserialize(text, { defaultBlock, defaultMarks }).document
change.insertFragment(fragment)
}
/**
* On select.
*
* @param {Event} e
* @param {Object} data
* @param {Change} change
*/
function onSelect(e, data, change) {
debug('onSelect', { data })
change.select(data.selection)
}
/**
* Render.
*
* @param {Object} props
* @param {State} state
* @param {Editor} editor
* @return {Object}
*/
function render(props, state, editor) {
return (
<Content
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()}
spellCheck={props.spellCheck}
state={state}
style={props.style}
tabIndex={props.tabIndex}
tagName={props.tagName}
/>
)
}
/**
* 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>
)
}
}
/**
* Add default rendering rules to the schema.
*
* @type {Object}
*/
const schema = {
rules: [
BLOCK_RENDER_RULE,
INLINE_RENDER_RULE
]
}
/**
* Return the core plugin.
*
* @type {Object}
*/
return {
onBeforeChange,
onBeforeInput,
onBlur,
onCopy,
onCut,
onDrop,
onKeyDown,
onPaste,
onSelect,
render,
schema,
}
}
/**
* Export.
*
* @type {Object}
*/
export default Plugin

View File

@@ -0,0 +1,53 @@
/**
* Extends a DOM `selection` to a given `el` and `offset`.
*
* COMPAT: In IE11, `selection.extend` doesn't exist natively, so we have to
* polyfill it with this. (2017/09/06)
*
* https://gist.github.com/tyler-johnson/0a3e8818de3f115b2a2dc47468ac0099
*
* @param {Selection} selection
* @param {Element} el
* @param {Number} offset
* @return {Selection}
*/
function extendSelection(selection, el, offset) {
// Use native method whenever possible.
if (typeof selection.extend === 'function') {
return selection.extend(el, offset)
}
const range = document.createRange()
const anchor = document.createRange()
const focus = document.createRange()
anchor.setStart(selection.anchorNode, selection.anchorOffset)
focus.setStart(el, offset)
const v = focus.compareBoundaryPoints(window.Range.START_TO_START, anchor)
// If the focus is after the anchor...
if (v >= 0) {
range.setStart(selection.anchorNode, selection.anchorOffset)
range.setEnd(el, offset)
}
// Otherwise, if the anchor if after the focus...
else {
range.setStart(el, offset)
range.setEnd(selection.anchorNode, selection.anchorOffset)
}
selection.removeAllRanges()
selection.addRange(range)
return selection
}
/**
* Export.
*
* @type {Function}
*/
export default extendSelection

View File

@@ -0,0 +1,37 @@
/**
* Find the closest ancestor of a DOM `element` that matches a given selector.
*
* COMPAT: In IE11, the `Node.closest` method doesn't exist natively, so we
* have to polyfill it. (2017/09/06)
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
*
* @param {Element} node
* @param {String} selector
* @return {Element}
*/
function findClosestNode(node, selector) {
if (typeof node.closest === 'function') return node.closest(selector)
const matches = (node.document || node.ownerDocument).querySelectorAll(selector)
let parentNode = node
let i
do {
i = matches.length
while (--i >= 0 && matches.item(i) !== parentNode);
}
while ((i < 0) && (parentNode = parentNode.parentElement))
return parentNode
}
/**
* Export.
*
* @type {Function}
*/
export default findClosestNode

View File

@@ -0,0 +1,21 @@
/**
* Find the deepest descendant of a DOM `element`.
*
* @param {Element} node
* @return {Element}
*/
function findDeepestNode(element) {
return element.firstChild
? findDeepestNode(element.firstChild)
: element
}
/**
* Export.
*
* @type {Function}
*/
export default findDeepestNode

View File

@@ -0,0 +1,25 @@
/**
* Find the DOM node for a `node`.
*
* @param {Node} node
* @return {Element}
*/
function findDOMNode(node) {
const el = window.document.querySelector(`[data-key="${node.key}"]`)
if (!el) {
throw new Error(`Unable to find a DOM node for "${node.key}". This is often because of forgetting to add \`props.attributes\` to a component returned from \`renderNode\`.`)
}
return el
}
/**
* Export.
*
* @type {Function}
*/
export default findDOMNode

View File

@@ -0,0 +1,46 @@
import findDeepestNode from './find-deepest-node'
/**
* Get caret position from selection point.
*
* @param {String} key
* @param {Number} offset
* @param {State} state
* @param {Editor} editor
* @param {Element} el
* @return {Object}
*/
function getCaretPosition(key, offset, state, editor, el) {
const { document } = state
const text = document.getDescendant(key)
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(key, schema)
const ranges = text.getRanges(decorators)
let a = 0
let index
let off
ranges.forEach((range, i) => {
const { length } = range.text
a += length
if (a < offset) return
index = i
off = offset - (a - length)
return false
})
const span = el.querySelector(`[data-offset-key="${key}-${index}"]`)
const node = findDeepestNode(span)
return { node, offset: off }
}
/**
* Export.
*
* @type {Function}
*/
export default getCaretPosition

View File

@@ -0,0 +1,46 @@
import { findDOMNode } from 'react-dom'
/**
* Get clipboard HTML data by capturing the HTML inserted by the browser's
* native paste action. To make this work, `preventDefault()` may not be
* called on the `onPaste` event. As this method is asynchronous, a callback
* is needed to return the HTML content. This solution was adapted from
* http://stackoverflow.com/a/6804718.
*
* @param {Component} component
* @param {Function} callback
*/
function getHtmlFromNativePaste(component, callback) {
const el = findDOMNode(component)
// Create an off-screen clone of the element and give it focus.
const clone = el.cloneNode()
clone.setAttribute('class', '')
clone.setAttribute('style', 'position: fixed; left: -9999px')
el.parentNode.insertBefore(clone, el)
clone.focus()
// Tick forward so the native paste behaviour occurs in cloned element and we
// can get what was pasted from the DOM.
setTimeout(() => {
if (clone.childElementCount > 0) {
// If the node contains any child nodes, that is the HTML content.
const html = clone.innerHTML
clone.parentNode.removeChild(clone)
callback(html)
} else {
// Only plain text, no HTML.
callback()
}
}, 0)
}
/**
* Export.
*
* @type {Function}
*/
export default getHtmlFromNativePaste

View File

@@ -0,0 +1,41 @@
import OffsetKey from './offset-key'
/**
* Get a point from a native selection's DOM `element` and `offset`.
*
* @param {Element} element
* @param {Number} offset
* @param {State} state
* @param {Editor} editor
* @return {Object}
*/
function getPoint(element, offset, state, editor) {
const { document } = state
const schema = editor.getSchema()
// If we can't find an offset key, we can't get a point.
const offsetKey = OffsetKey.findKey(element, offset)
if (!offsetKey) return null
// COMPAT: If someone is clicking from one Slate editor into another, the
// select event fires two, once for the old editor's `element` first, and
// then afterwards for the correct `element`. (2017/03/03)
const { key } = offsetKey
const node = document.getDescendant(key)
if (!node) return null
const decorators = document.getDescendantDecorators(key, schema)
const ranges = node.getRanges(decorators)
const point = OffsetKey.findPoint(offsetKey, ranges)
return point
}
/**
* Export.
*
* @type {Function}
*/
export default getPoint

View File

@@ -0,0 +1,151 @@
import Base64 from 'slate-base64-serializer'
import TRANSFER_TYPES from '../constants/transfer-types'
/**
* Fragment matching regexp for HTML nodes.
*
* @type {RegExp}
*/
const FRAGMENT_MATCHER = / data-slate-fragment="([^\s"]+)"/
/**
* Get the data and type from a native data `transfer`.
*
* @param {DataTransfer} transfer
* @return {Object}
*/
function getTransferData(transfer) {
let fragment = getType(transfer, TRANSFER_TYPES.FRAGMENT)
let node = getType(transfer, TRANSFER_TYPES.NODE)
const html = getType(transfer, 'text/html')
const rich = getType(transfer, 'text/rtf')
let text = getType(transfer, 'text/plain')
let files
// If there isn't a fragment, but there is HTML, check to see if the HTML is
// actually an encoded fragment.
if (
!fragment &&
html &&
~html.indexOf(' data-slate-fragment="')
) {
const matches = FRAGMENT_MATCHER.exec(html)
const [ full, encoded ] = matches // eslint-disable-line no-unused-vars
if (encoded) fragment = encoded
}
// COMPAT: Edge doesn't handle custom data types
// These will be embedded in text/plain in this case (2017/7/12)
if (text) {
const embeddedTypes = getEmbeddedTypes(text)
if (embeddedTypes[TRANSFER_TYPES.FRAGMENT]) fragment = embeddedTypes[TRANSFER_TYPES.FRAGMENT]
if (embeddedTypes[TRANSFER_TYPES.NODE]) node = embeddedTypes[TRANSFER_TYPES.NODE]
if (embeddedTypes['text/plain']) text = embeddedTypes['text/plain']
}
// Decode a fragment or node if they exist.
if (fragment) fragment = Base64.deserializeNode(fragment)
if (node) node = Base64.deserializeNode(node)
// COMPAT: Edge sometimes throws 'NotSupportedError'
// when accessing `transfer.items` (2017/7/12)
try {
// Get and normalize files if they exist.
if (transfer.items && transfer.items.length) {
files = Array.from(transfer.items)
.map(item => item.kind == 'file' ? item.getAsFile() : null)
.filter(exists => exists)
} else if (transfer.files && transfer.files.length) {
files = Array.from(transfer.files)
}
} catch (err) {
if (transfer.files && transfer.files.length) {
files = Array.from(transfer.files)
}
}
// Determine the type of the data.
const data = { files, fragment, html, node, rich, text }
data.type = getTransferType(data)
return data
}
/**
* Takes text input, checks whether contains embedded data
* and returns object with original text +/- additional data
*
* @param {String} text
* @return {Object}
*/
function getEmbeddedTypes(text) {
const prefix = 'SLATE-DATA-EMBED::'
if (text.substring(0, prefix.length) !== prefix) {
return { 'text/plain': text }
}
// Attempt to parse, if fails then just standard text/plain
// Otherwise, already had data embedded
try {
return JSON.parse(text.substring(prefix.length))
} catch (err) {
throw new Error('Unable to parse custom embedded drag data')
}
}
/**
* Get the type of a transfer from its `data`.
*
* @param {Object} data
* @return {String}
*/
function getTransferType(data) {
if (data.fragment) return 'fragment'
if (data.node) return 'node'
// COMPAT: Microsoft Word adds an image of the selected text to the data.
// Since files are preferred over HTML or text, this would cause the type to
// be considered `files`. But it also adds rich text data so we can check
// for that and properly set the type to `html` or `text`. (2016/11/21)
if (data.rich && data.html) return 'html'
if (data.rich && data.text) return 'text'
if (data.files && data.files.length) return 'files'
if (data.html) return 'html'
if (data.text) return 'text'
return 'unknown'
}
/**
* Get one of types `TYPES.FRAGMENT`, `TYPES.NODE`, `text/html`, `text/rtf` or
* `text/plain` from transfers's `data` if possible, otherwise return null.
*
* @param {Object} transfer
* @param {String} type
* @return {String}
*/
function getType(transfer, type) {
if (!transfer.types || !transfer.types.length) {
// COMPAT: In IE 11, there is no `types` field but `getData('Text')`
// is supported`. (2017/06/23)
return type === 'text/plain' ? transfer.getData('Text') || null : null
}
return transfer.types.indexOf(type) !== -1 ? transfer.getData(type) || null : null
}
/**
* Export.
*
* @type {Function}
*/
export default getTransferData

View File

@@ -0,0 +1,16 @@
/**
* Noop.
*
* @return {Void}
*/
function noop() {}
/**
* Export.
*
* @type {Function}
*/
export default noop

View File

@@ -0,0 +1,89 @@
/**
* From a DOM selection's `node` and `offset`, normalize so that it always
* refers to a text node.
*
* @param {Element} node
* @param {Number} offset
* @return {Object}
*/
function normalizeNodeAndOffset(node, offset) {
// If it's an element node, its offset refers to the index of its children
// including comment nodes, so try to find the right text child node.
if (node.nodeType == 1 && node.childNodes.length) {
const isLast = offset == node.childNodes.length
const direction = isLast ? 'backward' : 'forward'
const index = isLast ? offset - 1 : offset
node = getEditableChild(node, index, direction)
// If the node has children, traverse until we have a leaf node. Leaf nodes
// can be either text nodes, or other void DOM nodes.
while (node.nodeType == 1 && node.childNodes.length) {
const i = isLast ? node.childNodes.length - 1 : 0
node = getEditableChild(node, i, direction)
}
// Determine the new offset inside the text node.
offset = isLast ? node.textContent.length : 0
}
// Return the node and offset.
return { node, offset }
}
/**
* Get the nearest editable child at `index` in a `parent`, preferring
* `direction`.
*
* @param {Element} parent
* @param {Number} index
* @param {String} direction ('forward' or 'backward')
* @return {Element|Null}
*/
function getEditableChild(parent, index, direction) {
const { childNodes } = parent
let child = childNodes[index]
let i = index
let triedForward = false
let triedBackward = false
// While the child is a comment node, or an element node with no children,
// keep iterating to find a sibling non-void, non-comment node.
while (
(child.nodeType == 8) ||
(child.nodeType == 1 && child.childNodes.length == 0) ||
(child.nodeType == 1 && child.getAttribute('contenteditable') == 'false')
) {
if (triedForward && triedBackward) break
if (i >= childNodes.length) {
triedForward = true
i = index - 1
direction = 'backward'
continue
}
if (i < 0) {
triedBackward = true
i = index + 1
direction = 'forward'
continue
}
child = childNodes[i]
if (direction == 'forward') i++
if (direction == 'backward') i--
}
return child || null
}
/**
* Export.
*
* @type {Function}
*/
export default normalizeNodeAndOffset

View File

@@ -0,0 +1,166 @@
import normalizeNodeAndOffset from './normalize-node-and-offset'
import findClosestNode from './find-closest-node'
/**
* Offset key parser regex.
*
* @type {RegExp}
*/
const PARSER = /^(\w+)(?:-(\d+))?$/
/**
* Offset key attribute name.
*
* @type {String}
*/
const ATTRIBUTE = 'data-offset-key'
/**
* Offset key attribute selector.
*
* @type {String}
*/
const SELECTOR = `[${ATTRIBUTE}]`
/**
* Void node selection.
*
* @type {String}
*/
const VOID_SELECTOR = '[data-slate-void]'
/**
* Find the start and end bounds from an `offsetKey` and `ranges`.
*
* @param {Number} index
* @param {List<Range>} ranges
* @return {Object}
*/
function findBounds(index, ranges) {
const range = ranges.get(index)
const start = ranges
.slice(0, index)
.reduce((memo, r) => {
return memo += r.text.length
}, 0)
return {
start,
end: start + range.text.length
}
}
/**
* From a DOM node, find the closest parent's offset key.
*
* @param {Element} rawNode
* @param {Number} rawOffset
* @return {Object}
*/
function findKey(rawNode, rawOffset) {
let { node, offset } = normalizeNodeAndOffset(rawNode, rawOffset)
const { parentNode } = node
// Find the closest parent with an offset key attribute.
let closest = findClosestNode(parentNode, SELECTOR)
// For void nodes, the element with the offset key will be a cousin, not an
// ancestor, so find it by going down from the nearest void parent.
if (!closest) {
const closestVoid = findClosestNode(parentNode, VOID_SELECTOR)
if (!closestVoid) return null
closest = closestVoid.querySelector(SELECTOR)
offset = closest.textContent.length
}
// Get the string value of the offset key attribute.
const offsetKey = closest.getAttribute(ATTRIBUTE)
// If we still didn't find an offset key, abort.
if (!offsetKey) return null
// Return the parsed the offset key.
const parsed = parse(offsetKey)
return {
key: parsed.key,
index: parsed.index,
offset
}
}
/**
* Find the selection point from an `offsetKey` and `ranges`.
*
* @param {Object} offsetKey
* @param {List<Range>} ranges
* @return {Object}
*/
function findPoint(offsetKey, ranges) {
let { key, index, offset } = offsetKey
const { start, end } = findBounds(index, ranges)
// Don't let the offset be outside of the start and end bounds.
offset = start + offset
offset = Math.max(offset, start)
offset = Math.min(offset, end)
return {
key,
index,
start,
end,
offset
}
}
/**
* Parse an offset key `string`.
*
* @param {String} string
* @return {Object}
*/
function parse(string) {
const matches = PARSER.exec(string)
if (!matches) throw new Error(`Invalid offset key string "${string}".`)
const [ original, key, index ] = matches // eslint-disable-line no-unused-vars
return {
key,
index: parseInt(index, 10)
}
}
/**
* Stringify an offset key `object`.
*
* @param {Object} object
* @property {String} key
* @property {Number} index
* @return {String}
*/
function stringify(object) {
return `${object.key}-${object.index}`
}
/**
* Export.
*
* @type {Object}
*/
export default {
findBounds,
findKey,
findPoint,
parse,
stringify
}

View File

@@ -0,0 +1,39 @@
import getWindow from 'get-window'
import isBackward from 'selection-is-backward'
/**
* Scroll the current selection's focus point into view if needed.
*
* @param {Selection} selection
*/
function scrollToSelection(selection) {
if (!selection.anchorNode) return
const window = getWindow(selection.anchorNode)
const backward = isBackward(selection)
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
const { innerWidth, innerHeight, pageYOffset, pageXOffset } = window
const top = (backward ? rect.top : rect.bottom) + pageYOffset
const left = (backward ? rect.left : rect.right) + pageXOffset
const x = left < pageXOffset || innerWidth + pageXOffset < left
? left - innerWidth / 2
: pageXOffset
const y = top < pageYOffset || innerHeight + pageYOffset < top
? top - innerHeight / 2
: pageYOffset
window.scrollTo(x, y)
}
/**
* Export.
*
* @type {Function}
*/
export default scrollToSelection

View File

@@ -0,0 +1,47 @@
/**
* Set data with `type` and `content` on a `dataTransfer` object.
*
* COMPAT: In Edge, custom types throw errors, so embed all non-standard
* types in text/plain compound object. (2017/7/12)
*
* @param {DataTransfer} dataTransfer
* @param {String} type
* @param {String} content
*/
function setTransferData(dataTransfer, type, content) {
try {
dataTransfer.setData(type, content)
} catch (err) {
const prefix = 'SLATE-DATA-EMBED::'
const text = dataTransfer.getData('text/plain')
let obj = {}
// If the existing plain text data is prefixed, it's Slate JSON data.
if (text.substring(0, prefix.length) === prefix) {
try {
obj = JSON.parse(text.substring(prefix.length))
} catch (e) {
throw new Error('Failed to parse Slate data from `DataTransfer` object.')
}
}
// Otherwise, it's just set it as is.
else {
obj['text/plain'] = text
}
obj[type] = content
const string = `${prefix}${JSON.stringify(obj)}`
dataTransfer.setData('text/plain', string)
}
}
/**
* Export.
*
* @type {Function}
*/
export default setTransferData

View File

@@ -0,0 +1,54 @@
import parse5 from 'parse5' // eslint-disable-line import/no-extraneous-dependencies
const UNWANTED_ATTRS = [
'data-key',
'data-offset-key'
]
const UNWANTED_TOP_LEVEL_ATTRS = [
'autocorrect',
'spellcheck',
'style',
'data-gramm'
]
/**
* Clean an `element` of unwanted attributes.
*
* @param {Element} element
* @return {Element}
*/
function stripUnwantedAttrs(element) {
if (Array.isArray(element.attrs)) {
element.attrs = element.attrs.filter(({ name }) => { return !UNWANTED_ATTRS.includes(name) })
if (element.parentNode.nodeName === '#document-fragment') {
element.attrs = element.attrs.filter(({ name }) => { return !UNWANTED_TOP_LEVEL_ATTRS.includes(name) })
}
}
if (Array.isArray(element.childNodes)) {
element.childNodes.forEach(stripUnwantedAttrs)
}
if (element.nodeName === '#text') {
element.value = element.value.trim()
}
return element
}
/**
* Clean a renderer `html` string, removing dynamic attributes.
*
* @param {String} html
* @return {String}
*/
export default function clean(html) {
const $ = parse5.parseFragment(html)
$.childNodes.forEach(stripUnwantedAttrs)
return parse5.serialize($)
}

View File

@@ -0,0 +1,43 @@
import { createHyperscript } from 'slate-hyperscript'
/**
* Define a hyperscript.
*
* @type {Function}
*/
const h = createHyperscript({
blocks: {
line: 'line',
paragraph: 'paragraph',
quote: 'quote',
code: 'code',
image: {
type: 'image',
isVoid: true,
}
},
inlines: {
link: 'link',
hashtag: 'hashtag',
comment: 'comment',
emoji: {
type: 'emoji',
isVoid: true,
}
},
marks: {
b: 'bold',
i: 'italic',
u: 'underline',
},
})
/**
* Export.
*
* @type {Function}
*/
export default h

View File

@@ -0,0 +1,29 @@
/**
* Polyfills.
*/
import 'babel-polyfill' // eslint-disable-line import/no-extraneous-dependencies
/**
* Dependencies.
*/
import { resetKeyGenerator } from 'slate'
/**
* Tests.
*/
describe('slate-react', () => {
require('./plugins')
require('./rendering')
})
/**
* Reset Slate's internal state before each text.
*/
beforeEach(() => {
resetKeyGenerator()
})

View File

@@ -0,0 +1,25 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function (simulator) {
simulator.blur()
}
export const input = (
<state>
<document>
<paragraph />
</document>
<selection isFocused />
</state>
)
export const output = (
<state>
<document>
<paragraph />
</document>
<selection isFocused={false} />
</state>
)

View File

@@ -0,0 +1,28 @@
/** @jsx h */
import h from '../../../helpers/h'
export default function (simulator) {
simulator.keyDown(null, { key: 'enter' })
}
export const input = (
<state>
<document>
<paragraph>
<cursor />
</paragraph>
</document>
</state>
)
export const output = (
<state>
<document>
<paragraph />
<paragraph>
<cursor />
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,31 @@
/** @jsx h */
import h from '../../../helpers/h'
import { Selection } from 'slate'
export default function (simulator) {
const { state } = simulator
const text = state.document.getTexts().first()
const selection = Selection.create().collapseToStartOf(text).move(1).focus()
simulator.select(null, { selection })
}
export const input = (
<state>
<document>
<paragraph>
<cursor />word
</paragraph>
</document>
</state>
)
export const output = (
<state>
<document>
<paragraph>
w<cursor />ord
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,40 @@
import CorePlugin from '../../src/plugins/core'
import Simulator from 'slate-simulator'
import assert from 'assert'
import fs from 'fs'
import toCamel from 'to-camel-case' // eslint-disable-line import/no-extraneous-dependencies
import { basename, extname, resolve } from 'path'
/**
* Tests.
*/
describe('plugins', () => {
describe('core', () => {
const dir = resolve(__dirname, 'core')
const events = fs.readdirSync(dir).filter(e => e[0] != '.' && e != 'index.js')
for (const event of events) {
describe(`${toCamel(event)}`, () => {
const testDir = resolve(dir, event)
const tests = fs.readdirSync(testDir).filter(t => t[0] != '.' && !!~t.indexOf('.js')).map(t => basename(t, extname(t)))
for (const test of tests) {
it(test, async () => {
const module = require(resolve(testDir, test))
const { input, output, props = {}} = module
const fn = module.default
const plugins = [CorePlugin(props)]
const simulator = new Simulator({ plugins, state: input })
fn(simulator)
const actual = simulator.state.toJSON({ preserveSelection: true })
const expected = output.toJSON({ preserveSelection: true })
assert.deepEqual(actual, expected)
})
}
})
}
})
})

View File

@@ -0,0 +1,58 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
nodes: {
code: (props) => {
return (
React.createElement('pre', props.attributes,
React.createElement('code', {}, props.children)
)
)
}
}
}
export const state = (
<state>
<document>
<code>
word
</code>
<code>
word
</code>
<code>
word
</code>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<pre>
<code>
<span>
<span>word</span>
</span>
</code>
</pre>
<pre>
<code>
<span>
<span>word</span>
</span>
</code>
</pre>
<pre>
<code>
<span>
<span>word</span>
</span>
</code>
</pre>
</div>
`.trim()

View File

@@ -0,0 +1,37 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
nodes: {
image: (props) => {
return (
React.createElement('img', { src: props.node.data.get('src'), ...props.attributes })
)
}
}
}
export const state = (
<state>
<document>
<image src="https://example.com/image.png" />
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div data-slate-void="true" style="position:relative;">
<span style="position:absolute;top:0px;left:-9999px;text-indent:-9999px;">
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<div contenteditable="false">
<img src="https://example.com/image.png">
</div>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,38 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
nodes: {
code: (props) => {
return (
React.createElement('pre', props.attributes,
React.createElement('code', {}, props.children)
)
)
}
}
}
export const state = (
<state>
<document>
<code>
word
</code>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<pre>
<code>
<span>
<span>word</span>
</span>
</code>
</pre>
</div>
`.trim()

View File

@@ -0,0 +1,47 @@
/** @jsx h */
import h from '../../helpers/h'
import { Mark } from 'slate'
export const schema = {
nodes: {
paragraph: {
decorate(text, block) {
let { characters } = text
let second = characters.get(1)
const mark = Mark.create({ type: 'bold' })
const marks = second.marks.add(mark)
second = second.merge({ marks })
characters = characters.set(1, second)
return characters
}
}
},
marks: {
bold: {
fontWeight: 'bold',
}
}
}
export const state = (
<state>
<document>
<paragraph>
one
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>o</span>
<span><span style="font-weight:bold;">n</span></span>
<span>e</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,74 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
nodes: {
link: (props) => {
return (
React.createElement('a', { href: props.node.data.get('href'), ...props.attributes }, props.children)
)
}
}
}
export const state = (
<state>
<document>
<paragraph>
<link href="https://google.com">
word
</link>
<link href="https://google.com">
word
</link>
<link href="https://google.com">
word
</link>
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<a href="https://google.com">
<span>
<span>word</span>
</span>
</a>
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<a href="https://google.com">
<span>
<span>word</span>
</span>
</a>
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<a href="https://google.com">
<span>
<span>word</span>
</span>
</a>
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,51 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
nodes: {
emoji: (props) => {
return (
React.createElement('img', props.attributes)
)
}
}
}
export const state = (
<state>
<document>
<paragraph>
<emoji />
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<span data-slate-void="true">
<span style="color:transparent;">
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<span contenteditable="false">
<img>
</span>
</span>
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,48 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
nodes: {
link: (props) => {
return (
React.createElement('a', { href: props.node.data.get('href'), ...props.attributes }, props.children)
)
}
}
}
export const state = (
<state>
<document>
<paragraph>
<link href="https://google.com">
word
</link>
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<a href="https://google.com">
<span>
<span>word</span>
</span>
</a>
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,42 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
class Bold extends React.Component {
render() {
return (
React.createElement('strong', {}, this.props.children)
)
}
}
export const schema = {
marks: {
bold: Bold,
}
}
export const state = (
<state>
<document>
<paragraph>
one<b>two</b>three
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>one</span>
<span><strong>two</strong></span>
<span>three</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,36 @@
/** @jsx h */
import React from 'react'
import h from '../../helpers/h'
export const schema = {
marks: {
bold: (props) => {
return (
React.createElement('strong', {}, props.children)
)
}
}
}
export const state = (
<state>
<document>
<paragraph>
one<b>two</b>three
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>one</span>
<span><strong>two</strong></span>
<span>three</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {
marks: {
bold: {
fontWeight: 'bold'
}
}
}
export const state = (
<state>
<document>
<paragraph>
one<b>two</b>three
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>one</span>
<span><span style="font-weight:bold;">two</span></span>
<span>three</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,31 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {
marks: {
bold: 'bold',
}
}
export const state = (
<state>
<document>
<paragraph>
one<b>two</b>three
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>one</span>
<span><span class="bold">two</span></span>
<span>three</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,39 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {}
export const state = (
<state>
<document>
<paragraph>
<link>
word
</link>
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
<span style="position:relative;">
<span>
<span>word</span>
</span>
</span>
<span>
<span>
<span data-slate-zero-width="true">&#x200A;</span>
</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,25 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {}
export const state = (
<state>
<document>
<paragraph>
word
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>word</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,25 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {}
export const state = (
<state>
<document>
<paragraph>
<link />
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span><br></span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,23 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {}
export const state = (
<state>
<document>
<paragraph />
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span><br></span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,36 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {}
export const state = (
<state>
<document>
<quote>
<paragraph>
مرحبا بالعالم
</paragraph>
<paragraph>
שלום עולם
</paragraph>
</quote>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<div dir="rtl" style="position:relative;">
<span>
<span>مرحبا بالعالم</span>
</span>
</div>
<div dir="rtl" style="position:relative;">
<span>
<span>שלום עולם</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,41 @@
/** @jsx h */
import h from '../../helpers/h'
export const schema = {}
export const state = (
<state>
<document>
<paragraph>
Hello, world!
</paragraph>
<paragraph>
مرحبا بالعالم
</paragraph>
<paragraph>
שלום עולם
</paragraph>
</document>
</state>
)
export const output = `
<div data-slate-editor="true" contenteditable="true" role="textbox">
<div style="position:relative;">
<span>
<span>Hello, world!</span>
</span>
</div>
<div dir="rtl" style="position:relative;">
<span>
<span>مرحبا بالعالم</span>
</span>
</div>
<div dir="rtl" style="position:relative;">
<span>
<span>שלום עולם</span>
</span>
</div>
</div>
`.trim()

View File

@@ -0,0 +1,38 @@
import React from 'react'
import ReactDOM from 'react-dom/server'
import assert from 'assert'
import clean from '../helpers/clean'
import fs from 'fs-promise' // eslint-disable-line import/no-extraneous-dependencies
import parse5 from 'parse5' // eslint-disable-line import/no-extraneous-dependencies
import { Editor } from '../..'
import { basename, extname, resolve } from 'path'
/**
* Tests.
*/
describe('rendering', () => {
const dir = resolve(__dirname, './fixtures')
const tests = fs.readdirSync(dir).filter(t => t[0] != '.' && !!~t.indexOf('.js')).map(t => basename(t, extname(t)))
for (const test of tests) {
it(test, async () => {
const module = require(resolve(dir, test))
const { state, schema, output } = module
const props = {
state,
schema,
onChange() {},
}
const string = ReactDOM.renderToStaticMarkup(<Editor {...props} />)
const expected = parse5.serialize(parse5.parseFragment(output))
.trim()
.replace(/\n/gm, '')
.replace(/>\s*</g, '><')
assert.equal(clean(string), expected)
})
}
})