Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use consistent module layout and naming #97

Merged
merged 5 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarn/versions/fb01eea5.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@nytimes/react-prosemirror": minor
126 changes: 17 additions & 109 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,11 @@ yarn add @nytimes/react-prosemirror
- [`useEditorEffect`](#useeditoreffect)
- [`useEditorEventCallback`](#useeditoreventcallback)
- [`useEditorEventListener`](#useeditoreventlistener)
- [`useEditorView`, `EditorProvider` and `LayoutGroup`](#useeditorview-editorprovider-and-layoutgroup)
- [Building NodeViews with React](#building-nodeviews-with-react)
- [API](#api)
- [`ProseMirror`](#prosemirror)
- [`EditorProvider`](#editorprovider)
- [`LayoutGroup`](#layoutgroup)
- [`useLayoutGroupEffect`](#uselayoutgroupeffect)
- [`useEditorState`](#useeditorstate)
- [`useEditorView`](#useeditorview)
- [`useEditorEventCallback`](#useeditoreventcallback-1)
- [`useEditorEventListener`](#useeditoreventlistener-1)
- [`useEditorEffect`](#useeditoreffect-1)
Expand Down Expand Up @@ -311,22 +307,6 @@ function Paragraph({ node, children }) {
}
```

#### `useEditorView`, `EditorProvider` and `LayoutGroup`

Under the hood, the `ProseMirror` component essentially just composes three
separate tools: `useEditorView`, `EditorProvider`, and `LayoutGroup`. If you
find yourself in need of more control over these, they can also be used
independently.

`useEditorView` is a relatively simple hook that takes a mount point and
`EditorProps` as arguments and returns an EditorView instance.

`EditorProvider` is a simple React context, which should be provided the current
EditorView and EditorState.

`LayoutGroup` _must_ be rendered as a parent of the component using
`useEditorView`.

### Building NodeViews with React

The other way to integrate React and ProseMirror is to have ProseMirror render
Expand Down Expand Up @@ -401,23 +381,29 @@ function ProseMirrorEditor() {
### `ProseMirror`

```tsx
type ProseMirror = (
props: EditorProps & {
mount: HTMLElement | null;
children?: ReactNode | null;
defaultState?: EditorState;
state?: EditorState;
plugins?: readonly Plugin[];
dispatchTransaction?(this: EditorView, tr: Transaction): void;
}
) => JSX.Element;
import type { EditorState, Plugin, Transaction } from "prosemirror-state";
import type { EditorProps, EditorView, Plugin } from "prosemirror-view";
import type { ReactNode } from "react";

interface ProseMirrorProps extends EditorProps {
mount: HTMLElement | null;
children?: ReactNode | null;
defaultState?: EditorState;
state?: EditorState;
plugins?: readonly Plugin[];
dispatchTransaction?(this: EditorView, tr: Transaction): void;
}

type ProseMirror = (props: ProseMirrorProps) => JSX.Element;
```

Renders the ProseMirror View onto a DOM mount.
Renders a ProseMirror View.

The `mount` prop must be an actual HTMLElement instance. The JSX element
representing the mount should be passed as a child to the ProseMirror component.

Consult the ProseMirror documentation for information about the other props.

Example usage:

```tsx
Expand All @@ -432,64 +418,6 @@ function MyProseMirrorField() {
}
```

### `EditorProvider`

```tsx
type EditorProvider = React.Provider<{
editorView: EditorView | null;
editorState: EditorState | null;
registerEventListener<EventType extends keyof DOMEventMap>(
eventType: EventType,
handler: EventHandler<EventType>
): void;
unregisterEventListener<EventType extends keyof DOMEventMap>(
eventType: EventType,
handler: EventHandler<EventType>
): void;
}>;
```

Provides the EditorView, as well as the current EditorState. Should not be
consumed directly; instead see [`useEditorState`](#useeditorstate),
[`useEditorEventCallback`](#useeditorevent), and
[`useEditorEffect`](#useeditoreffect-1).

See [ProseMirrorInner.tsx](./src/components/ProseMirrorInner.tsx) for example
usage. Note that if you are using the [`ProseMirror`](#prosemirror) component,
you don't need to use this provider directly.

### `LayoutGroup`

```tsx
type LayoutGroup = (props: { children: React.ReactNode }) => JSX.Element;
```

Provides a deferral point for grouped layout effects. All effects registered
with `useLayoutGroupEffect` by children of this provider will execute _after_
all effects registered by `useLayoutEffect` by children of this provider.

See [ProseMirror.tsx](./src/components/ProseMirror.tsx) for example usage. Note
that if you are using the [`ProseMirror`](#prosemirror) component, you don't
need to use this context directly.

### `useLayoutGroupEffect`

```tsx
type useLayoutGroupEffect = (
effect: React.EffectCallback,
deps?: React.DependencyList
) => void;
```

Like `useLayoutEffect`, but all effect executions are run _after_ the
`LayoutGroup` layout effects phase.

This hook allows child components to enqueue layout effects that won't be safe
to run until after a parent component's layout effects have run.

Note that components that use this hook must be descendants of the
[`LayoutGroup`](#layoutgroup) component.

### `useEditorState`

```tsx
Expand All @@ -498,26 +426,6 @@ type useEditorState = () => EditorState;

Provides access to the current EditorState value.

### `useEditorView`

```tsx
type useEditorView = <T extends HTMLElement = HTMLElement>(
mount: T | null,
props: DirectEditorProps
) => EditorView | null;
```

Creates, mounts, and manages a ProseMirror `EditorView`.

All state and props updates are executed in a layout effect. To ensure that the
EditorState and EditorView are never out of sync, it's important that the
EditorView produced by this hook is only accessed through the hooks exposed by
this library.

See [ProseMirrorInner.tsx](./src/components/ProseMirrorInner.tsx) for example
usage. Note that if you are using the [`ProseMirror`](#prosemirror) component,
you don't need to use this hook directly.

### `useEditorEventCallback`

```tsx
Expand Down
18 changes: 18 additions & 0 deletions src/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from "react";
import type { ReactNode } from "react";

import { EditorContext } from "../contexts/EditorContext.js";
import { useEditorView } from "../hooks/useEditorView.js";
import type { UseEditorViewOptions } from "../hooks/useEditorView.js";

export interface EditorProps extends UseEditorViewOptions {
mount: HTMLElement | null;
children?: ReactNode | null;
}

export function Editor({ mount, children, ...options }: EditorProps) {
const value = useEditorView(mount, options);
return (
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
);
}
68 changes: 68 additions & 0 deletions src/components/LayoutGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useCallback, useLayoutEffect, useRef } from "react";
import type { EffectCallback } from "react";

import { LayoutGroupContext } from "../contexts/LayoutGroupContext.js";
import { useForceUpdate } from "../hooks/useForceUpdate.js";

export interface LayoutGroupProps {
children: React.ReactNode;
}

/**
* Provides a boundary for grouping layout effects.
*
* Descendant components can invoke the `useLayoutGroupEffect` hook to register
* effects that run after all descendants within the group have processed their
* regular layout effects.
*/
export function LayoutGroup({ children }: LayoutGroupProps) {
const createQueue = useRef(new Set<() => void>()).current;
const destroyQueue = useRef(new Set<() => void>()).current;

const forceUpdate = useForceUpdate();
const isUpdatePending = useRef(true);

const ensureFlush = useCallback(() => {
if (!isUpdatePending.current) {
forceUpdate();
isUpdatePending.current = true;
}
}, [forceUpdate]);

const register = useCallback<typeof useLayoutEffect>(
(effect: EffectCallback) => {
let destroy: ReturnType<EffectCallback>;
const create = () => {
destroy = effect();
};

createQueue.add(create);
ensureFlush();

return () => {
createQueue.delete(create);
if (destroy) {
destroyQueue.add(destroy);
ensureFlush();
}
};
},
[createQueue, destroyQueue, ensureFlush]
);

useLayoutEffect(() => {
isUpdatePending.current = false;
createQueue.forEach((create) => create());
createQueue.clear();
return () => {
destroyQueue.forEach((destroy) => destroy());
destroyQueue.clear();
};
});

return (
<LayoutGroupContext.Provider value={register}>
{children}
</LayoutGroupContext.Provider>
);
}
35 changes: 35 additions & 0 deletions src/components/NodeViews.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useState } from "react";
import type { ReactPortal } from "react";

import { NodeViewsContext } from "../contexts/NodeViewsContext.js";
import type { NodeViewsContextValue } from "../contexts/NodeViewsContext.js";
import { useEditorEffect } from "../hooks/useEditorEffect.js";
import { ROOT_NODE_KEY } from "../plugins/react.js";

type NodeViewsProps = {
portals: NodeViewsContextValue;
};

export function NodeViews({ portals }: NodeViewsProps) {
const rootRegisteredPortals = portals[ROOT_NODE_KEY];
const [rootPortals, setRootPortals] = useState<ReactPortal[]>(
rootRegisteredPortals?.map(({ portal }) => portal) ?? []
);

// `getPos` is technically derived from the EditorView
// state, so it's not safe to call until after the EditorView
// has been updated
useEditorEffect(() => {
setRootPortals(
rootRegisteredPortals
?.sort((a, b) => a.getPos() - b.getPos())
.map(({ portal }) => portal) ?? []
);
}, [rootRegisteredPortals]);

return (
<NodeViewsContext.Provider value={portals}>
{rootPortals}
</NodeViewsContext.Provider>
);
}
25 changes: 5 additions & 20 deletions src/components/ProseMirror.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,10 @@
import type { EditorState, Plugin, Transaction } from "prosemirror-state";
import type { EditorProps, EditorView } from "prosemirror-view";
import React from "react";
import type { ReactNode } from "react";

import { EditorProvider } from "../contexts/EditorContext.js";
import { LayoutGroup } from "../contexts/LayoutGroup.js";
import { useEditorView } from "../hooks/useEditorView.js";
import { Editor } from "./Editor.js";
import type { EditorProps } from "./Editor.js";
import { LayoutGroup } from "./LayoutGroup.js";

interface Props extends EditorProps {
mount: HTMLElement | null;
children?: ReactNode | null;
defaultState?: EditorState;
state?: EditorState;
plugins?: readonly Plugin[];
dispatchTransaction?(this: EditorView, tr: Transaction): void;
}

function Editor({ mount, children, ...props }: Props) {
const value = useEditorView(mount, props);
return <EditorProvider value={value}>{children}</EditorProvider>;
}
export type { EditorProps as ProseMirrorProps };

/**
* Renders the ProseMirror View onto a DOM mount.
Expand All @@ -42,7 +27,7 @@ function Editor({ mount, children, ...props }: Props) {
* }
* ```
*/
export function ProseMirror(props: Props) {
export function ProseMirror(props: EditorProps) {
return (
<LayoutGroup>
<Editor {...props} />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { act, render, screen } from "@testing-library/react";
import React, { useLayoutEffect, useState } from "react";

import { LayoutGroup, useLayoutGroupEffect } from "../LayoutGroup.js";
import { useLayoutGroupEffect } from "../../hooks/useLayoutGroupEffect.js";
import { LayoutGroup } from "../LayoutGroup.js";

describe("DeferredLayoutEffects", () => {
describe("LayoutGroup", () => {
jest.useFakeTimers("modern");

it("registers multiple effects and runs them", () => {
Expand Down
2 changes: 0 additions & 2 deletions src/contexts/EditorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,3 @@ export interface EditorContextValue {
export const EditorContext = createContext(
null as unknown as EditorContextValue
);

export const EditorProvider = EditorContext.Provider;
Loading
Loading