Skip to content

Commit

Permalink
Merge pull request #27 from nytimes/portal-tree-plugin
Browse files Browse the repository at this point in the history
Use a Plugin to assist in nesting node views according to node hierarchy
fixes #66
  • Loading branch information
saranrapjs authored Nov 16, 2023
2 parents fbb2a2d + 99f1811 commit 598b2f5
Show file tree
Hide file tree
Showing 18 changed files with 502 additions and 105 deletions.
2 changes: 2 additions & 0 deletions .yarn/versions/5efd1ae5.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@nytimes/react-prosemirror": patch
49 changes: 34 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ yarn add @nytimes/react-prosemirror
- [`useEditorEffect`](#useeditoreffect-1)
- [`useNodePos`](#usenodepos)
- [`useNodeViews`](#usenodeviews)
- [`react`](#react)

<!-- tocstop -->

Expand Down Expand Up @@ -99,7 +100,7 @@ export function ProseMirrorEditor() {
// It's important that mount is stored as state,
// rather than a ref, so that the ProseMirror component
// is re-rendered when it's set
const [mount, setMount] = useState();
const [mount, setMount] = useState<HTMLElement | null>(null);

return (
<ProseMirror mount={mount} defaultState={EditorState.create({ schema })}>
Expand All @@ -118,7 +119,7 @@ import { schema } from "prosemirror-schema-basic";
import { ProseMirror } from "@nytimes/react-prosemirror";

export function ProseMirrorEditor() {
const [mount, setMount] = useState();
const [mount, setMount] = useState<HTMLElement | null>(null);
const [editorState, setEditorState] = useState(
EditorState.create({ schema })
);
Expand Down Expand Up @@ -190,7 +191,7 @@ import { schema } from "prosemirror-schema-basic";
import { SelectionWidget } from "./SelectionWidget.tsx";

export function ProseMirrorEditor() {
const [mount, setMount] = useState()
const [mount, setMount] = useState<HTMLElement | null>(null);
const [editorState, setEditorState] = useState(EditorState.create({ schema }))

return (
Expand Down Expand Up @@ -231,6 +232,7 @@ import { useEditorEventCallback } from "@nytimes/react-prosemirror";

export function BoldButton() {
const onClick = useEditorEventCallback((view) => {
if (!view) return;
const toggleBoldMark = toggleMark(view.state.schema.marks.bold);
toggleBoldMark(view.state, view.dispatch, view);
});
Expand All @@ -245,7 +247,7 @@ import { schema } from "prosemirror-schema-basic";
import { BoldButton } from "./BoldButton.tsx";

export function ProseMirrorEditor() {
const [mount, setMount] = useState();
const [mount, setMount] = useState<HTMLElement | null>(null);
const [editorState, setEditorState] = useState(
EditorState.create({ schema })
);
Expand Down Expand Up @@ -333,7 +335,8 @@ EditorView and EditorState.
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.
augmenting NodeView constructors with React components, and `react`, a
ProseMirror Plugin for maintaining the React component hierarchy.

`useNodeViews` takes a map from node name to an extended NodeView constructor.
The NodeView constructor must return at least a `dom` attribute and a
Expand All @@ -345,6 +348,7 @@ import {
useNodeViews,
useEditorEventCallback,
NodeViewComponentProps,
react,
} from "@nytimes/react-prosemirror";
import { EditorState } from "prosemirror-state";
import { schema } from "prosemirror-schema-basic";
Expand Down Expand Up @@ -375,17 +379,19 @@ const reactNodeViews = {
}),
};

const editorState = EditorState.create({
schema,
// You must add the react if you use
// the useNodeViews or useNodePos hook.
plugins: [react()],
});

function ProseMirrorEditor() {
const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);

const [mount, setMount] = useState();
const [mount, setMount] = useState<HTMLElement | null>(null);

return (
<ProseMirror
mount={mount}
defaultState={EditorState.create({ schema })}
nodeViews={nodeViews}
>
<ProseMirror mount={mount} defaultState={editorState} nodeViews={nodeViews}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
Expand Down Expand Up @@ -646,16 +652,29 @@ type useNodeViews = (nodeViews: Record<string, ReactNodeViewConstructor>) => {
```

Hook for creating and rendering NodeViewConstructors that are powered by React
components.
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` and `useEditorEffect` hooks
to ensure safe access.
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`.
74 changes: 62 additions & 12 deletions demo/main.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,112 @@
import { baseKeymap } from "prosemirror-commands";
import {
baseKeymap,
chainCommands,
createParagraphNear,
liftEmptyBlock,
newlineInCode,
splitBlock,
} from "prosemirror-commands";
import { keymap } from "prosemirror-keymap";
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { liftListItem, splitListItem } from "prosemirror-schema-list";
import { EditorState, Transaction } from "prosemirror-state";
import "prosemirror-view/style/prosemirror.css";
import React, { useState } from "react";
import React, { useCallback, useState } from "react";
import { createRoot } from "react-dom/client";

import {
NodeViewComponentProps,
ProseMirror,
useEditorEffect,
useEditorState,
useNodeViews,
} from "../src/index.js";
import { ReactNodeViewConstructor } from "../src/nodeViews/createReactNodeViewConstructor.js";
import { react } from "../src/plugins/react.js";

import "./main.css";

const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*" },
list: { group: "block", content: "list_item+" },
list_item: { content: "paragraph+", toDOM: () => ["li", 0] },
text: { group: "inline" },
},
});

const editorState = EditorState.create({
doc: schema.topNodeType.create(null, [
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
schema.nodes.paragraph.createAndFill()!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
schema.nodes.list.createAndFill()!,
]),
schema,
plugins: [keymap(baseKeymap)],
plugins: [
keymap({
...baseKeymap,
Enter: chainCommands(
newlineInCode,
createParagraphNear,
liftEmptyBlock,
splitListItem(schema.nodes.list_item),
splitBlock
),
"Shift-Enter": baseKeymap.Enter,
"Shift-Tab": liftListItem(schema.nodes.list_item),
}),
react(),
],
});

function Paragraph({ children }: NodeViewComponentProps) {
return <p>{children}</p>;
}

function List({ children }: NodeViewComponentProps) {
return <ul>{children}</ul>;
}

function ListItem({ children }: NodeViewComponentProps) {
return <li>{children}</li>;
}

const reactNodeViews: Record<string, ReactNodeViewConstructor> = {
paragraph: () => ({
component: Paragraph,
dom: document.createElement("div"),
contentDOM: document.createElement("span"),
}),
list: () => ({
component: List,
dom: document.createElement("div"),
contentDOM: document.createElement("div"),
}),
list_item: () => ({
component: ListItem,
dom: document.createElement("div"),
contentDOM: document.createElement("div"),
}),
};

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

const dispatchTransaction = useCallback(
(tr: Transaction) => setState((oldState) => oldState.apply(tr)),
[]
);

return (
<main>
<h1>React ProseMirror Demo</h1>
<ProseMirror
mount={mount}
defaultState={editorState}
state={state}
nodeViews={nodeViews}
dispatchTransaction={dispatchTransaction}
>
<div ref={setMount} />
{renderNodeViews()}
Expand All @@ -64,8 +118,4 @@ function DemoEditor() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = createRoot(document.getElementById("root")!);

root.render(
<React.StrictMode>
<DemoEditor />
</React.StrictMode>
);
root.render(<DemoEditor />);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"prosemirror-commands": "^1.5.0",
"prosemirror-keymap": "^1.2.1",
"prosemirror-model": "^1.18.3",
"prosemirror-schema-list": "^1.2.2",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.29.1",
"react": "^18.2.0",
Expand Down
2 changes: 0 additions & 2 deletions src/components/ProseMirrorInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export type ProseMirrorProps = EditorProps & {
*/
export function ProseMirrorInner({
children,
dispatchTransaction,
mount,
...editorProps
}: ProseMirrorProps) {
Expand All @@ -51,7 +50,6 @@ export function ProseMirrorInner({
const editorView = useEditorView(mount, {
...editorProps,
plugins,
dispatchTransaction,
});

const editorState =
Expand Down
3 changes: 2 additions & 1 deletion src/components/__tests__/ProseMirror.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import React, { useEffect, useState } from "react";

import { useNodeViews } from "../../hooks/useNodeViews.js";
import { NodeViewComponentProps } from "../../nodeViews/createReactNodeViewConstructor.js";
import { react } from "../../plugins/react.js";
import {
setupProseMirrorView,
teardownProseMirrorView,
Expand Down Expand Up @@ -90,7 +91,7 @@ describe("ProseMirror", () => {
doc: { content: "paragraph+" },
},
});
const editorState = EditorState.create({ schema });
const editorState = EditorState.create({ schema, plugins: [react()] });

function Paragraph({ children }: NodeViewComponentProps) {
return <p data-testid="paragraph">{children}</p>;
Expand Down
22 changes: 22 additions & 0 deletions src/contexts/PortalRegistryContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ReactPortal, createContext } from "react";

import { NodeKey } from "../plugins/react.js";

export type RegisteredPortal = {
getPos: () => number;
portal: ReactPortal;
};

export type PortalRegistry = Record<NodeKey, RegisteredPortal[]>;

/**
* A map of node view keys to portals.
*
* Each node view registers a portal under its parent's
* key. Each can then retrieve the list of portals under their
* key, allowing portals to be rendered with the appropriate
* hierarchy.
*/
export const PortalRegistryContext = createContext<PortalRegistry>(
null as unknown as PortalRegistry
);
Loading

0 comments on commit 598b2f5

Please sign in to comment.