Skip to content

Commit

Permalink
Implement a plugin that tracks node positions. (#33)
Browse files Browse the repository at this point in the history
The plugin provides a mechanism for node views to uniquely identify
themselves via a key, as well as determine the position of other node
views from their respective keys. Keys remain mostly stable across
changes to the document.

This will enable other improvements, like a safer mechanism for
accessing node position from within NodeViews and proper React Context
hierarchies across node view components.
  • Loading branch information
smoores-dev authored Jun 1, 2023
1 parent f997b0c commit ae94a1a
Show file tree
Hide file tree
Showing 9 changed files with 214 additions and 47 deletions.
2 changes: 2 additions & 0 deletions .yarn/versions/f820acd4.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases:
"@nytimes/react-prosemirror": patch
4 changes: 2 additions & 2 deletions src/components/ProseMirrorInner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useMemo } from "react";
import type { ReactNode } from "react";

import { EditorProvider } from "../contexts/EditorContext.js";
import { useComponentEventListenersPlugin } from "../hooks/useComponentEventListenersPlugin.js";
import { useComponentEventListeners } from "../hooks/useComponentEventListeners.js";
import { useEditorView } from "../hooks/useEditorView.js";

export type ProseMirrorProps = DirectEditorProps & {
Expand Down Expand Up @@ -43,7 +43,7 @@ export function ProseMirrorInner({
componentEventListenersPlugin,
registerEventListener,
unregisterEventListener,
} = useComponentEventListenersPlugin();
} = useComponentEventListeners();

const plugins = useMemo(
() => [...(editorProps.plugins ?? []), componentEventListenersPlugin],
Expand Down
2 changes: 1 addition & 1 deletion src/contexts/EditorContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { EditorState } from "prosemirror-state";
import type { DOMEventMap, EditorView } from "prosemirror-view";
import { createContext } from "react";

import type { EventHandler } from "../hooks/useComponentEventListenersPlugin.js";
import type { EventHandler } from "../plugins/componentEventListeners";

interface EditorContextValue {
editorView: EditorView | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,10 @@
import { Plugin, PluginKey } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import type { DOMEventMap } from "prosemirror-view";
import { useCallback, useMemo, useState } from "react";
import { unstable_batchedUpdates as batch } from "react-dom";

export type EventHandler<
EventType extends keyof DOMEventMap = keyof DOMEventMap
> = (
this: Plugin,
view: EditorView,
event: DOMEventMap[EventType]
) => boolean | void;

function createComponentEventListenersPlugin(
eventHandlerRegistry: Map<keyof DOMEventMap, Set<EventHandler>>
) {
const domEventHandlers: Record<keyof DOMEventMap, EventHandler> = {};

for (const [eventType, handlers] of eventHandlerRegistry.entries()) {
function handleEvent(this: Plugin, view: EditorView, event: Event) {
for (const handler of handlers) {
let handled = false;
batch(() => {
handled = !!handler.call(this, view, event);
});
if (handled || event.defaultPrevented) return true;
}
return false;
}

domEventHandlers[eventType] = handleEvent;
}

const plugin = new Plugin({
key: new PluginKey("componentEventListeners"),
props: {
handleDOMEvents: domEventHandlers,
},
});

return plugin;
}
import {
EventHandler,
componentEventListeners,
} from "../plugins/componentEventListeners.js";

/**
* Produces a plugin that can be used with ProseMirror to handle DOM
Expand Down Expand Up @@ -68,7 +32,7 @@ function createComponentEventListenersPlugin(
* To accomplish this, we shallowly clone the registry whenever a new event
* type is registered.
*/
export function useComponentEventListenersPlugin() {
export function useComponentEventListeners() {
const [registry, setRegistry] = useState(
new Map<keyof DOMEventMap, Set<EventHandler>>()
);
Expand All @@ -94,7 +58,7 @@ export function useComponentEventListenersPlugin() {
);

const componentEventListenersPlugin = useMemo(
() => createComponentEventListenersPlugin(registry),
() => componentEventListeners(registry),
[registry]
);

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useEditorEventListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { DOMEventMap, EditorView } from "prosemirror-view";
import { useCallback, useContext, useRef } from "react";

import { EditorContext } from "../contexts/EditorContext.js";
import type { EventHandler } from "../plugins/componentEventListeners.js";

import type { EventHandler } from "./useComponentEventListenersPlugin.js";
import { useEditorEffect } from "./useEditorEffect.js";

/**
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export { useEditorEventListener } from "./hooks/useEditorEventListener.js";
export { useEditorState } from "./hooks/useEditorState.js";
export { useEditorView } from "./hooks/useEditorView.js";
export { useNodeViews } from "./hooks/useNodeViews.js";
export { useComponentEventListenersPlugin } from "./hooks/useComponentEventListenersPlugin.js";

export type {
NodeViewComponentProps,
Expand Down
78 changes: 78 additions & 0 deletions src/plugins/__tests__/react.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Schema } from "prosemirror-model";
import { EditorState } from "prosemirror-state";

import { react, reactPluginKey } from "../react.js";

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

describe("reactNodeViewPlugin", () => {
it("should create a unique key for each node", () => {
const editorState = EditorState.create({
doc: schema.topNodeType.create(null, [
schema.nodes.paragraph.create(),
schema.nodes.paragraph.create(),
schema.nodes.paragraph.create(),
]),
plugins: [react()],
});

const pluginState = reactPluginKey.getState(editorState)!;
expect(pluginState.posToKey.size).toBe(3);
});

it("should maintain key stability when possible", () => {
const initialEditorState = EditorState.create({
doc: schema.topNodeType.create(null, [
schema.nodes.paragraph.create(),
schema.nodes.paragraph.create(),
schema.nodes.paragraph.create(),
]),
plugins: [react()],
});

const initialPluginState = reactPluginKey.getState(initialEditorState)!;

const nextEditorState = initialEditorState.apply(
initialEditorState.tr.insertText("Hello, world!", 1)
);
const nextPluginState = reactPluginKey.getState(nextEditorState)!;

expect(Array.from(initialPluginState.keyToPos.keys())).toEqual(
Array.from(nextPluginState.keyToPos.keys())
);
});

it("should create unique keys for new nodes", () => {
const initialEditorState = EditorState.create({
doc: schema.topNodeType.create(null, [
schema.nodes.paragraph.create(),
schema.nodes.paragraph.create(),
schema.nodes.paragraph.create(),
]),
plugins: [react()],
});

const initialPluginState = reactPluginKey.getState(initialEditorState)!;

const nextEditorState = initialEditorState.apply(
initialEditorState.tr.insert(0, schema.nodes.list.createAndFill()!)
);
const nextPluginState = reactPluginKey.getState(nextEditorState)!;

// Adds new keys for new nodes
expect(nextPluginState.keyToPos.size).toBe(5);
// Maintains keys for previous nodes that are still there
Array.from(initialPluginState.keyToPos.keys()).forEach((key) => {
expect(Array.from(nextPluginState.keyToPos.keys())).toContain(key);
});
});
});
41 changes: 41 additions & 0 deletions src/plugins/componentEventListeners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Plugin, PluginKey } from "prosemirror-state";
import { DOMEventMap, EditorView } from "prosemirror-view";
import { unstable_batchedUpdates as batch } from "react-dom";

export type EventHandler<
EventType extends keyof DOMEventMap = keyof DOMEventMap
> = (
this: Plugin,
view: EditorView,
event: DOMEventMap[EventType]
) => boolean | void;

export function componentEventListeners(
eventHandlerRegistry: Map<keyof DOMEventMap, Set<EventHandler>>
) {
const domEventHandlers: Record<keyof DOMEventMap, EventHandler> = {};

for (const [eventType, handlers] of eventHandlerRegistry.entries()) {
function handleEvent(this: Plugin, view: EditorView, event: Event) {
for (const handler of handlers) {
let handled = false;
batch(() => {
handled = !!handler.call(this, view, event);
});
if (handled || event.defaultPrevented) return true;
}
return false;
}

domEventHandlers[eventType] = handleEvent;
}

const plugin = new Plugin({
key: new PluginKey("@nytimes/react-prosemirror/componentEventListeners"),
props: {
handleDOMEvents: domEventHandlers,
},
});

return plugin;
}
83 changes: 83 additions & 0 deletions src/plugins/react.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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<number, string>(),
keyToPos: new Map<NodeKey, number>(),
};
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<number, string>(),
keyToPos: new Map<string, number>(),
};
const nextKeys = new Set<string>();
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;
},
},
});
}

0 comments on commit ae94a1a

Please sign in to comment.