import type { Editor } from '@tiptap/core' import type { ForwardedRef, HTMLProps, LegacyRef, MutableRefObject } from 'react' import React, { forwardRef } from 'react' import ReactDOM from 'react-dom' import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' import type { ContentComponent, EditorWithContentComponent } from './Editor.js' import type { ReactRenderer } from './ReactRenderer.js' const mergeRefs = (...refs: Array | LegacyRef | undefined>) => { return (node: T) => { refs.forEach(ref => { if (typeof ref === 'function') { ref(node) } else if (ref) { ;(ref as MutableRefObject).current = node } }) } } /** * This component renders all of the editor's node views. */ const Portals: React.FC<{ contentComponent: ContentComponent }> = ({ contentComponent }) => { // For performance reasons, we render the node view portals on state changes only const renderers = useSyncExternalStore( contentComponent.subscribe, contentComponent.getSnapshot, contentComponent.getServerSnapshot, ) // This allows us to directly render the portals without any additional wrapper return <>{Object.values(renderers)} } export interface EditorContentProps extends HTMLProps { editor: Editor | null innerRef?: ForwardedRef } function getInstance(): ContentComponent { const subscribers = new Set<() => void>() let renderers: Record = {} return { /** * Subscribe to the editor instance's changes. */ subscribe(callback: () => void) { subscribers.add(callback) return () => { subscribers.delete(callback) } }, getSnapshot() { return renderers }, getServerSnapshot() { return renderers }, /** * Adds a new NodeView Renderer to the editor. */ setRenderer(id: string, renderer: ReactRenderer) { renderers = { ...renderers, [id]: ReactDOM.createPortal(renderer.reactElement, renderer.element, id), } subscribers.forEach(subscriber => subscriber()) }, /** * Removes a NodeView Renderer from the editor. */ removeRenderer(id: string) { const nextRenderers = { ...renderers } delete nextRenderers[id] renderers = nextRenderers subscribers.forEach(subscriber => subscriber()) }, } } export class PureEditorContent extends React.Component< EditorContentProps, { hasContentComponentInitialized: boolean } > { editorContentRef: React.RefObject initialized: boolean unsubscribeToContentComponent?: () => void constructor(props: EditorContentProps) { super(props) this.editorContentRef = React.createRef() this.initialized = false this.state = { hasContentComponentInitialized: Boolean((props.editor as EditorWithContentComponent | null)?.contentComponent), } } componentDidMount() { this.init() } componentDidUpdate() { this.init() } init() { const editor = this.props.editor as EditorWithContentComponent | null if (editor && !editor.isDestroyed && editor.view.dom?.parentNode) { if (editor.contentComponent) { return } const element = this.editorContentRef.current element.append(...editor.view.dom.parentNode.childNodes) editor.setOptions({ element, }) editor.contentComponent = getInstance() // Has the content component been initialized? if (!this.state.hasContentComponentInitialized) { // Subscribe to the content component this.unsubscribeToContentComponent = editor.contentComponent.subscribe(() => { this.setState(prevState => { if (!prevState.hasContentComponentInitialized) { return { hasContentComponentInitialized: true, } } return prevState }) // Unsubscribe to previous content component if (this.unsubscribeToContentComponent) { this.unsubscribeToContentComponent() } }) } editor.createNodeViews() this.initialized = true } } componentWillUnmount() { const editor = this.props.editor as EditorWithContentComponent | null if (!editor) { return } this.initialized = false if (!editor.isDestroyed) { editor.view.setProps({ nodeViews: {}, }) } if (this.unsubscribeToContentComponent) { this.unsubscribeToContentComponent() } editor.contentComponent = null // try to reset the editor element // may fail if this editor's view.dom was never initialized/mounted yet try { if (!editor.view.dom?.parentNode) { return } // TODO using the new editor.mount method might allow us to remove this const newElement = document.createElement('div') newElement.append(...editor.view.dom.parentNode.childNodes) editor.setOptions({ element: newElement, }) } catch { // do nothing, nothing to reset } } render() { const { editor, innerRef, ...rest } = this.props return ( <>
{/* @ts-ignore */} {editor?.contentComponent && } ) } } // EditorContent should be re-created whenever the Editor instance changes const EditorContentWithKey = forwardRef( (props: Omit, ref) => { const key = React.useMemo(() => { return Math.floor(Math.random() * 0xffffffff).toString() // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.editor]) // Can't use JSX here because it conflicts with the type definition of Vue's JSX, so use createElement return React.createElement(PureEditorContent, { key, innerRef: ref, ...props, }) }, ) export const EditorContent = React.memo(EditorContentWithKey)