import React from 'react'; import { NodeView, EditorView, Decoration } from 'prosemirror-view'; import { Node as PMNode } from 'prosemirror-model'; import { startMeasure, stopMeasure } from '@atlaskit/editor-common'; import { PortalProviderAPI } from '../ui/PortalProvider'; import { analyticsPluginKey } from '../plugins/analytics/plugin-key'; import { EventDispatcher, createDispatch } from '../event-dispatcher'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, AnalyticsDispatch, AnalyticsEventPayload, } from '../plugins/analytics'; import { analyticsEventKey } from '../plugins/analytics/consts'; import { ReactComponentProps, shouldUpdate, getPosHandler, ForwardRef, } from './types'; import { getParticipantsCount } from '../plugins/collab-edit/get-participants-count'; import { getFeatureFlags } from '../plugins/feature-flags-context'; import { ErrorBoundary } from '../ui/ErrorBoundary'; const DEFAULT_SAMPLING_RATE = 100; const DEFAULT_SLOW_THRESHOLD = 7; let nodeViewEventsCounter = 0; interface CreateDomRefOptions { displayInlineBlockForInlineNodes: boolean; } export default class ReactNodeView

implements NodeView { private domRef?: HTMLElement; private contentDOMWrapper?: Node; private reactComponent?: React.ComponentType; private portalProviderAPI: PortalProviderAPI; private hasContext: boolean; private _viewShouldUpdate?: shouldUpdate; protected eventDispatcher?: EventDispatcher; reactComponentProps: P; view: EditorView; getPos: getPosHandler; contentDOM: Node | undefined; node: PMNode; constructor( node: PMNode, view: EditorView, getPos: getPosHandler, portalProviderAPI: PortalProviderAPI, eventDispatcher: EventDispatcher, reactComponentProps?: P, reactComponent?: React.ComponentType, hasContext: boolean = false, viewShouldUpdate?: shouldUpdate, ) { this.node = node; this.view = view; this.getPos = getPos; this.portalProviderAPI = portalProviderAPI; this.reactComponentProps = reactComponentProps || ({} as P); this.reactComponent = reactComponent; this.hasContext = hasContext; this._viewShouldUpdate = viewShouldUpdate; this.eventDispatcher = eventDispatcher; } /** * This method exists to move initialization logic out of the constructor, * so object can be initialized properly before calling render first time. * * Example: * Instance properties get added to an object only after super call in * constructor, which leads to some methods being undefined during the * first render. */ init() { this.domRef = this.createDomRef(); this.setDomAttrs(this.node, this.domRef); const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() || { dom: undefined, contentDOM: undefined, }; if (this.domRef && contentDOMWrapper) { this.domRef.appendChild(contentDOMWrapper); this.contentDOM = contentDOM ? contentDOM : contentDOMWrapper; this.contentDOMWrapper = contentDOMWrapper || contentDOM; } // @see ED-3790 // something gets messed up during mutation processing inside of a // nodeView if DOM structure has nested plain "div"s, it doesn't see the // difference between them and it kills the nodeView this.domRef.classList.add(`${this.node.type.name}View-content-wrap`); const { samplingRate, slowThreshold, enabled: trackingEnabled, } = this.performanceOptions; trackingEnabled && startMeasure(`🦉${this.node.type.name}::ReactNodeView`); this.renderReactComponent(() => this.render(this.reactComponentProps, this.handleRef), ); trackingEnabled && stopMeasure(`🦉${this.node.type.name}::ReactNodeView`, (duration) => { if ( ++nodeViewEventsCounter % samplingRate === 0 && duration > slowThreshold ) { this.dispatchAnalyticsEvent({ action: ACTION.REACT_NODEVIEW_RENDERED, actionSubject: ACTION_SUBJECT.EDITOR, eventType: EVENT_TYPE.OPERATIONAL, attributes: { node: this.node.type.name, duration, participants: getParticipantsCount(this.view.state), }, }); } }); return this; } private renderReactComponent( component: () => React.ReactElement | null, ) { if (!this.domRef || !component) { return; } const componentWithErrorBoundary = () => ( {component()} ); this.portalProviderAPI.render( componentWithErrorBoundary, this.domRef!, this.hasContext, ); } createDomRef(options?: CreateDomRefOptions): HTMLElement { if (!this.node.isInline) { return document.createElement('div'); } const htmlElement = document.createElement('span'); const state = this.view.state; const featureFlags = getFeatureFlags(state); if ( featureFlags && featureFlags.displayInlineBlockForInlineNodes && options?.displayInlineBlockForInlineNodes !== false ) { htmlElement.style.display = 'inline-block'; htmlElement.style.userSelect = 'all'; } return htmlElement; } getContentDOM(): | { dom: Node; contentDOM?: Node | null | undefined } | undefined { return undefined; } handleRef = (node: HTMLElement | null) => this._handleRef(node); private _handleRef(node: HTMLElement | null) { const contentDOM = this.contentDOMWrapper || this.contentDOM; // move the contentDOM node inside the inner reference after rendering if (node && contentDOM && !node.contains(contentDOM)) { node.appendChild(contentDOM); } } render(props: P, forwardRef?: ForwardRef): React.ReactElement | null { return this.reactComponent ? ( ) : null; } update( node: PMNode, _decorations: Array, validUpdate: (currentNode: PMNode, newNode: PMNode) => boolean = () => true, ) { // @see https://github.com/ProseMirror/prosemirror/issues/648 const isValidUpdate = this.node.type === node.type && validUpdate(this.node, node); if (!isValidUpdate) { return false; } if (this.domRef && !this.node.sameMarkup(node)) { this.setDomAttrs(node, this.domRef); } // View should not process a re-render if this is false. // We dont want to destroy the view, so we return true. if (!this.viewShouldUpdate(node)) { this.node = node; return true; } this.node = node; this.renderReactComponent(() => this.render(this.reactComponentProps, this.handleRef), ); return true; } viewShouldUpdate(nextNode: PMNode): boolean { if (this._viewShouldUpdate) { return this._viewShouldUpdate(nextNode); } return true; } /** * Copies the attributes from a ProseMirror Node to a DOM node. * @param node The Prosemirror Node from which to source the attributes */ setDomAttrs(node: PMNode, element: HTMLElement) { Object.keys(node.attrs || {}).forEach((attr) => { element.setAttribute(attr, node.attrs[attr]); }); } get dom() { return this.domRef; } destroy() { if (!this.domRef) { return; } this.portalProviderAPI.remove(this.domRef); this.domRef = undefined; this.contentDOM = undefined; } get performanceOptions(): { enabled: boolean; samplingRate: number; slowThreshold: number; } { const pluginState = analyticsPluginKey.getState(this.view.state); const nodeViewTracking = pluginState && pluginState.performanceTracking ? pluginState.performanceTracking.nodeViewTracking || {} : {}; const samplingRate = nodeViewTracking.samplingRate || DEFAULT_SAMPLING_RATE; const slowThreshold = nodeViewTracking.slowThreshold || DEFAULT_SLOW_THRESHOLD; return { enabled: !!nodeViewTracking.enabled, samplingRate, slowThreshold, }; } private dispatchAnalyticsEvent = (payload: AnalyticsEventPayload) => { if (this.eventDispatcher) { const dispatch: AnalyticsDispatch = createDispatch(this.eventDispatcher); dispatch(analyticsEventKey, { payload, }); } }; static fromComponent( component: React.ComponentType, portalProviderAPI: PortalProviderAPI, eventDispatcher: EventDispatcher, props?: ReactComponentProps, viewShouldUpdate?: (nextNode: PMNode) => boolean, ) { return (node: PMNode, view: EditorView, getPos: getPosHandler) => new ReactNodeView( node, view, getPos, portalProviderAPI, eventDispatcher, props, component, false, viewShouldUpdate, ).init(); } }