This package provides an API modeled on React Hooks for dynamically computing ProseMirror node attributes.
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
NodeViewAPI 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.
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:
-
Reactive nodes must expose an identifier in their
attrwhich is managed byprosemirror-reactive. By default this attribute is calledid, but this key is configurable. -
Reactive nodes will be managed by a
NodeViewwhich is automatically generated by this library. If a node type is already managed by aNodeView— as specified in thenodeViewsoption inEditorViewconfig — then the library will delegate rendering to thatNodeViewthrough a wrapper that it manages. -
Reactive attrs are stored in plugin state and never actually added to the document (
view.state.doc).
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 theuseDocumentStatehook. 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.
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).
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.
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. TheUpdateFn<T>is called to change the state value. It is passed either a newT, or a function that transforms the currentTinto a new one. In other words, it is either(newValue: T) => unknownor(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 ifcountchanges, 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 theNodeusing the hook is destroyed by ProseMirror). If your hook uses things likeaddEventListenerorsetInterval, this is the place to callremoveEventListenerorclearInterval. -
useRef<T>(initialValue?: T): { current: T }: holds a "ref" with a mutablecurrentproperty that, likeuseState, holds a value that persists between calls to the hook. UnlikeuseState, you can use a simple assignment,myRef.current = someValue, to update its value. And unlikeuseState, 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 indocumentStateduring 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 usinginitialValueThis 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 asuseDocumentState, except it receives a fresh data structure during every ProseMirror transaction (essentially, every time the hook is re-run). This is useful for allowingNodesto 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 currentcountand then increment it for the nextNodeto 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 configurableidAttrKey), and the callback will receive those nodes as its arguments (defaulting toundefinedif 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.