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:
7
packages/slate-react/.npmignore
Normal file
7
packages/slate-react/.npmignore
Normal file
@@ -0,0 +1,7 @@
|
||||
benchmark
|
||||
docs
|
||||
examples
|
||||
src
|
||||
test
|
||||
tmp
|
||||
.babelrc
|
11
packages/slate-react/Readme.md
Normal file
11
packages/slate-react/Readme.md
Normal 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!
|
34
packages/slate-react/benchmark/index.js
Normal file
34
packages/slate-react/benchmark/index.js
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
28
packages/slate-react/benchmark/rendering/normal.js
Normal file
28
packages/slate-react/benchmark/rendering/normal.js
Normal 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>
|
||||
)
|
72
packages/slate-react/package.json
Normal file
72
packages/slate-react/package.json
Normal 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"
|
||||
]
|
||||
}
|
49
packages/slate-react/src/components/Readme.md
Normal file
49
packages/slate-react/src/components/Readme.md
Normal 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.
|
909
packages/slate-react/src/components/content.js
Normal file
909
packages/slate-react/src/components/content.js
Normal 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
|
316
packages/slate-react/src/components/editor.js
Normal file
316
packages/slate-react/src/components/editor.js
Normal 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
|
177
packages/slate-react/src/components/leaf.js
Normal file
177
packages/slate-react/src/components/leaf.js
Normal 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
|
384
packages/slate-react/src/components/node.js
Normal file
384
packages/slate-react/src/components/node.js
Normal 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
|
125
packages/slate-react/src/components/placeholder.js
Normal file
125
packages/slate-react/src/components/placeholder.js
Normal 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
|
255
packages/slate-react/src/components/void.js
Normal file
255
packages/slate-react/src/components/void.js
Normal 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
|
80
packages/slate-react/src/constants/environment.js
Normal file
80
packages/slate-react/src/constants/environment.js
Normal 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'
|
18
packages/slate-react/src/constants/transfer-types.js
Normal file
18
packages/slate-react/src/constants/transfer-types.js
Normal 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
|
31
packages/slate-react/src/index.js
Normal file
31
packages/slate-react/src/index.js
Normal 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,
|
||||
}
|
2
packages/slate-react/src/plugins/Readme.md
Normal file
2
packages/slate-react/src/plugins/Readme.md
Normal 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.
|
900
packages/slate-react/src/plugins/core.js
Normal file
900
packages/slate-react/src/plugins/core.js
Normal 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
|
53
packages/slate-react/src/utils/extend-selection.js
Normal file
53
packages/slate-react/src/utils/extend-selection.js
Normal 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
|
37
packages/slate-react/src/utils/find-closest-node.js
Normal file
37
packages/slate-react/src/utils/find-closest-node.js
Normal 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
|
21
packages/slate-react/src/utils/find-deepest-node.js
Normal file
21
packages/slate-react/src/utils/find-deepest-node.js
Normal 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
|
25
packages/slate-react/src/utils/find-dom-node.js
Normal file
25
packages/slate-react/src/utils/find-dom-node.js
Normal 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
|
46
packages/slate-react/src/utils/get-caret-position.js
Normal file
46
packages/slate-react/src/utils/get-caret-position.js
Normal 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
|
46
packages/slate-react/src/utils/get-html-from-native-paste.js
Normal file
46
packages/slate-react/src/utils/get-html-from-native-paste.js
Normal 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
|
41
packages/slate-react/src/utils/get-point.js
Normal file
41
packages/slate-react/src/utils/get-point.js
Normal 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
|
151
packages/slate-react/src/utils/get-transfer-data.js
Normal file
151
packages/slate-react/src/utils/get-transfer-data.js
Normal 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
|
16
packages/slate-react/src/utils/noop.js
Normal file
16
packages/slate-react/src/utils/noop.js
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
/**
|
||||
* Noop.
|
||||
*
|
||||
* @return {Void}
|
||||
*/
|
||||
|
||||
function noop() {}
|
||||
|
||||
/**
|
||||
* Export.
|
||||
*
|
||||
* @type {Function}
|
||||
*/
|
||||
|
||||
export default noop
|
89
packages/slate-react/src/utils/normalize-node-and-offset.js
Normal file
89
packages/slate-react/src/utils/normalize-node-and-offset.js
Normal 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
|
166
packages/slate-react/src/utils/offset-key.js
Normal file
166
packages/slate-react/src/utils/offset-key.js
Normal 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
|
||||
}
|
39
packages/slate-react/src/utils/scroll-to-selection.js
Normal file
39
packages/slate-react/src/utils/scroll-to-selection.js
Normal 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
|
47
packages/slate-react/src/utils/set-transfer-data.js
Normal file
47
packages/slate-react/src/utils/set-transfer-data.js
Normal 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
|
54
packages/slate-react/test/helpers/clean.js
Normal file
54
packages/slate-react/test/helpers/clean.js
Normal 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($)
|
||||
}
|
43
packages/slate-react/test/helpers/h.js
Normal file
43
packages/slate-react/test/helpers/h.js
Normal 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
|
29
packages/slate-react/test/index.js
Normal file
29
packages/slate-react/test/index.js
Normal 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()
|
||||
})
|
@@ -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>
|
||||
)
|
@@ -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>
|
||||
)
|
@@ -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>
|
||||
)
|
40
packages/slate-react/test/plugins/index.js
Normal file
40
packages/slate-react/test/plugins/index.js
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
@@ -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()
|
@@ -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"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<div contenteditable="false">
|
||||
<img src="https://example.com/image.png">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`.trim()
|
38
packages/slate-react/test/rendering/fixtures/custom-block.js
Normal file
38
packages/slate-react/test/rendering/fixtures/custom-block.js
Normal 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()
|
@@ -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()
|
@@ -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"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<a href="https://google.com">
|
||||
<span>
|
||||
<span>word</span>
|
||||
</span>
|
||||
</a>
|
||||
<span>
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<a href="https://google.com">
|
||||
<span>
|
||||
<span>word</span>
|
||||
</span>
|
||||
</a>
|
||||
<span>
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<a href="https://google.com">
|
||||
<span>
|
||||
<span>word</span>
|
||||
</span>
|
||||
</a>
|
||||
<span>
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`.trim()
|
@@ -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"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slate-void="true">
|
||||
<span style="color:transparent;">
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<span contenteditable="false">
|
||||
<img>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`.trim()
|
@@ -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"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<a href="https://google.com">
|
||||
<span>
|
||||
<span>word</span>
|
||||
</span>
|
||||
</a>
|
||||
<span>
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`.trim()
|
@@ -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()
|
@@ -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()
|
@@ -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()
|
@@ -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()
|
@@ -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"> </span>
|
||||
</span>
|
||||
</span>
|
||||
<span style="position:relative;">
|
||||
<span>
|
||||
<span>word</span>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span>
|
||||
<span data-slate-zero-width="true"> </span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`.trim()
|
@@ -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()
|
@@ -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()
|
23
packages/slate-react/test/rendering/fixtures/empty-block.js
Normal file
23
packages/slate-react/test/rendering/fixtures/empty-block.js
Normal 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()
|
@@ -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()
|
@@ -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()
|
38
packages/slate-react/test/rendering/index.js
Normal file
38
packages/slate-react/test/rendering/index.js
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
Reference in New Issue
Block a user