Skip to content

Commit

Permalink
Replace the EditorView entirely when it would redraw the document
Browse files Browse the repository at this point in the history
  • Loading branch information
smoores-dev committed Sep 17, 2024
1 parent e194d3e commit 0774922
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 34 deletions.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React-ProseMirror Demo</title>
<script type="module" crossorigin src="/react-prosemirror/assets/index-C72Cerdq.js"></script>
<script type="module" crossorigin src="/react-prosemirror/assets/index-COURB271.js"></script>
<link rel="stylesheet" crossorigin href="/react-prosemirror/assets/index-DAGU9WLy.css">
</head>
<body>
Expand Down
9 changes: 1 addition & 8 deletions src/components/ProseMirror.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@ import { EditorContext } from "../contexts/EditorContext.js";
import { NodeViewContext } from "../contexts/NodeViewContext.js";
import { computeDocDeco } from "../decorations/computeDocDeco.js";
import { viewDecorations } from "../decorations/viewDecorations.js";
import {
ReactEditorView,
UseEditorOptions,
useEditor,
} from "../hooks/useEditor.js";
import { usePendingViewEffects } from "../hooks/usePendingViewEffects.js";
import { UseEditorOptions, useEditor } from "../hooks/useEditor.js";

import { LayoutGroup } from "./LayoutGroup.js";
import { NodeViewComponentProps } from "./NodeViewComponentProps.js";
Expand Down Expand Up @@ -53,8 +48,6 @@ function ProseMirrorInner({
nodeViews: customNodeViews,
});

usePendingViewEffects(editor.view as ReactEditorView | null);

const innerDecos = editor.view
? viewDecorations(editor.view, editor.cursorWrapper)
: (DecorationSet.empty as unknown as DecorationSet);
Expand Down
92 changes: 90 additions & 2 deletions src/components/__tests__/ProseMirror.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,22 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { act, render, screen } from "@testing-library/react";
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { doc, em, hr, li, p, strong, ul } from "prosemirror-test-builder";
import { EditorState, Plugin } from "prosemirror-state";
import {
doc,
em,
hr,
li,
p,
schema,
strong,
ul,
} from "prosemirror-test-builder";
import { EditorView } from "prosemirror-view";
import React, { forwardRef, useEffect, useState } from "react";

import { useEditorEffect } from "../../hooks/useEditorEffect.js";
import { reactKeys } from "../../plugins/reactKeys.js";
import { tempEditor } from "../../testing/editorViewTestHelpers.js";
import { NodeViewComponentProps } from "../NodeViewComponentProps.js";
import { ProseMirror } from "../ProseMirror.js";
Expand Down Expand Up @@ -259,4 +271,80 @@ describe("ProseMirror", () => {
view.dispatch(view.state.tr.insertText("x"));
expect(view).toBe(thisBinding);
});

it("replaces the EditorView when ProseMirror would redraw", async () => {
const viewPlugin = () =>
new Plugin({
props: {
nodeViews: {
horizontal_rule() {
const dom = document.createElement("hr");
return {
dom,
};
},
},
},
});

const startDoc = doc(p());
const firstState = EditorState.create({
doc: startDoc,
schema,
plugins: [viewPlugin(), reactKeys()],
});

let firstView: EditorView | null = null;
let secondView: EditorView | null = null;

function Test() {
useEditorEffect((v) => {
if (firstView === null) {
firstView = v;
} else {
secondView = v;
}
});

return null;
}

const Paragraph = forwardRef<HTMLDivElement | null, NodeViewComponentProps>(
function Paragraph({ nodeProps, children, ...props }, ref) {
return (
<p ref={ref} data-testid="node-view" {...props}>
{children}
</p>
);
}
);

const { rerender } = render(
<ProseMirror state={firstState} nodeViews={{ paragraph: Paragraph }}>
<Test></Test>
<ProseMirrorDoc />
</ProseMirror>
);

expect(() => screen.getByTestId("node-view")).not.toThrow();

const secondState = EditorState.create({
doc: startDoc,
schema,
plugins: [viewPlugin(), reactKeys()],
});

rerender(
<ProseMirror state={secondState} nodeViews={{ paragraph: Paragraph }}>
<Test></Test>
<ProseMirrorDoc />
</ProseMirror>
);

expect(() => screen.getByTestId("node-view")).not.toThrow();

expect(firstView).not.toBeNull();
expect(secondView).not.toBeNull();
expect(firstView === secondView).toBeFalsy();
});
});
87 changes: 78 additions & 9 deletions src/hooks/useEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,10 @@ import {
DirectEditorProps,
EditorProps,
EditorView,
MarkViewConstructor,
NodeViewConstructor,
} from "prosemirror-view";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";

import { DOMNode } from "../dom.js";
Expand All @@ -25,6 +20,34 @@ import { NodeViewDesc } from "../viewdesc.js";
import { useComponentEventListeners } from "./useComponentEventListeners.js";
import { useForceUpdate } from "./useForceUpdate.js";

type NodeViewSet = {
[name: string]: NodeViewConstructor | MarkViewConstructor;
};

function buildNodeViews(view: ReactEditorView) {
const result: NodeViewSet = Object.create(null);
function add(obj: NodeViewSet) {
for (const prop in obj)
if (!Object.prototype.hasOwnProperty.call(result, prop))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
result[prop] = obj[prop]!;
}
view.someProp("nodeViews", add);
view.someProp("markViews", add);
return result;
}

function changedNodeViews(a: NodeViewSet, b: NodeViewSet) {
let nA = 0,
nB = 0;
for (const prop in a) {
if (a[prop] != b[prop]) return true;
nA++;
}
for (const _ in b) nB++;
return nA != nB;
}

// @ts-expect-error We're making use of knowledge of internal methods here
export class ReactEditorView extends EditorView {
private shouldUpdatePluginViews = false;
Expand Down Expand Up @@ -76,6 +99,33 @@ export class ReactEditorView extends EditorView {
this.docView = props.docView;
}

/**
* Whether the EditorView's updateStateInner method thinks that the
* docView needs to be blown away and redrawn.
*
* @privateremarks
*
* When ProseMirror View detects that the EditorState has been reconfigured
* to provide new custom node views, it calls an internal function that
* we can't override in order to recreate the entire editor DOM.
*
* This property mimics that check, so that we can replace the EditorView
* with another of our own, preventing ProseMirror View from taking over
* DOM management responsibility.
*/
get needsRedraw() {
if (
this.oldProps.state.plugins === this._props.state.plugins &&
this._props.plugins === this.oldProps.plugins
) {
return false;
}

const newNodeViews = buildNodeViews(this);
// @ts-expect-error Internal property
return changedNodeViews(this.nodeViews, newNodeViews);
}

/**
* Like setProps, but without executing any side effects.
* Safe to use in a component render method.
Expand Down Expand Up @@ -239,7 +289,7 @@ export function useEditor<T extends HTMLElement = HTMLElement>(
docView: docViewDescRef.current,
};

useEffect(() => {
useLayoutEffect(() => {
return () => {
view?.destroy();
};
Expand All @@ -266,6 +316,25 @@ export function useEditor<T extends HTMLElement = HTMLElement>(
}
});

// This rule is concerned about infinite updates due to the
// call to setView. These calls are deliberately conditional,
// so this is not a concern.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
// If ProseMirror View is about to redraw the entire document's
// DOM, clear the EditorView and reconstruct another, instead.
// This only happens when a newly instantiated EditorState has
// been provided.
if (view?.needsRedraw) {
setView(null);
return;
} else {
// @ts-expect-error Internal property - domObserver
view?.domObserver.selectionToDOM();
view?.runPendingEffects();
}
});

view?.pureSetProps(directEditorProps);

return useMemo(
Expand Down
11 changes: 0 additions & 11 deletions src/hooks/usePendingViewEffects.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/testing/editorViewTestHelpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function tempEditor({
function Test() {
useEditorEffect((v) => {
view = v;
}, []);
});

return null;
}
Expand All @@ -98,6 +98,7 @@ export function tempEditor({
<ProseMirrorDoc />
</ProseMirror>
);
return view;
}

return {
Expand Down

0 comments on commit 0774922

Please sign in to comment.