1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-30 10:29:48 +02:00

simplify schema rules, update docs

This commit is contained in:
Ian Storm Taylor
2016-08-14 15:51:07 -07:00
parent 06af3de5e5
commit 5091587814
8 changed files with 243 additions and 488 deletions

View File

@@ -104,17 +104,17 @@ Slate encourages you to write small, reusable modules. Check out the public ones
<br/>
### Documentation
If you're using Slate for the first time, check out the [Getting Started](./docs/guides/installing-slate.md) guides and the [Core Concepts](./docs/concepts) to familiarize yourself with Slate's architecture and mental models. Once you've gotten familiar with those, you'll probably want to check out the full [API Reference](./docs/reference).
If you're using Slate for the first time, check out the [Getting Started](./docs/walkthroughs/installing-slate.md) walkthroughs and the [Core Concepts](./docs/concepts) to familiarize yourself with Slate's architecture and mental models. Once you've gotten familiar with those, you'll probably want to check out the full [API Reference](./docs/reference).
- [**Guides**](./docs/guides)
- [Installing Slate](./docs/guides/installing-slate.md)
- [Using the Bundled Source](./docs/guides/using-the-bundled-source.md)
- [Adding Event Handlers](./docs/guides/adding-event-handlers.md)
- [Defining Custom Block Nodes](./docs/guides/defining-custom-block-nodes.md)
- [Applying Custom Formatting](./docs/guides/applying-custom-formatting.md)
- [Using Plugins](./docs/guides/using-plugins.md)
- [Saving to a Database](./docs/guides/saving-to-a-database.md)
- [Saving and Loading HTML Content](./docs/guides/saving-and-loading-html-content.md)
- [**Walkthroughts**](./docs/walkthroughs)
- [Installing Slate](./docs/walkthroughs/installing-slate.md)
- [Using the Bundled Source](./docs/walkthroughs/using-the-bundled-source.md)
- [Adding Event Handlers](./docs/walkthroughs/adding-event-handlers.md)
- [Defining Custom Block Nodes](./docs/walkthroughs/defining-custom-block-nodes.md)
- [Applying Custom Formatting](./docs/walkthroughs/applying-custom-formatting.md)
- [Using Plugins](./docs/walkthroughs/using-plugins.md)
- [Saving to a Database](./docs/walkthroughs/saving-to-a-database.md)
- [Saving and Loading HTML Content](./docs/walkthroughs/saving-and-loading-html-content.md)
- [**Concepts**](./docs/concepts)
- [Statelessness & Immutability](./docs/concepts/statelessness-and-immutability.md)
- [The Document Model](./docs/concepts/the-document-model.md)

View File

@@ -16,8 +16,8 @@ Slate schemas are built up of a set of rules. Every rule has a few properties:
```js
{
match: Function || Object,
component: Component || Function || Object || String,
decorator: Function,
render: Component || Function || Object || String,
decorate: Function,
validate: Function || Object,
transform: Function
}

View File

@@ -1,185 +0,0 @@
# Schemas
Every Slate editor has a "schema" associated with it, which contains information about the structure of its content. It lets you specify how to render each different type of node. And for more advanced use cases it lets you enforce rules about what the content of the editor can and cannot be.
- [Properties](#properties)
- [`marks`](#marks)
- [`nodes`](#nodes)
- [`rules`](#rules)
- [Rule Properties](#rule-properties)
- [`component`](#component)
- [`decorator`](#decorator)
- [`match`](#match)
- [`transform`](#transform)
- [`validate`](#validate)
## Properties
```js
{
marks: Object,
nodes: Object,
rules: Array
}
```
The top-level properties of a schema all give you a way to define `rules` that the schema enforces. The `nodes` and `marks` properties are just convenient ways to define the most common set of rules.
### `marks`
`Object type: Component || Function || Object || String`
```js
{
bold: props => <strong>{props.children}</strong>
}
```
```js
{
bold: {
fontWeight: 'bold'
}
}
```
```js
{
bold: 'my-bold-class-name'
}
```
An object that defines the [`Marks`](./mark.md) in the schema by `type`. Each key in the object refers to a mark by its `type`. The value defines how Slate will render the mark, and can either be a React component, an object of styles, or a class name.
### `nodes`
`Object<type, Component || Function>`
`Object<type, Rule>`
```js
{
quote: props => <blockquote {...props.attributes}>{props.children}</blockquote>
}
```
```js
{
code: {
component: props => <pre {...props.attributes}><code>{props.children}</code></pre>,
decorator: myCodeHighlighter
}
}
```
An object that defines the [`Block`](./block.md) and [`Inline`](./inline.md) nodes in the schema by `type`. Each key in the object refers to a node by its `type`. The values defines how Slate will render the node, and can optionall define any other property of a schema `Rule`.
### `rules`
`Array<Rule>`
```js
[
{
match: { kind: 'block', type: 'code' },
component: props => <pre {...props.attributes}><code>{props.children}</code></pre>,
decorator: myCodeHighlighter
}
]
```
An array of rules that define the schema's behavior. Each of the rules are evaluated in order to determine a match.
Internally, the `marks` and `nodes` properties of a schema are simply converted into `rules`.
## Rule Properties
```js
{
match: Function || Object,
component: Component || Function || Object || String,
decorator: Function,
validate: Function || Object,
transform: Function
}
```
Slate schemas are built up of a set of rules. Each of the properties will add certain functionality to the schema, based on the properties it defines.
### `match`
`Function`
`Object`
```js
(node) => node.kind == 'block' && node.type == 'quote'
```
```js
{
kind: 'block',
type: 'quote'
}
```
The `match` property is the only required property of a rule. It determines which nodes are matched when a rule is matched. In the simplest form it is a function which returns a boolean, but it can also be expressed in object form.
### `component`
`ReactComponent` <br/>
`Function component(props: Object) => Any` <br/>
`Object<cssProperty, cssValue>` <br/>
`String`
```js
(props) => <blockquote {...props.attributes}>{props.children}</blockquote>
```
The `component` property determines how Slate will render the node or mark that was matched by the `match` property. In addition to a React component, marks can also be rendered by supplying an object of styles, or a class name string.
### `decorator`
`Function decorate(text: Text, match: Node) => List<Character>`
The `decorator` property defines a function that can add extra marks to the text inside of a node, for example for code highlighting. It is called with the [`Text`](./text.md) node in question, and the [`Node`](./node.md) matched by the `match` property, and should return a list of [`Characters`](./character.md) with the desired marks applied.
### `validate`
`Function (match: Node) => Any` <br/>
`Object`
```js
{
nodesAnyOf: [
{ kind: 'block' }
],
nodesNoneOf: [
{ type: 'quote' }
]
}
```
The `validate` property defines a series of constraints that the [`Node`](./node.md) must abide by, for example that its children only be other [`Block`](./block.md) nodes. The full list of validations supported is:
- `kind` `String` — the
- `kinds`
- `maxLength`
- `maxNodes` `Number` — the maximum number of child nodes a node can have.
- `minLength`
- `minNodes` `Number`
- `nodesAnyOf` `Array` — an array of `match` objects that the nodes can match.
- `nodesExactlyOf` `Array` — an array of `match` objects that the nodes must all match exactly in order.
- `nodesNoneOf` `Array` — an array of `match` objects that the nodes must not match.
- `text`
- `type`
- `types`
## Matches
For any schema rule to be applied, it has to match a node in the editor's content. The most basic way to do this is to match by `kind` and `type`. For example:
## Components
The most basic use of a schema is to define which React components should be rendered for each node in the editor. For example, you might want to
## Match Properties
## Validate Properties

View File

@@ -8,9 +8,9 @@ Every Slate editor has a "schema" associated with it, which contains information
- [`nodes`](#nodes)
- [`rules`](#rules)
- [Rule Properties](#rule-properties)
- [`component`](#component)
- [`decorator`](#decorator)
- [`decorate`](#decorate)
- [`match`](#match)
- [`render`](#render)
- [`transform`](#transform)
- [`validate`](#validate)
@@ -19,8 +19,8 @@ Every Slate editor has a "schema" associated with it, which contains information
```js
{
marks: Object,
nodes: Object,
marks: Object,
rules: Array
}
```
@@ -62,8 +62,8 @@ An object that defines the [`Marks`](./mark.md) in the schema by `type`. Each ke
```js
{
code: {
component: props => <pre {...props.attributes}><code>{props.children}</code></pre>,
decorator: myCodeHighlighter
render: props => <pre {...props.attributes}><code>{props.children}</code></pre>,
decorate: myCodeHighlighter
}
}
```
@@ -78,7 +78,7 @@ An object that defines the [`Block`](./block.md) and [`Inline`](./inline.md) nod
{
match: { kind: 'block', type: 'code' },
component: props => <pre {...props.attributes}><code>{props.children}</code></pre>,
decorator: myCodeHighlighter
decorate: myCodeHighlighter
}
]
```
@@ -92,43 +92,88 @@ Internally, the `marks` and `nodes` properties of a schema are simply converted
```js
{
match: Function || Object,
component: Component || Function || Object || String,
decorator: Function,
validate: Function || Object,
transform: Function
match: Function,
decorate: Function,
render: Component || Function || Object || String,
transform: Function,
validate: Function
}
```
Slate schemas are built up of a set of rules. Each of the properties will add certain functionality to the schema, based on the properties it defines.
### `match`
`Object || Function`
`Function match(object: Node || Mark)`
```js
{
kind: 'block',
type: 'quote'
match: (object) => object.kind == 'block' && object.type == 'code'
}
```
The `match` property is the only required property of a rule. It determines which nodes are matched when a rule is matched.
The `match` property is the only required property of a rule. It determines which objects the rule applies to.
### `decorate`
`Function decorate(text: Node, object: Node) => List<Characters>`
## Matches
```js
{
decorate: (text, node) => {
let { characters } = text
let first = characters.get(0)
let { marks } = first
let mark = Mark.create({ type: 'bold' })
marks = marks.add(mark)
first = first.merge({ marks })
characters = characters.set(0, first)
return characters
}
}
```
For any schema rule to be applied, it has to match a node in the editor's content. The most basic way to do this is to match by `kind` and `type`. For example:
The `decorate` property allows you define a function that will apply extra marks to all of the ranges of text inside a node. It is called with a [`Text`](./text.md) node and the matched node. It should return a list of characters with the desired marks, which will then be added to the text before rendering.
### `render`
`Component` <br/>
`Function` <br/>
`Object` <br/>
`String`
```js
{
render: (props) => <pre {...props.attributes}><code>{props.children}</code></pre>
}
```
## Components
The `render` property determines which React component Slate will use to render a [`Node`](./node.md) or [`Mark`](./mark.md). Mark renderers can also be defined as an object of styles or a class name string for convenience.
The most basic use of a schema is to define which React components should be rendered for each node in the editor. For example, you might want to
### `transform`
`Function transform(transform: Transform, object: Node, failure: Any) => Transform`
```js
{
transform: (transform, node, invalidChildren) => {
invalidChildren.forEach((child) => {
transform = transform.removeNodeByKey(child.key)
})
return transform
}
}
```
The `transform` property is run to recover the editor's state after the `validate` property of a rule has determined that an object is invalid. It is passed a [`Transform`](./transform.md) that it can use to make modifications. It is also passed the return value of the `validate` function, which makes it easy to quickly determine the reason validation failed.
### `validate`
`Function validate(object: Node) => Any || Void`
## Match Properties
```js
{
validate: (node) => {
const invalidChildren = node.nodes.filter(child => child.kind == 'block')
return invalidChildren.size ? invalidChildren : null
}
}
```
## Validate Properties
The `validate` property allows you to define a constraint that the matching object must abide by. It should return either `Void` if the object is valid, or any non-void value if it is invalid. This makes it easy to return the exact reason that the object is invalid, which makes it simple to recover from the invalid state with the `transform` property.

View File

@@ -138,7 +138,7 @@ class Schema extends new Record(DEFAULTS) {
}
/**
* Return the component for an `object`.
* Return the renderer for an `object`.
*
* This method is private, because it should always be called on one of the
* often-changing immutable objects instead, since it will be memoized for
@@ -149,9 +149,9 @@ class Schema extends new Record(DEFAULTS) {
*/
__getComponent(object) {
const match = this.rules.find(rule => rule.match(object) && rule.component)
const match = this.rules.find(rule => rule.match(object) && rule.render)
if (!match) return
return match.component
return match.render
}
/**
@@ -167,17 +167,17 @@ class Schema extends new Record(DEFAULTS) {
__getDecorators(object) {
return this.rules
.filter(rule => rule.match(object) && rule.decorator)
.filter(rule => rule.match(object) && rule.decorate)
.map((rule) => {
return (text) => {
return rule.decorator(text, object)
return rule.decorate(text, object)
}
})
}
/**
* Validate an `object` against the schema, returning the failing rule and
* reason if the object is invalid, or void if it's valid.
* value if the object is invalid, or void if it's valid.
*
* This method is private, because it should always be called on one of the
* often-changing immutable objects instead, since it will be memoized for
@@ -188,20 +188,21 @@ class Schema extends new Record(DEFAULTS) {
*/
__validate(object) {
let reason
let value
const match = this.rules.find((rule) => {
if (!rule.match(object)) return
if (!rule.validate) return
reason = rule.validate(object)
return reason
value = rule.validate(object)
return value
})
if (!reason) return
if (!value) return
return {
rule: match,
reason
value,
}
}
@@ -227,9 +228,7 @@ function normalizeProperties(properties) {
rules = rules.concat(array)
}
return {
rules: rules.map(normalizeRule)
}
return { rules }
}
/**
@@ -243,23 +242,20 @@ function normalizeNodes(nodes) {
const rules = []
for (const key in nodes) {
const value = nodes[key]
const match = {
kinds: ['block', 'inline'],
type: key,
let rule = nodes[key]
if (typeOf(rule) == 'function' || isReactComponent(rule)) {
rule = { render: rule }
}
if (value.component || value.decorator || value.validate) {
rules.push({
match,
...value,
})
} else {
rules.push({
match,
component: value
})
rule.match = (object) => {
return (
(object.kind == 'block' || object.kind == 'inline') &&
object.type == key
)
}
rules.push(rule)
}
return rules
@@ -276,146 +272,37 @@ function normalizeMarks(marks) {
const rules = []
for (const key in marks) {
const value = marks[key]
const match = {
kind: 'mark',
type: key,
let rule = marks[key]
if (!rule.render && !rule.decorator && !rule.validate) {
rule = { render: rule }
}
if (value.component || value.decorator || value.validate) {
rules.push({
match,
...value,
component: normalizeMarkComponent(value.component),
})
} else {
rules.push({
match,
component: normalizeMarkComponent(value)
})
}
rule.render = normalizeMarkComponent(rule.render)
rule.match = object => object.kind == 'mark' && object.type == key
rules.push(rule)
}
return rules
}
/**
* Normalize a mark `component`.
* Normalize a mark `render` property.
*
* @param {Component || Function || Object || String} component
* @param {Component || Function || Object || String} render
* @return {Component}
*/
function normalizeMarkComponent(component) {
if (isReactComponent(component)) return component
function normalizeMarkComponent(render) {
if (isReactComponent(render)) return render
switch (typeOf(component)) {
switch (typeOf(render)) {
case 'function':
return component
return render
case 'object':
return props => <span style={component}>{props.children}</span>
return props => <span style={render}>{props.children}</span>
case 'string':
return props => <span className={component}>{props.children}</span>
}
}
/**
* Normalize a `rule` object.
*
* @param {Object} rule
* @return {Object}
*/
function normalizeRule(rule) {
return {
...rule,
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 'boolean': 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 'boolean': return () => validate
case 'object': return normalizeSpec(validate, true)
}
}
/**
* Normalize a `transform` spec.
*
* @param {Function || Object || String} transform
* @return {Function}
*/
function normalizeTransform(transform) {
switch (typeOf(transform)) {
case 'function': return transform
}
}
/**
* Normalize a `spec` object.
*
* @param {Object} obj
* @param {Boolean} giveReason
* @return {Boolean}
*/
function normalizeSpec(obj, giveReason) {
const spec = { ...obj }
if (spec.exactlyOf) spec.exactlyOf = spec.exactlyOf.map(s => normalizeSpec(s))
if (spec.anyOf) spec.anyOf = spec.anyOf.map(s => normalizeSpec(s))
if (spec.noneOf) spec.noneOf = spec.noneOf.map(s => normalizeSpec(s))
return (node) => {
for (const key in CHECKS) {
const value = spec[key]
if (value == null) continue
const fail = CHECKS[key]
const failure = fail(node, value)
if (failure === undefined) continue
if (giveReason) {
return {
type: key,
value: failure
}
} else {
return false
}
}
return giveReason ? undefined : true
return props => <span className={render}>{props.children}</span>
}
}

View File

@@ -373,14 +373,14 @@ class State extends new Record(DEFAULTS) {
document.filterDescendantsDeep((node) => {
if (failure = node.validate(schema)) {
const { reason, rule } = failure
transform = rule.transform(transform, node, reason)
const { value, rule } = failure
transform = rule.transform(transform, node, value)
}
})
if (failure = document.validate(schema)) {
const { reason, rule } = failure
transform = rule.transform(transform, document, reason)
const { value, rule } = failure
transform = rule.transform(transform, document, value)
}
return transform.steps.size

View File

@@ -32,108 +32,6 @@ function Plugin(options = {}) {
placeholderStyle,
} = options
/**
* The default block renderer.
*
* @param {Object} props
* @return {Element}
*/
function DEFAULT_BLOCK(props) {
return (
<div {...props.attributes} style={{ position: 'relative' }}>
{props.children}
{placeholder
? <Placeholder
className={placeholderClassName}
node={props.node}
parent={props.state.document}
state={props.state}
style={placeholderStyle}
>
{placeholder}
</Placeholder>
: null}
</div>
)
}
/**
* The default inline renderer.
*
* @param {Object} props
* @return {Element}
*/
function DEFAULT_INLINE(props) {
return (
<span {...props.attributes} style={{ position: 'relative' }}>
{props.children}
</span>
)
}
/**
* The default schema.
*
* @type {Object}
*/
const DEFAULT_SCHEMA = {
rules: [
{
match: {
kind: 'document'
},
validate: {
anyOf: [
{ kind: 'block' }
]
},
transform: (transform, match, reason) => {
return reason.value.reduce((tr, node) => {
return tr.removeNodeByKey(node.key)
}, transform)
}
},
{
match: {
kind: 'block'
},
validate: {
anyOf: [
{ kind: 'block' },
{ kind: 'inline' },
{ kind: 'text' },
]
},
transform: (transform, match, reason) => {
return reason.value.reduce((tr, node) => {
return tr.removeNodeByKey(node.key)
}, transform)
},
component: DEFAULT_BLOCK
},
{
match: {
kind: 'inline'
},
validate: {
anyOf: [
{ kind: 'inline' },
{ kind: 'text' },
]
},
transform: (transform, match, reason) => {
return reason.value.reduce((tr, node) => {
return tr.removeNodeByKey(node.key)
}, transform)
},
component: DEFAULT_INLINE
},
]
}
/**
* On before change, enforce the editor's schema.
*
@@ -672,17 +570,128 @@ function Plugin(options = {}) {
}
/**
* The core `node` renderer, which uses plain `<div>` or `<span>` depending on
* what kind of node it is.
* A default schema rule to render block nodes.
*
* @param {Node} node
* @return {Component} component
* @type {Object}
*/
function renderNode(node) {
return node.kind == 'block'
? DEFAULT_BLOCK
: DEFAULT_INLINE
const BLOCK_RENDER_RULE = {
match: (node) => {
return node.kind == 'block'
},
render: (props) => {
return (
<div {...props.attributes} style={{ position: 'relative' }}>
{props.children}
{placeholder
? <Placeholder
className={placeholderClassName}
node={props.node}
parent={props.state.document}
state={props.state}
style={placeholderStyle}
>
{placeholder}
</Placeholder>
: null}
</div>
)
}
}
/**
* A default schema rule to render inline nodes.
*
* @type {Object}
*/
const INLINE_RENDER_RULE = {
match: (node) => {
return node.kind == 'inline'
},
render: (props) => {
return (
<span {...props.attributes} style={{ position: 'relative' }}>
{props.children}
</span>
)
}
}
/**
* A default schema rule to only allow block nodes in documents.
*
* @type {Object}
*/
const DOCUMENT_CHILDREN_RULE = {
match: (node) => {
return node.kind == 'document'
},
validate: (document) => {
const { nodes } = document
const invalids = nodes.filter(n => n.kind != 'block')
return invalids.size ? invalids : null
},
transform: (transform, document, invalids) => {
return invalids.reduce((t, n) => t.removeNodeByKey(n.key), transform)
}
}
/**
* A default schema rule to only allow block, inline and text nodes in blocks.
*
* @type {Object}
*/
const BLOCK_CHILDREN_RULE = {
match: (node) => {
return node.kind == 'block'
},
validate: (block) => {
const { nodes } = block
const invalids = nodes.filter(n => n.kind != 'block' && n.kind != 'inline' && n.kind != 'text')
return invalids.size ? invalids : null
},
transform: (transform, block, invalids) => {
return invalids.reduce((t, n) => t.removeNodeByKey(n.key), transform)
}
}
/**
* A default schema rule to only allow inline and text nodes in inlines.
*
* @type {Object}
*/
const INLINE_CHILDREN_RULE = {
match: (object) => {
return object.kind == 'inline'
},
validate: (inline) => {
const { nodes } = inline
const invalids = nodes.filter(n => n.kind != 'inline' && n.kind != 'text')
return invalids.size ? invalids : null
},
transform: (transform, inline, invalids) => {
return invalids.reduce((t, n) => t.removeNodeByKey(n.key), transform)
}
}
/**
* The default schema.
*
* @type {Object}
*/
const schema = {
rules: [
BLOCK_RENDER_RULE,
INLINE_RENDER_RULE,
DOCUMENT_CHILDREN_RULE,
BLOCK_CHILDREN_RULE,
INLINE_CHILDREN_RULE,
]
}
/**
@@ -699,8 +708,7 @@ function Plugin(options = {}) {
onKeyDown,
onPaste,
onSelect,
renderNode,
schema: DEFAULT_SCHEMA
schema,
}
}

View File

@@ -5,7 +5,7 @@ const BOLD = {
fontWeight: 'bold'
}
function decorator(text) {
function decorate(text, block) {
let { characters } = text
let second = characters.get(1)
let mark = Mark.create({ type: 'bold' })
@@ -18,7 +18,7 @@ function decorator(text) {
export const schema = {
nodes: {
default: {
decorator
decorate
}
},
marks: {