Skip to content

Instantly share code, notes, and snippets.

@Nantris
Created May 3, 2019 22:59
Show Gist options
  • Save Nantris/b7ad82e70035edf6ba2eb02658243649 to your computer and use it in GitHub Desktop.
Save Nantris/b7ad82e70035edf6ba2eb02658243649 to your computer and use it in GitHub Desktop.
import Debug from 'debug'
import invariant from 'tiny-invariant'
import isPlainObject from 'is-plain-object'
import warning from 'tiny-warning'
import { List } from 'immutable'
import CommandsPlugin from '../plugins/commands'
import CorePlugin from '../plugins/core'
import Operation from '../models/operation'
import PathUtils from '../utils/path-utils'
import QueriesPlugin from '../plugins/queries'
import SchemaPlugin from '../plugins/schema'
import Value from '../models/value'
require('setimmediate')
const RESOLVED = Promise.resolve()
/**
* Debug.
*
* @type {Function}
*/
const debug = Debug('slate:editor')
/**
* Editor.
*
* @type {Editor}
*/
class Editor {
/**
* Create a new `Editor` with `attrs`.
*
* @param {Object} attrs
* @param {Object} options
*/
constructor(attrs = {}, options = {}) {
const { controller = this, construct = true } = options
const {
onChange = () => {},
plugins = [],
readOnly = false,
value = Value.create(),
} = attrs
this.controller = controller
this.middleware = {}
this.onChange = onChange
this.operations = List()
this.readOnly = null
this.value = null
this.tmp = {
dirty: [],
flushing: false,
merge: null,
normalize: true,
save: true,
}
const core = CorePlugin({ plugins })
registerPlugin(this, core)
if (construct) {
this.run('onConstruct')
this.setReadOnly(readOnly)
this.setValue(value, options)
}
}
/**
* Apply an `operation` to the editor, updating its value.
*
* @param {Operation|Object} operation
* @return {Editor}
*/
applyOperation(operation) {
const { operations, controller } = this
let value = this.value
// Add in the current `value` in case the operation was serialized.
if (isPlainObject(operation)) {
operation = { ...operation, value }
}
operation = Operation.create(operation)
// Save the operation into the history. Since `save` is a command, we need
// to do it without normalizing, since it would have side effects.
this.withoutNormalizing(() => {
controller.save(operation)
value = this.value
})
// Apply the operation to the value.
debug('apply', { operation })
this.value = operation.apply(value)
this.operations = operations.push(operation)
// Get the paths of the affected nodes, and mark them as dirty.
const newDirtyPaths = getDirtyPaths(operation)
const dirty = this.tmp.dirty.reduce((memo, path) => {
path = PathUtils.create(path)
const transformed = PathUtils.transform(path, operation)
memo = memo.concat(transformed.toArray())
return memo
}, newDirtyPaths)
this.tmp.dirty = dirty
// If we're not already, queue the flushing process on the next tick.
if (!this.tmp.flushing) {
this.tmp.flushing = true
RESOLVED.then(() => this.flush())
}
return controller
}
/**
* Flush the editor's current change.
*
* @return {Editor}
*/
flush() {
this.run('onChange')
const { value, operations, controller } = this
const change = { value, operations }
this.operations = List()
this.tmp.flushing = false
this.onChange(change)
return controller
}
/**
* Trigger a command by `type` with `...args`.
*
* @param {String|Function} type
* @param {Any} ...args
* @return {Editor}
*/
command(type, ...args) {
const { controller } = this
if (typeof type === 'function') {
type(controller, ...args)
normalizeDirtyPaths(this)
return controller
}
debug('command', { type, args })
const obj = { type, args }
this.run('onCommand', obj)
normalizeDirtyPaths(this)
return controller
}
/**
* Checks if a command by `type` has been registered.
*
* @param {String} type
* @return {Boolean}
*/
hasCommand(type) {
const { controller } = this
const has = type in controller && controller[type].__command
return has
}
/**
* Checks if a query by `type` has been registered.
*
* @param {String} type
* @return {Boolean}
*/
hasQuery(type) {
const { controller } = this
const has = type in controller && controller[type].__query
return has
}
/**
* Normalize all of the nodes in the document from scratch.
*
* @return {Editor}
*/
normalize() {
const { value, controller } = this
let { document } = value
const table = document.getKeysToPathsTable()
const paths = Object.values(table).map(PathUtils.create)
this.tmp.dirty = this.tmp.dirty.concat(paths)
normalizeDirtyPaths(this)
const { selection } = value
document = value.document
if (selection.isUnset && document.nodes.size) {
controller.moveToStartOfDocument()
}
return controller
}
/**
* Ask a query by `type` with `...args`.
*
* @param {String|Function} type
* @param {Any} ...args
* @return {Any}
*/
query(type, ...args) {
const { controller } = this
if (typeof type === 'function') {
return type(controller, ...args)
}
debug('query', { type, args })
const obj = { type, args }
return this.run('onQuery', obj)
}
/**
* Register a command `type` with the editor.
*
* @param {String} type
* @return {Editor}
*/
registerCommand(type) {
const { controller } = this
if (type in controller && controller[type].__command) {
return controller
}
invariant(
!(type in controller),
`You cannot register a \`${type}\` command because it would overwrite an existing property of the \`Editor\`.`
)
const method = (...args) => this.command(type, ...args)
controller[type] = method
method.__command = true
return controller
}
/**
* Register a query `type` with the editor.
*
* @param {String} type
* @return {Editor}
*/
registerQuery(type) {
const { controller } = this
if (type in controller && controller[type].__query) {
return controller
}
invariant(
!(type in controller),
`You cannot register a \`${type}\` query because it would overwrite an existing property of the \`Editor\`.`
)
const method = (...args) => this.query(type, ...args)
controller[type] = method
method.__query = true
return controller
}
/**
* Run through the middleware stack by `key` with `args`.
*
* @param {String} key
* @param {Any} ...args
* @return {Any}
*/
run(key, ...args) {
const { controller, middleware } = this
const fns = middleware[key] || []
let i = 0
function next(...overrides) {
const fn = fns[i++]
if (!fn) return
if (overrides.length) {
args = overrides
}
const ret = fn(...args, controller, next)
return ret
}
Object.defineProperty(next, 'change', {
get() {
invariant(
false,
'As of Slate 0.42, the `editor` is no longer passed as the third argument to event handlers. You can access it via `change.editor` instead.'
)
},
})
Object.defineProperty(next, 'onChange', {
get() {
invariant(
false,
'As of Slate 0.42, the `editor` is no longer passed as the third argument to event handlers. You can access it via `change.editor` instead.'
)
},
})
Object.defineProperty(next, 'props', {
get() {
invariant(
false,
'As of Slate 0.42, the `editor` is no longer passed as the third argument to event handlers. You can access it via `change.editor` instead.'
)
},
})
Object.defineProperty(next, 'schema', {
get() {
invariant(
false,
'As of Slate 0.42, the `editor` is no longer passed as the third argument to event handlers. You can access it via `change.editor` instead.'
)
},
})
Object.defineProperty(next, 'stack', {
get() {
invariant(
false,
'As of Slate 0.42, the `editor` is no longer passed as the third argument to event handlers. You can access it via `change.editor` instead.'
)
},
})
return next()
}
/**
* Set the `readOnly` flag.
*
* @param {Boolean} readOnly
* @return {Editor}
*/
setReadOnly(readOnly) {
this.readOnly = readOnly
return this
}
/**
* Set the editor's `value`.
*
* @param {Value} value
* @param {Options} options
* @return {Editor}
*/
setValue(value, options = {}) {
const { normalize = value !== this.value } = options
this.value = value
if (normalize) {
this.normalize()
}
return this
}
/**
* Apply a series of changes inside a synchronous `fn`, deferring
* normalization until after the function has finished executing.
*
* @param {Function} fn
* @return {Editor}
*/
withoutNormalizing(fn) {
const { controller } = this
const value = this.tmp.normalize
this.tmp.normalize = false
fn(controller)
this.tmp.normalize = value
normalizeDirtyPaths(this)
return controller
}
/**
* Deprecated.
*/
get editor() {
warning(
false,
"As of Slate 0.43 the `change` object has been replaced with `editor`, so you don't need to access `change.editor`."
)
return this.controller
}
change(fn, ...args) {
warning(
false,
'As of Slate 0.43 the `change` object has been replaced with `editor`, so the `editor.change()` method is deprecated.`'
)
fn(this.controller, ...args)
}
call(fn, ...args) {
warning(
false,
'As of Slate 0.43 the `editor.call(fn)` method has been deprecated, please use `editor.command(fn)` instead.'
)
fn(this.controller, ...args)
return this.controller
}
applyOperations(operations) {
warning(
false,
'As of Slate 0.43 the `applyOperations` method is deprecated, please apply each operation in a loop instead.'
)
operations.forEach(op => this.applyOperation(op))
return this.controller
}
setOperationFlag(key, value) {
warning(
false,
'As of slate@0.41 the `change.setOperationFlag` method has been deprecated.'
)
this.tmp[key] = value
return this
}
getFlag(key, options = {}) {
warning(
false,
'As of slate@0.41 the `change.getFlag` method has been deprecated.'
)
return options[key] !== undefined ? options[key] : this.tmp[key]
}
unsetOperationFlag(key) {
warning(
false,
'As of slate@0.41 the `change.unsetOperationFlag` method has been deprecated.'
)
delete this.tmp[key]
return this
}
withoutNormalization(fn) {
warning(
false,
'As of slate@0.41 the `change.withoutNormalization` helper has been renamed to `change.withoutNormalizing`.'
)
return this.withoutNormalizing(fn)
}
}
/**
* Get the "dirty" paths for a given `operation`.
*
* @param {Operation} operation
* @return {Array}
*/
function getDirtyPaths(operation) {
const { type, node, path, newPath } = operation
switch (type) {
case 'add_mark':
case 'insert_text':
case 'remove_mark':
case 'remove_text':
case 'set_mark':
case 'set_node': {
const ancestors = PathUtils.getAncestors(path).toArray()
return [...ancestors, path]
}
case 'insert_node': {
const table = node.getKeysToPathsTable()
const paths = Object.values(table).map(p => path.concat(p))
const ancestors = PathUtils.getAncestors(path).toArray()
return [...ancestors, path, ...paths]
}
case 'split_node': {
const ancestors = PathUtils.getAncestors(path).toArray()
const nextPath = PathUtils.increment(path)
return [...ancestors, path, nextPath]
}
case 'merge_node': {
const ancestors = PathUtils.getAncestors(path).toArray()
const previousPath = PathUtils.decrement(path)
return [...ancestors, previousPath]
}
case 'move_node': {
if (PathUtils.isEqual(path, newPath)) {
return []
}
const oldAncestors = PathUtils.getAncestors(path).reduce((arr, p) => {
arr.push(...PathUtils.transform(p, operation).toArray())
return arr
}, [])
const newAncestors = PathUtils.getAncestors(newPath).reduce((arr, p) => {
arr.push(...PathUtils.transform(p, operation).toArray())
return arr
}, [])
return [...oldAncestors, ...newAncestors]
}
case 'remove_node': {
const ancestors = PathUtils.getAncestors(path).toArray()
return [...ancestors]
}
default: {
return []
}
}
}
/**
* Normalize any new "dirty" paths that have been added to the change.
*
* @param {Editor}
*/
function normalizeDirtyPaths(editor) {
if (!editor.tmp.normalize) {
return
}
if (!editor.tmp.dirty.length) {
return
}
editor.withoutNormalizing(() => {
while (editor.tmp.dirty.length) {
const path = editor.tmp.dirty.pop()
normalizeNodeByPath(editor, path)
}
})
}
/**
* Normalize the node at a specific `path`.
*
* @param {Editor} editor
* @param {Array} path
*/
function normalizeNodeByPath(editor, path) {
const { controller } = editor
let { value } = editor
let { document } = value
let node = document.assertNode(path)
let iterations = 0
const max = 100 + (node.object === 'text' ? 1 : node.nodes.size)
while (node) {
const fn = node.normalize(controller)
if (!fn) {
break
}
// Run the normalize `fn` to fix the node.
fn(controller)
// Attempt to re-find the node by path, or by key if it has changed
// locations in the tree continue iterating.
value = editor.value
document = value.document
const { key } = node
let found = document.getDescendant(path)
if (found && found.key === key) {
node = found
} else {
found = document.getDescendant(key)
if (found) {
node = found
path = document.getPath(key)
} else {
// If it no longer exists by key, it was removed, so we're done.
break
}
}
// Increment the iterations counter, and check to make sure that we haven't
// exceeded the max. Without this check, it's easy for the `normalize`
// function of a schema rule to be written incorrectly and for an infinite
// invalid loop to occur.
iterations++
if (iterations > max) {
throw new Error(
'A schema rule could not be normalized after sufficient iterations. This is usually due to a `rule.normalize` or `plugin.normalizeNode` function of a schema being incorrectly written, causing an infinite loop.'
)
}
}
}
/**
* Register a `plugin` with the editor.
*
* @param {Editor} editor
* @param {Object|Array} plugin
*/
function registerPlugin(editor, plugin) {
if (Array.isArray(plugin)) {
plugin.forEach(p => registerPlugin(editor, p))
return
}
const { commands, queries, schema, ...rest } = plugin
if (commands) {
const commandsPlugin = CommandsPlugin(commands)
registerPlugin(editor, commandsPlugin)
}
if (queries) {
const queriesPlugin = QueriesPlugin(queries)
registerPlugin(editor, queriesPlugin)
}
if (schema) {
const schemaPlugin = SchemaPlugin(schema)
registerPlugin(editor, schemaPlugin)
}
for (const key in rest) {
const fn = rest[key]
const middleware = (editor.middleware[key] = editor.middleware[key] || [])
middleware.push(fn)
}
}
/**
* Export.
*
* @type {Editor}
*/
export default Editor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment