thetrevorharmon / thetrevorharmon.com

The blog & portfolio of Trevor Harmon.
http://thetrevorharmon.com
GNU General Public License v3.0
9 stars 0 forks source link

đź“ť Approaching CodeMirror with a React background #151

Closed thetrevorharmon closed 3 months ago

thetrevorharmon commented 1 year ago

outline

getting an editor working inside of react

CodeMirror provides this example of getting a basic editor working in a JS context:

import {EditorState} from "@codemirror/state"
import {EditorView, keymap} from "@codemirror/view"
import {defaultKeymap} from "@codemirror/commands"

let startState = EditorState.create({
  doc: "Hello World",
  extensions: [keymap.of(defaultKeymap)]
})

let view = new EditorView({
  state: startState,
  parent: document.body
})

The state and view are managed separately, and codemirror provides a way to automatically bind the view to a node in the DOM via the parent 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:

export function CodeEditor() {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const [view, setView] = useState<EditorView | null>(null);

  useEffect(() => {
    if (containerRef.current) {
      let state = EditorState.create({
        doc: "Hello World",
        extensions: [keymap.of(defaultKeymap)]
      });

      let view = new EditorView({
        state: startState,
        parent: containerRef.current
      });

      setView(view);
    }
  }, []);

  return <div ref={containerRef} />;
}

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:

import ReactCodeEditor from '@uiwjs/react-codemirror';

export function CodeEditor() {
  const doc = 'Hello World';
  const extensions = [keymap.of(defaultKeymap)];

  return <ReactCodeEditor extensions ={extensions} value={doc} />;
}

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

flowchart TD

a(Dom Event)
a --> b(determine if state should change)
b --> c(update state + DOM)
c --> d(perform any side effects)
d --> e(stable)
e --> a
flowchart TD

a(DOM event)
a --> b(Transaction)
b --> c(new state)
c --> d(update view)
d --> a

managing state between react and codemirror

thetrevorharmon commented 1 year 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:

  1. We first create a new empty compartment–an empty box.
  2. Next we call 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.
  3. When we detect a change to our react state, we call view.dispatch. Much like a reducer's dispatch function, the view.dispatch function is how we initiate a change in the state lifecycle.
  4. When we call 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:

  1. This takes in the 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.
  2. We use closures to encapsulate any arguments we need passed to generate a new extension.
  3. Both the compartment and extension are memoized to ensure that they never change, preventing render issues that can happen between React and CodeMirror.
  4. We use a dependencies array passed to 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: