Skip to content

pubpub/prosemirror-reactive

Repository files navigation

prosemirror-reactive

This package provides an API modeled on React Hooks for dynamically computing ProseMirror node attributes.

Motivation

ProseMirror provides a suite of tools to add interactions to an EditorView, and to render state outside of what's represented in the document itself:

  • A plugin system to respond to events
  • A decorations API to add transient markup to a document
  • The NodeView API to provide fine-grained control of a node's DOM representation

These tools are technically all you need to build things like footnote counters, dynamic references, and interactions with remote APIs. But there is a subset of these use cases that are well-represented by adding reactive attributes to a Node, whose values are dynamically computed based on the node's attrs, its position in the document, or the attrs of other nodes. This is precisely what prosemirror-reactive lets you do.

Reactive nodes and attributes

This library allows you to define "reactive attributes" on a Node schema that are automatically recomputed as necessary and passed into toDOM (or a NodeView). A "reactive node" is just one that has one or more reactiveAttrs. Some things to be aware of:

  1. Reactive nodes must expose an identifier in their attr which is managed by prosemirror-reactive. By default this attribute is called id, but this key is configurable.

  2. Reactive nodes will be managed by a NodeView which is automatically generated by this library. If a node type is already managed by a NodeView — as specified in the nodeViews option in EditorView config — then the library will delegate rendering to that NodeView through a wrapper that it manages.

  3. Reactive attrs are stored in plugin state and never actually added to the document (view.state.doc).

Setup

Import the createReactivePlugin function to create a ProseMirror plugin:

import { createReactivePlugin } from '@pubpub/prosemirror-reactive';

const reactivePlugin = createReactivePlugin({ 
    schema,
    idAttrKey,
    documentState
});

This function accepts a config object with these properties:

  • schema: Schema: the ProseMirror schema used by the editor. Required.
  • idAttrKey: string: the key to use for the identifier attribute which will be generated by the plugin and added to reactive nodes. Defaults to "id". Optional.
  • documentState: Record<string, any>: any other state you want to make available in the useDocumentState hook. Optional.

With the plugin in hand, use it to instantiate an EditorState:

const state = new EditorState({
    // ...doc, schema, etc...
    plugins: [reactivePlugin]
});

You're off to the races, but the plugin won't do anything until you make changes to your schema.

A reactive node schema

Let's suppose we wish to let users of our web service mention each other in a ProseMirror document. Users have a fixed slug which is immutable for the lifetime of their account, but they can change their display name at any time. This suggests we should store only the slug in the document, and compute a user's displayName each time it is loaded. This is an ideal use case for prosemirror-reactive, and a reactive schema for a user node might look a little like this:

const user = {
    atom: true,
    reactive: true, // required by prosemirror-reactive
    attrs: {
        id: { default: null }, // must match the idAttrKey provided to the plugin
        slug: { default: null }, 
    },
    reactiveAttrs: {
        displayName: useDisplayName,
    },
    toDOM: (node) => {
        const { slug, displayName } = node.attrs;
        const href = `/users/${slug}`;
        return ['a', { href }, displayName];
    },
}

Take a moment to notice that toDOM has access to a displayName attr. This attr is not part of the document — rather, it is the most up-to-date result of useDisplayName(node).

A reactive hook

Now let's define useDisplayName. In keeping with React convention, we call this function a "hook", and signal this with the use prefix. A hook can be simple transformation of data, but it can also call built-in hooks to store state and asynchronously run side effects. React has some Rules of Hooks that must be followed for the API to work properly, and their spirit is at work here. In particular, hooks must be called unconditionally and in the same order each time their calling function runs. In practice that means you must not call hooks only at the top level of the function, and not inside of an if statement, for loop, etc.

Without further ado, here is useDisplayName:

import { Node } from 'prosemirror-model';
import { useState, useEffect } from '@pubpub/prosemirror-reactive';

const useDisplayName = (node: Node) => {
    const { slug } = node.attrs;
    const [displayName, setDisplayName] = useState(null);

    useEffect(() => {
        fetch(`/api/user/${slug}`).then(userModel => {
            setDisplayName(userModel.displayName);
        });
    }, [displayName]);

    return displayName;
}

This is a stripped-down example for clarity. In a production application you might want to pre-load these values or debounce and batch these requests. You would also want to provide an acceptable fallback in toDOM to account for a null displayName.

Built-in hooks

The following three hooks are borrowed from React, and closely match their semantics in that library. If you know React, you should be able to use them as you'd expect.

  • useState<T>(initialValue): [T, UpdateFn<T>]: holds a piece of state that will be preserved across calls to the hook. The UpdateFn<T> is called to change the state value. It is passed either a new T, or a function that transforms the current T into a new one. In other words, it is either (newValue: T) => unknown or (updater: ((currentValue: T) => T)): unknown.

  • useEffect(effectFn: () => Teardown, dependencies?: any[]): void: runs a function as a side effect, after the hook has returned a value. This is useful for interacting with the outside world, e.g. making network requests. You can provide an array of dependencies as the second argument, and the hook will only run if those dependencies are referentially different between renders. That means that the dependency array [count] will cause the hook to only update if count changes, and the dependency array [] means the hook will only run once.

    Teardown = undefined | () => unknown. In other words, if you like, you can return a function from the effect which will be called before the effect is run again (or, eventually, before the Node using the hook is destroyed by ProseMirror). If your hook uses things like addEventListener or setInterval, this is the place to call removeEventListener or clearInterval.

  • useRef<T>(initialValue?: T): { current: T }: holds a "ref" with a mutable current property that, like useState, holds a value that persists between calls to the hook. Unlike useState, you can use a simple assignment, myRef.current = someValue, to update its value. And unlike useState, this will not trigger a re-render. This hook is useful as an escape hatch from the state-driven, reactive mode of programming this library emphasizes.

The following hooks are specific to prosemirror-reactive, and address ProseMirror-specific needs:

  • useDocumentState(path: (string | symbol)[], initialValue?: any): Record<any, any>: this provides access to the values provided in documentState during plugin setup. You can provide an arbitrary list of strings or symbols as a "path", and a mutable object (Record<any, any>) will be automatically instantiated and made available at that path, possibly using initialValue This hook is useful for retrieving configuration objects passed from a container application into ProseMirror.

  • useTransactionState(path: (string | symbol)[], initialValue?: any): Record<any, any>: this provides access to the same API as useDocumentState, except it receives a fresh data structure during every ProseMirror transaction (essentially, every time the hook is re-run). This is useful for allowing Nodes to access and modify a shared record during a transaction — a classic example of this is a footnote counter, where each node will make note of the current count and then increment it for the next Node to find.

  • useDeferredNode<T>(nodeIds: string | string[], callback: ((...nodes: Node[]) => T)): DeferredResult<T>. This allows a hook to defer returning a result until the reactive computations of one or more other nodes have completed. An array of node ID attributes are passed in (values of the configurable idAttrKey), and the callback will receive those nodes as its arguments (defaulting to undefined if they are available). This is useful for creating nodes that reference other nodes, such as dynamic links to numbered figures. It is possible to create a dependency cycle with this hook that will cause the plugin to hang, so use care.