I’ve been helping @moonrise-tk with this and I think this question could be better articulated. There’s definitely an interesting question in here:
Suppose you have a basic React app. This is typically how it would work.
function Editor() {
const ref = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const state = EditorState.create({
plugins: [],
})
const view = new EditorView(node, {
state,
plugins: [],
dispatchTransaction(tr) {
const nextState = this.state.apply(tr)
view.updateState(nextState)
},
})
}, [])
return <div ref={ref}></div>
}
Now suppose, you want to have some kind of props to this component to augment the functionality of the app. I’ll call it bogusMode
— it doesn’t matter what it does, but the idea is that it a global application state that augments the way prosemirror works.
function Editor({ bogusMode }) {
const ref = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const state = EditorState.create({
plugins: [
new Plugin({
state: {
init() {},
apply() {
if (bogusMode) {
// Do something different.
} else {
}
},
},
}),
],
})
const view = new EditorView(node, {
state,
plugins: [
new Plugin({
view() {
return {
update() {
if (bogusMode) {
// Do something different.
} else {
}
},
destroy() {},
}
},
}),
],
dispatchTransaction(tr) {
const nextState = this.state.apply(tr)
view.updateState(nextState)
},
})
}, [])
return <div ref={ref}></div>
}
In a state plugin, maybe we’re keeping track of word count and when the user’s language setting changes, then we want to compute a different word count. Or in a view plugin, maybe we want to render a popup menu in a different language…
I understand that this library is not designed around React, but I think this is might be a more general problem. There’s a few ways I can imagine dealing with this but its tricky…
The most obvious thing you can do is write the props to the ProseMirror state. This makes those props available within the plugin update/apply. And I think this totally makes sense.
useLayoutEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch(view.state.tr.setMeta(propsKey, {bogusMode}))
}, [bogusMode])
But things start to get a bit trickier when you think about lifting the editor state out of prosemirror and into the global application state (we’ve talked about this pattern before).
When you have some global application state, implementing a similar functional pattern as prosemirror, then you might start with something like this:
function Editor(props: {
state: EditorState
setState: (state: EditorState) => void
}) {
const { state, setState } = props
const ref = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const view = new EditorView(node, {
state,
plugins: [],
dispatchTransaction(tr) {
const nextState = this.state.apply(tr)
setState(nextState)
// Technically we shouldn't do this until the next re-render.
// But this causes issues if we dispatch more than one transaction
// in a single React render.
view.updateState(nextState)
},
})
viewRef.current = view
return () => view.destroy()
}, [])
// This allows us to update the editor state from elsewhere in the app
// and have it reflected in the editor immediately.
useLayoutEffect(() => {
const view = viewRef.current
if (!view) return
if (view.state !== state) {
view.updateState(state)
}
}, [state])
return <div ref={ref}></div>
}
One thing that’s nice about this pattern, is you can mutate the editor state from anywhere in the app. In my app, for example, when you rename a file from the sidebar, we want to update any relative links in the document and this is really simple because its a pure function of application state. renameFile(state, from, to) => state
.
Now from this perspective, it’s not so simple to call view.dispatch(view.state.tr.setMeta(propsKey, {bogusMode}))
because this will trigger a whole render loop… But this solution feels close.
Here’s an attempt (though I’m not happy with it):
function Editor(props: {
bogusMode: boolean
state: EditorState
setState: (state: EditorState) => void
}) {
const { state, setState, bogusMode } = props
const ref = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
// Keep track of the editor props...
const propsRef = useRef({ bogusMode })
propsRef.current = { bogusMode }
// When we render the editor, we'll apply the props to it.
const withProps = (state: EditorState) => {
return state.apply(state.tr.setMeta(propsKey, propsRef.current))
}
useLayoutEffect(() => {
const node = ref.current
if (!node) return
const view = new EditorView(node, {
// This should work fine.
state: withProps(state),
plugins: [],
dispatchTransaction(tr) {
// Ideally the global state would not have to these props.
const nextState = this.state.apply(tr)
setState(nextState)
// This feels awkward.
view.updateState(withProps(nextState))
},
})
viewRef.current = view
return () => view.destroy()
}, [])
useLayoutEffect(() => {
const view = viewRef.current
if (!view) return
// This is even more awkward. Definitely problematic...
if (view.state !== state) {
view.updateState(withProps(state))
}
}, [state, bogusMode])
return <div ref={ref}></div>
}
It might feel tempting to create a subscription / listener in the view plugin to react to changes in global state. And I think that would work! But this does not work in the more general case for the state plugin because the state apply function is a pure function of state…
At the end of the day, this seems like a problem of denormalization. What we ought to do, I think, is simply set bogusMode in the ProseMirror plugin state whenever we set bogusMode in the application state… I think that would solve this problem. Only downside of this approach is that it feels a bit cumbersome…
I think one step in the right direction might be to decouple init
and apply
for the state plugins… I think its important to be able to construct state, without any dependencies. But it would be nice to be able to define how the state evolves while incorporating the state of he rest of the application… In fact, I think we could pull out all the plugin apply
methods into EditorView.props.dispatchTransaction
. And that could allow this whole “props” approach to work for state plugins…
I’m still trying to digest this, but I’m curious if you have any thoughts @marijn @moonrise-tk