packages/remirror__extension-react-component/src/react-node-view.tsx
import React, { ComponentType, FunctionComponent, RefCallback } from 'react';
import {
Decoration,
EditorView,
entries,
ErrorConstant,
GetFixed,
invariant,
isDomNode,
isElementDomNode,
isFunction,
isNodeOfType,
isPlainObject,
isString,
kebabCase,
NodeView,
NodeWithAttributes,
pascalCase,
ProsemirrorAttributes,
ProsemirrorNode,
SELECTED_NODE_CLASS_NAME,
} from '@remirror/core';
import { DOMSerializer } from '@remirror/pm/model';
import { NodeSelection } from '@remirror/pm/state';
import type {
CreateNodeViewProps,
GetPosition,
NodeViewComponentProps,
ReactComponentOptions,
ReactNodeViewProps,
} from './node-view-types';
import type { PortalContainer } from './portals';
/**
* This is the node view rapper that makes
*/
export class ReactNodeView implements NodeView {
/**
* A shorthand method for creating the `ReactNodeView`.
*/
static create(
props: CreateNodeViewProps,
): (node: NodeWithAttributes, view: EditorView, getPosition: GetPosition) => NodeView {
const { portalContainer, ReactComponent, options } = props;
return (node: NodeWithAttributes, view: EditorView, getPosition: GetPosition) =>
new ReactNodeView({
options,
node,
view,
getPosition,
portalContainer,
ReactComponent,
});
}
/**
* The `ProsemirrorNode` that this nodeView is responsible for rendering.
*/
#node: NodeWithAttributes;
/**
* The decorations in the most recent update.
*/
#decorations: readonly Decoration[] = [];
/**
* The editor this nodeView belongs to.
*/
#view: EditorView;
/**
* A container and event dispatcher which keeps track of all dom elements that
* hold node views
*/
readonly #portalContainer: PortalContainer;
/**
* The extension responsible for creating this NodeView.
*/
readonly #Component: ComponentType<NodeViewComponentProps>;
/**
* Method for retrieving the position of the current nodeView
*/
readonly #getPosition: () => number | undefined;
/**
* The options passed through to the `ReactComponent`.
*/
readonly #options: GetFixed<ReactComponentOptions>;
#selected = false;
/**
* Whether or not the node is currently selected.
*/
public get selected(): boolean {
return this.#selected;
}
#contentDOM?: HTMLElement | undefined;
/**
* The wrapper for the content dom. Created from the node spec `toDOM` method.
*/
#contentDOMWrapper?: HTMLElement | undefined;
/**
* The DOM node that should hold the node's content.
*
* This is only meaningful if the NodeView is not a leaf type and it can
* accept content. When these criteria are met, `ProseMirror` will take care of
* rendering the node's children into it.
*
* In order to make use of this in a
*/
public get contentDOM(): HTMLElement | undefined {
return this.#contentDOM;
}
#dom: HTMLElement;
/**
* Provides readonly access to the dom element. The dom is automatically for
* react components.
*/
get dom(): HTMLElement {
return this.#dom;
}
/**
* Create the node view for a react component and render it into the dom.
*/
private constructor({
getPosition,
node,
portalContainer,
view,
ReactComponent,
options,
}: ReactNodeViewProps) {
invariant(isFunction(getPosition), {
message:
'You are attempting to use a node view for a mark type. This is not supported yet. Please check your configuration.',
});
this.#node = node;
this.#view = view;
this.#portalContainer = portalContainer;
this.#Component = ReactComponent;
this.#getPosition = getPosition;
this.#options = options;
this.#dom = this.createDom();
const { contentDOM, wrapper } = this.createContentDom() ?? {};
this.#contentDOM = contentDOM ?? undefined;
this.#contentDOMWrapper = wrapper;
if (this.#contentDOMWrapper) {
this.#dom.append(this.#contentDOMWrapper);
}
this.setDomAttributes(this.#node, this.#dom);
this.Component.displayName = pascalCase(`${this.#node.type.name}NodeView`);
this.renderComponent();
}
/**
* Render the react component into the dom.
*/
private renderComponent() {
this.#portalContainer.render({
Component: this.Component,
container: this.#dom,
});
}
/**
* Create the dom element which will hold the react component.
*/
private createDom(): HTMLElement {
const { defaultBlockNode, defaultInlineNode } = this.#options;
const element: HTMLElement = this.#node.isInline
? document.createElement(defaultInlineNode as keyof HTMLElementTagNameMap)
: document.createElement(defaultBlockNode as keyof HTMLElementTagNameMap);
// ProseMirror breaks down when it encounters multiple nested empty
// elements. This class prevents this from happening.
element.classList.add(`${kebabCase(this.#node.type.name)}-node-view-wrapper`);
return element;
}
/**
* The element that will contain the content for this element.
*/
private createContentDom():
| { wrapper: HTMLElement; contentDOM?: HTMLElement | null }
| undefined {
if (this.#node.isLeaf) {
return;
}
const domSpec = this.#node.type.spec.toDOM?.(this.#node);
// Only allow content if a domSpec exists which is used to render the content.
if (!domSpec) {
return;
}
// Let `ProseMirror` interpret the domSpec returned by `toDOM` to provide
// the dom and `contentDOM`.
const { contentDOM, dom } = DOMSerializer.renderSpec(document, domSpec);
// The content dom needs a wrapper node in react since the dom element which
// it renders inside isn't immediately mounted.
let wrapper: HTMLElement;
if (!isElementDomNode(dom)) {
return;
}
// Default to setting the wrapper to a different element.
wrapper = dom;
if (dom === contentDOM) {
wrapper = document.createElement('span');
wrapper.classList.add(`${kebabCase(this.#node.type.name)}-node-view-content-wrapper`);
wrapper.append(contentDOM);
}
if (isElementDomNode(contentDOM)) {
// contentDOM.setAttribute('contenteditable', `${this.#view.editable}`);
}
return { wrapper, contentDOM };
}
/**
* Adds a ref to the component that has been provided and can be used to set
* it as the content container. However it is advisable to either not use
* ReactNodeViews for nodes with content or to take control of rendering the
* content within the component..
*/
readonly #forwardRef: RefCallback<HTMLElement> = (node) => {
if (!node) {
return;
}
invariant(this.#contentDOMWrapper, {
code: ErrorConstant.REACT_NODE_VIEW,
message: `You have applied a ref to a node view provided for '${
this.#node.type.name
}' which doesn't support content.`,
});
node.append(this.#contentDOMWrapper);
};
/**
* Render the provided component.
*
* This method is passed into the HTML element.
*/
private readonly Component: FunctionComponent = () => {
const ReactComponent = this.#Component;
invariant(ReactComponent, {
code: ErrorConstant.REACT_NODE_VIEW,
message: `The custom react node view provided for ${
this.#node.type.name
} doesn't have a valid ReactComponent`,
});
return (
<ReactComponent
updateAttributes={this.updateAttributes}
selected={this.selected}
view={this.#view}
getPosition={this.#getPosition}
node={this.#node}
forwardRef={this.#forwardRef}
decorations={this.#decorations}
/>
);
};
/**
* Passed to the Component to enable updating the attributes from within the component.
*/
private readonly updateAttributes = (attrs: ProsemirrorAttributes) => {
if (!this.#view.editable) {
return;
}
const pos = this.#getPosition();
if (pos == null) {
return;
}
const tr = this.#view.state.tr.setNodeMarkup(pos, undefined, {
...this.#node.attrs,
...attrs,
});
this.#view.dispatch(tr);
};
/**
* This is called whenever the node is called.
*/
update(node: ProsemirrorNode, decorations: readonly Decoration[]): boolean {
if (!isNodeOfType({ types: this.#node.type, node })) {
return false;
}
if (this.#node === node && this.#decorations === decorations) {
return true;
}
if (!this.#node.sameMarkup(node)) {
this.setDomAttributes(node, this.#dom);
}
this.#node = node as NodeWithAttributes;
this.#decorations = decorations;
this.renderComponent();
return true;
}
/**
* Copies the attributes from a ProseMirror Node to the parent DOM node.
*
* @param node The Prosemirror Node from which to source the attributes
*/
setDomAttributes(node: ProsemirrorNode, element: HTMLElement): void {
const { toDOM } = this.#node.type.spec;
let attributes = node.attrs;
if (toDOM) {
const domSpec = toDOM(node);
if (isString(domSpec) || isDomNodeOutputSpec(domSpec)) {
return;
}
if (isPlainObject(domSpec[1])) {
attributes = domSpec[1];
}
}
for (const [attribute, value] of entries(attributes)) {
element.setAttribute(attribute, value);
}
}
/**
* Marks the node as being selected.
*/
selectNode(): void {
this.#selected = true;
if (this.#dom) {
this.#dom.classList.add(SELECTED_NODE_CLASS_NAME);
}
this.renderComponent();
}
/**
* Remove the selected node markings from this component.
*/
deselectNode(): void {
this.#selected = false;
if (this.#dom) {
this.#dom.classList.remove(SELECTED_NODE_CLASS_NAME);
}
this.renderComponent();
}
/**
* This is called whenever the node is being destroyed.
*/
destroy(): void {
this.#portalContainer.remove(this.#dom);
}
/**
* The handler which decides when mutations should be ignored.
*/
ignoreMutation(mutation: IgnoreMutationProps): boolean {
if (mutation.type === 'selection') {
// If a node type is unselectable, then ignore all selection mutations.
return !this.#node.type.spec.selectable;
}
if (!this.#contentDOMWrapper) {
return true;
}
return !this.#contentDOMWrapper.contains(mutation.target);
}
stopEvent(event: Event): boolean {
if (!this.#dom) {
return false;
}
if (isFunction(this.#options.stopEvent)) {
return this.#options.stopEvent({ event });
}
const target = event.target as HTMLElement;
const isInElement = this.#dom.contains(target) && !this.contentDOM?.contains(target);
// any event from child nodes should be handled by ProseMirror
if (!isInElement) {
return false;
}
const isDropEvent = event.type === 'drop';
const isInput =
['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(target.tagName) ||
target.isContentEditable;
// any input event within node views should be ignored by ProseMirror
if (isInput && !isDropEvent) {
return true;
}
const isDraggable = !!this.#node.type.spec.draggable;
const isSelectable = NodeSelection.isSelectable(this.#node);
const isCopyEvent = event.type === 'copy';
const isPasteEvent = event.type === 'paste';
const isCutEvent = event.type === 'cut';
const isClickEvent = event.type === 'mousedown';
const isDragEvent = event.type.startsWith('drag');
// ProseMirror tries to drag selectable nodes
// even if `draggable` is set to `false`
// this fix prevents that
if (!isDraggable && isSelectable && isDragEvent) {
event.preventDefault();
}
// these events are handled by prosemirror
if (
isDragEvent ||
isDropEvent ||
isCopyEvent ||
isPasteEvent ||
isCutEvent ||
(isClickEvent && isSelectable)
) {
return false;
}
return true;
}
}
type IgnoreMutationProps = MutationRecord | { type: 'selection'; target: Element };
function isDomNodeOutputSpec(value: unknown): value is Node | { dom: Node; contentDOM?: Node } {
return isDomNode(value) || (isPlainObject(value) && isDomNode(value.dom));
}