diff --git a/.yarn/versions/5e6945cc.yml b/.yarn/versions/5e6945cc.yml new file mode 100644 index 0000000..766dd55 --- /dev/null +++ b/.yarn/versions/5e6945cc.yml @@ -0,0 +1,2 @@ +releases: + "@handlewithcare/react-codemirror": patch diff --git a/README.md b/README.md index f2ef2c6..f8f7d9c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 ( + + ); +} + +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 ( + + + + ); +} +``` + +### 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 @@ -295,7 +359,7 @@ function useEditorState(): EditorState; Provides access to the current EditorState value. -### `useCompartment` +### `useReconfigure` ```ts function useReconfigure( @@ -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 ( + + + + ); +} +``` + ### `useEditorEventCallback` ```ts diff --git a/src/hooks/useEditor.ts b/src/hooks/useEditor.ts index 19bd234..82f30da 100644 --- a/src/hooks/useEditor.ts +++ b/src/hooks/useEditor.ts @@ -46,36 +46,24 @@ export function useEditor( const seen = useRef>(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(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; @@ -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)); diff --git a/src/hooks/useReconfigure.ts b/src/hooks/useReconfigure.ts index 01e00e2..a5447b2 100644 --- a/src/hooks/useReconfigure.ts +++ b/src/hooks/useReconfigure.ts @@ -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 ( * + * ); + * } + * + * 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 ( + * + * + * + * ); + * } + * ``` + * + * + */ +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[] = []; + + 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); + } +} diff --git a/src/index.ts b/src/index.ts index 666dc51..522ec12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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];