Prosemirror: Proposal: Less (global/magic) namespaces in API

Created on 18 Apr 2016  路  6Comments  路  Source: ProseMirror/prosemirror

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.

Namespaces

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.

Commands

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.)

Schema

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.)

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):

  • defineOption was replaced by a Plugin abstraction, which you pass to the plugins option to enable some extra functionality
  • DOM parsing and serializing info is now easier and smaller to declare, and goes directly in your node type definition
  • Commands and related abstractions are gone
  • The schema option no longer has a default and must always be supplied
  • The default schema is now in its own module (schema), rather than in the model module.
  • Keymaps for schema-specific bindings must be explicitly added to an editor. There's a helper for creating the default keymap for the default schema.
  • Providing menu items is also entirely manual now. Again, there's helpers for quickly putting together some default menu, but you have to call them yourself and pass the result to the menu plugins.
  • The format namespace is gone, as are things like 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.

All 6 comments

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.)

Explicit vs. implicit.

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.

Composing "middleware".

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):

image

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".

React is explicit.

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 functionality
  • DOM parsing and serializing info is now easier and smaller to declare, and goes directly in your node type definition
  • Commands and related abstractions are gone
  • The schema option no longer has a default and must always be supplied
  • The default schema is now in its own module (schema), rather than in the model module.
  • Keymaps for schema-specific bindings must be explicitly added to an editor. There's a helper for creating the default keymap for the default schema.
  • Providing menu items is also entirely manual now. Again, there's helpers for quickly putting together some default menu, but you have to call them yourself and pass the result to the menu plugins.
  • The format namespace is gone, as are things like 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.

Was this page helpful?
0 / 5 - 0 ratings