From a52c7aea2bb22918977287e4e58760805ee1457f Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Fri, 15 Sep 2023 16:13:05 -0400 Subject: [PATCH] Clean everything up --- README.md | 377 ++++++------------ demo/main.tsx | 210 +--------- src/components/DocNodeView.tsx | 26 +- src/components/EditorView.tsx | 119 ------ src/components/NodeViewComponentProps.tsx | 7 +- src/components/OutputSpec.tsx | 3 +- src/components/ProseMirror.tsx | 159 ++++++-- src/components/ProseMirrorInner.tsx | 75 ---- src/components/__tests__/EditorView.test.tsx | 121 ------ ...est.tsx => ProseMirror.domchange.test.tsx} | 13 +- ...x => ProseMirror.draw-decoration.test.tsx} | 13 +- ...raw.test.tsx => ProseMirror.draw.test.tsx} | 75 ++-- ...est.tsx => ProseMirror.node-view.test.tsx} | 0 ...est.tsx => ProseMirror.selection.test.tsx} | 12 +- src/components/__tests__/ProseMirror.test.tsx | 210 +++++----- src/contexts/EditorContext.ts | 2 +- src/{descriptors => decorations}/iterDeco.ts | 6 +- .../useEditorViewLayoutEffect.test.tsx | 2 +- src/hooks/useChildNodeViews.tsx | 21 +- src/hooks/useComponentEventListeners.tsx | 8 +- src/hooks/useEditorEffect.ts | 2 +- src/hooks/useEditorEventCallback.ts | 2 +- src/hooks/useEditorView.ts | 93 +++-- src/hooks/useNodeViewPortals.ts | 30 -- src/hooks/useNodeViews.ts | 25 -- src/hooks/useReactEditorState.ts | 8 - src/hooks/useReactEditorView.ts | 174 -------- src/hooks/useReactKeys.ts | 4 +- src/hooks/useView.ts | 13 - src/hooks/useViewPlugins.ts | 11 +- src/index.ts | 11 +- .../createReactNodeViewConstructor.tsx | 261 ------------ src/nodeViews/phrasingContentTags.ts | 49 --- .../{react.test.ts => reactKeys.test.ts} | 18 +- src/plugins/componentEventListeners.ts | 2 +- src/plugins/react.ts | 83 ---- src/plugins/reactKeys.ts | 12 +- src/prosemirror-view/README.md | 10 +- src/testing/editorViewTestHelpers.tsx | 27 +- 39 files changed, 586 insertions(+), 1708 deletions(-) delete mode 100644 src/components/EditorView.tsx delete mode 100644 src/components/ProseMirrorInner.tsx delete mode 100644 src/components/__tests__/EditorView.test.tsx rename src/components/__tests__/{EditorView.domchange.test.tsx => ProseMirror.domchange.test.tsx} (97%) rename src/components/__tests__/{EditorView.draw-decoration.test.tsx => ProseMirror.draw-decoration.test.tsx} (98%) rename src/components/__tests__/{EditorView.draw.test.tsx => ProseMirror.draw.test.tsx} (78%) rename src/components/__tests__/{EditorView.node-view.test.tsx => ProseMirror.node-view.test.tsx} (100%) rename src/components/__tests__/{EditorView.selection.test.tsx => ProseMirror.selection.test.tsx} (98%) rename src/{descriptors => decorations}/iterDeco.ts (97%) delete mode 100644 src/hooks/useNodeViewPortals.ts delete mode 100644 src/hooks/useNodeViews.ts delete mode 100644 src/hooks/useReactEditorState.ts delete mode 100644 src/hooks/useReactEditorView.ts delete mode 100644 src/hooks/useView.ts delete mode 100644 src/nodeViews/createReactNodeViewConstructor.tsx delete mode 100644 src/nodeViews/phrasingContentTags.ts rename src/plugins/__tests__/{react.test.ts => reactKeys.test.ts} (79%) delete mode 100644 src/plugins/react.ts diff --git a/README.md b/README.md index f81bdada..5d75c691 100644 --- a/README.md +++ b/README.md @@ -31,33 +31,29 @@ 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) + - [Building node views with React](#building-node-views-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) - - [`useNodeViews`](#usenodeviews) + - [`NodeViewComponentProps`](#nodeviewcomponentprops) + - [`widget`](#widget) ## The Problem -React is a framework for developing reactive user interfaces. To make updates -efficient, React separates updates into phases so that it can process updates in -batches. In the first phase, application code renders a virtual document. In the -second phase, the React DOM renderer finalizes the update by reconciling the -real document with the virtual document. The ProseMirror View library renders -ProseMirror documents in a single-phase update. Unlike React, it also allows -built-in editing features of the browser to modify the document under some -circumstances, deriving state updates from view updates rather than the other -way around. +To make updates efficient, React separates updates into phases so that it can +process updates in batches. In the first phase, application code renders a +virtual document. In the second phase, the React DOM renderer finalizes the +update by reconciling the real document with the virtual document. + +On the other hand, the ProseMirror View library renders ProseMirror documents in +a single-phase update. Unlike React, it also allows built-in editing features of +the browser to modify the document under some circumstances, deriving state +updates from view updates rather than the other way around. It is possible to use both React DOM and ProseMirror View, but using React DOM to render ProseMirror View components safely requires careful consideration of @@ -70,12 +66,28 @@ code that dispatches transactions may dispatch transactions based on incorrect state. Code that invokes methods of the ProseMirror view may make bad assumptions about its state that cause incorrect behavior or errors. +It's also challenging to effectively use React to define node views for +ProseMirror documents. Both ProseMirror and React expect to have full control +over their respective parts of the DOM. They both modify and destroy DOM nodes +as needed. Previous solutions (including previous iterations of this library) +have attempted to work around this power struggle by producing wrapper elements +to hand to ProseMirror, and then mounting React nodes within these (usually with +React Portals). + +This approach works, but tenuously. Having additional nodes in the document that +ProseMirror isn't strictly aware of can cause issues with its change detection +system, leading to challenging edge cases. +[Here's an example](https://github.com/nytimes/react-prosemirror/issues/42). +These extra wrapping elements also make it challenging to produce semantic +markup and introduce challenges when styling. + ## The Solution -There are two different directions to integrate ProseMirror and React: you can -render a ProseMirror EditorView inside of a React component, and you can use -React components to render ProseMirror NodeViews. This library provides tools -for accomplishing both of these goals. +This library provides an alternate implementation of ProseMirror's EditorView. +It uses React as the rendering engine, rather than ProseMirror's home-brewed DOM +update system. This allows us to provide a more comfortable integration with +ProseMirror's powerful data model, transformations, and event management +systems. ### Rendering ProseMirror Views within React @@ -85,26 +97,16 @@ build React applications that contain ProseMirror Views, even when the EditorState is lifted into React state, or a global state management system like Redux. -The simplest way to make use of these contexts is with the `` -component. The `` component can be used controlled or -uncontrolled, and takes a "mount" prop, used to specify which DOM node the -ProseMirror EditorView should be mounted on. +The simplest way to make use of these contexts is with the `` +component. The `` component can be used controlled (via the +`state` prop) or uncontrolled (via the `defaultState` prop). ```tsx import { EditorState } from "prosemirror-state"; import { ProseMirror } from "@nytimes/react-prosemirror"; 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(); - - return ( - -
- - ); + return ; } ``` @@ -117,42 +119,35 @@ import { schema } from "prosemirror-schema-basic"; import { ProseMirror } from "@nytimes/react-prosemirror"; export function ProseMirrorEditor() { - const [mount, setMount] = useState(); const [editorState, setEditorState] = useState( EditorState.create({ schema }) ); return ( { setEditorState((s) => s.apply(tr)); }} - > -
- + /> ); } ``` -The ProseMirror component will take care to ensure that the EditorView is always -updated with the latest EditorState after each render cycle. Because -synchronizing the EditorView is a side effect, it _must_ happen in the effects -phase of the React render lifecycle, _after_ all of the ProseMirror component's -children have run their render functions. This means that special care must be -taken to access the EditorView from within other React components. In order to -abstract away this complexity, React ProseMirror provides two hooks: -`useEditorEffect` and `useEditorEventCallback`. Both of these hooks can be used -from any children of the ProseMirror component. +The `EditorView` interface exposes several useful methods that provide access to +the DOM or data derived from its layout, such as `coordsFromPos`. These methods +should only be accessed outside of the render cycle, to ensure that the DOM has +been updated to match the latest state. React ProseMirror provides two hooks to +enable this access pattern: `useEditorEffect` and `useEditorEventCallback`. Both +of these hooks can be used from any children of the ProseMirror component. #### `useEditorEffect` Often, it's necessary to position React components relative to specific positions in the ProseMirror document. For example, you might have some widget that needs to be positioned at the user's cursor. In order to ensure that this -positioning happens when the EditorView is in sync with the latest EditorState, -we can use `useEditorEffect`. +positioning happens when the DOM is in sync with the latest EditorState, we can +use `useEditorEffect`. ```tsx // SelectionWidget.tsx @@ -189,12 +184,10 @@ import { schema } from "prosemirror-schema-basic"; import { SelectionWidget } from "./SelectionWidget.tsx"; export function ProseMirrorEditor() { - const [mount, setMount] = useState() const [editorState, setEditorState] = useState(EditorState.create({ schema })) return ( { setEditorState(s => s.apply(tr)) @@ -205,7 +198,6 @@ export function ProseMirrorEditor() { EditorView as children of the ProseMirror component */} -
) } @@ -244,14 +236,12 @@ import { schema } from "prosemirror-schema-basic"; import { BoldButton } from "./BoldButton.tsx"; export function ProseMirrorEditor() { - const [mount, setMount] = useState(); const [editorState, setEditorState] = useState( EditorState.create({ schema }) ); return ( { setEditorState((s) => s.apply(tr)); @@ -262,7 +252,6 @@ export function ProseMirrorEditor() { EditorView as children of the ProseMirror component */} -
); } @@ -288,17 +277,23 @@ semantics for ProseMirror's `handleDOMEvents` prop: You can use this hook to implement custom behavior in your NodeViews: ```tsx -import { useEditorEventListener } from "@nytimes/react-prosemirror"; +import { forwardRef, Ref } from "react"; +import { + useEditorEventListener, + NodeViewComponentProps, +} from "@nytimes/react-prosemirror"; -function Paragraph({ node, getPos, children }) { +const Paragraph = forwardRef(function Paragraph( + { node, pos, children, ...props }: NodeViewComponentProps, + ref: Ref +) { useEditorEventListener("keydown", (view, event) => { if (event.code !== "ArrowDown") { return false; } - const nodeStart = getPos(); - const nodeEnd = nodeStart + node.nodeSize; + const nodeEnd = pos + node.nodeSize; const { selection } = view.state; - if (selection.anchor < nodeStart || selection.anchor > nodeEnd) { + if (selection.anchor < pos || selection.anchor > nodeEnd) { return false; } event.preventDefault(); @@ -306,41 +301,25 @@ function Paragraph({ node, getPos, children }) { return true; }); - return

{children}

; -} + return ( +

+ {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 +### 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. - -`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: +node views using React components. Because React ProseMirror renders the +ProseMirror document with React, node view components don't need to do anything +special other than fulfill the +[`NodeViewComponentProps`](#nodeviewcomponentprops) interface. ```tsx +import { forwardRef, Ref } from "react"; import { - useNodeViews, useEditorEventCallback, NodeViewComponentProps, } from "@nytimes/react-prosemirror"; @@ -348,45 +327,37 @@ import { EditorState } from "prosemirror-state"; import { schema } from "prosemirror-schema-basic"; // Paragraph is more or less a normal React component, taking and rendering -// its children. The actual children will be constructed by ProseMirror and -// passed in here. Take a look at the NodeViewComponentProps type to -// see what other props will be passed to NodeView components. -function Paragraph({ children }: NodeViewComponentProps) { +// its children. All node view components _must_ forward refs to their top-level +// DOM elements. All node view components _should_ spread all of the props that they +// receive onto their top-level DOM elements; this is required for node Decorations +// that apply attributes rather than wrapping nodes in an additional element. +const Paragraph = forwardRef(function Paragraph( + { children, ...props }: NodeViewComponentProps, + ref: Ref +) { const onClick = useEditorEventCallback((view) => view.dispatch(whatever)); - return

{children}

; -} + 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 = { - paragraph: () => ({ - component: Paragraph, - // We render the Paragraph component itself into a div element - dom: document.createElement("div"), - // We render the paragraph node's ProseMirror contents into - // a span, which will be passed as children to the Paragraph - // component. - contentDOM: document.createElement("span"), - }), +const nodeViews = { + paragraph: Paragraph, }; function ProseMirrorEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); - - const [mount, setMount] = useState(); - return ( -
- {renderNodeViews()} - + /> ); } ``` @@ -405,83 +376,19 @@ type ProseMirror = ( ) => JSX.Element; ``` -Renders the ProseMirror View onto a DOM mount. - -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. +Renders the ProseMirror document. Example usage: ```tsx -function MyProseMirrorField() { - const [mount, setMount] = useState(null); +import { EditorState } from "prosemirror-state"; +import { ProseMirror } from "@nytimes/react-prosemirror"; - return ( - -
- - ); +export function ProseMirrorEditor() { + return ; } ``` -### `EditorProvider` - -```tsx -type EditorProvider = React.Provider<{ - editorView: EditorView | null; - editorState: EditorState | null; - registerEventListener( - eventType: EventType, - handler: EventHandler - ): void; - unregisterEventListener( - eventType: EventType, - handler: EventHandler - ): 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 @@ -490,26 +397,6 @@ type useEditorState = () => EditorState | null; Provides access to the current EditorState value. -### `useEditorView` - -```tsx -type useEditorView = ( - 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 @@ -589,58 +476,46 @@ export function SelectionWidget() { } ``` -### `useNodeViews` +### `NodeViewComponentProps` ```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 NodeViewComponentProps = { + decorations: readonly Decoration[]; + innerDecorations: DecorationSource; + node: Node; + children?: ReactNode | ReactNode[]; + isSelected: boolean; + pos: number; +} & HTMLAttributes; +``` -type useNodeViews = (nodeViews: Record) => { - nodeViews: Record; - renderNodeViews: () => ReactElement[]; -}; +The props that will be passed to all node view components. These props map +directly to the arguments passed to +[`NodeViewConstructor` functions](https://prosemirror.net/docs/ref/#view.NodeViewConstructor) +by the default ProseMirror EditorView implementation. + +Node view components may also be passed _any_ other valid HTML attribute props, +and should pass them through to their top-level DOM element. +[See the above example](#building-node-views-with-react) for more details. + +In addition to accepting these props, all node view components _must_ forward +their ref to their top-level DOM element. + +### `widget` + +```tsx +type widget = ( + pos: number, + component: ForwardRefExoticComponent< + RefAttributes & WidgetComponentProps + >, + spec?: ReactWidgetSpec +) => Decoration(pos, pos, new ReactWidgetType(component, spec)) ``` -Hook for creating and rendering NodeViewConstructors that are powered by React +Like ProseMirror View's `Decoration.widget`, but with support for React components. -`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. - -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). +**Note**: The default `Decoration.widget` implementation _will not_ work with +this library. If you wish to use widget decorations, you must use this library's +`widget` method, instead. diff --git a/demo/main.tsx b/demo/main.tsx index ceabc868..c0190622 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -1,8 +1,7 @@ import { baseKeymap, toggleMark } from "prosemirror-commands"; import { keymap } from "prosemirror-keymap"; import { Schema } from "prosemirror-model"; -import { EditorState, Plugin, TextSelection } from "prosemirror-state"; -import { a, doc, p, strong } from "prosemirror-test-builder"; +import { EditorState, Plugin } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; import "prosemirror-view/style/prosemirror.css"; import React, { @@ -15,11 +14,12 @@ import React, { } from "react"; import { createRoot } from "react-dom/client"; -import { EditorView } from "../src/components/EditorView.js"; -import { NodeViewComponentProps } from "../src/components/NodeViewComponentProps.js"; -import { widget } from "../src/decorations/ReactWidgetType.js"; -import { useView } from "../src/hooks/useView.js"; -import { reactKeys } from "../src/plugins/reactKeys.js"; +import { + NodeViewComponentProps, + ProseMirror, + reactKeys, + widget, +} from "../src/index.js"; import "./main.css"; @@ -99,32 +99,12 @@ const editorState = EditorState.create({ plugins: [reactKeys()], }); -// const startDoc = doc(p(strong(a("foo"), "bar"))); - -// const editorState = EditorState.create({ -// doc: startDoc, -// selection: TextSelection.create(startDoc, startDoc.tag.a), -// }); - const Paragraph = forwardRef(function Paragraph( - { - children, - className, - pos, - }: NodeViewComponentProps & - DetailedHTMLProps< - HTMLAttributes, - HTMLParagraphElement - >, + { children, className, ...props }: NodeViewComponentProps, ref: Ref ) { - useView((view) => { - view.focus(); - // eslint-disable-next-line no-console - // console.log(pos, view.coordsAtPos(pos)); - }); return ( -

+

{children}

); @@ -188,7 +168,8 @@ function DemoEditor() { return (

React ProseMirror Demo

- } className="ProseMirror" state={state} dispatchTransaction={(tr) => { @@ -220,9 +201,8 @@ function DemoEditor() { return DecorationSet.create(state.doc, decorations); }} plugins={plugins} - // @ts-expect-error TODO: Gotta fix these types nodeViews={{ paragraph: Paragraph }} - > + >
); } @@ -231,169 +211,3 @@ function DemoEditor() { const root = createRoot(document.getElementById("root")!); root.render(); - -// import { baseKeymap } from "prosemirror-commands"; -// import { keymap } from "prosemirror-keymap"; -// import { Schema } from "prosemirror-model"; -// import { EditorState } from "prosemirror-state"; -// import { Decoration, DecorationSet } from "prosemirror-view"; -// import "prosemirror-view/style/prosemirror.css"; -// import React, { 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 "./main.css"; - -// const schema = new Schema({ -// nodes: { -// doc: { content: "block+" }, -// paragraph: { -// group: "block", -// content: "inline*", -// toDOM() { -// return ["p", 0]; -// }, -// }, -// img: { -// group: "inline", -// inline: true, -// toDOM() { -// return [ -// "img", -// { -// src: "data:image/gif;base64,R0lGODlhBQAFAIABAAAAAP///yH5BAEKAAEALAAAAAAFAAUAAAIEjI+pWAA7", -// }, -// ]; -// }, -// }, -// list: { -// group: "block", -// content: "list_item+", -// toDOM() { -// return ["ul", 0]; -// }, -// }, -// list_item: { -// content: "paragraph+", -// toDOM() { -// return ["li", 0]; -// }, -// }, -// text: { group: "inline" }, -// }, -// marks: { -// em: { -// toDOM() { -// return ["em", 0]; -// }, -// }, -// strong: { -// toDOM() { -// return ["strong", 0]; -// }, -// }, -// }, -// }); - -// const editorState = EditorState.create({ -// schema, -// doc: schema.nodes.doc.create({}, [ -// schema.nodes.paragraph.create({}, [ -// schema.text("This ", [schema.marks.em.create()]), -// schema.text("is", [ -// schema.marks.em.create(), -// schema.marks.strong.create(), -// ]), -// schema.nodes.img.create(), -// schema.text(" the first paragraph"), -// ]), -// schema.nodes.paragraph.create( -// {}, -// schema.text("This is the second paragraph") -// ), -// schema.nodes.paragraph.create(), -// schema.nodes.paragraph.create( -// {}, -// schema.text("This is the third paragraph") -// ), -// ]), -// plugins: [keymap(baseKeymap)], -// }); - -// function Paragraph({ children }: NodeViewComponentProps) { -// return

{children}

; -// } - -// const reactNodeViews: Record = { -// paragraph: () => ({ -// component: Paragraph, -// dom: document.createElement("div"), -// contentDOM: document.createElement("span"), -// }), -// }; - -// function DemoEditor() { -// const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); -// const [mount, setMount] = useState(null); -// const [state, setState] = useState(editorState); - -// return ( -//
-//

React ProseMirror Demo

-// setState((prev) => prev.apply(tr))} -// // nodeViews={nodeViews} -// decorations={(s) => { -// const decorations = [ -// Decoration.inline(5, 15, { class: "inline-deco" }), -// ]; -// state.doc.forEach((node, offset, index) => { -// if (index === 1 || index === 2) { -// decorations.push( -// Decoration.node(offset, offset + node.nodeSize, { -// nodeName: "div", -// class: "node-deco", -// }) -// ); -// } -// if (index === 3) { -// decorations.push( -// Decoration.widget(offset + 10, () => { -// const el = document.createElement("div"); -// el.style.display = "inline-block"; -// el.style.padding = "0.75rem 1rem"; -// el.style.border = "solid thin black"; -// el.innerText = "Widget"; -// return el; -// }) -// ); -// } -// }); -// return DecorationSet.create(s.doc, decorations); -// }} -// > -//
-// {renderNodeViews()} -// -//
-// ); -// } - -// // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -// const root = createRoot(document.getElementById("root")!); - -// root.render( -// -// -// -// ); diff --git a/src/components/DocNodeView.tsx b/src/components/DocNodeView.tsx index be42dfa0..9684ee44 100644 --- a/src/components/DocNodeView.tsx +++ b/src/components/DocNodeView.tsx @@ -1,6 +1,9 @@ +/* eslint-disable react/prop-types */ import { Node } from "prosemirror-model"; import React, { ForwardedRef, + ReactElement, + cloneElement, forwardRef, useImperativeHandle, useRef, @@ -17,13 +20,13 @@ import { type Props = { className?: string; node: Node | undefined; - contentEditable: boolean; innerDeco: DecorationSource; outerDeco: Decoration[]; + as?: ReactElement; }; export const DocNodeView = forwardRef(function DocNodeView( - { node, contentEditable, innerDeco, outerDeco, ...props }: Props, + { className, node, innerDeco, outerDeco, as }: Props, ref: ForwardedRef ) { const innerRef = useRef(null); @@ -46,8 +49,20 @@ export const DocNodeView = forwardRef(function DocNodeView( const children = useChildNodeViews(-1, node, innerDeco); - const element = ( -
+ const element = as ? ( + cloneElement( + as, + { ref: innerRef, className, suppressContentEditableWarning: true }, + + {children} + + ) + ) : ( +
{children} @@ -61,5 +76,6 @@ export const DocNodeView = forwardRef(function DocNodeView( return element; } - return nodeDecorations.reduce(wrapInDeco, element); + const wrapped = nodeDecorations.reduce(wrapInDeco, element); + return wrapped; }); diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx deleted file mode 100644 index b4e2cb86..00000000 --- a/src/components/EditorView.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Command, EditorState, Transaction } from "prosemirror-state"; -import { DecorationSet } from "prosemirror-view"; -import React, { - ForwardRefExoticComponent, - ReactNode, - RefAttributes, - useEffect, - useMemo, - useState, -} from "react"; - -import { EditorViewContext } from "../contexts/EditorViewContext.js"; -import { LayoutGroup } from "../contexts/LayoutGroup.js"; -import { NodeViewContext } from "../contexts/NodeViewContext.js"; -import { useContentEditable } from "../hooks/useContentEditable.js"; -import { useReactEditorView } from "../hooks/useReactEditorView.js"; -import { useSyncSelection } from "../hooks/useSyncSelection.js"; -import { usePluginViews } from "../hooks/useViewPlugins.js"; -import { viewDecorations } from "../prosemirror-view/decoration.js"; -import { - DecorationSet as DecorationSetInternal, - DirectEditorProps, - EditorView as EditorViewClass, - computeDocDeco, -} from "../prosemirror-view/index.js"; - -import { DocNodeView } from "./DocNodeView.js"; -import { NodeViewComponentProps } from "./NodeViewComponentProps.js"; - -type EditorStateProps = - | { - state: EditorState; - defaultState?: never; - } - | { - state?: never; - defaultState: EditorState; - }; - -export type EditorProps = Omit< - DirectEditorProps, - "state" | "nodeViews" | "dispatchTransaction" -> & - EditorStateProps & { - keymap?: { [key: string]: Command }; - nodeViews?: { - [nodeType: string]: ForwardRefExoticComponent< - NodeViewComponentProps & RefAttributes - >; - }; - dispatchTransaction?: (this: EditorViewClass, tr: Transaction) => void; - }; - -export type Props = EditorProps & { className?: string; children?: ReactNode }; - -export function EditorView({ - className, - children, - editable: editableProp, - nodeViews = {}, - ...props -}: Props) { - const [mount, setMount] = useState(null); - - // This is only safe to use in effects/layout effects or - // event handlers! - const reactEditorView = useReactEditorView(mount, props); - - const editorState = - "state" in props ? props.state ?? null : reactEditorView?.state ?? null; - - const contextValue = useMemo( - () => ({ - view: reactEditorView, - state: editorState, - }), - [editorState, reactEditorView] - ); - - useEffect(() => { - reactEditorView?.domObserver.connectSelection(); - return () => reactEditorView?.domObserver.disconnectSelection(); - }, [reactEditorView?.domObserver]); - useSyncSelection(reactEditorView); - useContentEditable(reactEditorView); - usePluginViews(reactEditorView, props.plugins ?? []); - - const innerDecos = reactEditorView - ? viewDecorations(reactEditorView) - : (DecorationSetInternal.empty as unknown as DecorationSet); - - const outerDecos = reactEditorView ? computeDocDeco(reactEditorView) : []; - - return ( - - - - <> - - {children} - - - - - ); -} diff --git a/src/components/NodeViewComponentProps.tsx b/src/components/NodeViewComponentProps.tsx index 98602ae4..15986d0e 100644 --- a/src/components/NodeViewComponentProps.tsx +++ b/src/components/NodeViewComponentProps.tsx @@ -1,6 +1,7 @@ import { Node } from "prosemirror-model"; -import { Decoration, DecorationSource } from "prosemirror-view"; -import { ReactNode } from "react"; +import { HTMLAttributes, ReactNode } from "react"; + +import { Decoration, DecorationSource } from "../prosemirror-view/index.js"; export type NodeViewComponentProps = { decorations: readonly Decoration[]; @@ -9,4 +10,4 @@ export type NodeViewComponentProps = { children?: ReactNode | ReactNode[]; isSelected: boolean; pos: number; -}; +} & HTMLAttributes; diff --git a/src/components/OutputSpec.tsx b/src/components/OutputSpec.tsx index f5800f3d..09b1206c 100644 --- a/src/components/OutputSpec.tsx +++ b/src/components/OutputSpec.tsx @@ -35,7 +35,8 @@ const ForwardedOutputSpec = forwardRef(function OutputSpec( start = 2; for (const name in attrs) if (attrs[name] != null) { - const attrName = name.replace(" ", ":"); + const attrName = + name === "class" ? "className" : name.replace(" ", ":"); props[attrName] = attrs[name]; } } diff --git a/src/components/ProseMirror.tsx b/src/components/ProseMirror.tsx index 0f65e908..002a487d 100644 --- a/src/components/ProseMirror.tsx +++ b/src/components/ProseMirror.tsx @@ -1,34 +1,141 @@ -import React from "react"; +import { Command, EditorState, Transaction } from "prosemirror-state"; +import { DecorationSet } from "prosemirror-view"; +import React, { + ForwardRefExoticComponent, + ReactElement, + ReactNode, + RefAttributes, + useEffect, + useMemo, + useState, +} from "react"; +import { EditorContext } from "../contexts/EditorContext.js"; import { LayoutGroup } from "../contexts/LayoutGroup.js"; +import { NodeViewContext } from "../contexts/NodeViewContext.js"; +import { useComponentEventListeners } from "../hooks/useComponentEventListeners.js"; +import { useContentEditable } from "../hooks/useContentEditable.js"; +import { useEditorView } from "../hooks/useEditorView.js"; +import { useSyncSelection } from "../hooks/useSyncSelection.js"; +import { usePluginViews } from "../hooks/useViewPlugins.js"; +import { viewDecorations } from "../prosemirror-view/decoration.js"; +import { + DecorationSet as DecorationSetInternal, + DirectEditorProps, + EditorView as EditorViewClass, + computeDocDeco, +} from "../prosemirror-view/index.js"; + +import { DocNodeView } from "./DocNodeView.js"; +import { NodeViewComponentProps } from "./NodeViewComponentProps.js"; + +type EditorStateProps = + | { + state: EditorState; + defaultState?: never; + } + | { + state?: never; + defaultState: EditorState; + }; + +export type EditorProps = Omit< + DirectEditorProps, + "state" | "nodeViews" | "dispatchTransaction" +> & + EditorStateProps & { + keymap?: { [key: string]: Command }; + nodeViews?: { + [nodeType: string]: ForwardRefExoticComponent< + // We need to allow refs to any type of HTMLElement, but there's + // no way to express that that still allows consumers to correctly + // type their own refs. This is sufficient to ensure that there's + // a ref of _some_ kind, which is enough. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + NodeViewComponentProps & RefAttributes + >; + }; + dispatchTransaction?: (this: EditorViewClass, tr: Transaction) => void; + }; + +export type Props = EditorProps & { + className?: string; + children?: ReactNode; + as?: ReactElement; +}; + +export function ProseMirror({ + className, + children, + nodeViews = {}, + as, + ...props +}: Props) { + const [mount, setMount] = useState(null); + + const { + componentEventListenersPlugin, + registerEventListener, + unregisterEventListener, + } = useComponentEventListeners(); + + const plugins = useMemo( + () => [...(props.plugins ?? []), componentEventListenersPlugin], + [props.plugins, componentEventListenersPlugin] + ); + + const editorView = useEditorView(mount, { ...props, plugins }); + + const editorState = + "state" in props ? props.state ?? null : editorView?.state ?? null; + + const contextValue = useMemo( + () => ({ + editorView, + editorState, + registerEventListener, + unregisterEventListener, + }), + [editorState, editorView, registerEventListener, unregisterEventListener] + ); + + const viewPlugins = useMemo(() => props.plugins ?? [], [props.plugins]); + + useEffect(() => { + editorView?.domObserver.connectSelection(); + return () => editorView?.domObserver.disconnectSelection(); + }, [editorView?.domObserver]); + useSyncSelection(editorView); + useContentEditable(editorView); + usePluginViews(editorView, editorState, viewPlugins); + + const innerDecos = editorView + ? viewDecorations(editorView) + : (DecorationSetInternal.empty as unknown as DecorationSet); + + const outerDecos = editorView ? computeDocDeco(editorView) : []; -import { ProseMirrorInner, ProseMirrorProps } from "./ProseMirrorInner.js"; - -/** - * Renders the ProseMirror View onto a DOM mount. - * - * 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. - * - * e.g. - * - * ``` - * function MyProseMirrorField() { - * const [mount, setMount] = useState(null); - * - * return ( - * - *
- * - * ); - * } - * ``` - */ -export function ProseMirror(props: ProseMirrorProps) { return ( - + + + <> + + {children} + + + ); } diff --git a/src/components/ProseMirrorInner.tsx b/src/components/ProseMirrorInner.tsx deleted file mode 100644 index b11e9bf6..00000000 --- a/src/components/ProseMirrorInner.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { useMemo } from "react"; -import type { ReactNode } from "react"; - -import { EditorProvider } from "../contexts/EditorContext.js"; -import { useComponentEventListeners } from "../hooks/useComponentEventListeners.js"; -import { EditorProps, useEditorView } from "../hooks/useEditorView.js"; - -export type ProseMirrorProps = EditorProps & { - mount: HTMLElement | null; - children?: ReactNode | null; -}; - -/** - * Renders the ProseMirror View onto a DOM mount. - * - * 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. - * - * e.g. - * - * ``` - * function MyProseMirrorField() { - * const [mount, setMount] = useState(null); - * - * return ( - * - *
- * - * ); - * } - * ``` - */ -export function ProseMirrorInner({ - children, - dispatchTransaction, - mount, - ...editorProps -}: ProseMirrorProps) { - const { - componentEventListenersPlugin, - registerEventListener, - unregisterEventListener, - } = useComponentEventListeners(); - - const plugins = useMemo( - () => [...(editorProps.plugins ?? []), componentEventListenersPlugin], - [editorProps.plugins, componentEventListenersPlugin] - ); - - const editorView = useEditorView(mount, { - ...editorProps, - plugins, - dispatchTransaction, - }); - - const editorState = - "state" in editorProps ? editorProps.state : editorView?.state ?? null; - - const editorContextValue = useMemo( - () => ({ - editorView, - editorState, - registerEventListener, - unregisterEventListener, - }), - [editorState, editorView, registerEventListener, unregisterEventListener] - ); - - return ( - - {children ?? null} - - ); -} diff --git a/src/components/__tests__/EditorView.test.tsx b/src/components/__tests__/EditorView.test.tsx deleted file mode 100644 index 7ccdf038..00000000 --- a/src/components/__tests__/EditorView.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { expect } from "@jest/globals"; -import { act } from "@testing-library/react"; -import { doc, em, hr, li, p, strong, ul } from "prosemirror-test-builder"; - -import { tempEditor } from "../../testing/editorViewTestHelpers.js"; -import { setupProseMirrorView } from "../../testing/setupProseMirrorView.js"; - -describe("EditorView", () => { - beforeAll(() => { - setupProseMirrorView(); - }); - - it("reflects the current state in .props", () => { - const { view } = tempEditor({ - doc: doc(p()), - }); - - expect(view.state).toBe(view.props.state); - }); - - it("calls handleScrollToSelection when appropriate", () => { - let scrolled = 0; - - const { view } = tempEditor({ - doc: doc(p()), - handleScrollToSelection: () => { - scrolled++; - return false; - }, - }); - - act(() => { - view.dispatch(view.state.tr.scrollIntoView()); - }); - - expect(scrolled).toBe(1); - }); - - it("can be queried for the DOM position at a doc position", () => { - const { view } = tempEditor({ doc: doc(ul(li(p(strong("foo"))))) }); - - const inText = view.domAtPos(4); - expect(inText.offset).toBe(1); - expect(inText.node.nodeValue).toBe("foo"); - const beforeLI = view.domAtPos(1); - expect(beforeLI.offset).toBe(0); - expect(beforeLI.node.nodeName).toBe("UL"); - const afterP = view.domAtPos(7); - expect(afterP.offset).toBe(1); - expect(afterP.node.nodeName).toBe("LI"); - }); - - it("can bias DOM position queries to enter nodes", () => { - const { view } = tempEditor({ - doc: doc(p(em(strong("a"), "b"), "c")), - }); - - function get(pos: number, bias: number) { - const r = view.domAtPos(pos, bias); - return ( - (r.node.nodeType == 1 ? r.node.nodeName : r.node.nodeValue) + - "@" + - r.offset - ); - } - - expect(get(1, 0)).toBe("P@0"); - expect(get(1, -1)).toBe("P@0"); - expect(get(1, 1)).toBe("a@0"); - expect(get(2, -1)).toBe("a@1"); - expect(get(2, 0)).toBe("EM@1"); - expect(get(2, 1)).toBe("b@0"); - expect(get(3, -1)).toBe("b@1"); - expect(get(3, 0)).toBe("P@1"); - expect(get(3, 1)).toBe("c@0"); - expect(get(4, -1)).toBe("c@1"); - expect(get(4, 0)).toBe("P@2"); - expect(get(4, 1)).toBe("P@2"); - }); - - it("can be queried for a node's DOM representation", () => { - const { view } = tempEditor({ - doc: doc(p("foo"), hr()), - }); - - expect(view.nodeDOM(0)!.nodeName).toBe("P"); - expect(view.nodeDOM(5)!.nodeName).toBe("HR"); - expect(view.nodeDOM(3)).toBeNull(); - }); - - it("can map DOM positions to doc positions", () => { - const { view } = tempEditor({ - doc: doc(p("foo"), hr()), - }); - - expect(view.posAtDOM(view.dom.firstChild!.firstChild!, 2)).toBe(3); - expect(view.posAtDOM(view.dom, 1)).toBe(5); - expect(view.posAtDOM(view.dom, 2)).toBe(6); - expect(view.posAtDOM(view.dom.lastChild!, 0, -1)).toBe(5); - expect(view.posAtDOM(view.dom.lastChild!, 0, 1)).toBe(6); - }); - - it("binds this to itself in dispatchTransaction prop", () => { - let thisBinding: any; - - const { view } = tempEditor({ - doc: doc(p("foo"), hr()), - dispatchTransaction() { - // eslint-disable-next-line @typescript-eslint/no-this-alias - thisBinding = this; - }, - }); - - act(() => { - view.dispatch(view.state.tr.insertText("x")); - }); - expect(view).toBe(thisBinding); - }); -}); diff --git a/src/components/__tests__/EditorView.domchange.test.tsx b/src/components/__tests__/ProseMirror.domchange.test.tsx similarity index 97% rename from src/components/__tests__/EditorView.domchange.test.tsx rename to src/components/__tests__/ProseMirror.domchange.test.tsx index d87c82bf..4b4347af 100644 --- a/src/components/__tests__/EditorView.domchange.test.tsx +++ b/src/components/__tests__/ProseMirror.domchange.test.tsx @@ -117,12 +117,13 @@ describe("DOM change", () => { expect(view.state.selection.head).toBe(5); }); - // todoit("can read a simple composition", () => { - // let view = tempEditor({ doc: doc(p("hello")) }); - // findTextNode(view.dom, "hello")!.nodeValue = "hellox"; - // flush(view); - // ist(view.state.doc, doc(p("hellox")), eq); - // }); + it("can read a simple composition", async () => { + const { view } = tempEditor({ doc: doc(p("hello")) }); + await act(async () => { + await userEvent.type(view.dom, "x"); + }); + expect(view.state.doc).toEqualNode(doc(p("hellox"))); + }); // $$FORK: We _do_ repaint text nodes when they're typed into. // Unlike prosemirror-view, we prevent user inputs from modifying diff --git a/src/components/__tests__/EditorView.draw-decoration.test.tsx b/src/components/__tests__/ProseMirror.draw-decoration.test.tsx similarity index 98% rename from src/components/__tests__/EditorView.draw-decoration.test.tsx rename to src/components/__tests__/ProseMirror.draw-decoration.test.tsx index b7947a02..08f325e5 100644 --- a/src/components/__tests__/EditorView.draw-decoration.test.tsx +++ b/src/components/__tests__/ProseMirror.draw-decoration.test.tsx @@ -25,7 +25,7 @@ import { import React, { LegacyRef, forwardRef, useEffect } from "react"; import { widget } from "../../decorations/ReactWidgetType.js"; -import { useView } from "../../hooks/useView.js"; +import { useEditorEffect } from "../../hooks/useEditorEffect.js"; import { Decoration, DecorationSet, @@ -150,7 +150,7 @@ describe("Decoration drawing", () => { doc: doc(p("abcdef")), plugins: [decoPlugin(["3-5-foo", "4-6-bar", "1-7-baz"])], }); - const baz = view.dom.querySelectorAll(".baz") as any as HTMLElement[]; + const baz = view.dom.querySelectorAll(".baz") as unknown as HTMLElement[]; expect(baz).toHaveLength(5); expect(Array.prototype.map.call(baz, (x) => x.textContent).join("-")).toBe( "ab-c-d-e-f" @@ -168,7 +168,9 @@ describe("Decoration drawing", () => { doc: doc(p("foobar")), plugins: [decoPlugin(["1-widget", "4-widget", "7-widget"])], }); - const found = view.dom.querySelectorAll("button") as any as HTMLElement[]; + const found = view.dom.querySelectorAll( + "button" + ) as unknown as HTMLElement[]; expect(found).toHaveLength(3); expect(found[0]!.nextSibling!.textContent).toBe("foo"); expect(found[1]!.nextSibling!.textContent).toBe("bar"); @@ -724,8 +726,8 @@ describe("Decoration drawing", () => { widget( 3, forwardRef(function Span(props, ref) { - useView((view) => { - expect(view.state).toBe(state); + useEditorEffect((view) => { + expect(view?.state).toBe(state); }); return ( @@ -884,7 +886,6 @@ describe("Decoration drawing", () => { }: NodeViewComponentProps, ref ) { - // @ts-expect-error Don't worry about it decosFromFirstEditor = innerDecorations; return (

} {...props}> diff --git a/src/components/__tests__/EditorView.draw.test.tsx b/src/components/__tests__/ProseMirror.draw.test.tsx similarity index 78% rename from src/components/__tests__/EditorView.draw.test.tsx rename to src/components/__tests__/ProseMirror.draw.test.tsx index 499190d5..8fd4c2bf 100644 --- a/src/components/__tests__/EditorView.draw.test.tsx +++ b/src/components/__tests__/ProseMirror.draw.test.tsx @@ -1,11 +1,13 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { act } from "@testing-library/react"; -import { Schema } from "prosemirror-model"; +import { Node, Schema } from "prosemirror-model"; import { Plugin } from "prosemirror-state"; import { doc, h1, hr, p, pre, schema, strong } from "prosemirror-test-builder"; +import React, { forwardRef } from "react"; import { tempEditor } from "../../testing/editorViewTestHelpers.js"; +import { NodeViewComponentProps } from "../NodeViewComponentProps.js"; -// TODO: Address skipped tests describe("EditorView draw", () => { it("updates the DOM", () => { const { view } = tempEditor({ doc: doc(p("foo")) }); @@ -79,8 +81,8 @@ describe("EditorView draw", () => { expect(view.dom.querySelector("h1")).toBe(oldH); }); - it.skip("adds classes from the attributes prop", () => { - const { view } = tempEditor({ + it("adds classes from the attributes prop", () => { + const { view, rerender } = tempEditor({ doc: doc(p()), attributes: { class: "foo bar" }, }); @@ -88,13 +90,13 @@ describe("EditorView draw", () => { expect(view.dom.classList.contains("bar")).toBeTruthy(); expect(view.dom.classList.contains("ProseMirror")).toBeTruthy(); act(() => { - view.update({ state: view.state, attributes: { class: "baz" } }); + rerender({ attributes: { class: "baz" } }); }); expect(!view.dom.classList.contains("foo")).toBeTruthy(); expect(view.dom.classList.contains("baz")).toBeTruthy(); }); - it.skip("adds style from the attributes prop", () => { + it("adds style from the attributes prop", () => { const { view } = tempEditor({ doc: doc(p()), attributes: { style: "border: 1px solid red;" }, @@ -108,16 +110,15 @@ describe("EditorView draw", () => { expect(view.dom.style.color).toBe("red"); }); - it.skip("can set other attributes", () => { - const { view } = tempEditor({ + it("can set other attributes", () => { + const { view, rerender } = tempEditor({ doc: doc(p()), attributes: { spellcheck: "false", "aria-label": "hello" }, }); - expect(view.dom.spellcheck).toBe(false); + expect(view.dom.getAttribute("spellcheck")).toBe("false"); expect(view.dom.getAttribute("aria-label")).toBe("hello"); act(() => { - view.update({ - state: view.state, + rerender({ attributes: { style: "background-color: yellow" }, }); }); @@ -125,19 +126,22 @@ describe("EditorView draw", () => { expect(view.dom.style.backgroundColor).toBe("yellow"); }); - it.skip("can't set the contenteditable attribute", () => { + it("can't set the contenteditable attribute", () => { const { view } = tempEditor({ doc: doc(p()), attributes: { contenteditable: "false" }, }); - expect(view.dom.contentEditable).toBe("true"); + expect(view.dom.getAttribute("contenteditable")).toBe("true"); }); - it.skip("understands the editable prop", () => { - const { view } = tempEditor({ doc: doc(p()), editable: () => false }); - expect(view.dom.contentEditable).toBe("false"); - view.update({ state: view.state }); - expect(view.dom.contentEditable).toBe("true"); + it("understands the editable prop", () => { + const { view, rerender } = tempEditor({ + doc: doc(p()), + editable: () => false, + }); + expect(view.dom.getAttribute("contenteditable")).toBe("false"); + rerender({ editable: () => true }); + expect(view.dom.getAttribute("contenteditable")).toBe("true"); }); it("doesn't redraw following paragraphs when a paragraph is split", () => { @@ -158,7 +162,7 @@ describe("EditorView draw", () => { expect(view.dom.querySelectorAll("p")[2]).toBe(secondPara); }); - it.skip("creates and destroys plugin views", () => { + it("creates and destroys plugin views", () => { const events: string[] = []; class PluginView { update() { @@ -174,22 +178,25 @@ describe("EditorView draw", () => { return new PluginView(); }, }); - const { view } = tempEditor({ plugins: [plugin] }); + const { view, unmount } = tempEditor({ plugins: [plugin] }); act(() => { view.dispatch(view.state.tr.insertText("u")); }); - view.destroy(); + unmount(); expect(events.join(" ")).toBe("create update destroy"); }); - it.skip("redraws changed node views", () => { - const { view } = tempEditor({ doc: doc(p("foo"), hr()) }); + it("redraws changed node views", () => { + const { view, rerender } = tempEditor({ doc: doc(p("foo"), hr()) }); expect(view.dom.querySelector("hr")).toBeTruthy(); - view.setProps({ + rerender({ nodeViews: { - horizontal_rule: () => { - return { dom: document.createElement("var") }; - }, + horizontal_rule: forwardRef(function Var( + props: NodeViewComponentProps, + ref + ) { + return {props.children}; + }), }, }); expect(!view.dom.querySelector("hr")).toBeTruthy(); @@ -206,7 +213,7 @@ describe("EditorView draw", () => { expect(view.dom.querySelectorAll("strong")).toHaveLength(1); }); - it.skip("doesn't redraw too much when marks are present", () => { + it("doesn't redraw too much when marks are present", () => { const s = new Schema({ nodes: { doc: { content: "paragraph+", marks: "m" }, @@ -230,12 +237,14 @@ describe("EditorView draw", () => { doc: s.node("doc", null, paragraphs), }); const initialChildren = Array.from(view.dom.querySelectorAll("p")); - const newParagraphs = []; + const newParagraphs: Node[] = []; for (let i = -6; i < 0; i++) newParagraphs.push( s.node("paragraph", null, [s.text("para " + i)], [s.mark("m")]) ); - view.dispatch(view.state.tr.replaceWith(0, 8, newParagraphs)); + act(() => { + view.dispatch(view.state.tr.replaceWith(0, 8, newParagraphs)); + }); const currentChildren = Array.from(view.dom.querySelectorAll("p")); let sameAtEnd = 0; while ( @@ -245,6 +254,10 @@ describe("EditorView draw", () => { initialChildren[initialChildren.length - sameAtEnd - 1] ) sameAtEnd++; - expect(sameAtEnd).toBe(9); + // $$FORK: Our node stability isn't _quite_ as robust + // as prosemirror-view's. The node adjacent to the one + // that was replaced also gets repainted. + // expect(sameAtEnd).toBe(9); + expect(sameAtEnd).toBe(8); }); }); diff --git a/src/components/__tests__/EditorView.node-view.test.tsx b/src/components/__tests__/ProseMirror.node-view.test.tsx similarity index 100% rename from src/components/__tests__/EditorView.node-view.test.tsx rename to src/components/__tests__/ProseMirror.node-view.test.tsx diff --git a/src/components/__tests__/EditorView.selection.test.tsx b/src/components/__tests__/ProseMirror.selection.test.tsx similarity index 98% rename from src/components/__tests__/EditorView.selection.test.tsx rename to src/components/__tests__/ProseMirror.selection.test.tsx index 688805db..4c283ba4 100644 --- a/src/components/__tests__/EditorView.selection.test.tsx +++ b/src/components/__tests__/ProseMirror.selection.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable jest/no-disabled-tests */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { act, screen } from "@testing-library/react"; +import { act } from "@testing-library/react"; import { Node as PMNode } from "prosemirror-model"; import { NodeSelection, Selection } from "prosemirror-state"; import { @@ -24,18 +24,16 @@ import { DecorationSet, EditorView, } from "../../prosemirror-view/index.js"; -import { tempEditor } from "../../testing/editorViewTestHelpers.js"; +import { + findTextNode, + tempEditor, +} from "../../testing/editorViewTestHelpers.js"; import { setupProseMirrorView } from "../../testing/setupProseMirrorView.js"; const img = img_({ src: "data:image/gif;base64,R0lGODlhBQAFAIABAAAAAP///yH5BAEKAAEALAAAAAAFAAUAAAIEjI+pWAA7", }); -async function findTextNode(_: HTMLElement, text: string) { - const parent = await screen.findByText(text); - return parent.firstChild!; -} - function allPositions(doc: PMNode) { const found: number[] = []; function scan(node: PMNode, start: number) { diff --git a/src/components/__tests__/ProseMirror.test.tsx b/src/components/__tests__/ProseMirror.test.tsx index 6543a377..7ccdf038 100644 --- a/src/components/__tests__/ProseMirror.test.tsx +++ b/src/components/__tests__/ProseMirror.test.tsx @@ -1,145 +1,121 @@ -import { act, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { Schema } from "prosemirror-model"; -import { EditorState } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; -import React, { useEffect, useState } from "react"; - -import { useNodeViews } from "../../hooks/useNodeViews.js"; -import { NodeViewComponentProps } from "../../nodeViews/createReactNodeViewConstructor.js"; -import { - setupProseMirrorView, - teardownProseMirrorView, -} from "../../testing/setupProseMirrorView.js"; -import { ProseMirror } from "../ProseMirror.js"; - -describe("ProseMirror", () => { +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { expect } from "@jest/globals"; +import { act } from "@testing-library/react"; +import { doc, em, hr, li, p, strong, ul } from "prosemirror-test-builder"; + +import { tempEditor } from "../../testing/editorViewTestHelpers.js"; +import { setupProseMirrorView } from "../../testing/setupProseMirrorView.js"; + +describe("EditorView", () => { beforeAll(() => { setupProseMirrorView(); }); - it("renders a contenteditable", async () => { - const schema = new Schema({ - nodes: { - text: {}, - doc: { content: "text*" }, - }, + it("reflects the current state in .props", () => { + const { view } = tempEditor({ + doc: doc(p()), }); - const editorState = EditorState.create({ schema }); - function TestEditor() { - const [mount, setMount] = useState(null); + expect(view.state).toBe(view.props.state); + }); - return ( - -

- - ); - } - const user = userEvent.setup(); - render(); + it("calls handleScrollToSelection when appropriate", () => { + let scrolled = 0; - const editor = screen.getByTestId("editor"); - await user.type(editor, "Hello, world!"); + const { view } = tempEditor({ + doc: doc(p()), + handleScrollToSelection: () => { + scrolled++; + return false; + }, + }); + + act(() => { + view.dispatch(view.state.tr.scrollIntoView()); + }); - expect(editor.textContent).toBe("Hello, world!"); + expect(scrolled).toBe(1); }); - it("supports lifted editor state", async () => { - const schema = new Schema({ - nodes: { - text: {}, - doc: { content: "text*" }, - }, - }); - let outerEditorState = EditorState.create({ schema }); - function TestEditor() { - const [editorState, setEditorState] = useState(outerEditorState); - const [mount, setMount] = useState(null); + it("can be queried for the DOM position at a doc position", () => { + const { view } = tempEditor({ doc: doc(ul(li(p(strong("foo"))))) }); + + const inText = view.domAtPos(4); + expect(inText.offset).toBe(1); + expect(inText.node.nodeValue).toBe("foo"); + const beforeLI = view.domAtPos(1); + expect(beforeLI.offset).toBe(0); + expect(beforeLI.node.nodeName).toBe("UL"); + const afterP = view.domAtPos(7); + expect(afterP.offset).toBe(1); + expect(afterP.node.nodeName).toBe("LI"); + }); - useEffect(() => { - outerEditorState = editorState; - }, [editorState]); + it("can bias DOM position queries to enter nodes", () => { + const { view } = tempEditor({ + doc: doc(p(em(strong("a"), "b"), "c")), + }); + function get(pos: number, bias: number) { + const r = view.domAtPos(pos, bias); return ( - - act(() => setEditorState(editorState.apply(tr))) - } - > -
- + (r.node.nodeType == 1 ? r.node.nodeName : r.node.nodeValue) + + "@" + + r.offset ); } - const user = userEvent.setup(); - render(); - const editor = screen.getByTestId("editor"); - await user.type(editor, "Hello, world!"); - - expect(outerEditorState.doc.textContent).toBe("Hello, world!"); + expect(get(1, 0)).toBe("P@0"); + expect(get(1, -1)).toBe("P@0"); + expect(get(1, 1)).toBe("a@0"); + expect(get(2, -1)).toBe("a@1"); + expect(get(2, 0)).toBe("EM@1"); + expect(get(2, 1)).toBe("b@0"); + expect(get(3, -1)).toBe("b@1"); + expect(get(3, 0)).toBe("P@1"); + expect(get(3, 1)).toBe("c@0"); + expect(get(4, -1)).toBe("c@1"); + expect(get(4, 0)).toBe("P@2"); + expect(get(4, 1)).toBe("P@2"); }); - it("supports React NodeViews", async () => { - const schema = new Schema({ - nodes: { - text: {}, - paragraph: { content: "text*" }, - doc: { content: "paragraph+" }, - }, + it("can be queried for a node's DOM representation", () => { + const { view } = tempEditor({ + doc: doc(p("foo"), hr()), }); - const editorState = EditorState.create({ schema }); - - function Paragraph({ children }: NodeViewComponentProps) { - return

{children}

; - } - const reactNodeViews = { - paragraph: () => ({ - component: Paragraph, - dom: document.createElement("div"), - contentDOM: document.createElement("span"), - }), - }; - - function TestEditor() { - const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews); - const [mount, setMount] = useState(null); + expect(view.nodeDOM(0)!.nodeName).toBe("P"); + expect(view.nodeDOM(5)!.nodeName).toBe("HR"); + expect(view.nodeDOM(3)).toBeNull(); + }); - return ( - this.updateState(this.state.apply(tr))); - }} - nodeViews={nodeViews} - > -
- {renderNodeViews()} - - ); - } - const user = userEvent.setup(); - render(); + it("can map DOM positions to doc positions", () => { + const { view } = tempEditor({ + doc: doc(p("foo"), hr()), + }); - const editor = screen.getByTestId("editor"); + expect(view.posAtDOM(view.dom.firstChild!.firstChild!, 2)).toBe(3); + expect(view.posAtDOM(view.dom, 1)).toBe(5); + expect(view.posAtDOM(view.dom, 2)).toBe(6); + expect(view.posAtDOM(view.dom.lastChild!, 0, -1)).toBe(5); + expect(view.posAtDOM(view.dom.lastChild!, 0, 1)).toBe(6); + }); - await user.type(editor, "Hello, world!"); + it("binds this to itself in dispatchTransaction prop", () => { + let thisBinding: any; - expect(editor.textContent).toBe("Hello, world!"); - // Ensure that ProseMirror really rendered our Paragraph - // component, not just any old

tag - expect(screen.getAllByTestId("paragraph").length).toBeGreaterThanOrEqual(1); - }); + const { view } = tempEditor({ + doc: doc(p("foo"), hr()), + dispatchTransaction() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + thisBinding = this; + }, + }); - afterAll(() => { - teardownProseMirrorView(); + act(() => { + view.dispatch(view.state.tr.insertText("x")); + }); + expect(view).toBe(thisBinding); }); }); diff --git a/src/contexts/EditorContext.ts b/src/contexts/EditorContext.ts index 2941872f..31bd857d 100644 --- a/src/contexts/EditorContext.ts +++ b/src/contexts/EditorContext.ts @@ -1,8 +1,8 @@ import type { EditorState } from "prosemirror-state"; -import type { DOMEventMap, EditorView } from "prosemirror-view"; import { createContext } from "react"; import type { EventHandler } from "../plugins/componentEventListeners"; +import type { DOMEventMap, EditorView } from "../prosemirror-view/index.js"; interface EditorContextValue { editorView: EditorView | null; diff --git a/src/descriptors/iterDeco.ts b/src/decorations/iterDeco.ts similarity index 97% rename from src/descriptors/iterDeco.ts rename to src/decorations/iterDeco.ts index 9b8e81c4..fcb7ece3 100644 --- a/src/descriptors/iterDeco.ts +++ b/src/decorations/iterDeco.ts @@ -1,12 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Node } from "prosemirror-model"; -import { - ReactWidgetDecoration, - ReactWidgetType, -} from "../decorations/ReactWidgetType.js"; import { Decoration, DecorationSource } from "../prosemirror-view/index.js"; +import { ReactWidgetDecoration, ReactWidgetType } from "./ReactWidgetType.js"; + function compareSide(a: Decoration, b: Decoration) { return ( (a.type as unknown as ReactWidgetType).side - diff --git a/src/hooks/__tests__/useEditorViewLayoutEffect.test.tsx b/src/hooks/__tests__/useEditorViewLayoutEffect.test.tsx index 03b8611e..d1ff180c 100644 --- a/src/hooks/__tests__/useEditorViewLayoutEffect.test.tsx +++ b/src/hooks/__tests__/useEditorViewLayoutEffect.test.tsx @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { render } from "@testing-library/react"; import type { EditorState } from "prosemirror-state"; -import type { EditorView } from "prosemirror-view"; import React from "react"; import { EditorContext } from "../../contexts/EditorContext.js"; import { LayoutGroup } from "../../contexts/LayoutGroup.js"; +import type { EditorView } from "../../prosemirror-view/index.js"; import { useEditorEffect } from "../useEditorEffect.js"; function TestComponent({ diff --git a/src/hooks/useChildNodeViews.tsx b/src/hooks/useChildNodeViews.tsx index c3862be1..98ffcb8e 100644 --- a/src/hooks/useChildNodeViews.tsx +++ b/src/hooks/useChildNodeViews.tsx @@ -12,20 +12,20 @@ import { NonWidgetType, ReactWidgetDecoration, } from "../decorations/ReactWidgetType.js"; -import { iterDeco } from "../descriptors/iterDeco.js"; +import { iterDeco } from "../decorations/iterDeco.js"; import { Decoration, DecorationSource, } from "../prosemirror-view/decoration.js"; -import { useReactEditorState } from "./useReactEditorState.js"; +import { useEditorState } from "./useEditorState.js"; import { useReactKeys } from "./useReactKeys.js"; function cssToStyles(css: string) { const cssJson = `{"${css - .replace(/; */g, '","') - .replace(/: */g, '":"') - .replace(";", "")}"}`; + .replace(/;? *$/, "") + .replace(/;+ */g, '","') + .replace(/: */g, '":"')}"}`; const obj = JSON.parse(cssJson); @@ -43,6 +43,7 @@ export function wrapInDeco(reactNode: JSX.Element | string, deco: Decoration) { class: className, style: css, contenteditable: contentEditable, + spellcheck: spellCheck, ...attrs } = (deco.type as unknown as NonWidgetType).attrs; @@ -55,6 +56,7 @@ export function wrapInDeco(reactNode: JSX.Element | string, deco: Decoration) { { className, contentEditable, + spellCheck, style: css && cssToStyles(css), ...attrs, }, @@ -65,7 +67,8 @@ export function wrapInDeco(reactNode: JSX.Element | string, deco: Decoration) { return cloneElement(reactNode, { className: classnames(reactNode.props.className, className), contentEditable, - style: css && cssToStyles(css), + spellCheck, + style: { ...reactNode.props.style, ...(css && cssToStyles(css)) }, ...attrs, }); } @@ -100,7 +103,7 @@ type SharedMarksProps = { }; function InlineView({ innerPos, childViews }: SharedMarksProps) { - const editorState = useReactEditorState(); + const editorState = useEditorState(); const reactKeys = useReactKeys(); const partitioned = childViews.reduce((acc, child) => { @@ -259,7 +262,7 @@ export function useChildNodeViews( node: Node | undefined, innerDecorations: DecorationSource ) { - const editorState = useReactEditorState(); + const editorState = useEditorState(); const reactKeys = useReactKeys(); if (!node) return null; @@ -318,10 +321,10 @@ export function useChildNodeViews( if (queuedChildNodes.length) { children.push( >() + new Map>() ); const registerEventListener = useCallback( (eventType: keyof DOMEventMap, handler: EventHandler) => { - const handlers = registry.get(eventType) ?? new Set(); - handlers.add(handler); + const handlers = registry.get(eventType) ?? []; + handlers.unshift(handler); if (!registry.has(eventType)) { registry.set(eventType, handlers); setRegistry(new Map(registry)); @@ -52,7 +52,7 @@ export function useComponentEventListeners() { const unregisterEventListener = useCallback( (eventType: keyof DOMEventMap, handler: EventHandler) => { const handlers = registry.get(eventType); - handlers?.delete(handler); + handlers?.splice(handlers.indexOf(handler), 1); }, [registry] ); diff --git a/src/hooks/useEditorEffect.ts b/src/hooks/useEditorEffect.ts index 2ea2932a..f044b7fa 100644 --- a/src/hooks/useEditorEffect.ts +++ b/src/hooks/useEditorEffect.ts @@ -1,9 +1,9 @@ -import type { EditorView } from "prosemirror-view"; import { useContext } from "react"; import type { DependencyList } from "react"; import { EditorContext } from "../contexts/EditorContext.js"; import { useLayoutGroupEffect } from "../contexts/LayoutGroup.js"; +import type { EditorView } from "../prosemirror-view/index.js"; /** * Registers a layout effect to run after the EditorView has diff --git a/src/hooks/useEditorEventCallback.ts b/src/hooks/useEditorEventCallback.ts index 9386b19a..5e948546 100644 --- a/src/hooks/useEditorEventCallback.ts +++ b/src/hooks/useEditorEventCallback.ts @@ -1,7 +1,7 @@ -import type { EditorView } from "prosemirror-view"; import { useCallback, useContext, useRef } from "react"; import { EditorContext } from "../contexts/EditorContext.js"; +import type { EditorView } from "../prosemirror-view/index.js"; import { useEditorEffect } from "./useEditorEffect.js"; diff --git a/src/hooks/useEditorView.ts b/src/hooks/useEditorView.ts index cdaf6eba..6b4c2cb4 100644 --- a/src/hooks/useEditorView.ts +++ b/src/hooks/useEditorView.ts @@ -1,11 +1,69 @@ import type { EditorState, Transaction } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; -import type { DirectEditorProps } from "prosemirror-view"; import { useLayoutEffect, useState } from "react"; import { unstable_batchedUpdates as batch } from "react-dom"; +import { SelectionDOMObserver } from "../SelectionDOMObserver.js"; +import { + resetScrollPos, + storeScrollPos, +} from "../prosemirror-view/domcoords.js"; +import { DirectEditorProps, EditorView } from "../prosemirror-view/index.js"; +import { NodeViewDesc } from "../prosemirror-view/viewdesc.js"; + import { useForceUpdate } from "./useForceUpdate.js"; +class ReactEditorView extends EditorView { + init() { + this.domObserver.start(); + this.initInput(); + } + + updateStateInner(state: EditorState, _prevProps: DirectEditorProps) { + this.editable = !this.someProp( + "editable", + (value) => value(this.state) === false + ); + + const previousState = this.state; + this.state = state; + + const scroll = + previousState.plugins != state.plugins && !previousState.doc.eq(state.doc) + ? "reset" + : // @ts-expect-error scrollToSelection is internal + state.scrollToSelection > + // @ts-expect-error scrollToSelection is internal + previousState.scrollToSelection + ? "to selection" + : "preserve"; + + const updateSel = !state.selection.eq(previousState.selection); + + const oldScrollPos = + scroll == "preserve" && + updateSel && + this.dom.style.overflowAnchor == null && + storeScrollPos(this); + + if (scroll == "reset") { + this.dom.scrollTop = 0; + } else if (scroll == "to selection") { + this.scrollToSelection(); + } else if (oldScrollPos) { + resetScrollPos(oldScrollPos); + } + } + + // @ts-expect-error We need this to be an accessor + set docView(_) { + // disallowed + } + + get docView() { + return this.dom.pmViewDesc as NodeViewDesc; + } +} + function withBatchedUpdates( fn: (this: This, ...args: T) => void ): (...args: T) => void { @@ -84,31 +142,14 @@ export function useEditorView( const editorProps = withBatchedDispatch(props, forceUpdate); - const stateProp = "state" in editorProps ? editorProps.state : undefined; - const state = "defaultState" in editorProps ? editorProps.defaultState : editorProps.state; - const nonStateProps = Object.fromEntries( - Object.entries(editorProps).filter( - ([propName]) => propName !== "state" && propName !== "defaultState" - ) - ); - - useLayoutEffect(() => { - return () => { - if (view) { - view.destroy(); - } - }; - }, [view]); - useLayoutEffect(() => { if (view && view.dom !== mount) { setView(null); - return; } if (!mount) { @@ -116,11 +157,12 @@ export function useEditorView( } if (!view) { - const newView = new EditorView( + const newView = new ReactEditorView( { mount }, { ...editorProps, state, + DOMObserver: SelectionDOMObserver, } ); setView(newView); @@ -128,13 +170,10 @@ export function useEditorView( } }, [editorProps, mount, state, view]); - useLayoutEffect(() => { - view?.setProps(nonStateProps); - }, [view, nonStateProps]); - - useLayoutEffect(() => { - if (stateProp) view?.setProps({ state: stateProp }); - }, [view, stateProp]); + view?.setProps({ + ...editorProps, + ...("state" in editorProps && { state: editorProps.state }), + }); return view; } diff --git a/src/hooks/useNodeViewPortals.ts b/src/hooks/useNodeViewPortals.ts deleted file mode 100644 index 77878bc2..00000000 --- a/src/hooks/useNodeViewPortals.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ReactPortal, useCallback, useState } from "react"; -import { createPortal } from "react-dom"; - -import { RegisterElement } from "../nodeViews/createReactNodeViewConstructor.js"; - -/** - * Provides an array of React portals and a callback for registering - * new portals. - * - * The `registerPortal` callback is meant to be passed to - * `createNodeViewConstructor` as the `registerElement` argument. The - * `portals` array should be passed as children to the `ProseMirror` - * component. - */ -export function useNodeViewPortals() { - const [portals, setPortals] = useState([]); - - const registerPortal: RegisterElement = useCallback( - (child, container, key) => { - const portal = createPortal(child, container, key); - setPortals((oldPortals) => oldPortals.concat(portal)); - return () => { - setPortals((oldPortals) => oldPortals.filter((p) => p !== portal)); - }; - }, - [] - ); - - return { portals, registerPortal }; -} diff --git a/src/hooks/useNodeViews.ts b/src/hooks/useNodeViews.ts deleted file mode 100644 index a5b14f1e..00000000 --- a/src/hooks/useNodeViews.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useMemo } from "react"; - -import { - ReactNodeViewConstructor, - createReactNodeViewConstructor, -} from "../nodeViews/createReactNodeViewConstructor.js"; - -import { useNodeViewPortals } from "./useNodeViewPortals.js"; - -export function useNodeViews( - nodeViews: Record -) { - const { registerPortal, portals } = useNodeViewPortals(); - - 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: () => portals }; -} diff --git a/src/hooks/useReactEditorState.ts b/src/hooks/useReactEditorState.ts deleted file mode 100644 index 462fd2b5..00000000 --- a/src/hooks/useReactEditorState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useContext } from "react"; - -import { EditorViewContext } from "../contexts/EditorViewContext.js"; - -export function useReactEditorState() { - const { state } = useContext(EditorViewContext); - return state; -} diff --git a/src/hooks/useReactEditorView.ts b/src/hooks/useReactEditorView.ts deleted file mode 100644 index 56f1dfd2..00000000 --- a/src/hooks/useReactEditorView.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { EditorState, Transaction } from "prosemirror-state"; -import { useLayoutEffect, useState } from "react"; -import { unstable_batchedUpdates as batch } from "react-dom"; - -import { SelectionDOMObserver } from "../SelectionDOMObserver.js"; -import { - resetScrollPos, - storeScrollPos, -} from "../prosemirror-view/domcoords.js"; -import { DirectEditorProps, EditorView } from "../prosemirror-view/index.js"; -import { NodeViewDesc } from "../prosemirror-view/viewdesc.js"; - -import { useForceUpdate } from "./useForceUpdate.js"; - -class ReactEditorView extends EditorView { - init() { - this.domObserver.start(); - this.initInput(); - } - - updateStateInner(state: EditorState, _prevProps: DirectEditorProps) { - const previousState = this.state; - this.state = state; - - const scroll = - previousState.plugins != state.plugins && !previousState.doc.eq(state.doc) - ? "reset" - : // @ts-expect-error scrollToSelection is internal - state.scrollToSelection > - // @ts-expect-error scrollToSelection is internal - previousState.scrollToSelection - ? "to selection" - : "preserve"; - - const updateSel = !state.selection.eq(previousState.selection); - - const oldScrollPos = - scroll == "preserve" && - updateSel && - this.dom.style.overflowAnchor == null && - storeScrollPos(this); - - if (scroll == "reset") { - this.dom.scrollTop = 0; - } else if (scroll == "to selection") { - this.scrollToSelection(); - } else if (oldScrollPos) { - resetScrollPos(oldScrollPos); - } - } - - // @ts-expect-error We need this to be an accessor - set docView(_) { - // disallowed - } - - get docView() { - return this.dom.pmViewDesc as NodeViewDesc; - } -} - -function withBatchedUpdates( - fn: (this: This, ...args: T) => void -): (...args: T) => void { - return function (this: This, ...args: T) { - batch(() => { - fn.call(this, ...args); - }); - }; -} - -function defaultDispatchTransaction(this: EditorView, tr: Transaction) { - this.updateState(this.state.apply(tr)); -} - -type EditorStateProps = - | { - state: EditorState; - } - | { - defaultState: EditorState; - }; - -export type EditorProps = Omit & EditorStateProps; - -/** - * Enhances editor props so transactions dispatch in a batched update. - * - * It is important that changes to the editor get batched by React so that any - * components that dispatch transactions in effects do so after rendering with - * state changes from any previous transaction, so that they may use the latest - * state and not trigger nested transactions. - * - * TODO(OK-4006): We can remove this helper and pass the direct editor props to - * the Editor View unmodified after we upgrade to React 18, which batches every - * update by default. - */ -function withBatchedDispatch( - props: EditorProps, - forceUpdate: () => void -): EditorProps & { - dispatchTransaction: EditorView["dispatch"]; -} { - return { - ...props, - ...{ - dispatchTransaction: function dispatchTransaction( - this: EditorView, - tr: Transaction - ) { - const batchedDispatchTransaction = withBatchedUpdates( - props.dispatchTransaction ?? defaultDispatchTransaction - ); - batchedDispatchTransaction.call(this, tr); - forceUpdate(); - }, - }, - }; -} - -/** - * 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 `useEditorViewEvent` and - * `useEditorViewLayoutEffect` hooks. - */ -export function useReactEditorView( - mount: T | null, - props: EditorProps -): EditorView | null { - const [view, setView] = useState(null); - - const forceUpdate = useForceUpdate(); - - const editorProps = withBatchedDispatch(props, forceUpdate); - - const state = - "defaultState" in editorProps - ? editorProps.defaultState - : editorProps.state; - - useLayoutEffect(() => { - if (view && view.dom !== mount) { - setView(null); - } - - if (!mount) { - return; - } - - if (!view) { - const newView = new ReactEditorView( - { mount }, - { - ...editorProps, - state, - DOMObserver: SelectionDOMObserver, - } - ); - setView(newView); - return; - } - }, [editorProps, mount, state, view]); - - view?.setProps({ - ...editorProps, - ...("state" in editorProps && { state: editorProps.state }), - }); - - return view; -} diff --git a/src/hooks/useReactKeys.ts b/src/hooks/useReactKeys.ts index 9ea11ac5..fd6392da 100644 --- a/src/hooks/useReactKeys.ts +++ b/src/hooks/useReactKeys.ts @@ -1,8 +1,8 @@ import { reactKeysPluginKey } from "../plugins/reactKeys.js"; -import { useReactEditorState } from "./useReactEditorState.js"; +import { useEditorState } from "./useEditorState.js"; export function useReactKeys() { - const state = useReactEditorState(); + const state = useEditorState(); return state && reactKeysPluginKey.getState(state); } diff --git a/src/hooks/useView.ts b/src/hooks/useView.ts deleted file mode 100644 index 03f1b693..00000000 --- a/src/hooks/useView.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from "react"; - -import { EditorViewContext } from "../contexts/EditorViewContext.js"; -import { useLayoutGroupEffect } from "../contexts/LayoutGroup.js"; -import { EditorView } from "../prosemirror-view/index.js"; - -export function useView(effect: (view: EditorView) => void) { - const { view } = useContext(EditorViewContext); - useLayoutGroupEffect(() => { - if (!view) return; - return effect(view); - }); -} diff --git a/src/hooks/useViewPlugins.ts b/src/hooks/useViewPlugins.ts index 22713b99..42d0705a 100644 --- a/src/hooks/useViewPlugins.ts +++ b/src/hooks/useViewPlugins.ts @@ -1,4 +1,4 @@ -import { Plugin, PluginView } from "prosemirror-state"; +import { EditorState, Plugin, PluginView } from "prosemirror-state"; import { useLayoutEffect, useRef } from "react"; import { EditorView } from "../prosemirror-view/index.js"; @@ -7,9 +7,10 @@ import { usePrevious } from "./usePrev.js"; export function usePluginViews( view: EditorView | null, + state: EditorState | null, plugins: readonly Plugin[] ) { - const prevState = usePrevious(view?.state); + const prevState = usePrevious(state); const pluginViews = useRef([]); useLayoutEffect(() => { @@ -25,16 +26,18 @@ export function usePluginViews( if (!view) return; pluginViews.current = []; - for (const plugin of plugins) { + for (const plugin of [...plugins, ...view.state.plugins]) { // @ts-expect-error Side effect of the fork const pluginView = plugin.spec.view?.(view); if (pluginView) pluginViews.current.push(pluginView); } + }, [plugins, view]); + useLayoutEffect(() => { return () => { for (const pluginView of pluginViews.current) { if (pluginView.destroy) pluginView.destroy(); } }; - }, [plugins, view]); + }, []); } diff --git a/src/index.ts b/src/index.ts index 9825d80f..8806e917 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ "use client"; export { ProseMirror } from "./components/ProseMirror.js"; -export { EditorView } from "./components/EditorView.js"; export { EditorProvider } from "./contexts/EditorContext.js"; export { LayoutGroup, useLayoutGroupEffect } from "./contexts/LayoutGroup.js"; export { useEditorEffect } from "./hooks/useEditorEffect.js"; @@ -9,11 +8,7 @@ export { useEditorEventCallback } from "./hooks/useEditorEventCallback.js"; export { useEditorEventListener } from "./hooks/useEditorEventListener.js"; export { useEditorState } from "./hooks/useEditorState.js"; export { useEditorView } from "./hooks/useEditorView.js"; -export { useView } from "./hooks/useView.js"; -export { useNodeViews } from "./hooks/useNodeViews.js"; +export { reactKeys } from "./plugins/reactKeys.js"; +export { widget } from "./decorations/ReactWidgetType.js"; -export type { - NodeViewComponentProps, - ReactNodeView, - ReactNodeViewConstructor, -} from "./nodeViews/createReactNodeViewConstructor.js"; +export type { NodeViewComponentProps } from "./components/NodeViewComponentProps.js"; diff --git a/src/nodeViews/createReactNodeViewConstructor.tsx b/src/nodeViews/createReactNodeViewConstructor.tsx deleted file mode 100644 index 0c140d5e..00000000 --- a/src/nodeViews/createReactNodeViewConstructor.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import type { Node } from "prosemirror-model"; -import type { - Decoration, - DecorationSource, - EditorView, - NodeView, - NodeViewConstructor, -} from "prosemirror-view"; -import React, { - Dispatch, - SetStateAction, - forwardRef, - useImperativeHandle, - useState, -} from "react"; -import type { ComponentType, ReactNode } from "react"; -import { createPortal } from "react-dom"; - -import { phrasingContentTags } from "./phrasingContentTags.js"; - -export interface NodeViewComponentProps { - decorations: readonly Decoration[]; - getPos: () => number; - node: Node; - children: ReactNode; - isSelected: boolean; -} - -interface NodeViewWrapperState { - node: Node; - decorations: readonly Decoration[]; - isSelected: boolean; -} - -interface NodeViewWrapperProps { - editorView: EditorView; - getPos: () => number; - initialState: NodeViewWrapperState; -} - -interface NodeViewWrapperRef { - node: Node; - contentDOMWrapper: HTMLElement | null; - setNode: Dispatch>; - setDecorations: Dispatch>; - setIsSelected: Dispatch>; -} - -export type UnregisterElement = () => void; - -export type RegisterElement = ( - ...args: Parameters -) => 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 ReactNodeViewConstructor = ( - ...args: Parameters -) => ReactNodeView; - -/** - * Factory function for creating nodeViewConstructors that - * render as React components. - * - * `ReactComponent` 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 - * directly to the EditorView should use the - * `useEditorViewEvent` and `useEditorViewLayoutEffect` - * 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. - */ -export function createReactNodeViewConstructor( - reactNodeViewConstructor: ReactNodeViewConstructor, - registerElement: RegisterElement -) { - function nodeViewConstructor( - node: Node, - editorView: EditorView, - getPos: () => number, - decorations: readonly Decoration[], - innerDecorations: DecorationSource - ): NodeView { - const reactNodeView = reactNodeViewConstructor( - node, - editorView, - getPos, - decorations, - innerDecorations - ); - - let componentRef: NodeViewWrapperRef | null = null; - - const { dom, contentDOM, component: ReactComponent } = reactNodeView; - - // Use a span if the provided contentDOM is in the "phrasing" content - // category. Otherwise use a div. This is our best attempt at not - // breaking the intended content model, for now. - // - // https://developer.mozilla.org/en-US/docs/Web/HTML/Content_categories#phrasing_content - const ContentDOMWrapper = - contentDOM && - (phrasingContentTags.includes(contentDOM.tagName.toLocaleLowerCase()) - ? "span" - : "div"); - /** - * Wrapper component to provide some imperative handles for updating - * and re-rendering its child. Takes and renders an arbitrary ElementType - * that expects NodeViewComponentProps as props. - */ - const NodeViewWrapper = forwardRef< - NodeViewWrapperRef, - NodeViewWrapperProps - >(function NodeViewWrapper( - { initialState, getPos }: NodeViewWrapperProps, - ref - ) { - const [node, setNode] = useState(initialState.node); - const [decorations, setDecorations] = useState( - initialState.decorations - ); - const [isSelected, setIsSelected] = useState( - initialState.isSelected - ); - - const [contentDOMWrapper, setContentDOMWrapper] = - useState(null); - - useImperativeHandle( - ref, - () => ({ - node, - contentDOMWrapper: contentDOMWrapper, - setNode, - setDecorations, - setIsSelected, - }), - [node, contentDOMWrapper] - ); - - return ( - - {ContentDOMWrapper && ( - { - setContentDOMWrapper(nextContentDOMWrapper); - }} - /> - )} - - ); - }); - - NodeViewWrapper.displayName = `NodeView(${ - ReactComponent.displayName ?? ReactComponent.name - })`; - - const element = ( - { - componentRef = c; - - if (!componentRef || componentRef.node.isLeaf) return; - - const contentDOMWrapper = componentRef.contentDOMWrapper; - if ( - !contentDOMWrapper || - !(contentDOMWrapper instanceof HTMLElement) - ) { - return; - } - - // We always set contentDOM when !node.isLeaf, which is checked above - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - contentDOMWrapper.appendChild(contentDOM!); - - // Synchronize the ProseMirror selection to the DOM, because mounting the - // component changes the DOM outside of a ProseMirror update. - const { node } = componentRef; - const pos = getPos(); - const end = pos + node.nodeSize; - const { from, to } = editorView.state.selection; - if (editorView.hasFocus() && pos < from && to < end) { - // This call seems like it should be a no-op, given the editor already has - // focus, but it causes ProseMirror to synchronize the DOM selection with - // its state again, placing the DOM selection in a reasonable place within - // the node. - editorView.focus(); - } - }} - /> - ); - - const unregisterElement = registerElement( - element, - dom as HTMLElement, - Math.floor(Math.random() * 0xffffff).toString(16) - ); - - return { - ignoreMutation(record: MutationRecord) { - return !contentDOM?.contains(record.target); - }, - ...reactNodeView, - selectNode() { - componentRef?.setIsSelected(true); - reactNodeView.selectNode?.(); - }, - deselectNode() { - componentRef?.setIsSelected(false); - reactNodeView.deselectNode?.(); - }, - update( - node: Node, - decorations: readonly Decoration[], - innerDecorations: DecorationSource - ) { - if ( - reactNodeView.update?.(node, decorations, innerDecorations) === false - ) { - return false; - } - if (node.type === componentRef?.node.type) { - componentRef?.setNode(node); - componentRef?.setDecorations(decorations); - return true; - } - return false; - }, - destroy() { - unregisterElement(); - reactNodeView.destroy?.(); - }, - }; - } - - return nodeViewConstructor; -} diff --git a/src/nodeViews/phrasingContentTags.ts b/src/nodeViews/phrasingContentTags.ts deleted file mode 100644 index d67223f8..00000000 --- a/src/nodeViews/phrasingContentTags.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const phrasingContentTags = [ - "abbr", - "audio", - "b", - "bdo", - "br", - "button", - "canvas", - "cite", - "code", - "data", - "datalist", - "dfn", - "em", - "embed", - "i", - "iframe", - "img", - "input", - "kbd", - "keygen", - "label", - "mark", - "math", - "meter", - "noscript", - "object", - "output", - "picture", - "progress", - "q", - "ruby", - "s", - "samp", - "script", - "select", - "small", - "span", - "strong", - "sub", - "sup", - "svg", - "textarea", - "time", - "u", - "var", - "video", - "wbr", -]; diff --git a/src/plugins/__tests__/react.test.ts b/src/plugins/__tests__/reactKeys.test.ts similarity index 79% rename from src/plugins/__tests__/react.test.ts rename to src/plugins/__tests__/reactKeys.test.ts index 617ab433..ebd90191 100644 --- a/src/plugins/__tests__/react.test.ts +++ b/src/plugins/__tests__/reactKeys.test.ts @@ -2,7 +2,7 @@ import { Schema } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; -import { react, reactPluginKey } from "../react.js"; +import { reactKeys, reactKeysPluginKey } from "../reactKeys.js"; const schema = new Schema({ nodes: { @@ -22,10 +22,10 @@ describe("reactNodeViewPlugin", () => { schema.nodes.paragraph.create(), schema.nodes.paragraph.create(), ]), - plugins: [react()], + plugins: [reactKeys()], }); - const pluginState = reactPluginKey.getState(editorState)!; + const pluginState = reactKeysPluginKey.getState(editorState)!; expect(pluginState.posToKey.size).toBe(3); }); @@ -36,15 +36,15 @@ describe("reactNodeViewPlugin", () => { schema.nodes.paragraph.create(), schema.nodes.paragraph.create(), ]), - plugins: [react()], + plugins: [reactKeys()], }); - const initialPluginState = reactPluginKey.getState(initialEditorState)!; + const initialPluginState = reactKeysPluginKey.getState(initialEditorState)!; const nextEditorState = initialEditorState.apply( initialEditorState.tr.insertText("Hello, world!", 1) ); - const nextPluginState = reactPluginKey.getState(nextEditorState)!; + const nextPluginState = reactKeysPluginKey.getState(nextEditorState)!; expect(Array.from(initialPluginState.keyToPos.keys())).toEqual( Array.from(nextPluginState.keyToPos.keys()) @@ -58,15 +58,15 @@ describe("reactNodeViewPlugin", () => { schema.nodes.paragraph.create(), schema.nodes.paragraph.create(), ]), - plugins: [react()], + plugins: [reactKeys()], }); - const initialPluginState = reactPluginKey.getState(initialEditorState)!; + const initialPluginState = reactKeysPluginKey.getState(initialEditorState)!; const nextEditorState = initialEditorState.apply( initialEditorState.tr.insert(0, schema.nodes.list.createAndFill()!) ); - const nextPluginState = reactPluginKey.getState(nextEditorState)!; + const nextPluginState = reactKeysPluginKey.getState(nextEditorState)!; // Adds new keys for new nodes expect(nextPluginState.keyToPos.size).toBe(5); diff --git a/src/plugins/componentEventListeners.ts b/src/plugins/componentEventListeners.ts index 028f4bb9..c9ec35c9 100644 --- a/src/plugins/componentEventListeners.ts +++ b/src/plugins/componentEventListeners.ts @@ -11,7 +11,7 @@ export type EventHandler< ) => boolean | void; export function componentEventListeners( - eventHandlerRegistry: Map> + eventHandlerRegistry: Map> ) { const domEventHandlers: Record = {}; diff --git a/src/plugins/react.ts b/src/plugins/react.ts deleted file mode 100644 index 8e457218..00000000 --- a/src/plugins/react.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Plugin, PluginKey } from "prosemirror-state"; - -/** - * This is a stand-in for the doc node itself, which doesn't have a - * unique position to map to. - */ -export const ROOT_NODE_KEY = Symbol("portal registry root key"); - -export type NodeKey = string | typeof ROOT_NODE_KEY; - -/** - * Identifies a node view constructor as having been created - * by @nytimes/react-prosemirror - */ -export const REACT_NODE_VIEW = Symbol("react node view"); - -export function createNodeKey() { - return Math.floor(Math.random() * 0xffffff).toString(16); -} - -export const reactPluginKey = new PluginKey("@nytimes/react-prosemirror/react"); - -/** - * Tracks a unique key for each (non-text) node in the - * document, identified by its current position. Keys are - * (mostly) stable across transaction applications. The - * key for a given node can be accessed by that node's - * current position in the document, and vice versa. - */ -export function react() { - return new Plugin({ - key: reactPluginKey, - state: { - init(_, state) { - const next = { - posToKey: new Map(), - keyToPos: new Map(), - }; - state.doc.descendants((node, pos) => { - if (node.isText) return false; - - const key = createNodeKey(); - - next.posToKey.set(pos, key); - next.keyToPos.set(key, pos); - return true; - }); - return next; - }, - /** - * Keeps node keys (mostly) stable across transactions. - * - * To accomplish this, we map each node position backwards - * through the transaction to identify its previous position, - * and thereby retrieve its previous key. - */ - apply(tr, value, _, newState) { - if (!tr.docChanged) return value; - - const next = { - posToKey: new Map(), - keyToPos: new Map(), - }; - const nextKeys = new Set(); - newState.doc.descendants((node, pos) => { - if (node.isText) return false; - - const prevPos = tr.mapping.invert().map(pos); - const prevKey = value.posToKey.get(prevPos) ?? createNodeKey(); - // If this transaction adds a new node, there will be multiple - // nodes that map back to the same initial position. In this case, - // create new keys for new nodes. - const key = nextKeys.has(prevKey) ? createNodeKey() : prevKey; - next.posToKey.set(pos, key); - next.keyToPos.set(key, pos); - nextKeys.add(key); - return true; - }); - return next; - }, - }, - }); -} diff --git a/src/plugins/reactKeys.ts b/src/plugins/reactKeys.ts index a93166d2..9b547100 100644 --- a/src/plugins/reactKeys.ts +++ b/src/plugins/reactKeys.ts @@ -1,20 +1,12 @@ import { Plugin, PluginKey } from "prosemirror-state"; -/** - * This is a stand-in for the doc node itself, which doesn't have a - * unique position to map to. - */ -export const ROOT_NODE_KEY = Symbol("portal registry root key"); - -export type NodeKey = string | typeof ROOT_NODE_KEY; - export function createNodeKey() { return Math.floor(Math.random() * 0xffffff).toString(16); } export const reactKeysPluginKey = new PluginKey<{ posToKey: Map; - keyToPos: Map; + keyToPos: Map; }>("@nytimes/react-prosemirror/reactKeys"); /** @@ -31,7 +23,7 @@ export function reactKeys() { init(_, state) { const next = { posToKey: new Map(), - keyToPos: new Map(), + keyToPos: new Map(), }; state.doc.descendants((_, pos) => { const key = createNodeKey(); diff --git a/src/prosemirror-view/README.md b/src/prosemirror-view/README.md index 5bdc4c2e..ec8aea62 100644 --- a/src/prosemirror-view/README.md +++ b/src/prosemirror-view/README.md @@ -1,9 +1,5 @@ # ProseMirror Internals -To keep React ProseMirror's implementation as close as possible to ProseMirror -View's, we intentionally copy some files directly from the ProseMirror view -repo. This directory contains these files, which should be unmodified if -possible. - -Our linting and formatting are disable for this directory. This should make it -easier to incorporate upstream changes in the future if necessary. +This is a nearly direct clone of +https://github.com/prosemirror/prosemirror-view. Any modifications have been +marked with a comment starting with the string `$$FORK`. diff --git a/src/testing/editorViewTestHelpers.tsx b/src/testing/editorViewTestHelpers.tsx index 766b4dfa..fbf49bda 100644 --- a/src/testing/editorViewTestHelpers.tsx +++ b/src/testing/editorViewTestHelpers.tsx @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { expect } from "@jest/globals"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MatcherFunction } from "expect"; import { Node } from "prosemirror-model"; import { EditorState, TextSelection } from "prosemirror-state"; import { doc, eq, schema } from "prosemirror-test-builder"; import React from "react"; -import { EditorProps, EditorView } from "../components/EditorView.js"; -import { useView } from "../hooks/useView.js"; +import { EditorProps, ProseMirror } from "../components/ProseMirror.js"; +import { useEditorEffect } from "../hooks/useEditorEffect.js"; import { reactKeys } from "../plugins/reactKeys.js"; import { EditorView as EditorViewT } from "../prosemirror-view/index.js"; @@ -52,6 +52,7 @@ export function tempEditor({ >): { view: EditorViewT; rerender: (props: Omit) => void; + unmount: () => void; } { startDoc = startDoc ?? doc(); const state = EditorState.create({ @@ -68,28 +69,34 @@ export function tempEditor({ let view: any; function Test() { - useView((v) => { + useEditorEffect((v) => { view = v; }); return null; } - const { rerender } = render( - + const { rerender, unmount } = render( + - + ); function rerenderEditor({ ...newProps }: Omit) { rerender( - + - + ); } - return { rerender: rerenderEditor, view }; + return { rerender: rerenderEditor, unmount, view }; +} + +export async function findTextNode(_: HTMLElement, text: string) { + const parent = await screen.findByText(text); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return parent.firstChild!; }