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

first stab at adding a schema to core

This commit is contained in:
Ian Storm Taylor
2016-08-12 11:33:48 -07:00
parent 4ec2e24928
commit 8b5305f239
20 changed files with 592 additions and 11 deletions

View File

@@ -9,6 +9,48 @@ import initialState from './state.json'
* @type {Object}
*/
const schema = {
rules: [
{
match: { type: 'block-quote' },
component: props => <blockquote {...props.attributes}>{props.children}</blockquote>
},
{
match: { type: 'bulleted-list' },
component: props => <ul {...props.attributes}>{props.children}</ul>,
},
{
match: { type: 'heading-one' },
component: props => <h1 {...props.attributes}>{props.children}</h1>,
},
{
match: { type: 'heading-two' },
component: props => <h2 {...props.attributes}>{props.children}</h2>,
},
{
match: { type: 'heading-three' },
component: props => <h3 {...props.attributes}>{props.children}</h3>,
},
{
match: { type: 'heading-four' },
component: props => <h4 {...props.attributes}>{props.children}</h4>,
},
{
match: { type: 'heading-five' },
component: props => <h5 {...props.attributes}>{props.children}</h5>,
},
{
match: { type: 'heading-six' },
component: props => <h6 {...props.attributes}>{props.children}</h6>,
},
{
match: { type: 'list-item' },
component: props => <li {...props.attributes}>{props.children}</li>,
},
]
}
const NODES = {
'block-quote': props => <blockquote>{props.children}</blockquote>,
'bulleted-list': props => <ul>{props.children}</ul>,

View File

@@ -6,11 +6,11 @@ import OffsetKey from '../utils/offset-key'
import React from 'react'
import Selection from '../models/selection'
import Transfer from '../utils/transfer'
import TYPES from '../utils/types'
import TYPES from '../constants/types'
import getWindow from 'get-window'
import includes from 'lodash/includes'
import keycode from 'keycode'
import { IS_FIREFOX, IS_MAC } from '../utils/environment'
import { IS_FIREFOX, IS_MAC } from '../constants/environment'
/**
* Debug.

View File

@@ -1,9 +1,11 @@
import Content from './content'
import CorePlugin from '../plugins/core'
import React from 'react'
import State from '../models/state'
import Debug from 'debug'
import React from 'react'
import Schema from '../models/schema'
import State from '../models/state'
import isReactComponent from '../utils/is-react-component'
import typeOf from 'type-of'
/**
@@ -77,6 +79,7 @@ class Editor extends React.Component {
onSelectionChange: noop,
plugins: [],
readOnly: false,
schema: {},
spellCheck: true
};
@@ -90,6 +93,7 @@ class Editor extends React.Component {
super(props)
this.tmp = {}
this.state = {}
this.state.schema = Schema.create(props.schema)
this.state.plugins = this.resolvePlugins(props)
this.state.state = this.onBeforeChange(props.state)
@@ -108,6 +112,12 @@ class Editor extends React.Component {
*/
componentWillReceiveProps = (props) => {
if (props.schema != this.props.schema) {
this.setState({
schema: Schema.create(props.schema)
})
}
if (props.plugins != this.props.plugins) {
this.setState({
plugins: this.resolvePlugins(props)
@@ -145,6 +155,16 @@ class Editor extends React.Component {
this.onChange(state)
}
/**
* Get the editor's current `schema`.
*
* @return {Schema}
*/
getSchema = () => {
return this.state.schema
}
/**
* Get the editor's current `state`.
*
@@ -288,7 +308,7 @@ class Editor extends React.Component {
let ret = plugin.renderMark(mark, marks, this.state.state, this)
// Handle React components that aren't stateless functions.
if (ret && ret.prototype && ret.prototype.isReactComponent) return ret
if (isReactComponent(ret)) return ret
// Handle all other types...
switch (typeOf(ret)) {

View File

@@ -3,7 +3,7 @@ import Base64 from '../serializers/base-64'
import Debug from 'debug'
import React from 'react'
import ReactDOM from 'react-dom'
import TYPES from '../utils/types'
import TYPES from '../constants/types'
import Leaf from './leaf'
import Void from './void'
import scrollTo from '../utils/scroll-to'

View File

@@ -5,7 +5,7 @@ import OffsetKey from '../utils/offset-key'
import React from 'react'
import ReactDOM from 'react-dom'
import keycode from 'keycode'
import { IS_FIREFOX } from '../utils/environment'
import { IS_FIREFOX } from '../constants/environment'
/**
* Noop.

100
lib/constants/rules.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* The default Slate schema rules, which enforce the most basic constraints.
*
* @type {Array}
*/
const RULES = [
{
match: {
kind: 'document'
},
validate: {
nodes: {
anyOf: [
{ kind: 'block' }
]
}
},
transform: (transform, node) => {
return node.nodes
.filter(child => child.kind != 'block')
.reduce((tr, child) => tr.removeNodeByKey(child.key), transform)
}
},
// {
// match: { kind: 'block' },
// nodes: {
// anyOf: [
// { kind: 'block' },
// { kind: 'inline' },
// { kind: 'text' },
// ]
// },
// transform: (transform, node) => {
// return node
// .filterChildren(child => {
// return (
// child.kind != 'block' ||
// child.kind != 'inline' ||
// child.kind != 'text'
// )
// })
// .reduce((transform, child) => {
// return transform.removeNodeByKey(child.key)
// })
// }
// },
// {
// match: { kind: 'inline' },
// nodes: {
// anyOf: [
// { kind: 'inline' },
// { kind: 'text' }
// ]
// },
// transform: (transform, node) => {
// return node
// .filterChildren(child => {
// return child.kind != 'inline' || child.kind != 'text'
// })
// .reduce((transform, child) => {
// return transform.removeNodeByKey(child.key)
// })
// }
// },
// {
// match: { isVoid: true },
// text: ' ',
// transform: (transform, node) => {
// const { state } = transform
// const range = state.selection.moveToRangeOf(node)
// return transform.delete().insertText(' ')
// }
// },
// {
// match: (object) => {
// return (
// object.kind == 'block' &&
// object.nodes.size == 1 &&
// object.nodes.first().isVoid
// )
// },
// invalid: true,
// transform: (transform, node) => {
// const child = node.nodes.first()
// const text =
// return transform
// .insertNodeBeforeNodeByKey(child.)
// }
// }
]
/**
* Export.
*
* @type {Array}
*/
export default RULES

0
lib/constants/schema.js Normal file
View File

View File

@@ -117,6 +117,21 @@ const Node = {
}, Block.createList())
},
/**
* Recursively filter all ancestor nodes with `iterator`, depth-first.
*
* @param {Function} iterator
* @return {List} nodes
*/
filterDescendantsDeep(iterator) {
return this.nodes.reduce((matches, child, i, nodes) => {
if (child.kind != 'text') matches = matches.concat(child.filterDescendantsDeep(iterator))
if (iterator(child, i, nodes)) matches = matches.push(child)
return matches
}, Block.createList())
},
/**
* Get the closest block nodes for each text node in the node.
*

312
lib/models/schema.js Normal file
View File

@@ -0,0 +1,312 @@
import RULES from '../constants/rules'
import includes from 'lodash/includes'
import isReactComponent from '../utils/is-react-component'
import typeOf from 'type-of'
import memoize from '../utils/memoize'
import { Record } from 'immutable'
/**
* Default properties.
*
* @type {Object}
*/
const DEFAULTS = {
rules: [],
}
/**
* Schema.
*
* @type {Record}
*/
class Schema extends new Record(DEFAULTS) {
/**
* Create a new `Schema` with `properties`.
*
* @param {Object} properties
* @return {Schema} mark
*/
static create(properties = {}) {
if (properties instanceof Schema) return properties
let rules = [
...(properties.rules || []),
...RULES,
]
return new Schema({
rules: rules.map(normalizeRule)
})
}
/**
* Get the kind.
*
* @return {String} kind
*/
get kind() {
return 'schema'
}
/**
* Normalize a `state` against the schema.
*
* @param {State} state
* @return {State}
*/
normalize(state) {
const { document } = state
let transform = state.transform()
document.filterDescendants((node) => {
const rule = this.validateNode(node)
if (rule) transform = rule.transform(transform, node, state)
})
const next = transform.apply({ snapshot: false })
return next
}
/**
* Validate a `state` against the schema.
*
* @param {State} state
* @return {State}
*/
validate(state) {
return !!state.document.findDescendant(node => this.validateNode(node))
}
/**
* Validate a `node` against the schema, returning the rule that was not met
* if the node is invalid, or null if the rule was valid.
*
* @param {Node} node
* @return {Object || Void}
*/
validateNode(node) {
return this.rules
.filter(rule => rule.match(node))
.find(rule => !rule.validate(node))
}
}
/**
* Memoize read methods.
*/
memoize(Schema.prototype, [
'normalize',
'validate',
'validateNode',
])
/**
* Normalize the `properties` of a schema.
*
* @param {Object} properties
* @return {Object}
*/
function normalizeProperties(properties) {
let rules = []
// If there's a `rules` property, it is not the shorthand.
if (properties.rules) {
rules = properties.rules
}
// Otherwise it's the shorthand syntax, so expand each of the properties.
else {
for (const key in properties) {
const value = properties[key]
let rule
if (isReactComponent(value)) {
rule.match = { type: key }
rule.component = value
} else {
rule = {
type: key,
...value
}
}
rules.push(rule)
}
}
return {
rules: rules
.concat(RULES)
.map(normalizeRule)
}
}
/**
* Normalize a `rule` object.
*
* @param {Object} rule
* @return {Object}
*/
function normalizeRule(rule) {
return {
match: normalizeMatch(rule.match),
validate: normalizeValidate(rule.validate),
transform: normalizeTransform(rule.transform),
}
}
/**
* Normalize a `match` spec.
*
* @param {Function || Object || String} match
* @return {Function}
*/
function normalizeMatch(match) {
switch (typeOf(match)) {
case 'function': return match
case 'object': return normalizeSpec(match)
default: {
throw new Error(`Invalid \`match\` spec: "${match}".`)
}
}
}
/**
* Normalize a `validate` spec.
*
* @param {Function || Object || String} validate
* @return {Function}
*/
function normalizeValidate(validate) {
switch (typeOf(validate)) {
case 'function': return validate
case 'object': return normalizeSpec(validate)
default: {
throw new Error(`Invalid \`validate\` spec: "${validate}".`)
}
}
}
/**
* Normalize a `transform` spec.
*
* @param {Function || Object || String} transform
* @return {Function}
*/
function normalizeTransform(transform) {
switch (typeOf(transform)) {
case 'function': return transform
default: {
throw new Error(`Invalid \`transform\` spec: "${transform}".`)
}
}
}
/**
* Normalize a `spec` object.
*
* @param {Object} obj
* @return {Boolean}
*/
function normalizeSpec(obj) {
const spec = { ...obj }
const { nodes } = spec
// Normalize recursively for the node specs.
if (nodes) {
if (nodes.exactlyOf) spec.nodes.exactlyOf = nodes.exactlyOf.map(normalizeSpec)
if (nodes.anyOf) spec.nodes.anyOf = nodes.anyOf.map(normalizeSpec)
if (nodes.noneOf) spec.nodes.noneOf = nodes.noneOf.map(normalizeSpec)
}
// Return a checking function.
return (node) => {
// If marked as invalid explicitly, return early.
if (spec.invalid === true) return false
// Run the simple equality checks first.
if (
(spec.kind != null && spec.kind != node.kind) ||
(spec.type != null && spec.type != node.type) ||
(spec.isVoid != null && spec.isVoid != node.type) ||
(spec.kinds != null && !includes(spec.kinds, node.kind)) ||
(spec.types != null && !includes(spec.types, node.type))
) {
return false
}
// Ensure that the node has nodes.
if (spec.nodes && !node.nodes) {
return false
}
// Run the node recursive checks next, start with `exactlyOf`, which likely
// has the greatest chance of not matching.
if (spec.nodes && spec.nodes.exactlyOf) {
const specs = spec.nodes.exactlyOf
const matches = node.nodes.reduce((valid, child, i) => {
if (!valid) return false
const checker = specs[i]
if (!checker) return false
return checker(child)
}, true)
if (!matches) return false
}
// Run the `anyOf` next check.
if (spec.nodes && spec.nodes.anyOf) {
const specs = spec.nodes.anyOf
const matches = node.nodes.reduce((valid, child) => {
if (!valid) return false
return specs.reduce((pass, checker) => {
if (!pass) return false
return checker(child)
}, true)
})
if (!matches) return false
}
// Run the `noneOf` next check.
if (spec.nodes && spec.nodes.noneOf) {
const specs = spec.nodes.noneOf
const matches = node.nodes.reduce((valid, child) => {
if (!valid) return false
return specs.reduce((pass, checker) => {
if (!pass) return false
return !!checker(child)
}, true)
})
if (!matches) return false
}
return true
}
}
/**
* Export.
*
* @type {Record}
*/
export default Schema

View File

@@ -73,6 +73,21 @@ function Plugin(options = {}) {
)
}
/**
* On before change, enforce the editor's schema.
*
* @param {State} state
* @param {Editor} schema
* @return {State}
*/
function onBeforeChange(state, editor) {
if (state.isNative) return state
return editor
.getSchema()
.normalize(state)
}
/**
* On before input, see if we can let the browser continue with it's native
* input behavior, to avoid a re-render for performance.
@@ -615,6 +630,7 @@ function Plugin(options = {}) {
*/
return {
onBeforeChange,
onBeforeInput,
onBlur,
onCopy,

View File

@@ -0,0 +1,23 @@
/**
* Check if an `object` is a React component.
*
* @param {Object} object
* @return {Boolean}
*/
function isReactComponent(object) {
return (
object &&
object.prototype &&
object.prototype.isReactComponent
)
}
/**
* Export.
*
* @type {Function}
*/
export default isReactComponent

View File

@@ -1,6 +1,6 @@
import Base64 from '../serializers/base-64'
import TYPES from './types'
import TYPES from '../constants/types'
/**
* Fragment matching regexp for HTML nodes.

View File

@@ -0,0 +1,14 @@
nodes:
- kind: inline
type: default
nodes:
- kind: text
ranges:
- text: one
- kind: block
type: default
nodes:
- kind: text
ranges:
- text: two

View File

@@ -0,0 +1,8 @@
nodes:
- kind: block
type: default
nodes:
- kind: text
ranges:
- text: two

31
test/schema/index.js Normal file
View File

@@ -0,0 +1,31 @@
import fs from 'fs'
import strip from '../helpers/strip-dynamic'
import readMetadata from 'read-metadata'
import { Raw } from '../..'
import { strictEqual } from '../helpers/assert-json'
import { resolve } from 'path'
/**
* Tests.
*/
describe('schema', () => {
const tests = fs.readdirSync(resolve(__dirname, './fixtures'))
for (const test of tests) {
if (test[0] == '.') continue
it(test, () => {
const dir = resolve(__dirname, './fixtures', test)
const input = readMetadata.sync(resolve(dir, 'input.yaml'))
const expected = readMetadata.sync(resolve(dir, 'output.yaml'))
const schema = require(dir)
let state = Raw.deserialize(input, { terse: true })
state = schema.normalize(state)
const output = Raw.serialize(state, { terse: true })
strictEqual(strip(output), strip(expected))
})
}
})

View File

@@ -1,4 +1,5 @@
import './serializers'
import './schema'
import './rendering'
import './transforms'

View File

@@ -1,11 +1,10 @@
import assert from 'assert'
import fs from 'fs'
import readMetadata from 'read-metadata'
import strip from '../helpers/strip-dynamic'
import toCamel from 'to-camel-case'
import { Raw, State } from '../..'
import { equal, strictEqual } from '../helpers/assert-json'
import { Raw } from '../..'
import { strictEqual } from '../helpers/assert-json'
import { resolve } from 'path'
/**