Skip to content

Commit

Permalink
Nest node views according to node hierarchy
Browse files Browse the repository at this point in the history
  • Loading branch information
smoores-dev committed May 2, 2023
1 parent 5f319c5 commit bb3df55
Show file tree
Hide file tree
Showing 9 changed files with 414 additions and 73 deletions.
2 changes: 2 additions & 0 deletions .yarn/versions/b6788c2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@nytimes/react-prosemirror": patch
32 changes: 27 additions & 5 deletions demo/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@ const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*" },
list: { group: "block", content: "list_item+" },
list_item: { content: "inline*" },
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)],
});
Expand All @@ -32,12 +40,30 @@ 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() {
Expand All @@ -58,8 +84,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 />);
23 changes: 23 additions & 0 deletions src/contexts/PortalRegistryContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ReactPortal, createContext } from "react";

/**
* 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<
Record<PortalRegistryKey, ReactPortal[]>
>(null as unknown as Record<PortalRegistryKey, ReactPortal[]>);

/**
* Node views that don't have any React node view ancestors
* can specify their parent node view as the "root", and
* will be have their portals rendered as direct children of
* the ProseMirror component.
*/
export const PORTAL_REGISTRY_ROOT_KEY = Symbol("portal registry root key");

export type PortalRegistryKey = string | typeof PORTAL_REGISTRY_ROOT_KEY;
126 changes: 126 additions & 0 deletions src/hooks/__tests__/useNodeViews.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { render, screen } from "@testing-library/react";
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import React, { createContext, useContext, useState } from "react";

import { ProseMirror } from "../../components/ProseMirror.js";
import { NodeViewComponentProps } from "../../nodeViews/createReactNodeViewConstructor.js";
import { useNodeViews } from "../useNodeViews.js";

const schema = new Schema({
nodes: {
doc: { content: "block+" },
list: { group: "block", content: "list_item+" },
list_item: { content: "inline*" },
text: { group: "inline" },
},
});

const editorState = EditorState.create({
doc: schema.topNodeType.create(null, schema.nodes.list.createAndFill()),
schema,
});

describe("useNodeViews", () => {
it("should render node views", () => {
function List({ children }: NodeViewComponentProps) {
return (
<>
<span contentEditable={false}>list</span>
<ul>{children}</ul>
</>
);
}

function ListItem({ children }: NodeViewComponentProps) {
return (
<>
<span contentEditable={false}>list item</span>
<li>{children}</li>
</>
);
}

const reactNodeViews = {
list: () => ({
component: List,
dom: document.createElement("div"),
contentDOM: document.createElement("div"),
}),
list_item: () => ({
component: ListItem,
dom: document.createElement("div"),
contentDOM: document.createElement("div"),
}),
};

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

return (
<ProseMirror mount={mount} state={editorState} nodeViews={nodeViews}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}

render(<TestEditor />);

expect(screen.getByText("list")).toBeTruthy();
expect(screen.getByText("list item")).toBeTruthy();
});

it("should render child node views as children of their parents", () => {
const TestContext = createContext("default");

function List({ children }: NodeViewComponentProps) {
return (
<TestContext.Provider value="overriden">
<ul>{children}</ul>
</TestContext.Provider>
);
}

function ListItem({ children }: NodeViewComponentProps) {
const testContextValue = useContext(TestContext);

return (
<>
<span contentEditable={false}>{testContextValue}</span>
<li>{children}</li>
</>
);
}

const reactNodeViews = {
list: () => ({
component: List,
dom: document.createElement("div"),
contentDOM: document.createElement("div"),
}),
list_item: () => ({
component: ListItem,
dom: document.createElement("div"),
contentDOM: document.createElement("div"),
}),
};

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

return (
<ProseMirror mount={mount} state={editorState} nodeViews={nodeViews}>
<div ref={setMount} />
{renderNodeViews()}
</ProseMirror>
);
}

render(<TestEditor />);

expect(screen.getByText("overriden")).toBeTruthy();
});
});
30 changes: 0 additions & 30 deletions src/hooks/useNodeViewPortals.ts

This file was deleted.

25 changes: 0 additions & 25 deletions src/hooks/useNodeViews.ts

This file was deleted.

75 changes: 75 additions & 0 deletions src/hooks/useNodeViews.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, { ReactPortal, useCallback, useMemo, useState } from "react";
import { createPortal } from "react-dom";

import {
PORTAL_REGISTRY_ROOT_KEY,
PortalRegistryContext,
PortalRegistryKey,
} from "../contexts/PortalRegistryContext.js";
import {
ReactNodeViewConstructor,
RegisterElement,
createReactNodeViewConstructor,
} from "../nodeViews/createReactNodeViewConstructor.js";

type Props = {
portals: Record<PortalRegistryKey, ReactPortal[]>;
};

function NodeViews({ portals }: Props) {
const rootPortals = portals[PORTAL_REGISTRY_ROOT_KEY];

return (
<PortalRegistryContext.Provider value={portals}>
{rootPortals}
</PortalRegistryContext.Provider>
);
}

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

const registerPortal: RegisterElement = useCallback(
(registrationKey, child, container, key) => {
const portal = createPortal(child, container, key);
setPortals((oldPortals) => {
const oldChildPortals = oldPortals[registrationKey] ?? [];
const newChildPortals = oldChildPortals.concat(portal);
return {
...oldPortals,
[registrationKey]: newChildPortals,
};
});

return () => {
setPortals((oldPortals) => {
const oldChildPortals = oldPortals[registrationKey] ?? [];
const newChildPortals = oldChildPortals.filter((p) => p !== portal);
return {
...oldPortals,
[registrationKey]: newChildPortals,
};
});
};
},
[]
);

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

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

0 comments on commit bb3df55

Please sign in to comment.