This is a proposal for a bunch of API changes that attempt to make using ProseMirror less baffling. Most of the baffling parts of the API were designed that way in an attempt to avoid boilerplate, so the result will in some cases require more code. More code may be better than magic implicit effects.
I'm looking for feedback, and am going to let this stew for a while as I'm working on other things.
ProseMirror has the following global namespaces: options (defineOption), steps (defineStep), formats (defineSource, defineTarget). Reliance on namespaces is, I guess, a bad habit I carried over from my Common Lisp past. The pattern of directly including the module that defines a piece of functionality, rather than referring to it through a name, is something that's more 'JavaScripty', and probably easier to work with.
Instead of user-definable options, we could define an addon type that has methods to attach and detach itself from a ProseMirror instance, and have an option that allows you to pass in an array of addons to enable at startup. You'd then do something like
import {menuBar} from ...
let pm = new ProseMirror({
...
addons: [menuBar.configure(...), ...]
})
menuBar.detach(pm) // To disable at run-time
The same for steps and parser/serializer formats: you'd import them, and use their implementation, rather than their name, when you need them.
SchemaItem.register is also a namespace, but a per-node-type one. The action-at-a-distance of functionality being magically pulled out of the elements you put into your schema seems to confuse people.
We could instead have methods to add or remove commands, and package sets of commands as addons that put those commands into your editor (and remove them again when detached). The default schema commands could be exposed both as a single blob, and per-node sets, so that people can import them at the granularity they desire. We can probably get rid of the concept of a CommandSpec and just have commands be a single object.
Automatically deriving a keymap and a menu from your set of active commands would be replaced by either using the default keymap/menu or building up your own keymap/menu entirely from scratch. This is definitely a case where you get more boilerplate code, but also _much_ less complexity and magic.
The same principles would be applied to input rules.
Getting rid of SchemaItem.register entirely would be a nice bonus. It's kind of subtle, and inherited static methods don't work so well yet (Babel doesn't support them on IE9, Bubl茅 doesn't support them at all.)
I'd like to move some of the things that live in node type classes, such as their relations (what contains what, and how many) out of the classes and into the schema definition.
The default schema can and probably should be moved out of the model module and into its own module.
We might need the concept of a set of 'presets', to allow the default schema, commands, keybindings, etc, to simply be applied to an editor without doing a lot of lego assembly every time. But maybe the addon concept described before can fullfill that role -- you'd get composite addons that tie together a bunch of smaller addons into a coherent configuration.
(The word 'addon' is kind of awful, 'plugin' also doesn't have great connotations, 'module' is too general... suggestions for better terms welcome.)
I noticed today that to enable the Collaborative Editing module, you import the package which includes an inline defineOption() call. Using something like the addons property to enable this functionality instead makes sense and feels less magic-like.
Sorry for the late response here. I definitely like the way it sounds like you want to take things. (Some of these thoughts might not map properly to ProseMirror concepts, since I haven't been in the code for a while so I've forgotten naming, structure, etc.)
I understand the problems mentioned in #241, about how making things explicit can lead to the default experience being harder to get started with. But I think the best thing to do is first layout the completely explicit path, and then work around that to figure out how to make the 90% case implicit if need be. Using some concept of "presets" or "defaults" sounds like a good way to go with this. Ideally each preset is just a dictionary/list of regular pieces鈥攕imilar to how Babel works.
Agreed that there isn't a nice word too... I think "plugin" is the best at communicating what things do, with prior art from Babel, etc. Especially if "presets" is another term.
For what it's worth, I've been using Draft.js recently as well as some of the tooling that the community has built up around trying to make it more "pluggable".
There's the draft-js-plugins project that tries to basically give you control over all of the functionality of the editor via series of middleware-like functions that you pass in. I actually currently use my own similar take on it, but the idea is the same. For example, a lot of the custom functionality I've added to my editor I do as private plugins (which can eventually easily be open-sourced since they are completely decoupled):

Each one of them is built to be decoupled from my exact use cases, and instead take in a series of options. So that might plugin configuration ends up looking like:
/**
* Blocks.
*/
imageBlock: RenderAtomicBlock({
type: 'image',
Component: ImageBlock,
}),
hrBlock: RenderAtomicBlock({
type: 'hr',
Component: HrBlock,
}),
/**
* Block logic.
*/
softBreakOnReturn: SoftBreakOnReturn({
blockTypes: [
'code-block',
]
}),
resetEmptyOnReturn: ResetEmptyOnReturn({
resetTo: 'paragraph',
blockTypes: [
'blockquote',
'header-five',
'header-four',
'header-one',
'header-six',
'header-three',
'header-two',
'ordered-list-item',
'unordered-list-item',
],
}),
resetOnBackspace: ResetOnBackspace({
resetTo: 'paragraph',
blockTypes: [
'blockquote',
'code-block',
'header-one',
'header-two',
'header-three',
'header-four',
'header-five',
'header-six',
'ordered-list-item',
'unordered-list-item',
],
}),
// after reset empty on return, so that only non-empty blocks continue
preventContinuation: PreventContinuation({
resetTo: 'paragraph',
blockTypes: [
'blockquote',
'header-one',
'header-two',
'header-three',
'header-four',
'header-five',
'header-six',
]
}),
And more...
/**
* Auto-replace blocks.
*/
autoReplaceBlocks: [
AutoReplace({
trigger: ' ',
matchBefore: /^(\s*>)$/,
replacement: {
kind: 'block',
type: 'blockquote',
}
}),
AutoReplace({
trigger: 'enter',
matchBefore: /^(\s*`{3}\s*)$/,
replacement: {
kind: 'block',
type: 'code-block',
},
}),
AutoReplace({
trigger: ' ',
matchBefore: /^(\s*(#{1,6}))$/,
replacement: {
kind: 'block',
type: (trigger, matches) => {
const [ input, match, hashes ] = matches.before
switch (hashes) {
case '#': return 'header-one'
case '##': return 'header-two'
case '###': return 'header-three'
case '####': return 'header-four'
case '#####': return 'header-five'
case '######': return 'header-six'
}
},
},
}),
AutoReplace({
trigger: 'enter',
matchBefore: /^(\s*\*{3}\s*)$/,
replacement: {
kind: 'block',
type: 'hr',
},
}),
AutoReplace({
trigger: ' ',
matchBefore: /^(\s*[0-9a-zA-Z]+\.)$/,
replacement: {
kind: 'block',
type: 'ordered-list-item',
},
}),
AutoReplace({
trigger: ' ',
matchBefore: /^(\s*[\-\*])$/,
replacement: {
kind: 'block',
type: 'unordered-list-item',
},
}),
],
Individual plugins then have access to be able to add specific functions to the editor's middleware stack, to implement their own functionality. For example a plugin that simply collapses the selection when escape is pressed may not even need options:
/**
* Plugin.
*
* @param {Object} options
* @return {Object} plugin
*/
export default function collapseOnEscape(options) {
return {
/**
* On escape, collapse the current selection.
*
* @param {Event} e
* @param {Editor} editor
* @return {Boolean}
*/
onEscape(e, { getEditorState, onChange }) {
let state = getEditorState()
let selection = getCurrentSelection(state)
selection = collapseSelection(selection)
state = EditorState.forceSelection(state, selection)
onChange(state)
e.preventDefault()
return true
}
}
}
Whereas something more complex like input rules might actually end up adding many middleware functions, each to handle the different pieces of logic needed:
/**
* Plugin.
*
* @param {Object} options
* @property {String} trigger
* @property {RegExp or Function} matchBefore
* @property {RegExp or Function} matchAfter
* @property {Object} replacement
* @property {String} kind
* @property {String or Function} type
* @property {String or Function} text
* @property {Array} ignoreInside
* @return {Object} plugin
*/
export default function autoReplace({
trigger,
matchAfter,
matchBefore,
replacement,
ignoreInside = [],
}) {
assert(trigger, 'You must provide a `trigger`.')
assert(replacement, 'You must provide a `replacement`.')
assert(replacement.kind, 'You must provide a `replacement.kind`.')
const isTrigger = normalizeTrigger(trigger)
/**
* Replace on typing a `char`.
*
* @param {String} char
* @param {Editor} editor
* @return {Boolean} skip
*/
function handleBeforeInput(char, editor) {
if (!isTrigger(char)) return
return replace(char, editor)
}
/**
* Replace on hitting return.
*
* @param {Event} e
* @param {Editor} editor
* @return {Boolean} skip
*/
function handleReturn(e, editor) {
return replace('enter', editor)
}
/**
* Apply the replacement to `state`.
*
* @param {String} trigger
* @param {Editor} editor
* @return {Boolean} skip
*/
function replace(trigger, { getEditorState, onChange }) {
const state = getEditorState()
if (!isCurrentSelectionCollapsed(state)) return
const block = getCurrentBlock(state)
const type = block.getType()
if (ignoreInside.includes(type)) return
const matches = match(state)
const { before, after } = matches
if (!before && !after) return
switch (replacement.kind) {
case 'block': return autoReplaceBlock(trigger, matches, state, onChange)
case 'style': return autoReplaceStyle(trigger, matches, state, onChange)
case 'text': return autoReplaceText(trigger, matches, state, onChange)
}
}
/**
* Try to match the current state with the rules.
*
* @param {EditorState} state
* @return {Object} matches
*/
function match(state) {
const block = getCurrentBlock(state)
const selection = getCurrentSelection(state)
const anchor = selection.getAnchorOffset()
const text = block.getText()
let after = null
let before = null
if (matchAfter) {
const string = text.slice(anchor)
after = string.match(matchAfter)
}
if (matchBefore) {
const string = text.slice(0, anchor)
before = string.match(matchBefore)
}
// if both sides, require that both are matched, otherwise null
if (matchBefore && matchAfter && !before) after = null
if (matchBefore && matchAfter && !after) before = null
return { before, after }
}
/**
* Replace a block.
*
* @param {String} trigger
* @param {Object} matches
* @param {EditorState} state
* @param {Function} onChange
* @return {Boolean} skip
*/
function autoReplaceBlock(trigger, matches, state, onChange) {
const { before, after } = matches
const { offset, length } = getBounds(matches, state)
const block = getCurrentBlock(state)
const blockType = typeof replacement.type == 'function'
? replacement.type(trigger, matches)
: replacement.type
let content = getCurrentContent(state)
let selection = getCurrentSelection(state)
// set the new block type
content = Modifier.setBlockType(content, selection, blockType)
// remove the matched text
selection = createBlockSelection(block, offset, offset + length)
content = Modifier.replaceText(content, selection, '')
// push the change
state = EditorState.push(state, content, 'change-block-type')
// force the new selection
selection = content.getSelectionAfter()
state = EditorState.forceSelection(state, selection)
onChange(state)
return true
}
/**
* Replace an inline style.
*
* @param {String} trigger
* @param {Object} matches
* @param {EditorState} state
* @param {Function} onChange
* @return {Boolean} skip
*/
function autoReplaceStyle(trigger, matches, state, onChange) {
const { before, after } = matches
const { offset, length } = getBounds(matches, state)
const block = getCurrentBlock(state)
const style = replacement.style
let content = getCurrentContent(state)
let selection = getCurrentSelection(state)
let range = createBlockSelection(block, offset, offset + length)
let end = createBlockSelection(block, offset + length, offset + length)
if (replacement.text) {
const text = typeof replacement.text == 'function'
? replacement.text(trigger, matches)
: replacement.text
const last = offset + text.length
// remove the matched text
content = Modifier.replaceText(content, range, text)
range = createBlockSelection(block, offset, last)
end = createBlockSelection(block, last, last)
}
// apply the style
content = Modifier.applyInlineStyle(content, range, style)
// push the change
state = EditorState.push(state, content, 'change-inline-style')
// force the selection to the end of the matched string
state = EditorState.forceSelection(state, end)
onChange(state)
return true
}
/**
* Replace a string of text.
*
* @param {String} trigger
* @param {Object} matches
* @param {EditorState} state
* @param {Function} onChange
* @return {Boolean} skip
*/
function autoReplaceText(trigger, matches, state, onChange) {
const { before, after } = matches
const { offset, length } = getBounds(matches, state)
const block = getCurrentBlock(state)
const text = typeof replacement.text == 'function'
? replacement.text(trigger, matches)
: replacement.text
let end = offset + text.length
let range = createBlockSelection(block, offset, offset + length)
let selection = createBlockSelection(block, end, end)
let content = getCurrentContent(state)
// remove the matched text
content = Modifier.replaceText(content, range, text)
// push the change
state = EditorState.push(state, content, 'insert-characters')
// force the selectino to the end of the matched string
state = EditorState.forceSelection(state, selection)
onChange(state)
return true
}
/**
* Return the offset and length for `matches`.
*
* @param {Object} matches
* @property {Array} before
* @property {Array} after
* @param {EditorState} state
* @return {Object}
* @property {Number} offset
* @property {Number} length
*/
function getBounds(matches, state) {
const { before, after } = matches
const selection = getCurrentSelection(state)
let offset = selection.getAnchorOffset()
let length = 0
if (before && before[1]) {
offset -= before[1].length
length += before[1].length
}
if (after && after[1]) {
length += after[1].length
}
return { offset, length }
}
/**
* Return the proper hooks based on the trigger.
*/
if (trigger == 'enter') return { handleReturn }
return { handleBeforeInput }
}
/**
* Normalize a `trigger` option to a matching function.
*
* @param {Mixed} trigger
* @return {Function}
*/
function normalizeTrigger(trigger) {
const type = typeOf(trigger)
switch (type) {
case 'regexp': return string => !! string.match(trigger)
case 'string': return string => string == trigger
}
}
Or something like handling "atomic" blocks (that don't have text inside them, and are selected/edited as a single atomic unit) like images, also can result in multiple different middleware functions:
/**
* Plugin.
*
* @param {Object} options
* @property {Component} component
* @property {Boolean} editable
* @property {String} type
* @return {Object} plugin
*/
export default function renderAtomicBlock({
Component,
type,
}) {
return {
/**
* Block render map.
*
* @param {Editor} editor
* @return {Object} map
*/
blockRenderMap(editor) {
return {
[type]: {
element: 'figure',
}
}
},
/**
* A strategy to decorate all blocks of a certain entity by `type`.
*
* @param {ContentBlock} block
* @param {Editor} editor
* @param {Function} callback
* @return {Object} map
*/
blockRendererFn(block, editor) {
if (block.getType() != type) return
return {
component: decorateComponentWithProps(AtomicBlock, {
Component,
editor
})
}
},
/**
* When the state changes, make sure the selected state re-renders.
*
* @param {EditorState} state
* @param {Editor} editor
* @return {EditorState}
*/
onChange(state, editor) {
const block = getCurrentBlock(state)
const match = block.getType() == type
const lastMatch = lastBlock && lastBlock.getType() == type
let selection = state.getSelection()
if (!block) return
if (lastBlock == block) return
// not inside this type of atomic block, but the last block was this type,
// then force a selection to make sure it re-renders as not selected
if (!match && lastMatch) {
state = EditorState.forceSelection(state, selection)
// update cache
lastBlock = block
}
// inside this type of atomic block, update selection
else if (match) {
const node = getBlockDomNode(block)
if (!node) return
const spacer = node.firstChild.firstChild
const sel = document.getSelection()
const range = document.createRange()
range.setStart(spacer, 0)
range.setEnd(spacer, 0)
// force the update, to re-render the block
selection = createBlockSelection(block, 0, block.getLength())
state = EditorState.forceSelection(state, selection)
// reset the selection to what it really should be
requestAnimationFrame(() => {
sel.removeAllRanges()
sel.addRange(range)
})
// update cache
lastBlock = block
}
return state
},
/**
* Listen for key events inside the block:
*
* - Left arrow: move to the end of the previous block.
* - Right arrow: move to the start of the next block.
* - Backspace: delete the block.
*
* Listen for key events outside the block:
*
* - Backspace: delete the block if it's right before the selection.
* - Delete: delete the block if it's right after the selection.
* - Return: todo
*
* @param {Event} e
* @param {Editor} editor
* @return {Boolean} override
*/
keyBindingFn(e, { getEditorState, onChange }) {
let state = getEditorState()
let content = state.getCurrentContent()
let selection
const block = getCurrentBlock(state)
const isAtomic = block.getType() == type
// currently inside of the atomic block
if (isAtomic) {
e.preventDefault()
// left arrow
if (e.keyCode == 37) {
state = moveToEndOfPreviousBlock(state)
onChange(state)
}
// right arrow
if (e.keyCode == 39) {
state = moveToStartOfNextBlock(state)
onChange(state)
}
// backspace or delete
if (e.keyCode == 8 || e.keyCode == 46) {
selection = createBlockSelection(block)
content = Modifier.removeRange(content, selection, 'backward')
selection = content.getSelectionAfter()
content = Modifier.setBlockType(content, selection, 'paragraph')
selection = content.getSelectionAfter()
state = EditorState.push(state, content, 'remove-range')
state = EditorState.forceSelection(state, selection)
onChange(state)
}
return true
}
// not currently inside an atomic block
else {
// backspace
if (e.keyCode == 8) {
e.preventDefault()
selection = state.getSelection()
const previous = getPreviousBlock(block, content)
const atStart = isAtStartOfBlock(selection, block)
if (!isCollapsed(selection)) return
if (!previous || previous.getType() != type) return
if (!atStart) return
if (isCompletelyEmpty(block)) return
selection = createBlockSelection(previous, 0, 0)
state = EditorState.forceSelection(state, selection)
onChange(state)
return true
}
// delete
if (e.keyCode == 46) {
e.preventDefault()
selection = state.getSelection()
const next = getNextBlock(block, content)
const atEnd = isAtEndOfBlock(selection, block)
if (!isCollapsed(selection)) return
if (!next || next.getType() != type) return
if (!atEnd) return
selection = createBlockSelection(next, 0, 0)
state = EditorState.forceSelection(state, selection)
onChange(state)
return true
}
}
},
/**
* Listen for up arrow events, to move the selection to the end of the
* previous block.
*
* @param {Event} e
* @param {Editor} editor
* @return {Boolean} override
*/
onUpArrow(e, { getEditorState, onChange }) {
let state = getEditorState()
const content = state.getCurrentContent()
const block = getCurrentBlock(state)
if (block.getType() != type) return
e.preventDefault()
state = moveToEndOfPreviousBlock(state)
onChange(state)
return true
},
/**
* Listen for down arrow events, to move the selection to the start of the
* next block.
*
* @param {Event} e
* @param {Editor} editor
* @return {Boolean} override
*/
onDownArrow(e, { getEditorState, onChange }) {
let state = getEditorState()
const content = state.getCurrentContent()
const block = getCurrentBlock(state)
if (block.getType() != type) return
e.preventDefault()
state = moveToStartOfNextBlock(state)
onChange(state)
return true
},
/**
* Listen for return presses, to create a new paragraph block below the
* current atomic block.
*
* @param {Event} e
* @param {Editor} editor
* @return {Boolean} override
*/
handleReturn(e, { getEditorState, onChange }) {
let state = getEditorState()
let content = state.getCurrentContent()
let block = getCurrentBlock(state)
if (block.getType() != type) return
e.preventDefault()
state = insertBlockAfter(state, block, 'paragraph')
onChange(state)
return true
},
}
}
(The auto-replace functionality was inspired by how ProseMirror handles the input rules.) That level of composability makes it super easy to go about tweaking things or building up more complex functionality in the editor that spans across the different editing "primitives".
Since Draft.js is built on top of React, it lends itself well to declaring presets/plugins explicitly since it's so focused on the views being as stateless as possible. Something that tends to feel better in my opinion than things like menuBar.detach(pm) which are state-based. But without buying into React this might not be as possible with ProseMirror.
Thanks for the feedback!
first layout the completely explicit path, and then work around that to figure out how to make the 90% case implicit if need be
Yes, I've been thinking in this direction. Once you have all the pieces available as regular exports, you can do things like create helpers that put together the appropriate pieces by inspecting a schema object. These would still be easier to understand than magic properties on subclasses.
Something that tends to feel better in my opinion than things like menuBar.detach(pm) which are state-based
So in draft.js, you would reconfigure by passing different attrributes to the <Editor> component in different frames? That's neat, though possible expensive, and indeed not really workable outside of the React world.
Could you tell me a little more (or refer me to the relevant docs, which I didn't manage to find) on how you'd use a plugin like this in draft.js? Say you have the object returned by collapseOnEscape, do you splice its properties into your editor component's props? What if another plugin also wants to override onEscape or onChange?
Yeah, sadly the stateless concept is less doable outside React. As for how it gets included, good point, here's the source of my draft-pluggable editor, which is based on Draft.js Plugins:
import React from 'react'
import decorateComponentWithProps from 'decorate-component-with-props'
import defaultKeyBindingPlugin from './default-key-binding-plugin'
import flatten from 'lodash/flatten'
import { CompositeDecorator, Editor, EditorState, DefaultDraftBlockRenderMap } from 'draft-js'
import { Map } from 'immutable'
/**
* Proxy method properties that will call into the original editor's "ref".
*/
const PROXY_PROPS = [
'blur',
'exitCurrentMode',
'focus',
'getClipboard',
'getEditorKey',
'onDragEnter',
'onDragLeave',
'removeRenderGuard',
'restoreEditorDOM',
'setClipboard',
'setMode',
'setRenderGuard',
'update',
]
/**
* Handler properties that will be wrapped with a middleware stack, to be
* evaluated by each plugin.
*/
const HANDLER_PROPS = [
'blockRendererFn',
'blockStyleFn',
'handleBeforeInput',
'handleDrop',
'handleDroppedFiles',
'handleKeyCommand',
'handlePastedFiles',
'handlePastedText',
'handleReturn',
'keyBindingFn',
'onDownArrow',
'onEscape',
'onTab',
'onUpArrow',
]
/**
* All properties that are plugin-controlled, to create a single plugin for all
* of the top-level editor props, to override any plugin behavior.
*/
const PLUGIN_PROPS = [
...HANDLER_PROPS,
// 'blockRenderMap',
'customStyleMap',
]
/**
* Properties that are specific to the pluggable version of the editor, and that
* should not be passed into the original editor.
*/
const PLUGGABLE_PROPS = [
'defaultKeyBindings',
'editorState',
'onChange',
'plugins',
]
/**
* Component.
*/
class PluggableEditor extends React.Component {
/**
* Types.
*/
static propTypes = {
defaultKeyBindings: React.PropTypes.bool,
editorState: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func,
onChangeContent: React.PropTypes.func,
onChangeSelection: React.PropTypes.func,
plugins: React.PropTypes.array,
readOnly: React.PropTypes.bool,
};
/**
* Defaults.
*/
static defaultProps = {
defaultKeyBindings: true,
defaultValue: null,
onChange: function(){},
onChangeContent: function(){},
onChangeSelection: function(){},
plugins: [],
};
/**
* State.
*/
state = {
readOnly: false,
};
/**
* Create a proxy that calls into the original editor "ref" for each of the
* proxy methods.
*
* @param {Object} props
*/
constructor(props) {
super(props)
this.cache = {}
for (const method of PROXY_PROPS) {
this[method] = (...args) => this.refs.editor[method](...args)
}
}
/**
* On mount, set the decorator based on all of the plugins, and update the
* `editorState` since setting the decorator will have caused it to change.
*/
componentWillMount() {
const decorator = this.resolveDecorator()
const editorState = EditorState.set(this.props.editorState, { decorator })
this.onChange(editorState)
}
/**
* Get the editor's state.
*
* @return {EditorState} editorState
* @public
*/
getEditorState = () => {
return this.props.editorState
}
/**
* Set the editor's state. This is just a convenience for `onChange`, but it's
* more intuitive from the plugin author's perspective.
*
* @param {EditorState} editorState
* @public
*/
setEditorState = (editorState) => {
this.onChange(editorState)
}
/**
* Get the editor's read-only state.
*
* @return {Boolean} readOnly
* @public
*/
getEditorReadOnly = () => {
let { readOnly } = this.props
if (this.state.readOnly !== undefined) readOnly = this.state.readOnly
return readOnly
}
/**
* Set the editor's read-only state.
*
* @param {Boolean} readOnly
* @public
*/
setEditorReadOnly = (readOnly) => {
this.setState({ readOnly })
}
/**
* When the <Editor> changes, pass the value through the plugins as middleware
* so they can update with any extra changes, and then call `onChange`.
*
* @param {EditorState} editorState
* @public
*/
onChange = (editorState) => {
const { onChange, onChangeContent, onChangeSelection } = this.props
const plugins = this.resolvePlugins()
for (const plugin of plugins) {
if (!plugin.onChange) continue
editorState = plugin.onChange(editorState, this) || editorState
}
const contentState = editorState.getCurrentContent()
const selectionState = editorState.getSelection()
if (editorState != this.cache.editorState) onChange(editorState)
if (contentState != this.cache.contentState) onChangeContent(contentState)
if (selectionState != this.cache.selectionState) onChangeSelection(selectionState)
// hack for content state to also include entity changes
if (
editorState != this.cache.editorState &&
contentState == this.cache.contentState &&
selectionState == this.cache.selectionState
) {
onChangeContent(contentState)
}
this.cache.editorState = editorState
this.cache.contentState = contentState
this.cache.selectionState = selectionState
}
/**
* Render the editor.
*
* @return {Object}
*/
render() {
return (
<Editor
{...this.resolvePluginProps()}
{...this.resolveHandlerProps()}
{...this.resolveEditorProps()}
ref='editor'
onChange={this.onChange}
editorState={this.props.editorState}
readOnly={this.getEditorReadOnly()}
customStyleMap={this.resolveCustomStyleMap()}
suppressContentEditableWarning
/>
)
}
/**
* Resolve the `blockRenderMap` property by iterating all of the plugins.
*
* @return {ImmutableMap} map
*/
// resolveBlockRenderMap() {
// const plugins = this.resolvePlugins()
// let map = DefaultDraftBlockRenderMap
// for (const plugin of plugins) {
// if (!plugin.blockRenderMap) continue
// const raw = plugin.blockRenderMap(this)
// map = map.merge(Map(raw))
// }
// return map
// }
/**
* Resolve the `customStyleMap` property by iterating all of the plugins.
*
* @return {Object} styles
*/
resolveCustomStyleMap() {
const plugins = this.resolvePlugins()
let styles = {}
for (const plugin of plugins) {
if (!plugin.customStyleMap) continue
styles = {
...styles,
...plugin.customStyleMap,
}
}
return styles
}
/**
* Resolve a composite `decorator` function by joining any decorators provided
* by all of the plugins. Each decorator's component is also passed the
* `editor` in case it needs to change it's state based on external data.
*
* @return {CompositeDecorator} decorator
*/
resolveDecorator() {
const plugins = this.resolvePlugins()
let decorators = []
for (const plugin of plugins) {
if (!plugin.decorators) continue
decorators = [
...decorators,
...plugin.decorators,
]
}
for (const decorator of decorators) {
decorator.component = decorateComponentWithProps(decorator.component, {
editor: this
})
}
return new CompositeDecorator(decorators)
}
/**
* Resolve all of the top-level editor properties that should be passed into
* the editor, omitting properties that are specific to the pluggable version
* of the editor, and properties that are turned into the editor-level plugin.
*
* @return {Object} props
*/
resolveEditorProps() {
const props = {}
for (const key in this.props) {
if (PLUGIN_PROPS.includes(key)) continue
if (PLUGGABLE_PROPS.includes(key)) continue
props[key] = this.props[key]
}
return props
}
/**
* Resolve the handler method properties from all of the plugins.
*
* @return {Object} props
*/
resolveHandlerProps() {
let props = {}
for (const method of HANDLER_PROPS) {
props[method] = (...args) => {
args.push(this)
const plugins = this.resolvePlugins()
for (const plugin of plugins) {
if (!plugin[method]) continue
const ret = plugin[method](...args)
if (ret !== undefined) return ret
}
}
}
return props
}
/**
* Resolve the current plugins active for the editor.
*
* @return {Array} plugins
*/
resolvePlugins() {
const plugins = flatten(this.props.plugins.slice())
plugins.unshift(this.resolveEditorPropsPlugin())
if (this.props.defaultKeyBindings) plugins.push(defaultKeyBindingPlugin())
return plugins
}
/**
* Resolve any extra properties defined by all of the plugins.
*
* @return {Object} props
*/
resolvePluginProps() {
const plugins = this.resolvePlugins()
let props = {}
for (const plugin of plugins) {
if (!plugin.getEditorProps) continue
props = {
...props,
...plugin.getEditorProps(this)
}
}
return props
}
/**
* Resolve a single plugin from the top-level properties passed into the
* editor, that will be added first in the chain, so that it can override any
* of the other plugins's logic that it wants.
*
* @return {Object} plugin
*/
resolveEditorPropsPlugin() {
const plugin = {}
for (const prop of PLUGIN_PROPS) {
if (prop in this.props) plugin[prop] = this.props[prop]
}
return plugin
}
}
/**
* Export.
*/
export default PluggableEditor
Essentially, it's extending each of the plugins's properties onto the main <Editor> instance, making sure to handle each specific property the way it needs to be handled.
Most are just middleware functions, so they just need a sort of for loop that exits early if any of them return true. But there are others, like the customStyleMap one that instead needs to concat all of the dictionaries of styles together, or decorators which concats the arrays together.
Draft.js doesn't actually "know" about these plugins. It's just that the way the library is architected, it's simple to create a plugin layer above that does the managing and passing through. Although, ideally it would be handled at the Draft.js layer instead, so that plugins could truly be first-class. And then all of the internals could be rewritten as plugins that would be easy to remove/extend.
Right, that explains the mechanism. I agree that having plugin support in core, and putting as much features as possible into them, is desirable. Also, while I agree the concept of a single, say, onChange or onEnter callback is nice and clean and easy to reason about, I've found (when working on CodeMirror) that it just is too weak to support effective plugins. You need something like the code you showed, or just crude old event emitters, to be able to mix together arbitrary plugins. The cleanliness goes out of the window at that point, but the gains are probably worth it.
This has been done in (roughly) 2765b6c0852d3...8520f808125 (which reduce the code size by 500 lines, despite adding some new tests). Quick summary (there'll be more detailed release notes):
defineOption was replaced by a Plugin abstraction, which you pass to the plugins option to enable some extra functionalityschema option no longer has a default and must always be suppliedschema), rather than in the model module.getContent, setContent, and the docFormat option. The user is responsible for converting between formats.This introduces a little more boilerplate to code that sets up a simple editor, which is unfortunate, but it vastly reduces the amount of concepts you need to understand to customize things, and makes the process more additive and lego-like, instead of relying on subtle action-at-a-distance.
I'm sort of sad to let the vision of declaratively putting together schema nodes and having everything 'just work' go, but the previous system wasn't a very good implementation of that vision, and there were, even if you understood how it worked, plenty of opportunities to screw up and cause bad interaction. So let's try this simpler approach.
Most helpful comment
This has been done in (roughly) 2765b6c0852d3...8520f808125 (which reduce the code size by 500 lines, despite adding some new tests). Quick summary (there'll be more detailed release notes):
defineOptionwas replaced by aPluginabstraction, which you pass to thepluginsoption to enable some extra functionalityschemaoption no longer has a default and must always be suppliedschema), rather than in themodelmodule.getContent,setContent, and thedocFormatoption. The user is responsible for converting between formats.This introduces a little more boilerplate to code that sets up a simple editor, which is unfortunate, but it vastly reduces the amount of concepts you need to understand to customize things, and makes the process more additive and lego-like, instead of relying on subtle action-at-a-distance.
I'm sort of sad to let the vision of declaratively putting together schema nodes and having everything 'just work' go, but the previous system wasn't a very good implementation of that vision, and there were, even if you understood how it worked, plenty of opportunities to screw up and cause bad interaction. So let's try this simpler approach.