diff --git a/README.md b/README.md index 5002755e..bd6dc43a 100644 --- a/README.md +++ b/README.md @@ -122,16 +122,14 @@ import { ProseMirror } from "@nytimes/react-prosemirror"; export function ProseMirrorEditor() { const [mount, setMount] = useState(null); - const [editorState, setEditorState] = useState( - EditorState.create({ schema }) - ); + const [state, setState] = useState(EditorState.create({ schema })); return ( { - setEditorState((s) => s.apply(tr)); + setState((s) => s.apply(tr)); }} >
@@ -194,14 +192,14 @@ import { SelectionWidget } from "./SelectionWidget.tsx"; export function ProseMirrorEditor() { const [mount, setMount] = useState(null); - const [editorState, setEditorState] = useState(EditorState.create({ schema })) + const [state, setState] = useState(EditorState.create({ schema })) return ( { - setEditorState(s => s.apply(tr)) + setState(s => s.apply(tr)) }} > {/* @@ -249,14 +247,12 @@ import { BoldButton } from "./BoldButton.tsx"; export function ProseMirrorEditor() { const [mount, setMount] = useState(null); - const [editorState, setEditorState] = useState( - EditorState.create({ schema }) - ); + const [state, setState] = useState(EditorState.create({ schema })); return ( { setEditorState((s) => s.apply(tr)); }} @@ -380,7 +376,7 @@ const reactNodeViews = { }), }; -const editorState = EditorState.create({ +const state = EditorState.create({ schema, // You must add the react plugin if you use // the useNodeViews or useNodePos hook. @@ -392,7 +388,7 @@ function ProseMirrorEditor() { const [mount, setMount] = useState(null); return ( - +
{renderNodeViews()} @@ -406,11 +402,14 @@ function ProseMirrorEditor() { ```tsx type ProseMirror = ( - props: { - mount: HTMLElement; - children: ReactNode; - } & DirectEditorProps & - ({ defaultState: EditorState } | { state: EditorState }) + props: EditorProps & { + mount: HTMLElement | null; + children?: ReactNode | null; + defaultState?: EditorState; + state?: EditorState; + plugins?: readonly Plugin[]; + dispatchTransaction?(this: EditorView, tr: Transaction): void; + } ) => JSX.Element; ``` diff --git a/package.json b/package.json index 96d981ff..545f4c82 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", "@types/jest": "^27.0.0", + "@types/node": "^20.11.16", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.51.0", diff --git a/src/components/ProseMirror.tsx b/src/components/ProseMirror.tsx index 0f65e908..e3d21ca0 100644 --- a/src/components/ProseMirror.tsx +++ b/src/components/ProseMirror.tsx @@ -1,8 +1,25 @@ -import React from "react"; +import type { EditorState, Plugin, Transaction } from "prosemirror-state"; +import type { EditorProps, EditorView } from "prosemirror-view"; +import React, { memo } from "react"; +import type { ReactNode } from "react"; +import { EditorProvider } from "../contexts/EditorContext.js"; import { LayoutGroup } from "../contexts/LayoutGroup.js"; +import { useEditorView } from "../hooks/useEditorView.js"; -import { ProseMirrorInner, ProseMirrorProps } from "./ProseMirrorInner.js"; +interface Props extends EditorProps { + mount: HTMLElement | null; + children?: ReactNode | null; + defaultState?: EditorState; + state?: EditorState; + plugins?: readonly Plugin[]; + dispatchTransaction?(this: EditorView, tr: Transaction): void; +} + +function Editor({ mount, children, ...props }: Props) { + const value = useEditorView(mount, props); + return {children}; +} /** * Renders the ProseMirror View onto a DOM mount. @@ -25,10 +42,13 @@ import { ProseMirrorInner, ProseMirrorProps } from "./ProseMirrorInner.js"; * } * ``` */ -export function ProseMirror(props: ProseMirrorProps) { +function ProseMirror(props: Props) { return ( - + ); } + +const MemoizedProseMirror = memo(ProseMirror); +export { MemoizedProseMirror as ProseMirror }; diff --git a/src/components/ProseMirrorInner.tsx b/src/components/ProseMirrorInner.tsx deleted file mode 100644 index 3e7e0592..00000000 --- a/src/components/ProseMirrorInner.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useMemo } from "react"; -import type { ReactNode } from "react"; - -import { EditorProvider } from "../contexts/EditorContext.js"; -import { useComponentEventListeners } from "../hooks/useComponentEventListeners.js"; -import { EditorProps, useEditorView } from "../hooks/useEditorView.js"; - -export type ProseMirrorProps = EditorProps & { - mount: HTMLElement | null; - children?: ReactNode | null; -}; - -/** - * Renders the ProseMirror View onto a DOM mount. - * - * The `mount` prop must be an actual HTMLElement instance. The - * JSX element representing the mount should be passed as a child - * to the ProseMirror component. - * - * e.g. - * - * ``` - * function MyProseMirrorField() { - * const [mount, setMount] = useState(null); - * - * return ( - * - *
- * - * ); - * } - * ``` - */ -export function ProseMirrorInner({ - children, - mount, - ...editorProps -}: ProseMirrorProps) { - const { - componentEventListenersPlugin, - registerEventListener, - unregisterEventListener, - } = useComponentEventListeners(); - - const plugins = useMemo( - () => [...(editorProps.plugins ?? []), componentEventListenersPlugin], - [editorProps.plugins, componentEventListenersPlugin] - ); - - const editorView = useEditorView(mount, { - ...editorProps, - plugins, - }); - - const editorState = - "defaultState" in editorProps - ? // Only use the default state as a fallback for the first render where `editorView` isn't initialized yet - editorView?.state ?? editorProps.defaultState - : editorProps.state; - - const editorContextValue = useMemo( - () => ({ - editorView, - editorState, - registerEventListener, - unregisterEventListener, - }), - [editorState, editorView, registerEventListener, unregisterEventListener] - ); - - return ( - - {children ?? null} - - ); -} diff --git a/src/components/__tests__/ProseMirror.test.tsx b/src/components/__tests__/ProseMirror.test.tsx index f3c0e8e2..bc7836fe 100644 --- a/src/components/__tests__/ProseMirror.test.tsx +++ b/src/components/__tests__/ProseMirror.test.tsx @@ -2,7 +2,6 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Schema } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; import React, { useEffect, useState } from "react"; import { useNodeViews } from "../../hooks/useNodeViews.js"; @@ -14,6 +13,12 @@ import { } from "../../testing/setupProseMirrorView.js"; import { ProseMirror } from "../ProseMirror.js"; +// Mock `ReactDOM.flushSync` to call `act` to flush updates from DOM mutations. +jest.mock("react-dom", () => ({ + ...jest.requireActual("react-dom"), + flushSync: (fn: () => void) => act(fn), +})); + describe("ProseMirror", () => { beforeAll(() => { setupProseMirrorView(); @@ -32,11 +37,12 @@ describe("ProseMirror", () => { const [mount, setMount] = useState(null); return ( - +
); } + const user = userEvent.setup(); render(); @@ -46,41 +52,44 @@ describe("ProseMirror", () => { expect(editor.textContent).toBe("Hello, world!"); }); - it("supports lifted editor state", async () => { + it("supports controlling the editor state", async () => { const schema = new Schema({ nodes: { text: {}, doc: { content: "text*" }, }, }); - let outerEditorState = EditorState.create({ schema }); + + let observedState = EditorState.create({ schema }); + function TestEditor() { - const [editorState, setEditorState] = useState(outerEditorState); + const [state, setState] = useState(observedState); const [mount, setMount] = useState(null); useEffect(() => { - outerEditorState = editorState; - }, [editorState]); + observedState = state; + }, [state]); return ( - act(() => setEditorState(editorState.apply(tr))) - } + state={state} + dispatchTransaction={(tr) => { + setState((s) => s.apply(tr)); + }} >
); } + const user = userEvent.setup(); render(); const editor = screen.getByTestId("editor"); await user.type(editor, "Hello, world!"); - expect(outerEditorState.doc.textContent).toBe("Hello, world!"); + expect(observedState.doc.textContent).toBe("Hello, world!"); }); it("supports React NodeViews", async () => { @@ -106,27 +115,25 @@ describe("ProseMirror", () => { }; function TestEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); + const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); + const [state, setState] = useState(editorState); return ( this.updateState(this.state.apply(tr))); - }} + state={state} nodeViews={nodeViews} + dispatchTransaction={(tr) => { + setState((s) => s.apply(tr)); + }} >
{renderNodeViews()} ); } + const user = userEvent.setup(); render(); diff --git a/src/contexts/EditorContext.ts b/src/contexts/EditorContext.ts index f8af3732..ebe58a96 100644 --- a/src/contexts/EditorContext.ts +++ b/src/contexts/EditorContext.ts @@ -4,7 +4,7 @@ import { createContext } from "react"; import type { EventHandler } from "../plugins/componentEventListeners"; -interface EditorContextValue { +export interface EditorContextValue { editorView: EditorView | null; editorState: EditorState; registerEventListener( diff --git a/src/hooks/__tests__/useNodeViews.test.tsx b/src/hooks/__tests__/useNodeViews.test.tsx index c79d8497..a8cbc34e 100644 --- a/src/hooks/__tests__/useNodeViews.test.tsx +++ b/src/hooks/__tests__/useNodeViews.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { Schema } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; import React, { createContext, useContext, useState } from "react"; @@ -8,6 +8,12 @@ import { NodeViewComponentProps } from "../../nodeViews/createReactNodeViewConst import { react } from "../../plugins/react.js"; import { useNodeViews } from "../useNodeViews.js"; +// Mock `ReactDOM.flushSync` to call `act` to flush updates from DOM mutations. +jest.mock("react-dom", () => ({ + ...jest.requireActual("react-dom"), + flushSync: (fn: () => void) => act(fn), +})); + const schema = new Schema({ nodes: { doc: { content: "block+" }, @@ -17,7 +23,7 @@ const schema = new Schema({ }, }); -const editorState = EditorState.create({ +const state = EditorState.create({ doc: schema.topNodeType.create(null, schema.nodes.list.createAndFill()), schema, plugins: [react()], @@ -61,7 +67,7 @@ describe("useNodeViews", () => { const [mount, setMount] = useState(null); return ( - +
{renderNodeViews()} @@ -114,7 +120,7 @@ describe("useNodeViews", () => { const [mount, setMount] = useState(null); return ( - +
{renderNodeViews()} diff --git a/src/hooks/useEditorView.ts b/src/hooks/useEditorView.ts index adba099e..bf65fc98 100644 --- a/src/hooks/useEditorView.ts +++ b/src/hooks/useEditorView.ts @@ -1,56 +1,33 @@ -import type { EditorState, Transaction } from "prosemirror-state"; +import { Schema } from "prosemirror-model"; +import { EditorState } from "prosemirror-state"; +import type { Plugin, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; -import type { DirectEditorProps } from "prosemirror-view"; -import { useLayoutEffect, useState } from "react"; +import type { EditorProps } from "prosemirror-view"; +import { useLayoutEffect, useMemo, useState } from "react"; import { flushSync } from "react-dom"; -import { useForceUpdate } from "./useForceUpdate.js"; +import type { EditorContextValue } from "../contexts/EditorContext.js"; -function withFlushedUpdates( - fn: (this: This, ...args: T) => void -): (...args: T) => void { - return function (this: This, ...args: T) { - flushSync(() => { - fn.call(this, ...args); - }); - }; -} +import { useComponentEventListeners } from "./useComponentEventListeners.js"; -function defaultDispatchTransaction(this: EditorView, tr: Transaction) { - this.updateState(this.state.apply(tr)); -} +const EMPTY_SCHEMA = new Schema({ + nodes: { + doc: { content: "text*" }, + text: { inline: true }, + }, +}); -type EditorStateProps = - | { - state: EditorState; - } - | { - defaultState: EditorState; - }; +const EMPTY_STATE = EditorState.create({ + schema: EMPTY_SCHEMA, +}); -export type EditorProps = Omit & EditorStateProps; +let didWarnValueDefaultValue = false; -function withFlushedDispatch( - props: EditorProps, - forceUpdate: () => void -): EditorProps & { - dispatchTransaction: EditorView["dispatch"]; -} { - return { - ...props, - ...{ - dispatchTransaction: function dispatchTransaction( - this: EditorView, - tr: Transaction - ) { - const flushedDispatch = withFlushedUpdates( - props.dispatchTransaction ?? defaultDispatchTransaction - ); - flushedDispatch.call(this, tr); - if (!("state" in props)) forceUpdate(); - }, - }, - }; +interface Props extends EditorProps { + defaultState?: EditorState; + state?: EditorState; + plugins?: readonly Plugin[]; + dispatchTransaction?(this: EditorView, tr: Transaction): void; } /** @@ -59,70 +36,86 @@ function withFlushedDispatch( * All state and props updates are executed in a layout effect. * To ensure that the EditorState and EditorView are never out of * sync, it's important that the EditorView produced by this hook - * is only accessed through the `useEditorViewEvent` and - * `useEditorViewLayoutEffect` hooks. + * is only accessed through the provided hooks. */ export function useEditorView( mount: T | null, - props: EditorProps -): EditorView | null { - const [view, setView] = useState(null); - const forceUpdate = useForceUpdate(); - - const editorProps = withFlushedDispatch(props, forceUpdate); + props: Props +): EditorContextValue { + if (process.env.NODE_ENV !== "production") { + if ( + props.defaultState !== undefined && + props.state !== undefined && + !didWarnValueDefaultValue + ) { + console.error( + "A component contains a ProseMirror editor with both value and defaultValue props. " + + "ProseMirror editors must be either controlled or uncontrolled " + + "(specify either the value prop, or the defaultValue prop, but not both). " + + "Decide between using a controlled or uncontrolled ProseMirror editor " + + "and remove one of these props. More info: " + + "https://reactjs.org/link/controlled-components" + ); + didWarnValueDefaultValue = true; + } + } - const stateProp = "state" in editorProps ? editorProps.state : undefined; + const defaultState = props.defaultState ?? EMPTY_STATE; + const [_state, setState] = useState(defaultState); + const state = props.state ?? _state; - const state = - "defaultState" in editorProps - ? editorProps.defaultState - : editorProps.state; + const { + componentEventListenersPlugin, + registerEventListener, + unregisterEventListener, + } = useComponentEventListeners(); - const nonStateProps = Object.fromEntries( - Object.entries(editorProps).filter( - ([propName]) => propName !== "state" && propName !== "defaultState" - ) + const plugins = useMemo( + () => [...(props.plugins ?? []), componentEventListenersPlugin], + [props.plugins, componentEventListenersPlugin] ); - useLayoutEffect(() => { - return () => { - if (view) { - view.destroy(); + function dispatchTransaction(this: EditorView, tr: Transaction) { + flushSync(() => { + if (props.dispatchTransaction) { + props.dispatchTransaction.call(this, tr); + } else { + setState((s) => s.apply(tr)); } - }; - }, [view]); - - useLayoutEffect(() => { - if (view && view.dom !== mount) { - setView(null); - return; - } - - if (!mount) { - return; - } + }); + } - if (!view) { - setView( - new EditorView( - { mount }, - { - ...editorProps, - state, - } - ) - ); - return; - } - }, [editorProps, mount, state, view]); + const directEditorProps = { + ...props, + state, + plugins, + dispatchTransaction, + }; - useLayoutEffect(() => { - view?.setProps(nonStateProps); - }, [view, nonStateProps]); + const [view, setView] = useState(null); + // This effect runs on every render and handles the view lifecycle. + // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { - if (stateProp) view?.setProps({ state: stateProp }); - }, [view, stateProp]); - - return view; + if (view) { + if (view.dom === mount) { + view.setProps(directEditorProps); + } else { + view.destroy(); + setView(null); + } + } else if (mount) { + setView(new EditorView({ mount }, directEditorProps)); + } + }); + + return useMemo( + () => ({ + editorState: state, + editorView: view, + registerEventListener, + unregisterEventListener, + }), + [state, view, registerEventListener, unregisterEventListener] + ); } diff --git a/yarn.lock b/yarn.lock index d6143c7d..cbd41c5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1226,6 +1226,7 @@ __metadata: "@testing-library/react": ^13.4.0 "@testing-library/user-event": ^14.4.3 "@types/jest": ^27.0.0 + "@types/node": ^20.11.16 "@types/react": ^18.0.0 "@types/react-dom": ^18.0.0 "@typescript-eslint/eslint-plugin": ^5.51.0 @@ -1694,6 +1695,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.11.16": + version: 20.11.16 + resolution: "@types/node@npm:20.11.16" + dependencies: + undici-types: ~5.26.4 + checksum: 51f0831c1219bf4698e7430aeb9892237bd851deeb25ce23c5bb0ceefcc77c3b114e48f4e98d9fc26def5a87ba9d8079f0281dd37bee691140a93f133812c152 + languageName: node + linkType: hard + "@types/prettier@npm:^2.1.5": version: 2.7.2 resolution: "@types/prettier@npm:2.7.2" @@ -8264,6 +8274,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487 + languageName: node + linkType: hard + "unique-filename@npm:^2.0.0": version: 2.0.1 resolution: "unique-filename@npm:2.0.1"