Skip to content

Commit

Permalink
feat!: automatically use node view components
Browse files Browse the repository at this point in the history
Remove the need to call `useNodeView` and render the portals manually.
Make it happen automatically it `<ProseMirror>`.

Remove `useNodeView` from the public API.
  • Loading branch information
tilgovi committed Jan 23, 2025
1 parent 1bb79ad commit afd0833
Show file tree
Hide file tree
Showing 10 changed files with 85 additions and 151 deletions.
2 changes: 2 additions & 0 deletions .yarn/versions/1266e9a0.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@nytimes/react-prosemirror": major
122 changes: 27 additions & 95 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,12 @@ yarn add @nytimes/react-prosemirror
- [Building NodeViews with React](#building-nodeviews-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)

<!-- tocstop -->

Expand Down Expand Up @@ -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";
Expand All @@ -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 `<ProseMirror>` 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,
Expand All @@ -338,12 +335,11 @@ function Paragraph({ children }: NodeViewComponentProps) {
return <p onClick={onClick}>{children}</p>;
}

// 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
Expand All @@ -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<HTMLElement | null>(null);

return (
<ProseMirror mount={mount} nodeViews={nodeViews} defaultState={state}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}
Expand Down Expand Up @@ -417,6 +410,17 @@ function MyProseMirrorField() {
}
```

### `react`

```tsx
type react = Plugin<Map<number, string>>;
```

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
Expand Down Expand Up @@ -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<NodeViewComponentProps>;
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<string, ReactNodeViewConstructor>) => {
nodeViews: Record<string, NodeViewConstructor>;
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<Map<number, string>>;
```

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`.
8 changes: 3 additions & 5 deletions demo/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,7 +87,7 @@ function ListItem({ children }: NodeViewComponentProps) {
return <li>{children}</li>;
}

const reactNodeViews: Record<string, ReactNodeViewConstructor> = {
const nodeViews: Record<string, ReactNodeViewConstructor> = {
paragraph: () => ({
component: Paragraph,
dom: document.createElement("div"),
Expand All @@ -106,7 +106,6 @@ const reactNodeViews: Record<string, ReactNodeViewConstructor> = {
};

function DemoEditor() {
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
const [mount, setMount] = useState<HTMLDivElement | null>(null);
const [state, setState] = useState(defaultState);

Expand All @@ -119,13 +118,12 @@ function DemoEditor() {
<main>
<ProseMirror
mount={mount}
state={state}
nodeViews={nodeViews}
state={state}
dispatchTransaction={dispatchTransaction}
>
<Menu />
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
</main>
);
Expand Down
9 changes: 7 additions & 2 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ 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;
children?: ReactNode | null;
}

export function Editor({ mount, children, ...options }: EditorProps) {
const value = useEditorView(mount, options);
const { nodeViews, renderNodeViews } = useNodeViews(options.nodeViews)
const value = useEditorView(mount, { ...options, nodeViews });
return (
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
<EditorContext.Provider value={value}>
{children}
{renderNodeViews()}
</EditorContext.Provider>
);
}
5 changes: 1 addition & 4 deletions src/components/__tests__/ProseMirror.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -189,7 +188,7 @@ describe("ProseMirror", () => {
return <p data-testid="paragraph">{children}</p>;
}

const reactNodeViews = {
const nodeViews = {
paragraph: () => ({
component: Paragraph,
dom: document.createElement("div"),
Expand All @@ -199,7 +198,6 @@ describe("ProseMirror", () => {

function TestEditor() {
const [mount, setMount] = useState<HTMLDivElement | null>(null);
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);

return (
<ProseMirror
Expand All @@ -208,7 +206,6 @@ describe("ProseMirror", () => {
nodeViews={nodeViews}
>
<div data-testid="editor" ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}
Expand Down
9 changes: 2 additions & 7 deletions src/hooks/__tests__/useNodeViews.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down Expand Up @@ -49,7 +48,7 @@ describe("useNodeViews", () => {
);
}

const reactNodeViews = {
const nodeViews = {
list: () => ({
component: List,
dom: document.createElement("div"),
Expand All @@ -63,13 +62,11 @@ describe("useNodeViews", () => {
};

function TestEditor() {
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
const [mount, setMount] = useState<HTMLDivElement | null>(null);

return (
<ProseMirror mount={mount} nodeViews={nodeViews} defaultState={state}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}
Expand Down Expand Up @@ -102,7 +99,7 @@ describe("useNodeViews", () => {
);
}

const reactNodeViews = {
const nodeViews = {
list: () => ({
component: List,
dom: document.createElement("div"),
Expand All @@ -116,13 +113,11 @@ describe("useNodeViews", () => {
};

function TestEditor() {
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
const [mount, setMount] = useState<HTMLDivElement | null>(null);

return (
<ProseMirror mount={mount} nodeViews={nodeViews} defaultState={state}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/useEditorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -24,6 +25,7 @@ const EMPTY_STATE = EditorState.create({
let didWarnValueDefaultValue = false;

export interface UseEditorViewOptions extends EditorProps {
nodeViews?: Record<string, ReactNodeViewConstructor>;
defaultState?: EditorState;
state?: EditorState;
plugins?: Plugin[];
Expand Down
12 changes: 6 additions & 6 deletions src/hooks/useNodeViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
} from "../nodeViews/createReactNodeViewConstructor.js";

export function useNodeViews(
nodeViews: Record<string, ReactNodeViewConstructor>
nodeViews?: Record<string, ReactNodeViewConstructor>
) {
const [portals, setPortals] = useState({} as NodeViewsContextValue);

Expand Down Expand Up @@ -47,17 +47,17 @@ export function useNodeViews(
[]
);

const reactNodeViews = useMemo(() => {
const nodeViewEntries = Object.entries(nodeViews);
const reactNodeViewEntries = nodeViewEntries.map(([name, constructor]) => [
const wrappedNodeViews = useMemo(() => {
const nodeViewEntries = Object.entries(nodeViews ?? {});
const wrappedNodeViewEntries = nodeViewEntries.map(([name, constructor]) => [
name,
createReactNodeViewConstructor(constructor, registerPortal),
]);
return Object.fromEntries(reactNodeViewEntries);
return Object.fromEntries(wrappedNodeViewEntries);
}, [nodeViews, registerPortal]);

return {
nodeViews: reactNodeViews,
nodeViews: wrappedNodeViews,
renderNodeViews: () => <NodeViews portals={portals} />,
};
}
Loading

0 comments on commit afd0833

Please sign in to comment.