1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-02-01 05:16:10 +01:00

Add the Stack concept (#513)

* trying to get testing in browser to work

* add the "stack" concept

* remove old things from package.json

* minor fixes
This commit is contained in:
Ian Storm Taylor 2016-12-09 12:15:36 -08:00 committed by GitHub
parent e080e5a998
commit ad2642a3b5
18 changed files with 656 additions and 136 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ examples/build.dev.js
examples/build.prod.js
lib
perf/reference.json
test/support/build.js
# Temporary files.
tmp

View File

@ -36,7 +36,6 @@
"browserify-shim": "^3.8.12",
"disc": "^1.3.2",
"envify": "^3.4.1",
"enzyme": "^2.4.1",
"eslint": "^3.8.1",
"eslint-plugin-import": "^2.0.1",
"eslint-plugin-react": "^6.4.1",
@ -57,7 +56,6 @@
"prismjs": "^1.5.1",
"react": "~15.2.0",
"react-addons-perf": "~15.2.1",
"react-addons-test-utils": "~15.1.0",
"react-dom": "~15.2.0",
"react-frame-aware-selection-plugin": "0.0.5",
"react-frame-component": "^0.6.2",

View File

@ -1,9 +1,8 @@
import Content from './content'
import CorePlugin from '../plugins/core'
import Debug from 'debug'
import React from 'react'
import Schema from '../models/schema'
import Stack from '../models/stack'
import State from '../models/state'
import noop from '../utils/noop'
@ -29,7 +28,19 @@ const EVENT_HANDLERS = [
'onDrop',
'onKeyDown',
'onPaste',
'onSelect'
'onSelect',
]
/**
* Plugin-related properties of the editor.
*
* @type {Array}
*/
const PLUGINS_PROPS = [
...EVENT_HANDLERS,
'plugins',
'schema',
]
/**
@ -80,7 +91,7 @@ class Editor extends React.Component {
};
/**
* When created, compute the plugins from `props`.
* When constructed, create a new `Stack` and run `onBeforeChange`.
*
* @param {Object} props
*/
@ -89,40 +100,47 @@ class Editor extends React.Component {
super(props)
this.tmp = {}
this.state = {}
this.state.plugins = this.resolvePlugins(props)
this.state.schema = this.resolveSchema(this.state.plugins)
const state = this.onBeforeChange(props.state)
// Create a new `Stack`, omitting the `onChange` property since that has
// special significance on the editor itself.
const { onChange, ...rest } = props // eslint-disable-line no-unused-vars
const stack = Stack.create(rest)
this.state.stack = stack
// Resolve the state, running `onBeforeChange` first.
const state = stack.onBeforeChange(props.state, this)
this.cacheState(state)
this.state.state = state
// Mix in the event handlers.
// Create a bound event handler for each event.
for (const method of EVENT_HANDLERS) {
this[method] = (...args) => {
this.onEvent(method, ...args)
const next = this.state.stack[method](this.state.state, this, ...args)
this.onChange(next)
}
}
}
/**
* When the `props` are updated, recompute the state and plugins.
* When the `props` are updated, create a new `Stack` if necessary, and
* run `onBeforeChange`.
*
* @param {Object} props
*/
componentWillReceiveProps = (props) => {
if (props.plugins != this.props.plugins) {
const plugins = this.resolvePlugins(props)
const schema = this.resolveSchema(plugins)
this.setState({ plugins, schema })
let { stack } = this.state
// If any plugin-related properties will change, create a new `Stack`.
for (const prop of PLUGINS_PROPS) {
if (props[prop] == this.props[prop]) continue
const { onChange, ...rest } = props // eslint-disable-line no-unused-vars
stack = Stack.create(rest)
this.setState({ stack })
}
else if (props.schema != this.props.schema) {
const schema = this.resolveSchema(this.state.plugins)
this.setState({ schema })
}
const state = this.onBeforeChange(props.state)
// Resolve the state, running the before change handler of the stack.
const state = stack.onBeforeChange(props.state, this)
this.cacheState(state)
this.setState({ state })
}
@ -166,17 +184,17 @@ class Editor extends React.Component {
}
/**
* Get the editor's current `schema`.
* Get the editor's current schema.
*
* @return {Schema}
*/
getSchema = () => {
return this.state.schema
return this.state.stack.schema
}
/**
* Get the editor's current `state`.
* Get the editor's current state.
*
* @return {State}
*/
@ -185,26 +203,6 @@ class Editor extends React.Component {
return this.state.state
}
/**
* When the editor receives a new 'state'
*
* @param {State} state
* @return {State}
*/
onBeforeChange = (state) => {
if (state == this.state.state) return state
for (const plugin of this.state.plugins) {
if (!plugin.onBeforeChange) continue
const newState = plugin.onBeforeChange(state, this)
if (newState == null) continue
state = newState
}
return state
}
/**
* When the `state` changes, pass through plugins, then bubble up.
*
@ -213,45 +211,18 @@ class Editor extends React.Component {
onChange = (state) => {
if (state == this.state.state) return
const { tmp, props } = this
const { stack } = this.state
const { onChange, onDocumentChange, onSelectionChange } = props
for (const plugin of this.state.plugins) {
if (!plugin.onChange) continue
const newState = plugin.onChange(state, this)
if (newState == null) continue
state = newState
}
this.props.onChange(state)
if (state.document != this.tmp.document) {
this.props.onDocumentChange(state.document, state)
}
if (state.selection != this.tmp.selection) {
this.props.onSelectionChange(state.selection, state)
}
state = stack.onChange(state, this)
onChange(state)
if (state.document != tmp.document) onDocumentChange(state.document, state)
if (state.selection != tmp.selection) onSelectionChange(state.selection, state)
this.cacheState(state)
}
/**
* When an event by `name` fires, pass it through the plugins, and update the
* state if one of them chooses to.
*
* @param {String} name
* @param {Mixed} ...args
*/
onEvent = (name, ...args) => {
for (const plugin of this.state.plugins) {
if (!plugin[name]) continue
const newState = plugin[name](...args, this.state.state, this)
if (!newState) continue
this.onChange(newState)
break
}
}
/**
* Render the editor.
*
@ -260,7 +231,7 @@ class Editor extends React.Component {
render = () => {
const { props, state } = this
const handlers = { onChange: this.onChange }
const handlers = {}
for (const property of EVENT_HANDLERS) {
handlers[property] = this[property]
@ -271,62 +242,18 @@ class Editor extends React.Component {
return (
<Content
{...handlers}
className={props.className}
editor={this}
onChange={this.onChange}
schema={this.getSchema()}
state={this.getState()}
className={props.className}
readOnly={props.readOnly}
schema={state.schema}
spellCheck={props.spellCheck}
state={state.state}
style={props.style}
/>
)
}
/**
* Resolve the editor's current plugins from `props` when they change.
*
* Add a plugin made from the editor's own `props` at the beginning of the
* stack. That way, you can add a `onKeyDown` handler to the editor itself,
* and it will override all of the existing plugins.
*
* Also add the "core" functionality plugin that handles the most basic events
* for the editor, like delete characters and such.
*
* @param {Object} props
* @return {Array}
*/
resolvePlugins = (props) => {
// eslint-disable-next-line no-unused-vars
const { onChange, plugins, ...editorPlugin } = props
const corePlugin = CorePlugin(props)
return [
editorPlugin,
...plugins,
corePlugin
]
}
/**
* Resolve the editor's schema from a set of `plugins`.
*
* @param {Array} plugins
* @return {Schema}
*/
resolveSchema = (plugins) => {
let rules = []
for (const plugin of plugins) {
if (!plugin.schema) continue
const schema = Schema.create(plugin.schema)
rules = rules.concat(schema.rules)
}
const schema = Schema.create({ rules })
return schema
}
}
/**

View File

@ -18,6 +18,7 @@ import Inline from './models/inline'
import Mark from './models/mark'
import Schema from './models/schema'
import Selection from './models/selection'
import Stack from './models/stack'
import State from './models/state'
import Text from './models/text'
import Range from './models/range'
@ -64,6 +65,7 @@ export {
Raw,
Schema,
Selection,
Stack,
State,
Text,
Transforms,
@ -87,6 +89,7 @@ export default {
Raw,
Schema,
Selection,
Stack,
State,
Text,
Transforms,

View File

@ -11,7 +11,7 @@ import { Record, Set } from 'immutable'
const DEFAULTS = {
marks: new Set(),
text: ''
text: '',
}
/**

197
src/models/stack.js Normal file
View File

@ -0,0 +1,197 @@
import CorePlugin from '../plugins/core'
import Debug from 'debug'
import Schema from './schema'
import State from './state'
import { Record } from 'immutable'
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:stack')
/**
* Methods that are triggered on events and can change the state.
*
* @type {Array}
*/
const EVENT_METHODS = [
'onBeforeInput',
'onBlur',
'onCopy',
'onCut',
'onDrop',
'onKeyDown',
'onPaste',
'onSelect',
]
/**
* Methods that accumulate an updated state.
*
* @type {Array}
*/
const ACCUMULATOR_METHODS = [
'onBeforeChange',
'onChange',
]
/**
* All the runnable methods.
*
* @type {Array}
*/
const RUNNABLE_METHODS = []
.concat(EVENT_METHODS)
.concat(ACCUMULATOR_METHODS)
/**
* Default properties.
*
* @type {Object}
*/
const DEFAULTS = {
plugins: [],
schema: new Schema(),
}
/**
* Stack.
*
* @type {Stack}
*/
class Stack extends new Record(DEFAULTS) {
/**
* Constructor.
*
* @param {Object} properties
* @property {Array} plugins
* @property {Schema|Object} schema
* @property {Function} ...handlers
*/
static create(properties) {
const plugins = resolvePlugins(properties)
const schema = resolveSchema(plugins)
return new Stack({ plugins, schema })
}
/**
* Get the kind.
*
* @return {String}
*/
get kind() {
return 'stack'
}
/**
* Run a `method` in the stack with `state`.
*
* @param {String} method
* @param {State} state
* @param {Editor} editor
* @param {Mixed} ...args
* @return {State}
*/
run(method, state, editor, ...args) {
debug(method)
if (method == 'onChange') {
state = this.onBeforeChange(state, editor)
}
for (const plugin of this.plugins) {
if (!plugin[method]) continue
const next = plugin[method](...args, state, editor)
if (next == null) {
continue
} else if (next instanceof State) {
state = next
if (!ACCUMULATOR_METHODS.includes(method)) break
} else {
throw new Error(`A plugin returned an unexpected state value: ${next}`)
}
}
return state
}
}
/**
* Mix in the runnable methods.
*/
for (const method of RUNNABLE_METHODS) {
Stack.prototype[method] = function (...args) {
return this.run(method, ...args)
}
}
/**
* Resolve a schema from a set of `plugins`.
*
* @param {Array} plugins
* @return {Schema}
*/
function resolveSchema(plugins) {
let rules = []
for (const plugin of plugins) {
if (plugin.schema == null) continue
const schema = Schema.create(plugin.schema)
rules = rules.concat(schema.rules)
}
const schema = Schema.create({ rules })
return schema
}
/**
* Resolve an array of plugins from `properties`.
*
* In addition to the plugins provided in `properties.plugins`, this will
* create two other plugins:
*
* - A plugin made from the top-level `properties` 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) {
const { plugins = [], ...overridePlugin } = props
const corePlugin = CorePlugin(props)
return [
overridePlugin,
...plugins,
corePlugin
]
}
/**
* Export.
*
* @type {Stack}
*/
export default Stack

View File

@ -22,7 +22,7 @@ class Transform {
* Constructor.
*
* @param {Object} properties
* @property {State} properties.state
* @property {State} state
*/
constructor(properties) {

View File

@ -45,7 +45,7 @@ function Plugin(options = {}) {
if (state.isNative) return state
const schema = editor.getSchema()
const { state: prevState } = editor.state
const prevState = editor.getState()
// Since schema can only normalize the document, we avoid creating
// a transform and normalize the selection if the document is the same
@ -55,6 +55,7 @@ function Plugin(options = {}) {
.normalize(schema)
.apply({ save: false })
debug('onBeforeChange')
return newState
}

View File

@ -4,6 +4,7 @@ import Character from '../models/character'
import Document from '../models/document'
import Inline from '../models/inline'
import Mark from '../models/mark'
import Selection from '../models/selection'
import State from '../models/state'
import Text from '../models/text'
import isEmpty from 'is-empty'
@ -144,6 +145,24 @@ const Raw = {
}))
},
/**
* Deserialize a JSON `object` representing a `Selection`.
*
* @param {Object} object
* @param {Object} options (optional)
* @return {State}
*/
deserializeSelection(object, options = {}) {
return Selection.create({
anchorKey: object.anchorKey,
anchorOffset: object.anchorOffset,
focusKey: object.focusKey,
focusOffset: object.focusOffset,
isFocused: object.isFocused,
})
},
/**
* Deserialize a JSON `object` representing a `State`.
*
@ -155,9 +174,14 @@ const Raw = {
deserializeState(object, options = {}) {
if (options.terse) object = Raw.untersifyState(object)
return State.create({
document: Raw.deserializeDocument(object.document, options)
})
const document = Raw.deserializeDocument(object.document, options)
let selection
if (object.selection != null) {
selection = Raw.deserializeSelection(object.selection, options)
}
return State.create({ document, selection })
},
/**
@ -337,6 +361,30 @@ const Raw = {
: object
},
/**
* Serialize a `selection`.
*
* @param {Selection} selection
* @param {Object} options (optional)
* @return {Object}
*/
serializeSelection(selection, options = {}) {
const object = {
kind: selection.kind,
anchorKey: selection.anchorKey,
anchorOffset: selection.anchorOffset,
focusKey: selection.focusKey,
focusOffset: selection.focusOffset,
isBackward: selection.isBackward,
isFocused: selection.isFocused,
}
return options.terse
? Raw.tersifySelection(object)
: object
},
/**
* Serialize a `state`.
*
@ -348,12 +396,19 @@ const Raw = {
serializeState(state, options = {}) {
const object = {
document: Raw.serializeDocument(state.document, options),
selection: Raw.serializeSelection(state.selection, options),
kind: state.kind
}
return options.terse
if (!options.preserveSelection) {
delete object.selection
}
const ret = options.terse
? Raw.tersifyState(object)
: object
return ret
},
/**
@ -461,6 +516,23 @@ const Raw = {
return ret
},
/**
* Create a terse representation of a selection `object.`
*
* @param {Object} object
* @return {Object}
*/
tersifySelection(object) {
return {
anchorKey: object.anchorKey,
anchorOffset: object.anchorOffset,
focusKey: object.focusKey,
focusOffset: object.focusOffset,
isFocused: object.isFocused,
}
},
/**
* Create a terse representation of a state `object`.
*
@ -469,7 +541,14 @@ const Raw = {
*/
tersifyState(object) {
return object.document
if (object.selection == null) {
return object.document
}
return {
document: object.document,
selection: object.selection
}
},
/**
@ -562,6 +641,25 @@ const Raw = {
}
},
/**
* Convert a terse representation of a selection `object` into a non-terse one.
*
* @param {Object} object
* @return {Object}
*/
untersifySelection(object) {
return {
kind: 'selection',
anchorKey: object.anchorKey,
anchorOffset: object.anchorOffset,
focusKey: object.focusKey,
focusOffset: object.focusOffset,
isBackward: null,
isFocused: false
}
},
/**
* Convert a terse representation of a state `object` into a non-terse one.
*
@ -570,6 +668,14 @@ const Raw = {
*/
untersifyState(object) {
if (object.selection != null) {
return {
kind: 'state',
document: object.document,
selection: object.selection,
}
}
return {
kind: 'state',
document: {

View File

@ -0,0 +1,6 @@
import Simulate from '../../../../helpers/simulate'
export default function (state, stack) {
return Simulate.blur(stack, state)
}

View File

@ -0,0 +1,17 @@
document:
key: a
nodes:
- key: b
kind: block
type: paragraph
nodes:
- key: c
kind: text
text: ""
selection:
anchorKey: c
anchorOffset: 0
focusKey: c
focusOffset: 0
isFocused: true

View File

@ -0,0 +1,17 @@
document:
key: a
nodes:
- key: b
kind: block
type: paragraph
nodes:
- key: c
kind: text
text: ""
selection:
anchorKey: c
anchorOffset: 0
focusKey: c
focusOffset: 0
isFocused: false

View File

@ -0,0 +1,6 @@
import Simulate from '../../../../helpers/simulate'
export default function (state, stack) {
return Simulate.keyDown(stack, state, null, { key: 'enter' })
}

View File

@ -0,0 +1,24 @@
document:
key: a
nodes:
- key: b
kind: block
type: paragraph
nodes:
- key: c
kind: text
text: ""
- key: d
kind: block
type: code
nodes:
- key: e
kind: text
text: ""
selection:
anchorKey: e
anchorOffset: 0
focusKey: e
focusOffset: 0
isFocused: true

View File

@ -0,0 +1,31 @@
document:
key: a
nodes:
- key: b
kind: block
type: paragraph
nodes:
- key: c
kind: text
text: ""
- key: d
kind: block
type: code
nodes:
- key: e
kind: text
text: ""
- key: "1"
kind: block
type: code
nodes:
- key: "0"
kind: text
text: ""
selection:
anchorKey: "0"
anchorOffset: 0
focusKey: "0"
focusOffset: 0
isFocused: true

50
test/behavior/index.js Normal file
View File

@ -0,0 +1,50 @@
import assert from 'assert'
import fs from 'fs'
import readYaml from 'read-yaml-promise'
import toCamel from 'to-camel-case'
import { Stack, Raw } from '../..'
import { resolve } from 'path'
/**
* Tests.
*/
describe('behavior', () => {
const fixturesDir = resolve(__dirname, 'fixtures')
const events = fs.readdirSync(fixturesDir)
for (const event of events) {
if (event[0] == '.') continue
describe(`${toCamel(event)}`, () => {
const testsDir = resolve(__dirname, 'fixtures', event)
const tests = fs.readdirSync(testsDir)
for (const test of tests) {
if (test[0] === '.') continue
it(test, async () => {
const dir = resolve(__dirname, 'fixtures', event, test)
const input = await readYaml(resolve(dir, 'input.yaml'))
const expected = await readYaml(resolve(dir, 'output.yaml'))
const module = require(dir)
const fn = module.default
let state = Raw.deserialize(input, { terse: true })
const props = module.props || {}
const stack = Stack.create(props)
state = fn(state, stack)
const output = Raw.serialize(state, {
terse: true,
preserveKeys: true,
preserveSelection: true,
})
assert.deepEqual(output, expected)
})
}
})
}
})

121
test/helpers/simulate.js Normal file
View File

@ -0,0 +1,121 @@
/**
* Event handlers that can be simulated.
*
* @type {Array}
*/
const EVENT_HANDLERS = [
'onBeforeInput',
'onBlur',
'onCopy',
'onCut',
'onDrop',
'onKeyDown',
'onPaste',
'onSelect',
]
/**
* Change handlers that can be simulated.
*
* @type {Array}
*/
const CHANGE_HANDLERS = [
'onBeforeChange',
'onChange'
]
/**
* Simulate utility.
*
* @type {Object}
*/
const Simulate = {}
/**
* Generate the event simulators.
*/
EVENT_HANDLERS.forEach((handler) => {
const method = getMethodName(handler)
Simulate[method] = function (stack, state, e, data) {
const editor = createEditor(stack, state)
const event = createEvent(e || {})
let next = stack[handler](state, editor, event, data)
if (next == state) return state
next = stack.onChange(next, editor)
return next
}
})
/**
* Generate the change simulators.
*/
CHANGE_HANDLERS.forEach((handler) => {
const method = getMethodName(handler)
Simulate[method] = function (stack, state) {
const editor = createEditor(stack, state)
const next = stack[handler](state, editor)
return next
}
})
/**
* Get the method name from a `handler` name.
*
* @param {String} handler
* @return {String}
*/
function getMethodName(handler) {
return handler.charAt(2).toLowerCase() + handler.slice(3)
}
/**
* Create a fake editor from a `stack` and `state`.
*
* @param {Stack} stack
* @param {State} state
*/
function createEditor(stack, state) {
return {
getSchema: () => stack.schema,
getState: () => state,
}
}
/**
* Create a fake event with `attributes`.
*
* @param {Object} attributes
* @return {Object}
*/
function createEvent(attributes) {
let event = {
preventDefault: () => event.isDefaultPrevented = true,
stopPropagation: () => event.isPropagationStopped = true,
isDefaultPrevented: false,
isPropagationStopped: false,
...attributes,
}
return event
}
/**
* Export.
*
* @type {Object}
*/
export default Simulate

View File

@ -1,4 +1,10 @@
import { resetKeyGenerator } from '..'
/**
* Polyfills.
*/
import 'babel-polyfill'
/**
@ -9,3 +15,12 @@ import './rendering'
import './schema'
import './serializers'
import './transforms'
import './behavior'
/**
* Reset Slate's internal state before each text.
*/
beforeEach(() => {
resetKeyGenerator()
})