From f33ccb0da3c743e6c69ff479521fe7eafe00f170 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Sat, 15 Mar 2025 19:44:19 -0400 Subject: [PATCH] Add useCompartment hook and README docs --- .yarn/versions/9c02ca2d.yml | 2 + README.md | 365 +++++++++++++++++++++++++++++++++++- demo/main.tsx | 7 +- src/hooks/useCompartment.ts | 42 +++++ src/index.ts | 2 + 5 files changed, 407 insertions(+), 11 deletions(-) create mode 100644 .yarn/versions/9c02ca2d.yml create mode 100644 src/hooks/useCompartment.ts diff --git a/.yarn/versions/9c02ca2d.yml b/.yarn/versions/9c02ca2d.yml new file mode 100644 index 0000000..766dd55 --- /dev/null +++ b/.yarn/versions/9c02ca2d.yml @@ -0,0 +1,2 @@ +releases: + "@handlewithcare/react-codemirror": patch diff --git a/README.md b/README.md index d6fbd49..f0b11a1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,362 @@ # @handlewithcare/react-codemirror -- Basic hooks DONE -- CMEditor.Before / CMEditor.After slots NOT WORKING -- Compartments? -- React-based widgets/replace decos? This may require a whole React EditorView - to avoid state tearing TODO +A simple library for safely integrating [React](https://react.dev) and +[CodeMirror](https://codemirror.net). + +## Installation + +npm: + +```sh +npm install @handlewithcare/react-codemirror +``` + +yarn: + +```sh +yarn add @handlewithcare/react-codemirror +``` + + + +- [Overview](#overview) +- [Usage](#usage) +- [API](#api) + - [`CodeMirror`](#codemirror) + - [`CodeMirrorEditor`](#codemirroreditor) + - [`useEditorState`](#useeditorstate) + - [`useCompartment`](#usecompartment) + - [`useEditorEventCallback`](#useeditoreventcallback) + - [`useEditorEffect`](#useeditoreffect) +- [Looking for someone to collaborate with?](#looking-for-someone-to-collaborate-with) + + + +## Overview + +This library provides an API similar to that of +[`@handlewithcare/react-prosemirror`](https://github.com/handlewithcarecollective/react-prosemirror) +for integrating React with CodeMirror. The surface area is considerably smaller, +because CodeMirror has no notion of NodeViews. A future version of this library +may support React-based widgets and tooltips. + +## Usage + +To get started, render the `CodeMirror` and `CodeMirrorEditor` components: + +```tsx +import { EditorState, type Transaction } from "@codemirror/state"; +import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { basicSetup } from "codemirror"; +import React, { StrictMode, useCallback, useState } from "react"; + +const editorState = EditorState.create({ doc: "", basicSetup }); + +function CodeEditor() { + const [state, setState] = useState(editorState); + + const dispatchTransactions = useCallback((trs: readonly Transaction[]) => { + setState(trs[trs.length - 1]!.state); + }, []); + + return ( + + + + ); +} +``` + +The `CodeMirrorEditor` is where the actual CodeMirror editor will be +instantiated. It can be nested anywhere as a descendant of the `CodeMirror` +component. + +The `useCompartment` hook can be used to configure dynamic CodeMirror +extensions. Here’s an example, using a simple language picker: + +```tsx +// LanguagePicker.tsx +import { htmlLanguage, html } from "@codemirror/lang-html"; +import { language, type Language } from "@codemirror/language"; +import { javascript, javascriptLanguage } from "@codemirror/lang-javascript"; + +interface Props { + value: Language; + onChange: (lang: Language) => void; +} + +function LanguagePicker({ value, onChange }: Props) { + return ( + + ); +} + +// CodeEditor.tsx +import { javascript } from "@codemirror/lang-javascript"; +import { language } from "@codemirror/language"; +import { EditorState, type Transaction } from "@codemirror/state"; +import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { basicSetup } from "codemirror"; +import React, { StrictMode, useCallback, useState } from "react"; + +import { LanguagePicker } from "./LanguagePicker.tsx"; + +function CodeEditor() { + const [languageExt, reconfigureLanguage] = useCompartment(javascript()); + + const extensions = [basicSetup, languageExt]; + + const [state, setState] = useState(() => + EditorState.create({ doc: "", extensions }), + ); + + const dispatchTransactions = useCallback((trs: readonly Transaction[]) => { + setState(trs[trs.length - 1]!.state); + }, []); + + return ( + + reconfigureLanguage(lang)} + /> + + + ); +} +``` + +The `useEditorEventCallback` hook is a more general purpose hook that allows +components that are descendants of the `CodeMirror` component to dispatch a +transaction or otherwise interact with the CodeMirror EditorView in an event +callback: + +```tsx +// AutocompleteButton.tsx +import { useEditorEventCallback } from "@handlewithcare/react-codemirror"; + +export function AutocompleteButton() { + const onClick = useEditorEventCallback(async (view) => { + const result = await fetchMagicAutoComplete(view.state.doc.toString()); + view.dispatch({ + changes: { + from: 0, + to: view.state.doc.size, + insert: result, + }, + }); + }); + + return ; +} + +// CodeEditor.tsx +import { EditorState, type Transaction } from "@codemirror/state"; +import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { basicSetup } from "codemirror"; +import React, { StrictMode, useCallback, useState } from "react"; + +import { AutocompleteButton } from "./AutocompleteButton.tsx"; + +const editorState = EditorState.create({ doc: "", basicSetup }); + +function CodeEditor() { + const [state, setState] = useState(editorState); + + const dispatchTransactions = useCallback((trs: readonly Transaction[]) => { + setState(trs[trs.length - 1]!.state); + }, []); + + return ( + + + + + ); +} +``` + +## API + +### `CodeMirror` + +```ts +function CodeMirror( + props: Omit & { + defaultState?: EditorState; + children: ReactNode; + }, +): JSX.Element; +``` + +Provides the CodeMirror context. + +Example usage: + +```tsx +import { EditorState, type Transaction } from "@codemirror/state"; +import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { basicSetup } from "codemirror"; + +const editorState = EditorState.create({ doc: "", basicSetup }); + +function CodeEditor() { + return ( + + + + ); +} +``` + +### `CodeMirrorEditor` + +```ts +function CodeMirrorEditor(): JSX.Element; +``` + +Renders the actual editable CodeMirror editor. + +This **must** be a descendant of the `CodeMirror` component. It may be wrapped +in other components, and other children may be passed before or after. + +Example usage: + +```tsx +import { EditorState, type Transaction } from "@codemirror/state"; +import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { basicSetup } from "codemirror"; + +const editorState = EditorState.create({ doc: "", basicSetup }); + +function CodeEditor() { + return ( + + + + + + + + ); +} +``` + +### `useEditorState` + +```ts +function useEditorState(): EditorState; +``` + +Provides access to the current EditorState value. + +### `useCompartment` + +```ts +function useCompartment( + initialExtension: Extension, +): readonly [Extension, (extension: Extension) => void]; +``` + +Returns a compartment of the provided extension, and a method to reconfigure it. + +Example usage: + +```tsx +import { javascript } from "@codemirror/lang-javascript"; +import { language } from "@codemirror/language"; +import { EditorState, type Transaction } from "@codemirror/state"; +import { CodeMirror, CodeMirrorEditor } from "@handlewithcare/react-codemirror"; +import { basicSetup } from "codemirror"; +import React, { StrictMode, useCallback, useState } from "react"; + +import { LanguagePicker } from "./LanguagePicker.tsx"; + +function CodeEditor() { + const [languageExt, reconfigureLanguage] = useCompartment(javascript()); + + const extensions = [basicSetup, languageExt]; + + const [state, setState] = useState(() => + EditorState.create({ doc: "", extensions }), + ); + + const dispatchTransactions = useCallback((trs: readonly Transaction[]) => { + setState(trs[trs.length - 1]!.state); + }, []); + + return ( + + reconfigureLanguage(lang)} + /> + + + ); +} +``` + +### `useEditorEventCallback` + +```ts +function useEditorEventcallback( + callback: (view: EditorView, ...args: T) => void, +): void; +``` + +Returns a stable function reference to be used as an event handler callback. + +The callback will be called with the EditorView instance as its first argument. + +### `useEditorEffect` + +```ts +function useEditorEffect( + effect: (view: EditorView) => void | (() => void), + dependencies?: React.DependencyList, +): void; +``` + +Registers a layout effect to run after the EditorView has been updated with the +latest EditorState. + +Effects can take an EditorView instance as an argument. This hook should be used +to execute layout effects that depend on the EditorView, such as for positioning +DOM nodes based on CodeMirror positions. + +Layout effects registered with this hook still fire synchronously after all DOM +mutations, but they do so _after_ the EditorView has been updated, even when the +EditorView lives in an ancestor component. + +## Looking for someone to collaborate with? + +Reach out to [Handle with Care](https://handlewithcare.dev/#get-in-touch)! We're +a product development collective with years of experience bringing excellent +ideas to life. We love React and ProseMirror, and we're always looking for new +folks to work with! diff --git a/demo/main.tsx b/demo/main.tsx index 2abc5af..f51749e 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,17 +1,12 @@ import { javascript } from "@codemirror/lang-javascript"; import { 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 } from "../src/index.js"; -const extensions = [ - basicSetup, - javascript({ jsx: true, typescript: true }), - oneDark, -]; +const extensions = [basicSetup, javascript({ jsx: true, typescript: true })]; const editorState = EditorState.create({ doc: `const a = "a"`, extensions }); diff --git a/src/hooks/useCompartment.ts b/src/hooks/useCompartment.ts new file mode 100644 index 0000000..91bb1f0 --- /dev/null +++ b/src/hooks/useCompartment.ts @@ -0,0 +1,42 @@ +import { Compartment, type Extension } from "@codemirror/state"; +import { useRef } from "react"; + +import { useEditorEventCallback } from "./useEditorEventCallback.js"; + +/** + * Returns a compartment of the provided extension, and a method + * to reconfigure it. + * + * @example + * + * ``` + * const [languageConf, reconfigureLanguage] = useCompartment(javascript()) + * const extensions = [basicSetup, languageConf] + * + * return ( + * + * reconfigureLanguage(lang)} + * /> + * + * + * ) + * ``` + */ +export function useCompartment(initialExtension: Extension) { + const compartmentRef = useRef(new Compartment()); + const extensionRef = useRef(compartmentRef.current.of(initialExtension)); + + const reconfigure = useEditorEventCallback((view, extension: Extension) => { + view.dispatch({ + effects: compartmentRef.current.reconfigure(extension), + }); + }); + + return [extensionRef.current, reconfigure] as const; +} diff --git a/src/index.ts b/src/index.ts index 94a0fa3..b6313ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,5 @@ export { CodeMirror } from "./components/CodeMirror.js"; export { CodeMirrorEditor } from "./components/CodeMirrorEditor.js"; export { useEditorEffect } from "./hooks/useEditorEffect.js"; export { useEditorEventCallback } from "./hooks/useEditorEventCallback.js"; +export { useEditorState } from "./hooks/useEditorState.js"; +export { useCompartment } from "./hooks/useCompartment.js";