Skip to content

Use @handlewithcare/react-codemirror in CodeBlock - #145

Open
smoores-dev wants to merge 4 commits into
mainfrom
better-code-block
Open

Use @handlewithcare/react-codemirror in CodeBlock#145
smoores-dev wants to merge 4 commits into
mainfrom
better-code-block

Conversation

@smoores-dev

Copy link
Copy Markdown
Member

@handlewithcare/react-codemirror has some behavior that makes it better suited for a ProseMirror integration than @uiw/react-codemirror. It allows you to lift the EditorState out of the CodeMirror component, which means that we can derive parts of the state, like the selection from the ProseMirror state.

We can also compute smaller diffs when updating the state, rather than completely replacing the doc (which triggers recalculation of the syntax highlighting, decorations, etc) any time the node content is updated from outside the CodeMirror editor.

@smoores-dev
smoores-dev requested review from a team and tilgovi as code owners September 27, 2025 00:03
@smoores-dev
smoores-dev enabled auto-merge (squash) September 27, 2025 00:08
Comment thread demo/nodeViews/CodeBlock.tsx Outdated
Comment on lines +190 to +193
useStopEvent((_view, event) => {
if (event instanceof InputEvent) return true;
return false;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why's this needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want CodeMirror to handle all of the input events, and do not want them to propagate up to ProseMirror. I guess I did not test whether this is actually necessary to accomplish that

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. Yeah, it seems like this doesn't do anything haha

Comment on lines +120 to +137
if (
editorState.selection.from >= getPos() &&
editorState.selection.to <= getPos() + node.nodeSize &&
(codeMirrorState.selection.main.anchor !==
editorState.selection.$anchor.parentOffset ||
codeMirrorState.selection.main.head !==
editorState.selection.$head.parentOffset)
) {
setCodeMirrorState(
(prev) =>
prev.update({
selection: {
anchor: editorState.selection.$anchor.parentOffset,
head: editorState.selection.$head.parentOffset,
},
}).state
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's going on here? Syncing the selection from the parent editor into the CM editor?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup!

Comment on lines +95 to +118
// We need to maintain extension state and selection
// between renders, so we can't recompute the CodeMirror
// EditorState from node.textContent on each render.
//
// The next best thing is to update state during render
// when the node content doesn’t match the EditorState.
// This will trigger another render, but React will abort
// this render without running effects or committing
// to the DOM, so we avoid any state tearing.
if (node.textContent !== codeMirrorState.doc.toString()) {
setCodeMirrorState((prev) => {
const current = prev.doc.toString();
const incoming = node.textContent;
const diffed = diff(current, incoming);

return prev.update({
changes: diffed.map((change) => ({
from: change.fromA,
to: change.toA,
insert: incoming.slice(change.fromB, change.toB),
})),
}).state;
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of concern comes up everywhere I've ever seen a nested editor. I'd love a nice pattern for it. Is it safe to do the set in render or is it better done in an effect?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could get by assuming that we can always ignore outside changes, because if the outside change were to create/destroy/replace the content totally this node would get rekeyed and we'd remount anyway. Like, I wonder if we can lean on our React keys plugin to make some assumptions here!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it safe to do the set in render or is it better done in an effect?

Yes, and actually it's specifically recommended to do this rather than an effect: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes. As noted in the comment, React will abort the render when a state setter is called during render and restart with the new state, so it's significantly more performant to do this than an effect (also avoids some state tearing).

I wonder if we could get by assuming that we can always ignore outside changes,

In this literal demo editor, maybe, but no, I don't think this is safe, generally. Collab changes, for example, would change the node content without causing a rekey/remount. Also, like, find/replace, etc. Any transaction could conceivably change the contents of this node!

Comment thread demo/nodeViews/CodeBlock.tsx Outdated
Comment on lines +198 to +208
ref={(el) => {
ref.current = el;
if (!outerRef) {
return;
}
if (typeof outerRef === "function") {
outerRef(el);
} else {
outerRef.current = el;
}
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not seeing any place where the inner ref is used. Can we just pass through the forwarded ref without this ceremony?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yes, this is probably stale (this diff appears much larger than it actually is because stuff got moved around, this is leftover from the previous implementation)

state.selection.from >= getPos() &&
state.selection.to <= getPos() + node.nodeSize
) {
view.focus();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: we could use a useSetSelection to provide this functionality through the node view spec of the desc.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, nice! Yeah that would be great.

@handlewithcare/react-codemirror has some behavior
over @uiw/react-codemirror that makes it better suited
for a ProseMirror integration. It allows you to lift the
EditorState out of the CodeMirror component, which means
that we can derive parts of the state, like the selection
from the ProseMirror state.

We can also compute smaller diffs when updating the state,
rather than completely replacing the doc (which triggers
recalculation of the syntax highlighting, decorations, etc)
any time the node content is updated from outside the
CodeMirror editor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants