Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarn/versions/45a55aa9.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@handlewithcare/react-codemirror": minor
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand Down
86 changes: 85 additions & 1 deletion demo/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import {
CodeMirror,
CodeMirrorEditor,
react,
useEditorState,
useReconfigure,
} from "../src/index.js";
Expand All @@ -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 (
<button
onClick={() => {
reconfigureTheme(dark ? [] : oneDark);
}}
>
Enable {dark ? "light" : "dark"} mode
</button>
);
}

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 (
<main>
<h1>React CodeMirror demo</h1>
<CodeMirror
state={state}
dispatchTransactions={dispatchTransactions}
extensions={extensions}
>
<ThemePicker />
<CodeMirrorEditor />
</CodeMirror>
</main>
);
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = createRoot(document.getElementById("root")!);

root.render(
<StrictMode>
<DemoEditor />
</StrictMode>,
);
`,
extensions,
});

function ThemePicker() {
const state = useEditorState();
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -76,7 +76,6 @@
},
"packageManager": "yarn@4.6.0",
"peerDependencies": {
"@codemirror/merge": "*",
"@codemirror/state": "*",
"@codemirror/view": "*",
"react": ">=17 <=19",
Expand Down
10 changes: 10 additions & 0 deletions src/extensions/tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { StateField, type Transaction } from "@codemirror/state";

export const tracking = StateField.define<Transaction[]>({
create() {
return [];
},
update(value, tr) {
return [...value, tr];
},
});
41 changes: 10 additions & 31 deletions src/hooks/useEditor.ts
Original file line number Diff line number Diff line change
@@ -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<EditorViewConfig, "parent"> & {
defaultState?: EditorState;
Expand Down Expand Up @@ -40,6 +41,8 @@ export function useEditor(
const [_state, setState] = useState<EditorState>(defaultState);
const state = options.state ?? _state;

const seen = useRef<Set<Transaction>>(new Set());

const dispatchTransactions = useCallback(
function dispatchTransactions(
trs: readonly Transaction[],
Expand Down Expand Up @@ -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));
}
});

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
37 changes: 22 additions & 15 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down