1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-09-08 22:20:41 +02:00

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

* init lerna, move files into ./packages

* move test files into ./packages

* more moving around

* fill out package.json files

* fixing imports

* more fixing of imports, and horribleness

* convert examples, fix linting errors

* add documentation

* update docs

* get tests passing

* update travis.yml

* update travis.yml

* update travis.yml

* update test script

* update travis.yml

* update scripts

* try simplifying travis.yml

* ocd stuff

* remove slate-core-test-helpers package

* add package readmes

* update reference docs structure

* refactor slate-simulator into its own package

* add docs for new packages

* update docs

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

19
packages/Readme.md Normal file
View File

@@ -0,0 +1,19 @@
# Packages
Slate's codebase is monorepo managed with [Lerna](https://lernajs.io/). It consists of a handful of packages—although you won't always use all of them. They are:
- [`slate`](./slate) — which includes Slate's core logic.
- [`slate-react`](./slate) — the React components for rendering Slate editors.
- [`slate-hyperscript`](./slate-hyperscript) — a hyperscript helper to write Slate documents in JSX!
And some others...
- [`slate-base64-serializer`](./slate-base64-serializer) — a Base64 string serializer for Slate documents.
- [`slate-html-serializer`](./slate-html-serializer) — an HTML serializer for Slate documents.
- [`slate-plain-serializer`](./slate-plain-serializer) — a plain text serializer for Slate documents.
- [`slate-prop-types`](./slate-prop-types) — a set of React prop types for checking Slate values.
And some internal ones...
- [`slate-logger`](./slate-logger) — a simpler internal logger for other Slate packages to use.

View File

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

View File

@@ -0,0 +1,4 @@
# `slate-base64-serializer`
This package contains a base 64 serializer for Slate documents.

View File

@@ -0,0 +1,37 @@
{
"name": "slate-base64-serializer",
"description": "A Base64 serializer for Slate editors.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"peerDependencies": {
"slate": "^0.23.0"
},
"devDependencies": {
"babel-cli": "^6.10.1",
"browserify": "^13.0.1",
"mocha": "^2.5.3",
"slate": "^0.23.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 SlateBase64Serializer > ./dist/slate-base64-serializer.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 SlateBase64Serializer | uglifyjs > ./dist/slate-base64-serializer.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": {
"slate": "Slate"
},
"keywords": [
"deserialize",
"base64",
"editor",
"serialize",
"serializer",
"slate"
]
}

View File

@@ -0,0 +1,94 @@
import { State } from 'slate'
/**
* Encode a JSON `object` as base-64 `string`.
*
* @param {Object} object
* @return {String}
*/
function encode(object) {
const string = JSON.stringify(object)
const encoded = window.btoa(window.encodeURIComponent(string))
return encoded
}
/**
* Decode a base-64 `string` to a JSON `object`.
*
* @param {String} string
* @return {Object}
*/
function decode(string) {
const decoded = window.decodeURIComponent(window.atob(string))
const object = JSON.parse(decoded)
return object
}
/**
* Deserialize a State `string`.
*
* @param {String} string
* @return {State}
*/
function deserialize(string, options) {
const raw = decode(string)
const state = State.fromJSON(raw, options)
return state
}
/**
* Deserialize a Node `string`.
*
* @param {String} string
* @return {Node}
*/
function deserializeNode(string, options) {
const { Node } = require('slate')
const raw = decode(string)
const node = Node.fromJSON(raw, options)
return node
}
/**
* Serialize a `state`.
*
* @param {State} state
* @return {String}
*/
function serialize(state, options) {
const raw = state.toJSON(options)
const encoded = encode(raw)
return encoded
}
/**
* Serialize a `node`.
*
* @param {Node} node
* @return {String}
*/
function serializeNode(node, options) {
const raw = node.toJSON(options)
const encoded = encode(raw)
return encoded
}
/**
* Export.
*
* @type {Object}
*/
export default {
deserialize,
deserializeNode,
serialize,
serializeNode
}

View File

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

View File

@@ -0,0 +1,4 @@
# `slate-html-serializer`
This package contains an HTML serializer for Slate documents, that you can configure depending on your custom schema.

View File

@@ -0,0 +1,50 @@
{
"name": "slate-html-serializer",
"description": "An HTML serializer for Slate editors.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"dependencies": {
"slate-logger": "^0.0.0",
"type-of": "^2.0.1"
},
"peerDependencies": {
"immutable": "^3.8.1",
"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",
"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 SlateHtmlSerializer > ./dist/slate-html-serializer.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 SlateHtmlSerializer | uglifyjs > ./dist/slate-html-serializer.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": {
"immutable": "Immutable",
"react": "React",
"react-dom": "ReactDOM",
"react-dom/server": "ReactDOMServer",
"slate": "Slate"
},
"keywords": [
"deserialize",
"editor",
"html",
"serialize",
"serializer",
"slate",
"xml"
]
}

View File

@@ -0,0 +1,432 @@
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import logger from 'slate-logger'
import typeOf from 'type-of'
import { Node, State } from 'slate'
import { Record } from 'immutable'
/**
* String.
*
* @type {String}
*/
const String = new Record({
kind: 'string',
text: ''
})
/**
* A rule to (de)serialize text nodes. This is automatically added to the HTML
* serializer so that users don't have to worry about text-level serialization.
*
* @type {Object}
*/
const TEXT_RULE = {
deserialize(el) {
if (el.tagName == 'br') {
return {
kind: 'text',
ranges: [{ text: '\n' }],
}
}
if (el.nodeName == '#text') {
if (el.value && el.value.match(/<!--.*?-->/)) return
return {
kind: 'text',
ranges: [{ text: el.value || el.nodeValue }],
}
}
},
serialize(obj, children) {
if (obj.kind == 'string') {
return children
.split('\n')
.reduce((array, text, i) => {
if (i != 0) array.push(<br />)
array.push(text)
return array
}, [])
}
}
}
/**
* A default `parseHtml` option using the native `DOMParser`.
*
* @param {String} html
* @return {Object}
*/
function defaultParseHtml(html) {
if (typeof DOMParser == 'undefined') {
throw new Error('The native `DOMParser` global which the `Html` serializer uses by default is not present in this environment. You must supply the `options.parseHtml` function instead.')
}
const parsed = new DOMParser().parseFromString(html, 'text/html')
// Unwrap from <html> and <body>.
const fragment = parsed.childNodes[0].childNodes[1]
return fragment
}
/**
* HTML serializer.
*
* @type {Html}
*/
class Html {
/**
* Create a new serializer with `rules`.
*
* @param {Object} options
* @property {Array} rules
* @property {String|Object|Block} defaultBlock
* @property {Function} parseHtml
*/
constructor(options = {}) {
let {
defaultBlock = 'paragraph',
parseHtml = defaultParseHtml,
rules = [],
} = options
if (options.defaultBlockType) {
logger.deprecate('0.23.0', 'The `options.defaultBlockType` argument of the `Html` serializer is deprecated, use `options.defaultBlock` instead.')
defaultBlock = options.defaultBlockType
}
defaultBlock = Node.createProperties(defaultBlock)
this.rules = [ ...rules, TEXT_RULE ]
this.defaultBlock = defaultBlock
this.parseHtml = parseHtml
}
/**
* Deserialize pasted HTML.
*
* @param {String} html
* @param {Object} options
* @property {Boolean} toRaw
* @return {State}
*/
deserialize = (html, options = {}) => {
let { toJSON = false } = options
if (options.toRaw) {
logger.deprecate('0.23.0', 'The `options.toRaw` argument of the `Html` serializer is deprecated, use `options.toJSON` instead.')
toJSON = options.toRaw
}
const { defaultBlock, parseHtml } = this
const fragment = parseHtml(html)
const children = Array.from(fragment.childNodes)
let nodes = this.deserializeElements(children)
// COMPAT: ensure that all top-level inline nodes are wrapped into a block.
nodes = nodes.reduce((memo, node, i, original) => {
if (node.kind == 'block') {
memo.push(node)
return memo
}
if (i > 0 && original[i - 1].kind != 'block') {
const block = memo[memo.length - 1]
block.nodes.push(node)
return memo
}
const block = {
kind: 'block',
data: {},
isVoid: false,
...defaultBlock,
nodes: [node],
}
memo.push(block)
return memo
}, [])
// TODO: pretty sure this is no longer needed.
if (nodes.length == 0) {
nodes = [{
kind: 'block',
data: {},
isVoid: false,
...defaultBlock,
nodes: [
{
kind: 'text',
ranges: [
{
kind: 'range',
text: '',
marks: [],
}
]
}
],
}]
}
const json = {
kind: 'state',
document: {
kind: 'document',
data: {},
nodes,
}
}
const ret = toJSON ? json : State.fromJSON(json)
return ret
}
/**
* Deserialize an array of DOM elements.
*
* @param {Array} elements
* @return {Array}
*/
deserializeElements = (elements = []) => {
let nodes = []
elements.filter(this.cruftNewline).forEach((element) => {
const node = this.deserializeElement(element)
switch (typeOf(node)) {
case 'array':
nodes = nodes.concat(node)
break
case 'object':
nodes.push(node)
break
}
})
return nodes
}
/**
* Deserialize a DOM element.
*
* @param {Object} element
* @return {Any}
*/
deserializeElement = (element) => {
let node
if (!element.tagName) {
element.tagName = ''
}
const next = (elements) => {
if (typeof NodeList !== 'undefined' && elements instanceof NodeList) {
elements = Array.from(elements)
}
switch (typeOf(elements)) {
case 'array':
return this.deserializeElements(elements)
case 'object':
return this.deserializeElement(elements)
case 'null':
case 'undefined':
return
default:
throw new Error(`The \`next\` argument was called with invalid children: "${elements}".`)
}
}
for (let i = 0; i < this.rules.length; i++) {
const rule = this.rules[i]
if (!rule.deserialize) continue
const ret = rule.deserialize(element, next)
const type = typeOf(ret)
if (type != 'array' && type != 'object' && type != 'null' && type != 'undefined') {
throw new Error(`A rule returned an invalid deserialized representation: "${node}".`)
}
if (ret === undefined) {
continue
} else if (ret === null) {
return null
} else if (ret.kind == 'mark') {
node = this.deserializeMark(ret)
} else {
node = ret
}
break
}
return node || next(element.childNodes)
}
/**
* Deserialize a `mark` object.
*
* @param {Object} mark
* @return {Array}
*/
deserializeMark = (mark) => {
const { type, data } = mark
const applyMark = (node) => {
if (node.kind == 'mark') {
return this.deserializeMark(node)
}
else if (node.kind == 'text') {
node.ranges = node.ranges.map((range) => {
range.marks = range.marks || []
range.marks.push({ type, data })
return range
})
}
else {
node.nodes = node.nodes.map(applyMark)
}
return node
}
return mark.nodes.reduce((nodes, node) => {
const ret = applyMark(node)
if (Array.isArray(ret)) return nodes.concat(ret)
nodes.push(ret)
return nodes
}, [])
}
/**
* Serialize a `state` object into an HTML string.
*
* @param {State} state
* @param {Object} options
* @property {Boolean} render
* @return {String|Array}
*/
serialize = (state, options = {}) => {
const { document } = state
const elements = document.nodes.map(this.serializeNode)
if (options.render === false) return elements
const html = ReactDOMServer.renderToStaticMarkup(<body>{elements}</body>)
const inner = html.slice(6, -7)
return inner
}
/**
* Serialize a `node`.
*
* @param {Node} node
* @return {String}
*/
serializeNode = (node) => {
if (node.kind == 'text') {
const ranges = node.getRanges()
return ranges.map(this.serializeRange)
}
const children = node.nodes.map(this.serializeNode)
for (let i = 0; i < this.rules.length; i++) {
const rule = this.rules[i]
if (!rule.serialize) continue
const ret = rule.serialize(node, children)
if (ret) return addKey(ret)
}
throw new Error(`No serializer defined for node of type "${node.type}".`)
}
/**
* Serialize a `range`.
*
* @param {Range} range
* @return {String}
*/
serializeRange = (range) => {
const string = new String({ text: range.text })
const text = this.serializeString(string)
return range.marks.reduce((children, mark) => {
for (let i = 0; i < this.rules.length; i++) {
const rule = this.rules[i]
if (!rule.serialize) continue
const ret = rule.serialize(mark, children)
if (ret) return addKey(ret)
}
throw new Error(`No serializer defined for mark of type "${mark.type}".`)
}, text)
}
/**
* Serialize a `string`.
*
* @param {String} string
* @return {String}
*/
serializeString = (string) => {
for (let i = 0; i < this.rules.length; i++) {
const rule = this.rules[i]
if (!rule.serialize) continue
const ret = rule.serialize(string, string.text)
if (ret) return ret
}
}
/**
* Filter out cruft newline nodes inserted by the DOM parser.
*
* @param {Object} element
* @return {Boolean}
*/
cruftNewline = (element) => {
return !(element.nodeName == '#text' && element.value == '\n')
}
}
/**
* Add a unique key to a React `element`.
*
* @param {Element} element
* @return {Element}
*/
let key = 0
function addKey(element) {
return React.cloneElement(element, { key: key++ })
}
/**
* Export.
*
* @type {Html}
*/
export default Html

View File

@@ -0,0 +1,45 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'blockquote': {
return {
kind: 'block',
type: 'quote',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<blockquote><p>one</p></blockquote>
`.trim()
export const output = (
<state>
<document>
<quote>
<paragraph>
one
</paragraph>
</quote>
</document>
</state>
)

View File

@@ -0,0 +1,34 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p></p>
`.trim()
export const output = (
<state>
<document>
<paragraph />
</document>
</state>
)

View File

@@ -0,0 +1,37 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
data: { thing: 'value' },
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>one</p>
`.trim()
export const output = (
<state>
<document>
<paragraph thing="value">
one
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,34 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'img': {
return {
kind: 'block',
type: 'image',
isVoid: true,
}
}
}
}
}
]
}
export const input = `
<img/>
`.trim()
export const output = (
<state>
<document>
<image />
</document>
</state>
)

View File

@@ -0,0 +1,36 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>one</p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
one
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,46 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
}
}
}
],
defaultBlock: {
type: 'default',
data: {
thing: 'value'
}
}
}
export const input = `
<p>one</p>
<div>two</div>
`.trim()
export const output = (
<state>
<document>
<paragraph>
one
</paragraph>
<block type="default" data={{ thing: 'value' }}>
two
</block>
</document>
</state>
)

View File

@@ -0,0 +1,16 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {}
export const input = ''
export const output = (
<state>
<document>
<paragraph />
</document>
</state>
)

View File

@@ -0,0 +1,37 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<!-- This comment should be ignored -->
<p>one</p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
one
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,54 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'a': {
return {
kind: 'inline',
type: 'link',
nodes: next(el.childNodes),
}
}
case 'span': {
return {
kind: 'inline',
type: 'hashtag',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p><a><span>one</span></a></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
<link>
<hashtag>
one
</hashtag>
</link>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,43 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'a': {
return {
kind: 'inline',
type: 'link',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p><a></a></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
<link />
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,46 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'a': {
return {
kind: 'inline',
type: 'link',
data: { thing: 'value' },
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p><a>one</a></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
<link thing="value">
one
</link>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,44 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'img': {
return {
kind: 'inline',
type: 'emoji',
isVoid: true,
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p><img/></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
<emoji />
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,45 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'a': {
return {
kind: 'inline',
type: 'link',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p><a>one</a></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
<link>
one
</link>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,50 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'strong': {
return {
kind: 'mark',
type: 'bold',
nodes: next(el.childNodes),
}
}
case 'em': {
return {
kind: 'mark',
type: 'italic',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>o<strong>n</strong><strong>e</strong></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
o<b>ne</b>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,50 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'strong': {
return {
kind: 'mark',
type: 'bold',
nodes: next(el.childNodes),
}
}
case 'em': {
return {
kind: 'mark',
type: 'italic',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>o<em>n<strong>e</strong></em></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
o<i>n<b>e</b></i>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,44 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'strong': {
return {
kind: 'mark',
type: 'bold',
data: { thing: 'value' },
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>on<strong>e</strong></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
on<b thing="value">e</b>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,43 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'strong': {
return {
kind: 'mark',
type: 'bold',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>on<strong>e</strong></p>
`.trim()
export const output = (
<state>
<document>
<paragraph>
on<b>e</b>
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,37 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
return {
kind: 'block',
type: 'paragraph',
}
}
},
{
deserialize(el, next) {
return {
kind: 'block',
type: 'quote',
}
}
},
]
}
export const input = `
<p>one</p>
`.trim()
export const output = (
<state>
<document>
<paragraph />
</document>
</state>
)

View File

@@ -0,0 +1,34 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(),
}
}
}
}
}
]
}
export const input = `
<p>one</p>
`.trim()
export const output = (
<state>
<document>
<paragraph />
</document>
</state>
)

View File

@@ -0,0 +1,47 @@
/** @jsx h */
import h from '../helpers/h'
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'div': {
return null
}
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
case 'img': {
return {
kind: 'block',
type: 'image',
isVoid: true,
}
}
}
}
}
]
}
export const input = `
<p><img/></p>
<div><img/></div>
`.trim()
export const output = (
<state>
<document>
<paragraph>
<image />
</paragraph>
</document>
</state>
)

View File

@@ -0,0 +1,50 @@
export const config = {
rules: [
{
deserialize(el, next) {
switch (el.tagName.toLowerCase()) {
case 'p': {
return {
kind: 'block',
type: 'paragraph',
nodes: next(el.childNodes),
}
}
}
}
}
]
}
export const input = `
<p>one</p>
`.trim()
export const output = {
kind: 'state',
document: {
kind: 'document',
data: {},
nodes: [
{
kind: 'block',
type: 'paragraph',
nodes: [
{
kind: 'text',
ranges: [
{
text: 'one',
}
]
}
]
}
]
}
}
export const options = {
toJSON: true,
}

View File

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

View File

@@ -0,0 +1,65 @@
/**
* Polyfills.
*/
import 'babel-polyfill' // eslint-disable-line import/no-extraneous-dependencies
/**
* Dependencies.
*/
import Html from '..'
import assert from 'assert'
import fs from 'fs'
import parse5 from 'parse5' // eslint-disable-line import/no-extraneous-dependencies
import { State, resetKeyGenerator } from 'slate'
import { basename, extname, resolve } from 'path'
/**
* Reset Slate's internal state before each text.
*/
beforeEach(() => {
resetKeyGenerator()
})
/**
* Tests.
*/
describe('slate-html-serializer', () => {
describe('deserialize()', () => {
const dir = resolve(__dirname, './deserialize')
const tests = fs.readdirSync(dir).filter(t => t[0] != '.').map(t => basename(t, extname(t)))
for (const test of tests) {
it(test, async () => {
const module = require(resolve(dir, test))
const { input, output, config, options } = module
const html = new Html({ parseHtml: parse5.parseFragment, ...config })
const state = html.deserialize(input, options)
const actual = State.isState(state) ? state.toJSON() : state
const expected = State.isState(output) ? output.toJSON() : output
assert.deepEqual(actual, expected)
})
}
})
describe('serialize()', () => {
const dir = resolve(__dirname, './serialize')
const tests = fs.readdirSync(dir).filter(t => t[0] != '.').map(t => basename(t, extname(t)))
for (const test of tests) {
it(test, async () => {
const module = require(resolve(dir, test))
const { input, output, rules, options } = module
const html = new Html({ rules, parseHtml: parse5.parseFragment })
const string = html.serialize(input, options)
const actual = string
const expected = output
assert.deepEqual(actual, expected)
})
}
})
})

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind != 'block') return
switch (obj.type) {
case 'paragraph': return React.createElement('p', {}, children)
case 'quote': return React.createElement('blockquote', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<quote>
<paragraph>
one
</paragraph>
</quote>
</document>
</state>
)
export const output = `
<blockquote><p>one</p></blockquote>
`.trim()

View File

@@ -0,0 +1,29 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', { 'data-thing': obj.data.get('thing') }, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph thing="value">
one
</paragraph>
</document>
</state>
)
export const output = `
<p data-thing="value">one</p>
`.trim()

View File

@@ -0,0 +1,27 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'image') {
return React.createElement('img')
}
}
}
]
export const input = (
<state>
<document>
<image />
</document>
</state>
)
export const output = `
<img/>
`.trim()

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
if (obj.kind == 'mark' && obj.type == 'bold') {
return React.createElement('strong', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
on<b>e</b>
</paragraph>
</document>
</state>
)
export const output = `
<p>on<strong>e</strong></p>
`.trim()

View File

@@ -0,0 +1,29 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
one
</paragraph>
</document>
</state>
)
export const output = `
<p>one</p>
`.trim()

View File

@@ -0,0 +1,41 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
if (obj.kind == 'inline' && obj.type == 'link') {
return React.createElement('a', {}, children)
}
if (obj.kind == 'inline' && obj.type == 'hashtag') {
return React.createElement('span', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
<link>
<hashtag>
one
</hashtag>
</link>
</paragraph>
</document>
</state>
)
export const output = `
<p><a><span>one</span></a></p>
`.trim()

View File

@@ -0,0 +1,35 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
if (obj.kind == 'inline' && obj.type == 'link') {
return React.createElement('a', { href: obj.data.get('href') }, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
<link href="https://google.com">
one
</link>
</paragraph>
</document>
</state>
)
export const output = `
<p><a href="https://google.com">one</a></p>
`.trim()

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
if (obj.kind == 'inline' && obj.type == 'emoji') {
return React.createElement('img')
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
<emoji />
</paragraph>
</document>
</state>
)
export const output = `
<p><img/></p>
`.trim()

View File

@@ -0,0 +1,39 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
if (obj.kind == 'inline' && obj.type == 'link') {
return React.createElement('a', {}, children)
}
if (obj.kind == 'mark' && obj.type == 'bold') {
return React.createElement('strong', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
<link>
on<b>e</b>
</link>
</paragraph>
</document>
</state>
)
export const output = `
<p><a>on<strong>e</strong></a></p>
`.trim()

View File

@@ -0,0 +1,35 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
if (obj.kind == 'inline' && obj.type == 'link') {
return React.createElement('a', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
<link>
one
</link>
</paragraph>
</document>
</state>
)
export const output = `
<p><a>one</a></p>
`.trim()

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import React from 'react'
import h from '../helpers/h'
export const rules = [
{},
{
serialize(obj, children) {}
},
{
serialize(obj, children) {
if (obj.kind == 'block' && obj.type == 'paragraph') {
return React.createElement('p', {}, children)
}
}
}
]
export const input = (
<state>
<document>
<paragraph>
one
</paragraph>
</document>
</state>
)
export const output = `
<p>one</p>
`.trim()

View File

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

View File

@@ -0,0 +1,4 @@
# `slate-hyperscript`
This package contains a hyperscript helper for creating Slate documents with JSX!

View File

@@ -0,0 +1,41 @@
{
"name": "slate-hyperscript",
"description": "A hyperscript helper for creating Slate documents.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"dependencies": {
"is-empty": "^1.0.0",
"is-plain-object": "^2.0.4"
},
"peerDependencies": {
"slate": "^0.23.0"
},
"devDependencies": {
"babel-cli": "^6.10.1",
"browserify": "^13.0.1",
"mocha": "^2.5.3",
"slate": "^0.23.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 SlateHyperscript > ./dist/slate-hyperscript.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 SlateHyperscript | uglifyjs > ./dist/slate-hyperscript.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": {
"slate": "Slate"
},
"keywords": [
"hyperscript",
"jsx",
"html",
"slate",
"sugar",
"xml"
]
}

View File

@@ -0,0 +1,351 @@
import isEmpty from 'is-empty'
import isPlainObject from 'is-plain-object'
import {
Block,
Document,
Inline,
Mark,
Node,
Selection,
State,
Text
} from 'slate'
/**
* Create selection point constants, for comparison by reference.
*
* @type {Object}
*/
const ANCHOR = {}
const CURSOR = {}
const FOCUS = {}
/**
* The default Slate hyperscript creator functions.
*
* @type {Object}
*/
const CREATORS = {
anchor(tagName, attributes, children) {
return ANCHOR
},
block(tagName, attributes, children) {
return Block.create({
...attributes,
nodes: createChildren(children),
})
},
cursor(tagName, attributes, children) {
return CURSOR
},
document(tagName, attributes, children) {
return Document.create({
...attributes,
nodes: createChildren(children),
})
},
focus(tagName, attributes, children) {
return FOCUS
},
inline(tagName, attributes, children) {
return Inline.create({
...attributes,
nodes: createChildren(children),
})
},
mark(tagName, attributes, children) {
const marks = Mark.createSet([attributes])
const nodes = createChildren(children, { marks })
return nodes
},
selection(tagName, attributes, children) {
return Selection.create(attributes)
},
state(tagName, attributes, children) {
const { data } = attributes
const document = children.find(Document.isDocument)
let selection = children.find(Selection.isSelection) || Selection.create()
const props = {}
// Search the document's texts to see if any of them have the anchor or
// focus information saved, so we can set the selection.
if (document) {
document.getTexts().forEach((text) => {
if (text.__anchor != null) {
props.anchorKey = text.key
props.anchorOffset = text.__anchor
props.isFocused = true
}
if (text.__focus != null) {
props.focusKey = text.key
props.focusOffset = text.__focus
props.isFocused = true
}
})
}
if (props.anchorKey && !props.focusKey) {
throw new Error(`Slate hyperscript must have both \`<anchor/>\` and \`<focus/>\` defined if one is defined, but you only defined \`<anchor/>\`. For collapsed selections, use \`<cursor/>\`.`)
}
if (!props.anchorKey && props.focusKey) {
throw new Error(`Slate hyperscript must have both \`<anchor/>\` and \`<focus/>\` defined if one is defined, but you only defined \`<focus/>\`. For collapsed selections, use \`<cursor/>\`.`)
}
if (!isEmpty(props)) {
selection = selection.merge(props).normalize(document)
}
const state = State.create({ data, document, selection })
return state
},
text(tagName, attributes, children) {
const nodes = createChildren(children, { key: attributes.key })
return nodes
},
}
/**
* Create a Slate hyperscript function with `options`.
*
* @param {Object} options
* @return {Function}
*/
function createHyperscript(options = {}) {
const creators = resolveCreators(options)
function create(tagName, attributes, ...children) {
const creator = creators[tagName]
if (!creator) {
throw new Error(`No hyperscript creator found for tag: "${tagName}"`)
}
if (attributes == null) {
attributes = {}
}
if (!isPlainObject(attributes)) {
children = [attributes].concat(children)
attributes = {}
}
children = children
.filter(child => Boolean(child))
.reduce((memo, child) => memo.concat(child), [])
const element = creator(tagName, attributes, children)
return element
}
return create
}
/**
* Create an array of `children`, storing selection anchor and focus.
*
* @param {Array} children
* @param {Object} options
* @return {Array}
*/
function createChildren(children, options = {}) {
const array = []
let length = 0
// When creating the new node, try to preserve a key if one exists.
const firstText = children.find(c => Text.isText(c))
const key = options.key ? options.key : firstText ? firstText.key : undefined
let node = Text.create({ key })
// Create a helper to update the current node while preserving any stored
// anchor or focus information.
function setNode(next) {
const { __anchor, __focus } = node
if (__anchor != null) next.__anchor = __anchor
if (__focus != null) next.__focus = __focus
node = next
}
children.forEach((child) => {
// If the child is a non-text node, push the current node and the new child
// onto the array, then creating a new node for future selection tracking.
if (Node.isNode(child) && !Text.isText(child)) {
if (node.text.length || node.__anchor != null || node.__focus != null) array.push(node)
array.push(child)
node = Text.create()
length = 0
}
// If the child is a string insert it into the node.
if (typeof child == 'string') {
setNode(node.insertText(node.text.length, child, options.marks))
length += child.length
}
// If the node is a `Text` add its text and marks to the existing node. If
// the existing node is empty, and the `key` option wasn't set, preserve the
// child's key when updating the node.
if (Text.isText(child)) {
const { __anchor, __focus } = child
let i = node.text.length
if (!options.key && node.text.length == 0) {
setNode(node.set('key', child.key))
}
child.getRanges().forEach((range) => {
let { marks } = range
if (options.marks) marks = marks.union(options.marks)
setNode(node.insertText(i, range.text, marks))
i += range.text.length
})
if (__anchor != null) node.__anchor = __anchor + length
if (__focus != null) node.__focus = __focus + length
length += child.text.length
}
// If the child is a selection object store the current position.
if (child == ANCHOR || child == CURSOR) node.__anchor = length
if (child == FOCUS || child == CURSOR) node.__focus = length
})
// Make sure the most recent node is added.
array.push(node)
return array
}
/**
* Resolve a set of hyperscript creators an `options` object.
*
* @param {Object} options
* @return {Object}
*/
function resolveCreators(options) {
const {
blocks = {},
inlines = {},
marks = {},
} = options
const creators = {
...CREATORS,
...(options.creators || {}),
}
Object.keys(blocks).map((key) => {
creators[key] = normalizeNode(key, blocks[key], 'block')
})
Object.keys(inlines).map((key) => {
creators[key] = normalizeNode(key, inlines[key], 'inline')
})
Object.keys(marks).map((key) => {
creators[key] = normalizeMark(key, marks[key])
})
return creators
}
/**
* Normalize a node creator with `key` and `value`, of `kind`.
*
* @param {String} key
* @param {Function|Object|String} value
* @param {String} kind
* @return {Function}
*/
function normalizeNode(key, value, kind) {
if (typeof value == 'function') {
return value
}
if (typeof value == 'string') {
value = { type: value }
}
if (isPlainObject(value)) {
return (tagName, attributes, children) => {
const { key: attrKey, ...rest } = attributes
const attrs = {
...value,
kind,
key: attrKey,
data: {
...(value.data || {}),
...rest,
}
}
return CREATORS[kind](tagName, attrs, children)
}
}
throw new Error(`Slate hyperscript ${kind} creators can be either functions, objects or strings, but you passed: ${value}`)
}
/**
* Normalize a mark creator with `key` and `value`.
*
* @param {String} key
* @param {Function|Object|String} value
* @return {Function}
*/
function normalizeMark(key, value) {
if (typeof value == 'function') {
return value
}
if (typeof value == 'string') {
value = { type: value }
}
if (isPlainObject(value)) {
return (tagName, attributes, children) => {
const attrs = {
...value,
data: {
...(value.data || {}),
...attributes,
}
}
return CREATORS.mark(tagName, attrs, children)
}
}
throw new Error(`Slate hyperscript mark creators can be either functions, objects or strings, but you passed: ${value}`)
}
/**
* Export.
*
* @type {Function}
*/
export default createHyperscript()
export { createHyperscript }

View File

View File

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

View File

@@ -0,0 +1,4 @@
# `slate-logger`
This package contains the logger that Slate uses to log warnings and deprecations only when in development environments.

View File

@@ -0,0 +1,23 @@
{
"name": "slate-logger",
"description": "An simple, internal logger for Slate.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"devDependencies": {
"babel-cli": "^6.10.1",
"browserify": "^13.0.1",
"mocha": "^2.5.3",
"slate": "^0.23.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 SlateLogger > ./dist/slate-logger.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 SlateLogger | uglifyjs > ./dist/slate-logger.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"
}
}

View File

@@ -0,0 +1,65 @@
/* eslint-disable no-console */
/**
* Is in development?
*
* @type {Boolean}
*/
const IS_DEV = (
typeof process !== 'undefined' &&
process.env &&
process.env.NODE_ENV !== 'production'
)
/**
* Log a `message` at `level`.
*
* @param {String} level
* @param {String} message
* @param {Any} ...args
*/
function log(level, message, ...args) {
if (!IS_DEV) {
return
}
if (typeof console != 'undefined' && typeof console[level] == 'function') {
console[level](message, ...args)
}
}
/**
* Log a development warning `message`.
*
* @param {String} message
* @param {Any} ...args
*/
function warn(message, ...args) {
log('warn', `Warning: ${message}`, ...args)
}
/**
* Log a deprecation warning `message`, with helpful `version` number.
*
* @param {String} version
* @param {String} message
* @param {Any} ...args
*/
function deprecate(version, message, ...args) {
log('warn', `Deprecation (v${version}): ${message}`, ...args)
}
/**
* Export.
*
* @type {Function}
*/
export default {
deprecate,
warn,
}

View File

View File

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

View File

@@ -0,0 +1,4 @@
# `slate-plain-serializer`
This package contains a plain-text serializer for Slate documents.

View File

@@ -0,0 +1,47 @@
{
"name": "slate-plain-serializer",
"description": "A plain text serializer for Slate editors.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"dependencies": {
"slate-logger": "^0.0.0"
},
"peerDependencies": {
"immutable": "^3.8.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",
"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 SlatePlainSerializer > ./dist/slate-plain-serializer.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 SlatePlainSerializer | uglifyjs > ./dist/slate-plain-serializer.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": {
"immutable": "Immutable",
"slate": "Slate"
},
"keywords": [
"deserialize",
"editor",
"plain",
"plaintext",
"serialize",
"serializer",
"slate",
"string",
"text",
"xml"
]
}

View File

@@ -0,0 +1,106 @@
import logger from 'slate-logger'
import { Block, Mark, Node, State } from 'slate'
import { Set } from 'immutable'
/**
* Deserialize a plain text `string` to a state.
*
* @param {String} string
* @param {Object} options
* @property {Boolean} toJSON
* @property {String|Object|Block} defaultBlock
* @property {Array|Set} defaultMarks
* @return {State}
*/
function deserialize(string, options = {}) {
let {
defaultBlock = 'line',
defaultMarks = [],
toJSON = false,
} = options
if (options.toRaw) {
logger.deprecate('0.23.0', 'The `options.toRaw` argument of the `Plain` serializer is deprecated, use `options.toJSON` instead.')
toJSON = options.toRaw
}
if (Set.isSet(defaultMarks)) {
defaultMarks = defaultMarks.toArray()
}
defaultBlock = Node.createProperties(defaultBlock)
defaultMarks = defaultMarks.map(Mark.createProperties)
const json = {
kind: 'state',
document: {
kind: 'document',
data: {},
nodes: string.split('\n').map((line) => {
return {
...defaultBlock,
kind: 'block',
isVoid: false,
data: {},
nodes: [
{
kind: 'text',
ranges: [
{
kind: 'range',
text: line,
marks: defaultMarks,
}
]
}
]
}
}),
}
}
const ret = toJSON ? json : State.fromJSON(json)
return ret
}
/**
* Serialize a `state` to plain text.
*
* @param {State} state
* @return {String}
*/
function serialize(state) {
return serializeNode(state.document)
}
/**
* Serialize a `node` to plain text.
*
* @param {Node} node
* @return {String}
*/
function serializeNode(node) {
if (
(node.kind == 'document') ||
(node.kind == 'block' && Block.isBlockList(node.nodes))
) {
return node.nodes.map(serializeNode).join('\n')
} else {
return node.text
}
}
/**
* Export.
*
* @type {Object}
*/
export default {
deserialize,
serialize
}

View File

@@ -0,0 +1,21 @@
/** @jsx h */
import h from '../helpers/h'
export const input = `
one
two
`.trim()
export const output = (
<state>
<document>
<line>
one
</line>
<line>
two
</line>
</document>
</state>
)

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import h from '../helpers/h'
export const input = `
one
`.trim()
export const output = (
<state>
<document>
<line>
one
</line>
</document>
</state>
)

View File

@@ -0,0 +1,36 @@
export const input = `
one
`.trim()
export const output = {
kind: 'state',
document: {
kind: 'document',
data: {},
nodes: [
{
kind: 'block',
type: 'line',
isVoid: false,
data: {},
nodes: [
{
kind: 'text',
ranges: [
{
kind: 'range',
text: 'one',
marks: [],
}
]
}
]
}
]
}
}
export const options = {
toJSON: true
}

View File

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

View File

@@ -0,0 +1,62 @@
/**
* Polyfills.
*/
import 'babel-polyfill' // eslint-disable-line import/no-extraneous-dependencies
/**
* Dependencies.
*/
import Plain from '..'
import assert from 'assert'
import fs from 'fs'
import { State, resetKeyGenerator } from 'slate'
import { basename, extname, resolve } from 'path'
/**
* Reset Slate's internal state before each text.
*/
beforeEach(() => {
resetKeyGenerator()
})
/**
* Tests.
*/
describe('slate-plain-serializer', () => {
describe('deserialize()', () => {
const dir = resolve(__dirname, './deserialize')
const tests = fs.readdirSync(dir).filter(t => t[0] != '.').map(t => basename(t, extname(t)))
for (const test of tests) {
it(test, async () => {
const module = require(resolve(dir, test))
const { input, output, options } = module
const state = Plain.deserialize(input, options)
const actual = State.isState(state) ? state.toJSON() : state
const expected = State.isState(output) ? output.toJSON() : output
assert.deepEqual(actual, expected)
})
}
})
describe('serialize()', () => {
const dir = resolve(__dirname, './serialize')
const tests = fs.readdirSync(dir).filter(t => t[0] != '.').map(t => basename(t, extname(t)))
for (const test of tests) {
it(test, async () => {
const module = require(resolve(dir, test))
const { input, output, options } = module
const string = Plain.serialize(input, options)
const actual = string
const expected = output
assert.deepEqual(actual, expected)
})
}
})
})

View File

@@ -0,0 +1,23 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
one
</paragraph>
<paragraph />
<paragraph>
three
</paragraph>
</document>
</state>
)
export const output = `
one
three
`.trim()

View File

@@ -0,0 +1,25 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
one
</paragraph>
<paragraph>
two
</paragraph>
<paragraph>
three
</paragraph>
</document>
</state>
)
export const output = `
one
two
three
`.trim()

View File

@@ -0,0 +1,31 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<quote>
<paragraph>
one
</paragraph>
<paragraph>
two
</paragraph>
</quote>
<quote>
<paragraph />
<paragraph>
four
</paragraph>
</quote>
</document>
</state>
)
export const output = `
one
two
four
`.trim()

View File

@@ -0,0 +1,33 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<quote>
<paragraph>
one
</paragraph>
<paragraph>
two
</paragraph>
</quote>
<quote>
<paragraph>
three
</paragraph>
<paragraph>
four
</paragraph>
</quote>
</document>
</state>
)
export const output = `
one
two
three
four
`.trim()

View File

@@ -0,0 +1,35 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<quote>
<quote>
<paragraph>
one
</paragraph>
<paragraph>
two
</paragraph>
</quote>
<quote>
<paragraph>
three
</paragraph>
<paragraph>
four
</paragraph>
</quote>
</quote>
</document>
</state>
)
export const output = `
one
two
three
four
`.trim()

View File

@@ -0,0 +1,27 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<quote>
<paragraph>
one
</paragraph>
<paragraph>
<link>
<hashtag>
two
</hashtag>
</link>
</paragraph>
</quote>
</document>
</state>
)
export const output = `
one
two
`.trim()

View File

@@ -0,0 +1,27 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<quote>
<paragraph>
<link>
one
</link>
</paragraph>
<paragraph>
<link>
two
</link>
</paragraph>
</quote>
</document>
</state>
)
export const output = `
one
two
`.trim()

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph thing="value">
one
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

@@ -0,0 +1,13 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<image />
</document>
</state>
)
export const output = ' '

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
on<b>e</b>
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

@@ -0,0 +1,17 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
one
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

@@ -0,0 +1,21 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
<link>
<hashtag>
one
</hashtag>
</link>
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
<link thing="value">
one
</link>
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

@@ -0,0 +1,15 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
<emoji />
</paragraph>
</document>
</state>
)
export const output = ' '

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
<link>
on<b>e</b>
</link>
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

@@ -0,0 +1,19 @@
/** @jsx h */
import h from '../helpers/h'
export const input = (
<state>
<document>
<paragraph>
<link>
one
</link>
</paragraph>
</document>
</state>
)
export const output = `
one
`.trim()

View File

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

View File

@@ -0,0 +1,4 @@
# `slate-prop-types`
This package contains a set of React prop types for Slate values that you can use in your own components and plugins.

View File

@@ -0,0 +1,42 @@
{
"name": "slate-prop-types",
"description": "A set of React prop type checkers for Slate editors.",
"version": "0.0.0",
"license": "MIT",
"repository": "git://github.com/ianstormtaylor/slate.git",
"main": "./lib/index.js",
"dependencies": {
"slate-logger": "^0.0.0"
},
"peerDependencies": {
"immutable": "^3.8.0",
"slate": "^0.23.0"
},
"devDependencies": {
"babel-cli": "^6.10.1",
"browserify": "^13.0.1",
"mocha": "^2.5.3",
"slate": "^0.23.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 SlatePropTypes > ./dist/slate-prop-types.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 SlatePropTypes | uglifyjs > ./dist/slate-prop-types.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": {
"immutable": "Immutable",
"slate": "Slate"
},
"keywords": [
"editor",
"prop",
"proptypes",
"react",
"slate",
"types"
]
}

View File

@@ -0,0 +1,85 @@
import {
Block,
Change,
Character,
Data,
Document,
History,
Inline,
Mark,
Node,
Range,
Schema,
Selection,
Stack,
State,
Text,
} from 'slate'
/**
* Create a prop type checker for Slate objects with `name` and `validate`.
*
* @param {String} name
* @param {Function} validate
* @return {Function}
*/
function create(name, validate) {
function check(isRequired, props, propName, componentName, location) {
const value = props[propName]
if (value == null && !isRequired) return null
if (value == null && isRequired) return new Error(`The ${location} \`${propName}\` is marked as required in \`${componentName}\`, but it was not supplied.`)
if (validate(value)) return null
return new Error(`Invalid ${location} \`${propName}\` supplied to \`${componentName}\`, expected a Slate \`${name}\` but received: ${value}`)
}
function propType(...args) {
return check(false, ...args)
}
propType.isRequired = function (...args) {
return check(true, ...args)
}
return propType
}
/**
* Prop type checkers.
*
* @type {Object}
*/
const Types = {
block: create('Block', v => Block.isBlock(v)),
blocks: create('List<Block>', v => Block.isBlockList(v)),
change: create('Change', v => Change.isChange(v)),
character: create('Character', v => Character.isCharacter(v)),
characters: create('List<Character>', v => Character.isCharacterList(v)),
data: create('Data', v => Data.isData(v)),
document: create('Document', v => Document.isDocument(v)),
history: create('History', v => History.isHistory(v)),
inline: create('Inline', v => Inline.isInline(v)),
inlines: create('Inline', v => Inline.isInlineList(v)),
mark: create('Mark', v => Mark.isMark(v)),
marks: create('Set<Mark>', v => Mark.isMarkSet(v)),
node: create('Node', v => Node.isNode(v)),
nodes: create('List<Node>', v => Node.isNodeList(v)),
range: create('Range', v => Range.isRange(v)),
ranges: create('List<Range>', v => Range.isRangeList(v)),
schema: create('Schema', v => Schema.isSchema(v)),
selection: create('Selection', v => Selection.isSelection(v)),
stack: create('Stack', v => Stack.isStack(v)),
state: create('State', v => State.isState(v)),
text: create('Text', v => Text.isText(v)),
texts: create('List<Text>', v => Text.isTextList(v)),
}
/**
* Export.
*
* @type {Object}
*/
export default Types

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,909 @@
import Base64 from 'slate-base64-serializer'
import Debug from 'debug'
import React from 'react'
import SlateTypes from 'slate-prop-types'
import Types from 'prop-types'
import getWindow from 'get-window'
import keycode from 'keycode'
import { Selection } from 'slate'
import TRANSFER_TYPES from '../constants/transfer-types'
import Node from './node'
import extendSelection from '../utils/extend-selection'
import findClosestNode from '../utils/find-closest-node'
import getCaretPosition from '../utils/get-caret-position'
import getHtmlFromNativePaste from '../utils/get-html-from-native-paste'
import getPoint from '../utils/get-point'
import getTransferData from '../utils/get-transfer-data'
import setTransferData from '../utils/set-transfer-data'
import { IS_FIREFOX, IS_MAC, IS_IE } from '../constants/environment'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:content')
/**
* Content.
*
* @type {Component}
*/
class Content extends React.Component {
/**
* Property types.
*
* @type {Object}
*/
static propTypes = {
autoCorrect: Types.bool.isRequired,
autoFocus: Types.bool.isRequired,
children: Types.array.isRequired,
className: Types.string,
editor: Types.object.isRequired,
onBeforeInput: Types.func.isRequired,
onBlur: Types.func.isRequired,
onCopy: Types.func.isRequired,
onCut: Types.func.isRequired,
onDrop: Types.func.isRequired,
onFocus: Types.func.isRequired,
onKeyDown: Types.func.isRequired,
onKeyUp: Types.func.isRequired,
onPaste: Types.func.isRequired,
onSelect: Types.func.isRequired,
readOnly: Types.bool.isRequired,
role: Types.string,
schema: SlateTypes.schema.isRequired,
spellCheck: Types.bool.isRequired,
state: SlateTypes.state.isRequired,
style: Types.object,
tabIndex: Types.number,
tagName: Types.string,
}
/**
* Default properties.
*
* @type {Object}
*/
static defaultProps = {
style: {},
tagName: 'div',
}
/**
* Constructor.
*
* @param {Object} props
*/
constructor(props) {
super(props)
this.tmp = {}
this.tmp.compositions = 0
this.tmp.forces = 0
}
/**
* When the editor first mounts in the DOM we need to:
*
* - Update the selection, in case it starts focused.
* - Focus the editor if `autoFocus` is set.
*/
componentDidMount = () => {
this.updateSelection()
if (this.props.autoFocus) {
this.element.focus()
}
}
/**
* On update, update the selection.
*/
componentDidUpdate = () => {
this.updateSelection()
}
/**
* Update the native DOM selection to reflect the internal model.
*/
updateSelection = () => {
const { editor, state } = this.props
const { selection } = state
const window = getWindow(this.element)
const native = window.getSelection()
// If both selections are blurred, do nothing.
if (!native.rangeCount && selection.isBlurred) return
// If the selection has been blurred, but is still inside the editor in the
// DOM, blur it manually.
if (selection.isBlurred) {
if (!this.isInEditor(native.anchorNode)) return
native.removeAllRanges()
this.element.blur()
debug('updateSelection', { selection, native })
return
}
// If the selection isn't set, do nothing.
if (selection.isUnset) return
// Otherwise, figure out which DOM nodes should be selected...
const { anchorKey, anchorOffset, focusKey, focusOffset, isCollapsed } = selection
const anchor = getCaretPosition(anchorKey, anchorOffset, state, editor, this.element)
const focus = isCollapsed
? anchor
: getCaretPosition(focusKey, focusOffset, state, editor, this.element)
// If they are already selected, do nothing.
if (
anchor.node == native.anchorNode &&
anchor.offset == native.anchorOffset &&
focus.node == native.focusNode &&
focus.offset == native.focusOffset
) {
return
}
// Otherwise, set the `isSelecting` flag and update the selection.
this.tmp.isSelecting = true
native.removeAllRanges()
const range = window.document.createRange()
range.setStart(anchor.node, anchor.offset)
native.addRange(range)
if (!isCollapsed) extendSelection(native, focus.node, focus.offset)
// Then unset the `isSelecting` flag after a delay.
setTimeout(() => {
// COMPAT: In Firefox, it's not enough to create a range, you also need to
// focus the contenteditable element too. (2016/11/16)
if (IS_FIREFOX) this.element.focus()
this.tmp.isSelecting = false
})
debug('updateSelection', { selection, native })
}
/**
* The React ref method to set the root content element locally.
*
* @param {Element} element
*/
ref = (element) => {
this.element = element
}
/**
* Check if an event `target` is fired from within the contenteditable
* element. This should be false for edits happening in non-contenteditable
* children, such as void nodes and other nested Slate editors.
*
* @param {Element} target
* @return {Boolean}
*/
isInEditor = (target) => {
const { element } = this
// COMPAT: Text nodes don't have `isContentEditable` property. So, when
// `target` is a text node use its parent node for check.
const el = target.nodeType === 3 ? target.parentNode : target
return (
(el.isContentEditable) &&
(el === element || findClosestNode(el, '[data-slate-editor]') === element)
)
}
/**
* On before input, bubble up.
*
* @param {Event} event
*/
onBeforeInput = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const data = {}
debug('onBeforeInput', { event, data })
this.props.onBeforeInput(event, data)
}
/**
* On blur, update the selection to be not focused.
*
* @param {Event} event
*/
onBlur = (event) => {
if (this.props.readOnly) return
if (this.tmp.isCopying) return
if (!this.isInEditor(event.target)) return
// If the active element is still the editor, the blur event is due to the
// window itself being blurred (eg. when changing tabs) so we should ignore
// the event, since we want to maintain focus when returning.
const window = getWindow(this.element)
if (window.document.activeElement == this.element) return
const data = {}
debug('onBlur', { event, data })
this.props.onBlur(event, data)
}
/**
* On focus, update the selection to be focused.
*
* @param {Event} event
*/
onFocus = (event) => {
if (this.props.readOnly) return
if (this.tmp.isCopying) return
if (!this.isInEditor(event.target)) return
// COMPAT: If the editor has nested editable elements, the focus can go to
// those elements. In Firefox, this must be prevented because it results in
// issues with keyboard navigation. (2017/03/30)
if (IS_FIREFOX && event.target != this.element) {
this.element.focus()
return
}
const data = {}
debug('onFocus', { event, data })
this.props.onFocus(event, data)
}
/**
* On composition start, set the `isComposing` flag.
*
* @param {Event} event
*/
onCompositionStart = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.isComposing = true
this.tmp.compositions++
debug('onCompositionStart', { event })
}
/**
* On composition end, remove the `isComposing` flag on the next tick. Also
* increment the `forces` key, which will force the contenteditable element
* to completely re-render, since IME puts React in an unreconcilable state.
*
* @param {Event} event
*/
onCompositionEnd = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.forces++
const count = this.tmp.compositions
// The `count` check here ensures that if another composition starts
// before the timeout has closed out this one, we will abort unsetting the
// `isComposing` flag, since a composition in still in affect.
setTimeout(() => {
if (this.tmp.compositions > count) return
this.tmp.isComposing = false
})
debug('onCompositionEnd', { event })
}
/**
* On copy, defer to `onCutCopy`, then bubble up.
*
* @param {Event} event
*/
onCopy = (event) => {
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
this.tmp.isCopying = true
window.requestAnimationFrame(() => {
this.tmp.isCopying = false
})
const { state } = this.props
const data = {}
data.type = 'fragment'
data.fragment = state.fragment
debug('onCopy', { event, data })
this.props.onCopy(event, data)
}
/**
* On cut, defer to `onCutCopy`, then bubble up.
*
* @param {Event} event
*/
onCut = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
this.tmp.isCopying = true
window.requestAnimationFrame(() => {
this.tmp.isCopying = false
})
const { state } = this.props
const data = {}
data.type = 'fragment'
data.fragment = state.fragment
debug('onCut', { event, data })
this.props.onCut(event, data)
}
/**
* On drag end, unset the `isDragging` flag.
*
* @param {Event} event
*/
onDragEnd = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.isDragging = false
this.tmp.isInternalDrag = null
debug('onDragEnd', { event })
}
/**
* On drag over, set the `isDragging` flag and the `isInternalDrag` flag.
*
* @param {Event} event
*/
onDragOver = (event) => {
if (!this.isInEditor(event.target)) return
if (this.tmp.isDragging) return
this.tmp.isDragging = true
this.tmp.isInternalDrag = false
debug('onDragOver', { event })
}
/**
* On drag start, set the `isDragging` flag and the `isInternalDrag` flag.
*
* @param {Event} event
*/
onDragStart = (event) => {
if (!this.isInEditor(event.target)) return
this.tmp.isDragging = true
this.tmp.isInternalDrag = true
const { dataTransfer } = event.nativeEvent
const data = getTransferData(dataTransfer)
// If it's a node being dragged, the data type is already set.
if (data.type == 'node') return
const { state } = this.props
const { fragment } = state
const encoded = Base64.serializeNode(fragment)
setTransferData(dataTransfer, TRANSFER_TYPES.FRAGMENT, encoded)
debug('onDragStart', { event })
}
/**
* On drop.
*
* @param {Event} event
*/
onDrop = (event) => {
event.preventDefault()
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
const { state, editor } = this.props
const { nativeEvent } = event
const { dataTransfer, x, y } = nativeEvent
const data = getTransferData(dataTransfer)
// Resolve the point where the drop occured.
let range
// COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25)
if (window.document.caretRangeFromPoint) {
range = window.document.caretRangeFromPoint(x, y)
} else {
range = window.document.createRange()
range.setStart(nativeEvent.rangeParent, nativeEvent.rangeOffset)
}
const { startContainer, startOffset } = range
const point = getPoint(startContainer, startOffset, state, editor)
if (!point) return
const target = Selection.create({
anchorKey: point.key,
anchorOffset: point.offset,
focusKey: point.key,
focusOffset: point.offset,
isFocused: true
})
// Add drop-specific information to the data.
data.target = target
// COMPAT: Edge throws "Permission denied" errors when
// accessing `dropEffect` or `effectAllowed` (2017/7/12)
try {
data.effect = dataTransfer.dropEffect
} catch (err) {
data.effect = null
}
if (data.type == 'fragment' || data.type == 'node') {
data.isInternal = this.tmp.isInternalDrag
}
debug('onDrop', { event, data })
this.props.onDrop(event, data)
}
/**
* On input, handle spellcheck and other similar edits that don't go trigger
* the `onBeforeInput` and instead update the DOM directly.
*
* @param {Event} event
*/
onInput = (event) => {
if (this.tmp.isComposing) return
if (this.props.state.isBlurred) return
if (!this.isInEditor(event.target)) return
debug('onInput', { event })
const window = getWindow(event.target)
const { state, editor } = this.props
// Get the selection point.
const native = window.getSelection()
const { anchorNode, anchorOffset } = native
const point = getPoint(anchorNode, anchorOffset, state, editor)
if (!point) return
// Get the range in question.
const { key, index, start, end } = point
const { document, selection } = state
const schema = editor.getSchema()
const decorators = document.getDescendantDecorators(key, schema)
const node = document.getDescendant(key)
const block = document.getClosestBlock(node.key)
const ranges = node.getRanges(decorators)
const lastText = block.getLastText()
// Get the text information.
let { textContent } = anchorNode
const lastChar = textContent.charAt(textContent.length - 1)
const isLastText = node == lastText
const isLastRange = index == ranges.size - 1
// If we're dealing with the last leaf, and the DOM text ends in a new line,
// we will have added another new line in <Leaf>'s render method to account
// for browsers collapsing a single trailing new lines, so remove it.
if (isLastText && isLastRange && lastChar == '\n') {
textContent = textContent.slice(0, -1)
}
// If the text is no different, abort.
const range = ranges.get(index)
const { text, marks } = range
if (textContent == text) return
// Determine what the selection should be after changing the text.
const delta = textContent.length - text.length
const after = selection.collapseToEnd().move(delta)
// Change the current state to have the text replaced.
editor.change((change) => {
change
.select({
anchorKey: key,
anchorOffset: start,
focusKey: key,
focusOffset: end
})
.delete()
.insertText(textContent, marks)
.select(after)
})
}
/**
* On key down, prevent the default behavior of certain commands that will
* leave the editor in an out-of-sync state, then bubble up.
*
* @param {Event} event
*/
onKeyDown = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const { altKey, ctrlKey, metaKey, shiftKey, which } = event
const key = keycode(which)
const data = {}
// Keep track of an `isShifting` flag, because it's often used to trigger
// "Paste and Match Style" commands, but isn't available on the event in a
// normal paste event.
if (key == 'shift') {
this.tmp.isShifting = true
}
// When composing, these characters commit the composition but also move the
// selection before we're able to handle it, so prevent their default,
// selection-moving behavior.
if (
this.tmp.isComposing &&
(key == 'left' || key == 'right' || key == 'up' || key == 'down')
) {
event.preventDefault()
return
}
// Add helpful properties for handling hotkeys to the data object.
data.code = which
data.key = key
data.isAlt = altKey
data.isCmd = IS_MAC ? metaKey && !altKey : false
data.isCtrl = ctrlKey && !altKey
data.isLine = IS_MAC ? metaKey : false
data.isMeta = metaKey
data.isMod = IS_MAC ? metaKey && !altKey : ctrlKey && !altKey
data.isModAlt = IS_MAC ? metaKey && altKey : ctrlKey && altKey
data.isShift = shiftKey
data.isWord = IS_MAC ? altKey : ctrlKey
// These key commands have native behavior in contenteditable elements which
// will cause our state to be out of sync, so prevent them.
if (
(key == 'enter') ||
(key == 'backspace') ||
(key == 'delete') ||
(key == 'b' && data.isMod) ||
(key == 'i' && data.isMod) ||
(key == 'y' && data.isMod) ||
(key == 'z' && data.isMod)
) {
event.preventDefault()
}
debug('onKeyDown', { event, data })
this.props.onKeyDown(event, data)
}
/**
* On key up, unset the `isShifting` flag.
*
* @param {Event} event
*/
onKeyUp = (event) => {
const { altKey, ctrlKey, metaKey, shiftKey, which } = event
const key = keycode(which)
const data = {}
if (key == 'shift') {
this.tmp.isShifting = false
}
// Add helpful properties for handling hotkeys to the data object.
data.code = which
data.key = key
data.isAlt = altKey
data.isCmd = IS_MAC ? metaKey && !altKey : false
data.isCtrl = ctrlKey && !altKey
data.isLine = IS_MAC ? metaKey : false
data.isMeta = metaKey
data.isMod = IS_MAC ? metaKey && !altKey : ctrlKey && !altKey
data.isModAlt = IS_MAC ? metaKey && altKey : ctrlKey && altKey
data.isShift = shiftKey
data.isWord = IS_MAC ? altKey : ctrlKey
debug('onKeyUp', { event, data })
this.props.onKeyUp(event, data)
}
/**
* On paste, determine the type and bubble up.
*
* @param {Event} event
*/
onPaste = (event) => {
if (this.props.readOnly) return
if (!this.isInEditor(event.target)) return
const data = getTransferData(event.clipboardData)
// Attach the `isShift` flag, so that people can use it to trigger "Paste
// and Match Style" logic.
data.isShift = !!this.tmp.isShifting
debug('onPaste', { event, data })
// COMPAT: In IE 11, only plain text can be retrieved from the event's
// `clipboardData`. To get HTML, use the browser's native paste action which
// can only be handled synchronously. (2017/06/23)
if (IS_IE) {
// Do not use `event.preventDefault()` as we need the native paste action.
getHtmlFromNativePaste(event.target, (html) => {
// If pasted HTML can be retreived, it is added to the `data` object,
// setting the `type` to `html`.
this.props.onPaste(event, html === undefined ? data : { ...data, html, type: 'html' })
})
} else {
event.preventDefault()
this.props.onPaste(event, data)
}
}
/**
* On select, update the current state's selection.
*
* @param {Event} event
*/
onSelect = (event) => {
if (this.props.readOnly) return
if (this.tmp.isCopying) return
if (this.tmp.isComposing) return
if (this.tmp.isSelecting) return
if (!this.isInEditor(event.target)) return
const window = getWindow(event.target)
const { state, editor } = this.props
const { document, selection } = state
const native = window.getSelection()
const data = {}
// If there are no ranges, the editor was blurred natively.
if (!native.rangeCount) {
data.selection = selection.set('isFocused', false)
}
// Otherwise, determine the Slate selection from the native one.
else {
const { anchorNode, anchorOffset, focusNode, focusOffset } = native
const anchor = getPoint(anchorNode, anchorOffset, state, editor)
const focus = getPoint(focusNode, focusOffset, state, editor)
if (!anchor || !focus) return
// There are situations where a select event will fire with a new native
// selection that resolves to the same internal position. In those cases
// we don't need to trigger any changes, since our internal model is
// already up to date, but we do want to update the native selection again
// to make sure it is in sync.
if (
anchor.key == selection.anchorKey &&
anchor.offset == selection.anchorOffset &&
focus.key == selection.focusKey &&
focus.offset == selection.focusOffset &&
selection.isFocused
) {
this.updateSelection()
return
}
const properties = {
anchorKey: anchor.key,
anchorOffset: anchor.offset,
focusKey: focus.key,
focusOffset: focus.offset,
isFocused: true,
isBackward: null
}
const anchorText = document.getNode(anchor.key)
const focusText = document.getNode(focus.key)
const anchorInline = document.getClosestInline(anchor.key)
const focusInline = document.getClosestInline(focus.key)
const focusBlock = document.getClosestBlock(focus.key)
const anchorBlock = document.getClosestBlock(anchor.key)
// COMPAT: If the anchor point is at the start of a non-void, and the
// focus point is inside a void node with an offset that isn't `0`, set
// the focus offset to `0`. This is due to void nodes <span>'s being
// positioned off screen, resulting in the offset always being greater
// than `0`. Since we can't know what it really should be, and since an
// offset of `0` is less destructive because it creates a hanging
// selection, go with `0`. (2017/09/07)
if (
anchorBlock &&
!anchorBlock.isVoid &&
anchor.offset == 0 &&
focusBlock &&
focusBlock.isVoid &&
focus.offset != 0
) {
properties.focusOffset = 0
}
// COMPAT: If the selection is at the end of a non-void inline node, and
// there is a node after it, put it in the node after instead. This
// standardizes the behavior, since it's indistinguishable to the user.
if (
anchorInline &&
!anchorInline.isVoid &&
anchor.offset == anchorText.text.length
) {
const block = document.getClosestBlock(anchor.key)
const next = block.getNextText(anchor.key)
if (next) {
properties.anchorKey = next.key
properties.anchorOffset = 0
}
}
if (
focusInline &&
!focusInline.isVoid &&
focus.offset == focusText.text.length
) {
const block = document.getClosestBlock(focus.key)
const next = block.getNextText(focus.key)
if (next) {
properties.focusKey = next.key
properties.focusOffset = 0
}
}
data.selection = selection
.merge(properties)
.normalize(document)
}
debug('onSelect', { event, data })
this.props.onSelect(event, data)
}
/**
* Render the editor content.
*
* @return {Element}
*/
render() {
const { props } = this
const { className, readOnly, state, tabIndex, role, tagName } = props
const Container = tagName
const { document, selection } = state
const indexes = document.getSelectionIndexes(selection, selection.isFocused)
const children = document.nodes.toArray().map((child, i) => {
const isSelected = !!indexes && indexes.start <= i && i < indexes.end
return this.renderNode(child, isSelected)
})
const style = {
// Prevent the default outline styles.
outline: 'none',
// Preserve adjacent whitespace and new lines.
whiteSpace: 'pre-wrap',
// Allow words to break if they are too long.
wordWrap: 'break-word',
// COMPAT: In iOS, a formatting menu with bold, italic and underline
// buttons is shown which causes our internal state to get out of sync in
// weird ways. This hides that. (2016/06/21)
...(readOnly ? {} : { WebkitUserModify: 'read-write-plaintext-only' }),
// Allow for passed-in styles to override anything.
...props.style,
}
// COMPAT: In Firefox, spellchecking can remove entire wrapping elements
// including inline ones like `<a>`, which is jarring for the user but also
// causes the DOM to get into an irreconcilable state. (2016/09/01)
const spellCheck = IS_FIREFOX ? false : props.spellCheck
debug('render', { props })
return (
<Container
data-slate-editor
key={this.tmp.forces}
ref={this.ref}
data-key={document.key}
contentEditable={!readOnly}
suppressContentEditableWarning
className={className}
onBeforeInput={this.onBeforeInput}
onBlur={this.onBlur}
onFocus={this.onFocus}
onCompositionEnd={this.onCompositionEnd}
onCompositionStart={this.onCompositionStart}
onCopy={this.onCopy}
onCut={this.onCut}
onDragEnd={this.onDragEnd}
onDragOver={this.onDragOver}
onDragStart={this.onDragStart}
onDrop={this.onDrop}
onInput={this.onInput}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onPaste={this.onPaste}
onSelect={this.onSelect}
autoCorrect={props.autoCorrect}
spellCheck={spellCheck}
style={style}
role={readOnly ? null : (role || 'textbox')}
tabIndex={tabIndex}
// COMPAT: The Grammarly Chrome extension works by changing the DOM out
// from under `contenteditable` elements, which leads to weird behaviors
// so we have to disable it like this. (2017/04/24)
data-gramm={false}
>
{children}
{this.props.children}
</Container>
)
}
/**
* Render a `child` node of the document.
*
* @param {Node} child
* @param {Boolean} isSelected
* @return {Element}
*/
renderNode = (child, isSelected) => {
const { editor, readOnly, schema, state } = this.props
const { document } = state
return (
<Node
block={null}
editor={editor}
isSelected={isSelected}
key={child.key}
node={child}
parent={document}
readOnly={readOnly}
schema={schema}
state={state}
/>
)
}
}
/**
* Export.
*
* @type {Component}
*/
export default Content

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More