diff --git a/.eslintrc.json b/.eslintrc.json index 3b50ffe4..96c64314 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,7 @@ "node": false }, "rules": { - "no-console": ["off", { "allow": "error" }], + "no-console": ["error", { "allow": ["error"] }], "react-hooks/exhaustive-deps": [ "warn", { diff --git a/.yarn/versions/27cac6b1.yml b/.yarn/versions/27cac6b1.yml new file mode 100644 index 00000000..90948e78 --- /dev/null +++ b/.yarn/versions/27cac6b1.yml @@ -0,0 +1,2 @@ +releases: + "@nytimes/react-prosemirror": patch diff --git a/README.md b/README.md index a1583fe9..5708849a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ export function ProseMirrorEditor() { const [mount, setMount] = useState(); return ( - +
); @@ -368,7 +368,7 @@ function ProseMirrorEditor() { return (
@@ -383,13 +383,13 @@ function ProseMirrorEditor() { ### `ProseMirror` ```tsx -type ProseMirror = (props: { - dispatchTransaction: (tr: Transaction) => void; - editorProps: EditorProps; - editorState: EditorState; - mount: HTMLElement | null; - children?: ReactNode | null; -}) => JSX.Element; +type ProseMirror = ( + props: { + mount: HTMLElement; + children: ReactNode; + } & DirectEditorProps & + ({ defaultState: EditorState } | { state: EditorState }) +) => JSX.Element; ``` Renders the ProseMirror View onto a DOM mount. diff --git a/demo/main.tsx b/demo/main.tsx index ba1863f5..9c311cbc 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -9,6 +9,8 @@ import { createRoot } from "react-dom/client"; import { NodeViewComponentProps, ProseMirror, + useEditorEffect, + useEditorState, useNodeViews, } from "../src/index.js"; import { ReactNodeViewConstructor } from "../src/nodeViews/createReactNodeViewConstructor.js"; @@ -47,7 +49,11 @@ function DemoEditor() { return (

React ProseMirror Demo

- +
{renderNodeViews()} diff --git a/src/components/ProseMirrorInner.tsx b/src/components/ProseMirrorInner.tsx index 0441edfb..b11e9bf6 100644 --- a/src/components/ProseMirrorInner.tsx +++ b/src/components/ProseMirrorInner.tsx @@ -1,12 +1,11 @@ -import type { DirectEditorProps } from "prosemirror-view"; import React, { useMemo } from "react"; import type { ReactNode } from "react"; import { EditorProvider } from "../contexts/EditorContext.js"; import { useComponentEventListeners } from "../hooks/useComponentEventListeners.js"; -import { useEditorView } from "../hooks/useEditorView.js"; +import { EditorProps, useEditorView } from "../hooks/useEditorView.js"; -export type ProseMirrorProps = DirectEditorProps & { +export type ProseMirrorProps = EditorProps & { mount: HTMLElement | null; children?: ReactNode | null; }; @@ -35,7 +34,6 @@ export type ProseMirrorProps = DirectEditorProps & { export function ProseMirrorInner({ children, dispatchTransaction, - state, mount, ...editorProps }: ProseMirrorProps) { @@ -53,18 +51,20 @@ export function ProseMirrorInner({ const editorView = useEditorView(mount, { ...editorProps, plugins, - state, dispatchTransaction, }); + const editorState = + "state" in editorProps ? editorProps.state : editorView?.state ?? null; + const editorContextValue = useMemo( () => ({ editorView, - editorState: state, + editorState, registerEventListener, unregisterEventListener, }), - [editorView, state, registerEventListener, unregisterEventListener] + [editorState, editorView, registerEventListener, unregisterEventListener] ); return ( diff --git a/src/hooks/useEditorView.ts b/src/hooks/useEditorView.ts index 583338c5..5373210e 100644 --- a/src/hooks/useEditorView.ts +++ b/src/hooks/useEditorView.ts @@ -1,9 +1,11 @@ -import type { Transaction } from "prosemirror-state"; +import type { EditorState, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import type { DirectEditorProps } from "prosemirror-view"; import { useLayoutEffect, useState } from "react"; import { unstable_batchedUpdates as batch } from "react-dom"; +import { useForceUpdate } from "./useForceUpdate.js"; + function withBatchedUpdates( fn: (this: This, ...args: T) => void ): (...args: T) => void { @@ -14,12 +16,19 @@ function withBatchedUpdates( }; } -const defaultDispatchTransaction = function ( - this: EditorView, - tr: Transaction -) { - batch(() => this.updateState(this.state.apply(tr))); -}; +function defaultDispatchTransaction(this: EditorView, tr: Transaction) { + this.updateState(this.state.apply(tr)); +} + +type EditorStateProps = + | { + state: EditorState; + } + | { + defaultState: EditorState; + }; + +export type EditorProps = Omit & EditorStateProps; /** * Enhances editor props so transactions dispatch in a batched update. @@ -33,13 +42,25 @@ const defaultDispatchTransaction = function ( * the Editor View unmodified after we upgrade to React 18, which batches every * update by default. */ -function withBatchedDispatch(props: DirectEditorProps): DirectEditorProps { +function withBatchedDispatch( + props: EditorProps, + forceUpdate: () => void +): EditorProps & { + dispatchTransaction: EditorView["dispatch"]; +} { return { ...props, ...{ - dispatchTransaction: props.dispatchTransaction - ? withBatchedUpdates(props.dispatchTransaction) - : defaultDispatchTransaction, + dispatchTransaction: function dispatchTransaction( + this: EditorView, + tr: Transaction + ) { + const batchedDispatchTransaction = withBatchedUpdates( + props.dispatchTransaction ?? defaultDispatchTransaction + ); + batchedDispatchTransaction.call(this, tr); + forceUpdate(); + }, }, }; } @@ -55,12 +76,26 @@ function withBatchedDispatch(props: DirectEditorProps): DirectEditorProps { */ export function useEditorView( mount: T | null, - props: DirectEditorProps + props: EditorProps ): EditorView | null { const [view, setView] = useState(null); - props = withBatchedDispatch(props); - const { state, ...nonStateProps } = props; + const forceUpdate = useForceUpdate(); + + const editorProps = withBatchedDispatch(props, forceUpdate); + + const stateProp = "state" in editorProps ? editorProps.state : undefined; + + const state = + "defaultState" in editorProps + ? editorProps.defaultState + : editorProps.state; + + const nonStateProps = Object.fromEntries( + Object.entries(editorProps).filter( + ([propName]) => propName !== "state" && propName !== "defaultState" + ) + ); useLayoutEffect(() => { return () => { @@ -81,18 +116,26 @@ export function useEditorView( } if (!view) { - setView(new EditorView({ mount }, props)); + setView( + new EditorView( + { mount }, + { + ...editorProps, + state, + } + ) + ); return; } - }, [mount, props, view]); + }, [editorProps, mount, state, view]); useLayoutEffect(() => { view?.setProps(nonStateProps); }, [view, nonStateProps]); useLayoutEffect(() => { - view?.setProps({ state }); - }, [view, state]); + if (stateProp) view?.setProps({ state: stateProp }); + }, [view, stateProp]); return view; }