Created
July 5, 2020 17:24
-
-
Save pie6k/56b5d4f02bbce9a1de4b6fae6cfdfdce to your computer and use it in GitHub Desktop.
React Node View
This file contains hidden or 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
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