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/5e6945cc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@handlewithcare/react-codemirror": patch
118 changes: 115 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ yarn add @handlewithcare/react-codemirror
- [Overview](#overview)
- [Usage](#usage)
- [Dynamic extensions](#dynamic-extensions)
- [`useReconfigure`](#usereconfigure)
- [`useSyncExtensions`](#usesyncextensions)
- [Other hooks](#other-hooks)
- [API](#api)
- [`CodeMirror`](#codemirror)
- [`CodeMirrorEditor`](#codemirroreditor)
- [`react`](#react)
- [`useEditorState`](#useeditorstate)
- [`useCompartment`](#usecompartment)
- [`useReconfigure`](#usereconfigure-1)
- [`useSyncExtensions`](#usesyncextensions-1)
- [`useEditorEventCallback`](#useeditoreventcallback)
- [`useEditorEffect`](#useeditoreffect)
- [Looking for someone to collaborate with?](#looking-for-someone-to-collaborate-with)
Expand Down Expand Up @@ -84,8 +88,13 @@ sync with the CodeMirror EditorState.

### Dynamic extensions

#### `useReconfigure`

The `useReconfigure` hook can be used to configure dynamic CodeMirror
extensions. Here’s an example, using a simple theme switcher:
extensions. The function returned by `useReconfigure` should only be used in an
event callback. If you need to keep a compartment in sync with some external
state, see [`useSyncExtensions`](#usesyncexetensions). Here’s an example with
`useReconfigure`, using a simple theme switcher:

```tsx
// ThemePicker.tsx
Expand Down Expand Up @@ -152,6 +161,61 @@ function CodeEditor() {
}
```

#### `useSyncExtensions`

The `useSyncExtensions` hook can be used keep the CodeMirror EditorState's
extensions in sync with external state. In general, this should be avoided —
it's better to either derive state _from_ your CodeMirror EditorState or lift
the EditorState into your global/top-level state and update it at the same time
as you update other state (like the user's theme). However, sometimes you need
to have a local EditorState that is derived from state that lives higher up in
the tree.

The `useSyncExtensions` hook _must_ be used in the component that owns the
EditorState, otherwise React will throw an error about updating state from
another component.

```tsx
function ThemePicker() {
const theme = useSelector((state) => state.theme);
const dispatch = useDispatch();

return (
<button
onClick={() => {
dispatch(setTheme(theme === "light" ? "dark" : "light"));
}}
>
Enable {theme === "dark" ? "light" : "dark"} mode
</button>
);
}

function Editor() {
const [state, setState] = useState(editorState);

const theme = useSelector((state) => state.theme);
const themeExtension = theme === "light" ? [] : oneDark;
useSyncExtensions([themeCompartment], [themeExtension], state, setState);

const dispatchTransactions = useCallback((trs: readonly Transaction[]) => {
setState(trs[trs.length - 1].state);
}, []);

return (
<CodeMirror
state={state}
dispatchTransactions={dispatchTransactions}
extensions={extensions}
>
<CodeMirrorEditor />
</CodeMirror>
);
}
```

### Other hooks

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
Expand Down Expand Up @@ -295,7 +359,7 @@ function useEditorState(): EditorState;

Provides access to the current EditorState value.

### `useCompartment`
### `useReconfigure`

```ts
function useReconfigure(
Expand Down Expand Up @@ -334,6 +398,54 @@ export function ThemePicker() {
}
```

### `useSyncExtensions`

```ts
function useSyncExtensions(
compartments: Compartment[],
extensions: Extension[],
editorState: EditorState,
setEditorState: (editorState: EditorState) => void,
): void;
```

Keep compartmentalized extensions in sync with external state.

If the state that determines the value of a compartment necessarily lives
outside the CodeMirror EditorState (say, an app-wide theme picker), this hook
can be used to keep it in sync with the EditorState.

To avoid state tearing, this hook calls the `setEditorState` argument in the
render phase. This means that it _must_ be used in the component that owns the
EditorState. If your EditorState lives in a global state manager, you should not
use this hook.

Example usage:

```tsx
function Editor() {
const [state, setState] = useState(editorState);

const theme = useSelector((state) => state.theme);
const themeExtension = theme === "light" ? [] : oneDark;
useSyncExtensions([themeCompartment], [themeExtension], state, setState);

const dispatchTransactions = useCallback((trs: readonly Transaction[]) => {
setState(trs[trs.length - 1].state);
}, []);

return (
<CodeMirror
state={state}
dispatchTransactions={dispatchTransactions}
extensions={extensions}
>
<CodeMirrorEditor />
</CodeMirror>
);
}
```

### `useEditorEventCallback`

```ts
Expand Down
38 changes: 12 additions & 26 deletions src/hooks/useEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,36 +46,24 @@ export function useEditor(

const seen = useRef<Set<Transaction>>(new Set(state.field(tracking)));

function dispatchTransactions(trs: readonly Transaction[], view: EditorView) {
if (!options.state) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setState(trs[trs.length - 1]!.state);
}

if (options.dispatchTransactions) {
options.dispatchTransactions(trs, view);
}
}
const dispatchTransactionsRef = useRef(dispatchTransactions);

const config = {
...options,
state,
dispatchTransactions: function (
trs: readonly Transaction[],
view: EditorView,
) {
dispatchTransactionsRef.current(trs, view);
},
};

const [view, setView] = useState<EditorView | null>(null);

const createEditorView = useEffectEvent((parent: HTMLDivElement | null) => {
if (parent) {
return new EditorView({
parent,
...config,
...options,
state,
dispatchTransactions(trs: readonly Transaction[], view: EditorView) {
if (!options.state) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
setState(trs[trs.length - 1]!.state);
}

if (options.dispatchTransactions) {
options.dispatchTransactions(trs, view);
}
},
});
}
return null;
Expand All @@ -91,8 +79,6 @@ export function useEditor(
}, [parent, createEditorView]);

useClientLayoutEffect(() => {
dispatchTransactionsRef.current = dispatchTransactions;

const trs = state.field(tracking);
const newTrs = trs.filter((tr) => !seen.current.has(tr));

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useReconfigure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useEditorEventCallback } from "./useEditorEventCallback.js";
* const state = useEditorState();
* const theme = themeCompartment.get(state);
* const dark = theme === oneDark;
* const reconfigureTheme = useReconfigure(themeCompartment)*;
* const reconfigureTheme = useReconfigure(themeCompartment);
*
* return (
* <button
Expand Down
117 changes: 117 additions & 0 deletions src/hooks/useSyncExtensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
type Compartment,
type EditorState,
type Extension,
type StateEffect,
} from "@codemirror/state";

function isExtensionArray(extension: Extension): extension is Extension[] {
return Array.isArray(extension);
}

function extensionIsEqual(a: Extension, b: Extension) {
if (isExtensionArray(a)) {
if (!isExtensionArray(b)) return false;

if (a.length !== b.length) return false;

for (let i = 0; i < a.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const childA = a[i]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const childB = b[i]!;

if (!extensionIsEqual(childA, childB)) return false;
}

return true;
}

return a === b;
}

/**
* Keep compartmentalized extensions in sync with external state.
*
* If the state that determines the value of a compartment necessarily
* lives outside the CodeMirror EditorState (say, an app-wide theme
* picker), this hook can be used to keep it in sync with the EditorState.
*
* To avoid state tearing, this hook calls the `setEditorState` argument
* in the render phase. This means that it _must_ be used in the
* component that owns the EditorState. If your EditorState lives in a
* global state manager, you should not use this hook.
*
* @example
*
* ```
* function ThemePicker() {
* const theme = useSelector((state) => state.theme)
* const dispatch = useDispatch()
*
* return (
* <button
* onClick={() => {
* dispatch(setTheme(theme === "light" ? "dark" : "light"));
* }}
* >
* Enable {theme === "dark" ? "light" : "dark"} mode
* </button>
* );
* }
*
* function Editor() {
* const [state, setState] = useState(editorState);
*
* const theme = useSelector((state) => state.theme)
* const themeExtension = theme === "light" ? [] : oneDark
* useSyncExtensions([themeCompartment], [themeExtension], state, setState);
*
* const dispatchTransactions = useCallback((trs: readonly Transaction[]) => {
* setState(trs[trs.length - 1].state);
* }, []);
*
* return (
* <CodeMirror
* state={state}
* dispatchTransactions={dispatchTransactions}
* extensions={extensions}
* >
* <CodeMirrorEditor />
* </CodeMirror>
* );
* }
* ```
*
*
*/
export function useSyncExtensions(
compartments: Compartment[],
extensions: Extension[],
editorState: EditorState,
setEditorState: (editorState: EditorState) => void,
) {
if (compartments.length !== extensions.length) {
throw new Error(
`Compartments and extensions must have the same length. There should be one extension for each compartment.`,
);
}

const effects: StateEffect<unknown>[] = [];

for (let i = 0; i < compartments.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const compartment = compartments[i]!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const extension = extensions[i]!;

const prevExtension = compartment.get(editorState);
if (!prevExtension || !extensionIsEqual(extension, prevExtension)) {
effects.push(compartment.reconfigure(extension));
}
}

if (effects.length) {
setEditorState(editorState.update({ effects }).state);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export { useEditorEffect } from "./hooks/useEditorEffect.js";
export { useEditorEventCallback } from "./hooks/useEditorEventCallback.js";
export { useEditorState } from "./hooks/useEditorState.js";
export { useReconfigure } from "./hooks/useReconfigure.js";
export { useSyncExtensions } from "./hooks/useSyncExtensions.js";
import { tracking } from "./extensions/tracking.js";

export const react = [tracking];