-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement a plugin that tracks node positions. (#33)
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
1 parent
f997b0c
commit ae94a1a
Showing
9 changed files
with
214 additions
and
47 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
releases: | ||
"@nytimes/react-prosemirror": patch |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}, | ||
}, | ||
}); | ||
} |