From b3bc7026290c4aa7ab0179ed444d2f06fb2062f5 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Tue, 26 Aug 2025 12:59:45 -0400 Subject: [PATCH] Add a react extension that tracks transactions --- .yarn/versions/45a55aa9.yml | 2 + README.md | 24 +++++++++-- demo/main.tsx | 86 ++++++++++++++++++++++++++++++++++++- package.json | 3 +- src/extensions/tracking.ts | 10 +++++ src/hooks/useEditor.ts | 41 +++++------------- src/index.ts | 3 ++ yarn.lock | 37 +++++++++------- 8 files changed, 154 insertions(+), 52 deletions(-) create mode 100644 .yarn/versions/45a55aa9.yml create mode 100644 src/extensions/tracking.ts diff --git a/.yarn/versions/45a55aa9.yml b/.yarn/versions/45a55aa9.yml new file mode 100644 index 0000000..548c656 --- /dev/null +++ b/.yarn/versions/45a55aa9.yml @@ -0,0 +1,2 @@ +releases: + "@handlewithcare/react-codemirror": minor diff --git a/README.md b/README.md index 9d222d9..f2ef2c6 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ yarn add @handlewithcare/react-codemirror - [Overview](#overview) - [Usage](#usage) + - [Dynamic extensions](#dynamic-extensions) - [API](#api) - [`CodeMirror`](#codemirror) - [`CodeMirrorEditor`](#codemirroreditor) + - [`react`](#react) - [`useEditorState`](#useeditorstate) - [`useCompartment`](#usecompartment) - [`useEditorEventCallback`](#useeditoreventcallback) @@ -42,15 +44,17 @@ may support React-based widgets and tooltips. ## Usage -To get started, render the `CodeMirror` and `CodeMirrorEditor` components: +To get started, render the `CodeMirror` and `CodeMirrorEditor` components, and +add the `react` extension to your EditorState: ```tsx import { EditorState, type Transaction } from "@codemirror/state"; -import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { CodeMirror, CodeMirrorEditor, react } from "@handlewithcare/react-codemirror"; import { basicSetup } from "codemirror"; import React, { StrictMode, useCallback, useState } from "react"; -const editorState = EditorState.create({ doc: "", basicSetup }); +// NOTE: You must also add the `react` extension to your EditorState! +const editorState = EditorState.create({ doc: "", [basicSetup, react] }); function CodeEditor() { const [state, setState] = useState(editorState); @@ -75,6 +79,11 @@ The `CodeMirrorEditor` is where the actual CodeMirror editor will be instantiated. It can be nested anywhere as a descendant of the `CodeMirror` component. +The `react` extension is necessary for ensuring that the React state stays in +sync with the CodeMirror EditorState. + +### Dynamic extensions + The `useReconfigure` hook can be used to configure dynamic CodeMirror extensions. Here’s an example, using a simple theme switcher: @@ -269,6 +278,15 @@ function CodeEditor() { } ``` +### `react` + +```ts +type Extension; +``` + +A CodeMirror extension that allows react-codemirror to keep React state in sync +with CodeMirror state. + ### `useEditorState` ```ts diff --git a/demo/main.tsx b/demo/main.tsx index df8f95d..d1f2edb 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client"; import { CodeMirror, CodeMirrorEditor, + react, useEditorState, useReconfigure, } from "../src/index.js"; @@ -17,10 +18,93 @@ const themeCompartment = new Compartment(); const extensions = [ basicSetup, javascript({ jsx: true, typescript: true }), + react, themeCompartment.of(oneDark), ]; -const editorState = EditorState.create({ doc: `const a = "a"`, extensions }); +const editorState = EditorState.create({ + doc: `/** + * @fileoverview + * This is the code that runs this demo! + */ + +import { javascript } from "@codemirror/lang-javascript"; +import { Compartment, EditorState, type Transaction } from "@codemirror/state"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { basicSetup } from "codemirror"; +import React, { StrictMode, useCallback, useState } from "react"; +import { createRoot } from "react-dom/client"; + +import { + CodeMirror, + CodeMirrorEditor, + react, + useEditorState, + useReconfigure, +} from "@handlewithcare/react-codemirror"; + +const themeCompartment = new Compartment(); + +const extensions = [ + basicSetup, + javascript({ jsx: true, typescript: true }), + react, + themeCompartment.of(oneDark), +]; + +const editorState = EditorState.create({ doc: \`const a = "a"\`, extensions }); + +function ThemePicker() { + const state = useEditorState(); + const theme = themeCompartment.get(state); + const dark = theme === oneDark; + const reconfigureTheme = useReconfigure(themeCompartment); + + return ( + + ); +} + +function DemoEditor() { + const [state, setState] = useState(editorState); + + const dispatchTransactions = useCallback((trs: readonly Transaction[]) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + setState(trs[trs.length - 1]!.state); + }, []); + + return ( +
+

React CodeMirror demo

+ + + + +
+ ); +} + +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const root = createRoot(document.getElementById("root")!); + +root.render( + + + , +); +`, + extensions, +}); function ThemePicker() { const state = useEditorState(); diff --git a/package.json b/package.json index ce26160..534ad95 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@codemirror/lang-javascript": "^6.2.3", - "@codemirror/merge": "^6.9.0", + "@codemirror/lang-json": "^6.0.2", "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.3", @@ -76,7 +76,6 @@ }, "packageManager": "yarn@4.6.0", "peerDependencies": { - "@codemirror/merge": "*", "@codemirror/state": "*", "@codemirror/view": "*", "react": ">=17 <=19", diff --git a/src/extensions/tracking.ts b/src/extensions/tracking.ts new file mode 100644 index 0000000..8e266c4 --- /dev/null +++ b/src/extensions/tracking.ts @@ -0,0 +1,10 @@ +import { StateField, type Transaction } from "@codemirror/state"; + +export const tracking = StateField.define({ + create() { + return []; + }, + update(value, tr) { + return [...value, tr]; + }, +}); diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts index 864d055..6dd30ac 100644 --- a/src/hooks/useEditor.ts +++ b/src/hooks/useEditor.ts @@ -1,7 +1,8 @@ -import { diff } from "@codemirror/merge"; import { EditorState, type Transaction } from "@codemirror/state"; import { EditorView, type EditorViewConfig } from "@codemirror/view"; -import { useCallback, useLayoutEffect, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +import { tracking } from "../extensions/tracking.js"; export type UseViewOptions = Omit & { defaultState?: EditorState; @@ -40,6 +41,8 @@ export function useEditor( const [_state, setState] = useState(defaultState); const state = options.state ?? _state; + const seen = useRef>(new Set()); + const dispatchTransactions = useCallback( function dispatchTransactions( trs: readonly Transaction[], @@ -86,36 +89,12 @@ export function useEditor( return; } - // Fully replace the state after reconfiguration. We don't have - // access to the effects that were used to produce the new state - // config, so we have to just do a brute force replace - // @ts-expect-error Internal properties - if (view.state.config !== state.config) { - view.setState(state); - return; - } + const trs = state.field(tracking); + const newTrs = trs.filter((tr) => !seen.current.has(tr)); - if ( - !view.state.doc.eq(state.doc) || - !view.state.selection.eq(state.selection) - ) { - // This can take a few milliseconds on a large document, - // but it prevents codemirror from having to redo syntax - // highlighting, etc, from scratch on each update - const current = view.state.doc.toString(); - const incoming = state.doc.toString(); - const diffed = diff(current, incoming); - - view.update([ - view.state.update({ - changes: diffed.map((change) => ({ - from: change.fromA, - to: change.toA, - insert: incoming.slice(change.fromB, change.toB), - })), - selection: state.selection, - }), - ]); + if (newTrs.length) { + view.update(newTrs); + newTrs.forEach((tr) => seen.current.add(tr)); } }); diff --git a/src/index.ts b/src/index.ts index 136af7d..666dc51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,6 @@ export { useEditorEffect } from "./hooks/useEditorEffect.js"; export { useEditorEventCallback } from "./hooks/useEditorEventCallback.js"; export { useEditorState } from "./hooks/useEditorState.js"; export { useReconfigure } from "./hooks/useReconfigure.js"; +import { tracking } from "./extensions/tracking.js"; + +export const react = [tracking]; diff --git a/yarn.lock b/yarn.lock index e8ebee4..c93d54b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -260,6 +260,16 @@ __metadata: languageName: node linkType: hard +"@codemirror/lang-json@npm:^6.0.2": + version: 6.0.2 + resolution: "@codemirror/lang-json@npm:6.0.2" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@lezer/json": "npm:^1.0.0" + checksum: 10/a8e57ad5c6cc53edd0d8bce4c26fa189dd5a95c371e72d32957d4a5c857f814761d9dfab81361f8e8ae9da7bcf657e7e1343f39694f9cffb1c144dd3ef7092a0 + languageName: node + linkType: hard + "@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.6.0": version: 6.10.8 resolution: "@codemirror/language@npm:6.10.8" @@ -285,19 +295,6 @@ __metadata: languageName: node linkType: hard -"@codemirror/merge@npm:^6.9.0": - version: 6.9.0 - resolution: "@codemirror/merge@npm:6.9.0" - dependencies: - "@codemirror/language": "npm:^6.0.0" - "@codemirror/state": "npm:^6.0.0" - "@codemirror/view": "npm:^6.17.0" - "@lezer/highlight": "npm:^1.0.0" - style-mod: "npm:^4.1.0" - checksum: 10/16d4f745c113e93a4da120a4f854743451c4dce9ba46dcba1a53926714a1f8396742b570764ce044a3c647de77c91a24c7d05af3b3ea4620eb516fe466a3bc5e - languageName: node - linkType: hard - "@codemirror/search@npm:^6.0.0": version: 6.5.10 resolution: "@codemirror/search@npm:6.5.10" @@ -612,7 +609,7 @@ __metadata: resolution: "@handlewithcare/react-codemirror@workspace:." dependencies: "@codemirror/lang-javascript": "npm:^6.2.3" - "@codemirror/merge": "npm:^6.9.0" + "@codemirror/lang-json": "npm:^6.0.2" "@codemirror/state": "npm:^6.5.2" "@codemirror/theme-one-dark": "npm:^6.1.2" "@codemirror/view": "npm:^6.36.3" @@ -648,7 +645,6 @@ __metadata: vite: "npm:^6.2.0" vitest: "npm:^3.0.7" peerDependencies: - "@codemirror/merge": "*" "@codemirror/state": "*" "@codemirror/view": "*" react: ">=17 <=19" @@ -786,6 +782,17 @@ __metadata: languageName: node linkType: hard +"@lezer/json@npm:^1.0.0": + version: 1.0.3 + resolution: "@lezer/json@npm:1.0.3" + dependencies: + "@lezer/common": "npm:^1.2.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.0.0" + checksum: 10/48e7b945fdfa2b5b6f862e27bc31f3991cba93f18df7fed0059b25f119b64dedd50bbc709d279e16e2b3eee10e7758d7d80c6d98d21bc15c284809d268837897 + languageName: node + linkType: hard + "@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.3.0": version: 1.4.2 resolution: "@lezer/lr@npm:1.4.2"