Skip to content

Instantly share code, notes, and snippets.

@pie6k
Created July 5, 2020 17:24
Show Gist options
  • Save pie6k/56b5d4f02bbce9a1de4b6fae6cfdfdce to your computer and use it in GitHub Desktop.
Save pie6k/56b5d4f02bbce9a1de4b6fae6cfdfdce to your computer and use it in GitHub Desktop.
React Node View
import { Node, NodeSpec, AttributeSpec, ParseRule, Fragment, NodeType } from 'prosemirror-model';
import { EditorState, Plugin } from 'prosemirror-state';
import { Decoration, EditorView, NodeView } from 'prosemirror-view';
import { ComponentType } from 'react';
import ReactDOM from 'react-dom';
import { createGlobalStyle, css } from 'styled-components';
/**
* Props for react component responsible for rendering node view
*/
interface CustomElementProps<Attrs> {
attrs: Attrs;
updateAttrs: (newAttrs: Attrs) => void;
}
/**
* Node spec that requires providing spec for every attribute so we avoid errors in prosemirror caused
* by missing attributes
*/
type NodeSpecWithAttrs<Attrs> = NodeSpec & {
attrs: {
[key in keyof Attrs]: AttributeSpec;
};
};
/**
* Make sure we don't register the same node name twice
*/
let registeredNodes: string[] = [];
let i = 0;
/**
* Some css tweaks
*/
export const globalProseNodesStyles = css`
.view-node {
&.node-inline {
vertical-align: middle;
display: inline;
}
&.ProseMirror-selectednode {
background-color: #bad6fa;
}
}
`;
/**
* This is a bit hacky.
*
* In order to parse attributes from dom node, I don't want to have to traverse results of react
* nodes.
*
* So I keep global weakmap DOMNODE > current attributes
*
* This way I have easy way of collecting those from any dom node rendered by react node view
*/
interface DomNodeData {
attrs: any;
}
const domNodesDataMap = new WeakMap<HTMLElement, DomNodeData>();
interface NodeData<Attrs> {
type: string;
attrs: Attrs;
content: NodeData<Attrs>[] | null;
}
export function createCustomNode<Attrs>(
name: string,
spec: NodeSpecWithAttrs<Attrs>,
Component: ComponentType<CustomElementProps<Attrs>>,
) {
if (registeredNodes.includes(name)) {
throw new Error(`Note type ${name} is already registered`);
}
registeredNodes.push(name);
// css class name for this node view
const NODE_CLASS_NAME = `node-${name}`;
const domNodeKind = spec.inline ? 'span' : 'div';
const parseRule: ParseRule = {
node: name,
// tag: `${domNodeKind}[class*="${NODE_CLASS_NAME}"]`,
tag: domNodeKind,
getAttrs(node) {
if (typeof node === 'string') {
return false;
}
const attributes = domNodesDataMap.get(node as HTMLElement);
if (attributes === undefined) {
return false;
}
return attributes;
},
};
const nodeTypeSpec: NodeSpec = {
draggable: false,
parseDOM: [parseRule],
toDOM(node) {
const element = createHostNode();
domNodesDataMap.set(element, { attrs: node.attrs });
ReactDOM.render(<Component attrs={node.attrs as Attrs} updateAttrs={(attrs: Attrs) => {}} />, element);
return element;
},
...spec,
};
const plugin = new Plugin({
props: {
nodeViews: {
[name]: initialize,
},
},
});
function insert(state: EditorState, position: number, attrs: Attrs) {
const schema = state.schema;
const node = Node.fromJSON(schema, { type: name, attrs });
const tr = state.tr.insert(position, node);
return tr;
// return state.apply(tr);
}
function findNodes(root: any): NodeData<Attrs>[] {
const results: any[] = [];
if (!root) {
return results;
}
if (root.type === name) {
results.push(root);
return results;
}
if (!root.content || !root.content.length) {
return results;
}
for (const child of root.content) {
results.push(...findNodes(child));
}
return results;
}
function createHostNode() {
const element = document.createElement(domNodeKind);
element.classList.add(
'view-node',
NODE_CLASS_NAME,
'react-node',
nodeTypeSpec.inline ? 'node-inline' : 'node-block',
);
// element.draggable = true;
return element;
}
class View implements NodeView {
private getPos: (() => number) | boolean;
private view: EditorView;
public dom: HTMLElement;
public contentDOM = null;
constructor(node: Node, view: EditorView, getPos: (() => number) | boolean, decorations?: Decoration[]) {
console.log({ decorations });
this.getPos = getPos;
this.view = view;
this.dom = createHostNode();
this.render(node.attrs as Attrs);
this.updateAttrs = this.updateAttrs.bind(this);
}
// ignoreMutation() {
// return true;
// }
stopEvent() {
return false;
}
setSelection() {
console.log('setting sel');
}
private updateAttrs(newAttrs: Attrs) {
const getPos = this.getPos;
if (typeof getPos === 'boolean') {
return;
}
const transaction = this.view.state.tr.setNodeMarkup(
getPos(), // For custom node views, this function is passed into the constructor. It'll return the position of the node in the document.
undefined, // No node type change
newAttrs, // Replace (update) attributes to your `video` block here
);
this.view.dispatch(transaction);
}
private render(attrs: Attrs) {
i++;
if (i > 20) {
throw 2;
}
domNodesDataMap.set(this.dom, { attrs });
console.log('render');
ReactDOM.render(
<Component
attrs={attrs}
// has to be arrow function because of this binding
updateAttrs={(attrs: Attrs) => this.updateAttrs(attrs)}
/>,
this.dom,
);
}
update(node: Node, decorations?: Decoration[]): boolean {
console.log('update', decorations, node.content.size);
this.render(node.attrs as Attrs);
return true;
}
destroy() {
console.log('destroy');
ReactDOM.unmountComponentAtNode(this.dom);
this.dom.remove();
}
}
function initialize(
node: Node,
view: EditorView,
getPos: (() => number) | boolean,
decorations?: Decoration[],
): NodeView {
return new View(node, view, getPos, decorations);
}
const schemaAppendMap = {
[name]: nodeTypeSpec,
};
return {
findNodes,
insert,
initialize,
plugin,
View,
schemaAppendMap,
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment