diff --git a/.yarn/versions/1266e9a0.yml b/.yarn/versions/1266e9a0.yml new file mode 100644 index 00000000..d39eb381 --- /dev/null +++ b/.yarn/versions/1266e9a0.yml @@ -0,0 +1,2 @@ +releases: + "@nytimes/react-prosemirror": major diff --git a/README.md b/README.md index 1aec59b8..e971b3e7 100644 --- a/README.md +++ b/README.md @@ -31,16 +31,15 @@ yarn add @nytimes/react-prosemirror - [`useEditorEffect`](#useeditoreffect) - [`useEditorEventCallback`](#useeditoreventcallback) - [`useEditorEventListener`](#useeditoreventlistener) - - [Building NodeViews with React](#building-nodeviews-with-react) + - [Building node views with React](#building-node-views-with-react) - [API](#api) - [`ProseMirror`](#prosemirror) + - [`react`](#react) - [`useEditorState`](#useeditorstate) - [`useEditorEventCallback`](#useeditoreventcallback-1) - [`useEditorEventListener`](#useeditoreventlistener-1) - [`useEditorEffect`](#useeditoreffect-1) - [`useNodePos`](#usenodepos) - - [`useNodeViews`](#usenodeviews) - - [`react`](#react) @@ -280,7 +279,7 @@ semantics for ProseMirror's `handleDOMEvents` prop: want to prevent the default contenteditable behavior, you must call `event.preventDefault`. -You can use this hook to implement custom behavior in your NodeViews: +You can use this hook to implement custom behavior in your node views: ```tsx import { useEditorEventListener } from "@nytimes/react-prosemirror"; @@ -306,22 +305,20 @@ function Paragraph({ node, children }) { } ``` -### Building NodeViews with React +### Building node views with React The other way to integrate React and ProseMirror is to have ProseMirror render -NodeViews using React components. This is somewhat more complex than the -previous section. This library provides a `useNodeViews` hook, a factory for -augmenting NodeView constructors with React components, and `react`, a -ProseMirror Plugin for maintaining the React component hierarchy. +node views using React components. The `` component recognizes when +a node view constructor returns a node view with a `component` property and it +renders the React component into the ProseMirror DOM element using a portal. The +node view constructor must return at least `dom` and `component` property, but +can also return any other node view properties. To support React node views, the +editor state must include the React plugin (see below). -`useNodeViews` takes a map from node name to an extended NodeView constructor. -The NodeView constructor must return at least a `dom` attribute and a -`component` attribute, but can also return any other NodeView attributes. Here's -an example of its usage: +Example usage: ```tsx import { - useNodeViews, useEditorEventCallback, NodeViewComponentProps, react, @@ -338,12 +335,11 @@ function Paragraph({ children }: NodeViewComponentProps) { return

{children}

; } -// Make sure that your ReactNodeViews are defined outside of -// your component, or are properly memoized. ProseMirror will -// teardown and rebuild all NodeViews if the nodeView prop is -// updated, leading to unbounded recursion if this object doesn't -// have a stable reference. -const reactNodeViews = { +// Make sure that your node views are defined outside of your copmonent, or are +// properly memoized. ProseMirror will teardown and rebuild all node views if +// the `nodeView` prop changes, leading to unbounded recursion if the reference +// is not stable. +const nodeViews = { paragraph: () => ({ component: Paragraph, // We render the Paragraph component itself into a div element @@ -355,21 +351,18 @@ const reactNodeViews = { }), }; +// You must add the `react` plugin to use React node views. const state = EditorState.create({ schema, - // You must add the react plugin if you use - // the useNodeViews or useNodePos hook. plugins: [react()], }); function ProseMirrorEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); return (
- {renderNodeViews()} ); } @@ -417,6 +410,17 @@ function MyProseMirrorField() { } ``` +### `react` + +```tsx +type react = Plugin>; +``` + +A ProseMirror Plugin that assists in maintaining the correct hierarchy for React +node views. + +If you use React node views, then your `EditorState` _must_ include this plugin. + ### `useEditorState` ```tsx @@ -509,75 +513,3 @@ type useNodePos = () => number; Returns the node's current position in the document. Takes the place of ProseMirror's `getPos` function that gets passed to NodeView's, which is unsafe to use in React render functions. - -This hook can only be used in React components rendered with -[`useNodeViews`](#usenodeviews). - -### `useNodeViews` - -```tsx -/** - * Extension of ProseMirror's NodeViewConstructor type to include - * `component`, the React component to used render the NodeView. - * All properties other than `component` and `dom` are optional. - */ -type ReactNodeViewConstructor = ( - node: Node, - view: EditorView, - getPos: () => number, - decorations: readonly Decoration[], - innerDecorations: DecorationSource -) => { - dom: HTMLElement | null; - component: React.ComponentType; - contentDOM?: HTMLElement | null; - selectNode?: () => void; - deselectNode?: () => void; - setSelection?: ( - anchor: number, - head: number, - root: Document | ShadowRoot - ) => void; - stopEvent?: (event: Event) => boolean; - ignoreMutation?: (mutation: MutationRecord) => boolean; - destroy?: () => void; - update?: ( - node: Node, - decorations: readonly Decoration[], - innerDecoration: DecorationSource - ) => boolean; -}; - -type useNodeViews = (nodeViews: Record) => { - nodeViews: Record; - renderNodeViews: () => ReactElement[]; -}; -``` - -Hook for creating and rendering NodeViewConstructors that are powered by React -components. To use this hook, you must also include -[`react`](#reactnodeviewplugin) in your `EditorState`. - -`component` can be any React component that takes `NodeViewComponentProps`. It -will be passed as props all of the arguments to the `nodeViewConstructor` except -for `editorView`. NodeView components that need access directly to the -EditorView should use the `useEditorEventCallback`, `useEditorEventListener` and -`useEditorEffect` hooks to ensure safe access. - -For contentful Nodes, the NodeView component will also be passed a `children` -prop containing an empty element. ProseMirror will render content nodes into -this element. Like in ProseMirror, the existence of a `contentDOM` attribute -determines whether a NodeView is contentful (i.e. the NodeView has editable -content that should be managed by ProseMirror). - -### `react` - -```tsx -type react = Plugin>; -``` - -A ProseMirror Plugin that assists in maintaining the correct hierarchy for React -node views. - -If you use `useNodeViews` or `useNodePos`, you _must_ include this plugin in -your `EditorState`. diff --git a/demo/main.tsx b/demo/main.tsx index 12ada0a5..b8d77d52 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -16,7 +16,7 @@ import "prosemirror-view/style/prosemirror.css"; import React, { useCallback, useState } from "react"; import { createRoot } from "react-dom/client"; -import { ProseMirror, useNodeViews } from "../src/index.js"; +import { ProseMirror } from "../src/index.js"; import type { NodeViewComponentProps } from "../src/index.js"; import type { ReactNodeViewConstructor } from "../src/nodeViews/createReactNodeViewConstructor.js"; import { react } from "../src/plugins/react.js"; @@ -87,7 +87,7 @@ function ListItem({ children }: NodeViewComponentProps) { return
  • {children}
  • ; } -const reactNodeViews: Record = { +const nodeViews: Record = { paragraph: () => ({ component: Paragraph, dom: document.createElement("div"), @@ -106,7 +106,6 @@ const reactNodeViews: Record = { }; function DemoEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); const [state, setState] = useState(defaultState); @@ -119,13 +118,12 @@ function DemoEditor() {
    - {renderNodeViews()}
    ); diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index dea928c6..7c860b61 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from "react"; import { EditorContext } from "../contexts/EditorContext.js"; import { useEditorView } from "../hooks/useEditorView.js"; import type { UseEditorViewOptions } from "../hooks/useEditorView.js"; +import { useNodeViews } from "../hooks/useNodeViews.js"; export interface EditorProps extends UseEditorViewOptions { mount: HTMLElement | null; @@ -11,8 +12,12 @@ export interface EditorProps extends UseEditorViewOptions { } export function Editor({ mount, children, ...options }: EditorProps) { - const value = useEditorView(mount, options); + const { nodeViews, nodeViewsComponent } = useNodeViews(options.nodeViews); + const value = useEditorView(mount, { ...options, nodeViews }); return ( - {children} + + {children} + {nodeViewsComponent} + ); } diff --git a/src/components/__tests__/ProseMirror.test.tsx b/src/components/__tests__/ProseMirror.test.tsx index b050bdc0..567d0ce6 100644 --- a/src/components/__tests__/ProseMirror.test.tsx +++ b/src/components/__tests__/ProseMirror.test.tsx @@ -5,7 +5,6 @@ import { EditorState } from "prosemirror-state"; import type { Transaction } from "prosemirror-state"; import React, { useEffect, useState } from "react"; -import { useNodeViews } from "../../hooks/useNodeViews.js"; import type { NodeViewComponentProps } from "../../nodeViews/createReactNodeViewConstructor.js"; import { react } from "../../plugins/react.js"; import { @@ -189,7 +188,7 @@ describe("ProseMirror", () => { return

    {children}

    ; } - const reactNodeViews = { + const nodeViews = { paragraph: () => ({ component: Paragraph, dom: document.createElement("div"), @@ -199,7 +198,6 @@ describe("ProseMirror", () => { function TestEditor() { const [mount, setMount] = useState(null); - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); return ( { nodeViews={nodeViews} >
    - {renderNodeViews()} ); } diff --git a/src/hooks/__tests__/useNodeViews.test.tsx b/src/hooks/__tests__/useNodeViews.test.tsx index 3bd90ddf..4205840a 100644 --- a/src/hooks/__tests__/useNodeViews.test.tsx +++ b/src/hooks/__tests__/useNodeViews.test.tsx @@ -6,7 +6,6 @@ import React, { createContext, useContext, useState } from "react"; import { ProseMirror } from "../../components/ProseMirror.js"; import type { NodeViewComponentProps } from "../../nodeViews/createReactNodeViewConstructor.js"; 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", () => ({ @@ -49,7 +48,7 @@ describe("useNodeViews", () => { ); } - const reactNodeViews = { + const nodeViews = { list: () => ({ component: List, dom: document.createElement("div"), @@ -63,13 +62,11 @@ describe("useNodeViews", () => { }; function TestEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); return (
    - {renderNodeViews()} ); } @@ -102,7 +99,7 @@ describe("useNodeViews", () => { ); } - const reactNodeViews = { + const nodeViews = { list: () => ({ component: List, dom: document.createElement("div"), @@ -116,13 +113,11 @@ describe("useNodeViews", () => { }; function TestEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); const [mount, setMount] = useState(null); return (
    - {renderNodeViews()} ); } diff --git a/src/hooks/useEditorView.ts b/src/hooks/useEditorView.ts index e996d5be..9c64ee8d 100644 --- a/src/hooks/useEditorView.ts +++ b/src/hooks/useEditorView.ts @@ -7,6 +7,7 @@ import { useLayoutEffect, useMemo, useState } from "react"; import { flushSync } from "react-dom"; import type { EditorContextValue } from "../contexts/EditorContext.js"; +import type { ReactNodeViewConstructor } from "../nodeViews/createReactNodeViewConstructor.js"; import { useComponentEventListeners } from "./useComponentEventListeners.js"; @@ -24,6 +25,7 @@ const EMPTY_STATE = EditorState.create({ let didWarnValueDefaultValue = false; export interface UseEditorViewOptions extends EditorProps { + nodeViews?: Record; defaultState?: EditorState; state?: EditorState; plugins?: Plugin[]; diff --git a/src/hooks/useNodeViews.tsx b/src/hooks/useNodeViews.tsx index fdcff8b4..ee16eafd 100644 --- a/src/hooks/useNodeViews.tsx +++ b/src/hooks/useNodeViews.tsx @@ -14,7 +14,7 @@ import type { } from "../nodeViews/createReactNodeViewConstructor.js"; export function useNodeViews( - nodeViews: Record + nodeViews?: Record ) { const [portals, setPortals] = useState({} as NodeViewsContextValue); @@ -47,17 +47,19 @@ export function useNodeViews( [] ); - const reactNodeViews = useMemo(() => { - const nodeViewEntries = Object.entries(nodeViews); - const reactNodeViewEntries = nodeViewEntries.map(([name, constructor]) => [ - name, - createReactNodeViewConstructor(constructor, registerPortal), - ]); - return Object.fromEntries(reactNodeViewEntries); + const wrappedNodeViews = useMemo(() => { + const nodeViewEntries = Object.entries(nodeViews ?? {}); + const wrappedNodeViewEntries = nodeViewEntries.map( + ([name, constructor]) => [ + name, + createReactNodeViewConstructor(constructor, registerPortal), + ] + ); + return Object.fromEntries(wrappedNodeViewEntries); }, [nodeViews, registerPortal]); return { - nodeViews: reactNodeViews, - renderNodeViews: () => , + nodeViews: wrappedNodeViews, + nodeViewsComponent: , }; } diff --git a/src/index.ts b/src/index.ts index db4228ff..82fa6411 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,8 +13,6 @@ export { useEditorState } from "./hooks/useEditorState.js"; export { useNodePos } from "./hooks/useNodePos.js"; -export { useNodeViews } from "./hooks/useNodeViews.js"; - export type { NodeViewComponentProps, ReactNodeView, diff --git a/src/nodeViews/createReactNodeViewConstructor.tsx b/src/nodeViews/createReactNodeViewConstructor.tsx index 4349dbf3..b9300e7f 100644 --- a/src/nodeViews/createReactNodeViewConstructor.tsx +++ b/src/nodeViews/createReactNodeViewConstructor.tsx @@ -67,16 +67,8 @@ export type RegisterPortal = ( portal: ReactPortal ) => UnregisterElement; -type _ReactNodeView = NodeView & { - component: ComponentType; -}; - -// We use a mapped type to improve LSP information for this type. -// The language server will actually spell out the properties and -// corresponding types of the mapped type, rather than repeating -// the ugly Omit<...> & { component: ... } type above. -export type ReactNodeView = { - [Property in keyof _ReactNodeView]: _ReactNodeView[Property]; +export type ReactNodeView = NodeView & { + component?: ComponentType; }; export type ReactNodeViewConstructor = ( @@ -89,6 +81,8 @@ export type ReactNodeViewConstructor = ( */ export const REACT_NODE_VIEW = Symbol("react node view"); +let didWarnReactPlugin = false; + /** * Searches upward for the nearest node with a node key, * returning the first node key it finds associated with @@ -123,7 +117,7 @@ export function findNodeKeyUp(editorView: EditorView, pos: number): NodeKey { * Factory function for creating nodeViewConstructors that * render as React components. * - * `ReactComponent` can be any React component that takes + * `NodeView` can be any React component that takes * `NodeViewComponentProps`. It will be passed all of the * arguments to the `nodeViewConstructor` except for * `editorView`. NodeView components that need access @@ -136,17 +130,17 @@ export function findNodeKeyUp(editorView: EditorView, pos: number): NodeKey { * ProseMirror will render content nodes into this element. */ export function createReactNodeViewConstructor( - reactNodeViewConstructor: ReactNodeViewConstructor, + nodeViewConstructor: ReactNodeViewConstructor, registerPortal: RegisterPortal ) { - function nodeViewConstructor( + function nodeViewConstructorWrapper( node: Node, editorView: EditorView, getPos: () => number, decorations: readonly Decoration[], innerDecorations: DecorationSource ): NodeView { - const reactNodeView = reactNodeViewConstructor( + const nodeView = nodeViewConstructor( node, editorView, getPos, @@ -154,9 +148,24 @@ export function createReactNodeViewConstructor( innerDecorations ); - let componentRef: NodeViewWrapperRef | null = null; + const { component: NodeView } = nodeView; + if (!NodeView) { + return nodeView; + } + + const reactPluginState = reactPluginKey.getState(editorView.state); + if (!reactPluginState) { + if (!didWarnReactPlugin) { + console.error( + "The React ProseMirror plugin is required to use React node views. " + + "Make sure to add it to the ProseMirror editor state." + ); + didWarnReactPlugin = true; + } + return nodeView; + } - const { dom, contentDOM, component: ReactComponent } = reactNodeView; + const { dom, contentDOM } = nodeView; // Use a span if the provided contentDOM is in the "phrasing" content // category. Otherwise use a div. This is our best attempt at not @@ -169,11 +178,6 @@ export function createReactNodeViewConstructor( ? "span" : "div"); - const reactPluginState = reactPluginKey.getState(editorView.state); - if (!reactPluginState) - throw new Error( - "Can't find the react() ProseMirror plugin, required for useNodeViews(). Was it added to the EditorState.plugins?" - ); const nodeKey = reactPluginState.posToKey.get(getPos()) ?? createNodeKey(); /** @@ -230,7 +234,7 @@ export function createReactNodeViewConstructor( ); return ( - )} - + ); }); NodeViewWrapper.displayName = `NodeView(${ - ReactComponent.displayName ?? ReactComponent.name + NodeView.displayName ?? NodeView.name })`; + let componentRef: NodeViewWrapperRef | null = null; const element = (