Closed thetrevorharmon closed 3 months ago
This will have unintended consequences as CodeMirror is not built in a way that we can simply yank out and replace a state field. The "CodeMirror way" to approach this would be to use a Compartment
. The docs describe a compartment as:
Extension compartments can be used to make a configuration dynamic. By wrapping part of your configuration in a compartment, you can later replace that part through a transaction.
I think of a Compartment as a box around an extension–we can replace the contents of the box but CodeMirror still knows about and uses the box surrounding the contents. Let's rewrite our state field slightly so that accepts an initial value:
function mostRecentDeleteCutState(initialState: Date | null) {
return StateField.define<Date | null>({
create(state) {
return initialState;
},
update(value, transaction) {
if (transaction.isUserEvent('delete.cut')) {
return new Date();
}
return value;
},
});
}
Now that we can easily generate a new state field with our updated React state, let's use the Compartment
class to create a "box" for the state field to go in:
// 1
const compartment = new Compartment();
// 2
const stateField = compartment.of(mostRecentDeleteCutState(null));
const resetStateField = mostRecentDeleteCut == null;
useEffect(() => {
// 3
view.dispatch({
// 4
effects: compartment.reconfigure(mostRecentDeleteCutState(null)),
});
}, [resetStateField]);
Here's an explanation of what is happening in this block of code:
compartment.of()
and pass in our state field. This places the newly-created state field into the "box" and returns it. Techincally we are returning our state field inside of the container of compartment
, but CodeMirror looks at the contents of the box.view.dispatch
. Much like a reducer's dispatch
function, the view.dispatch
function is how we initiate a change in the state lifecycle.compartment.reconfigure()
, it will return a StateEffect
that essentially tells CodeMirror "please replace the contents of the box with these new contents". We pass in a call to the state field function we created earlier–we could pass in whatever value we want to set as the new initial value. Finally, that state effect is passed to view.dispatch
via the effects
key of the object.Since we're looking at this in the context of React–what if we wanted to create a generic hook to manage this for us? Here's what that would look like:
export function useExtensionWithDependency(
// 1
view: EditorView | null,
// 2
extensionFactory: () => Extension,
deps: any[],
) {
// 3
const compartment = useMemo(() => new Compartment(), []);
const extension = useMemo(() => compartment.of(extensionFactory()), []);
useEffect(() => {
if (view) {
view.dispatch({
effects: compartment.reconfigure(extensionFactory()),
});
}
// 4
}, deps);
return extension;
}
Some callouts about this code:
view
object–if you are using React CodeMirror's react component, you get access at the view
object via a forward ref on the react component. Because it uses refs, it might be null
which is why the type is EditorView | null
.useExtensionWithDependency
in order to know when we should reconfigure the compartment.Now let's refactor our example to use this hook:
const [mostRecentDeleteCut, setMostRecentDeleteCut] = useState<Date | null>(
null,
);
const resetStateField = mostRecentDeleteCut == null;
const extension = useExtensionWithDependency(
view,
() => mostRecentDeleteCutState(null),
[resetStateField],
);
To put it all together–here's an example of a code editor that uses all of these patterns that you can play with:
outline
getting an editor working inside of react
CodeMirror provides this example of getting a basic editor working in a JS context:
The state and view are managed separately, and codemirror provides a way to automatically bind the
view
to a node in the DOM via theparent
argument. In React, React "owns" all of the DOM by rendering JSX. The way you would set this up in a React component may look something like:This would automatically create and bind the editor once the
ref
is set up. While this may give you a lot of control, it also requires you to manage everything. Instead of doing this manually, I would recommend utilizing the react-codemirror package. It manages all of this for you. If you use that package, your setup code can look like this:React-codemirror includes many props that enable you to customize the editor, and also includes a hook version if you decide to use a hook instead of a component. I would recommend reading the docs to get started.
lifecycle
managing state between react and codemirror
diagram of codemirror flow with "hook" outside of the flow to update react
here are ways to sync state
as much as possible, make codemirror the source of truth
going the opposite way is difficult (but not impossible)
react-codemirror is the package to use
it does a bunch of things that are fairly boilerplate-y