@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
434 lines (411 loc) • 17.3 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
/**
* @jsxRuntime classic
* @jsx jsx
*/
import React from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { jsx } from '@emotion/react';
import { DOMSerializer } from '@atlaskit/editor-prosemirror/model';
import { fg } from '@atlaskit/platform-feature-flags';
import { ACTION_SUBJECT, ACTION_SUBJECT_ID } from '../analytics';
import { isSSR } from '../core-utils/is-ssr';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { analyticsEventKey, getPerformanceOptions, startMeasureReactNodeViewRendered, stopMeasureReactNodeViewRendered } from '../utils';
import { getBrowserInfo } from '../utils/browser';
import { ZERO_WIDTH_SPACE } from '../whitespace';
import { generateUniqueNodeKey } from './generateUniqueNodeKey';
import { getOrCreateOnVisibleObserver } from './onVisibleObserverFactory';
export const inlineNodeViewClassname = 'inlineNodeView';
const canRenderFallback = node => {
return node.type.isInline && node.type.isAtom && node.type.isLeaf;
};
// list of inline nodes with toDOM fallback implementations that can be virtualized. As
// additional nodes are converted they should be added here
const virtualizedNodeAllowlist = ['inlineCard'];
function createNodeView({
nodeViewParams,
pmPluginFactoryParams,
Component,
extraComponentProps,
extraNodeViewProps
}) {
// We set a variable for the current node which is
// used for comparisions when doing updates, before being
// overwritten to the updated node.
let currentNode = nodeViewParams.node;
const key = generateUniqueNodeKey();
// First we setup the dom element which will be rendered and "tracked" by prosemirror
// and also used as a "editor portal" (not react portal) target by the editor
// portal provider api, for rendering the Component passed.
let domRef = document.createElement('span');
domRef.contentEditable = 'false';
setDomAttrs(nodeViewParams.node, domRef);
const fallbackRef = {
current: null
};
// @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
domRef.classList.add(`${nodeViewParams.node.type.name}View-content-wrap`, `${inlineNodeViewClassname}`);
// This util is shared for tracking rendering, and the ErrorBoundary that
// is setup to wrap the Component when rendering
// NOTE: This is not a prosemirror dispatch
function dispatchAnalyticsEvent(payload) {
pmPluginFactoryParams.eventDispatcher.emit(analyticsEventKey, {
payload
});
}
// This is called to render the Component into domRef which is inside the
// prosemirror View.
// Internally it uses the unstable_renderSubtreeIntoContainer api to render,
// to the passed dom element (domRef) which means it is automatically
// "cleaned up" when you do a "re render".
function renderComponent() {
pmPluginFactoryParams.portalProviderAPI.render(getPortalChildren({
dispatchAnalyticsEvent,
currentNode,
nodeViewParams,
Component,
extraComponentProps
}), domRef, key);
}
const {
samplingRate,
slowThreshold,
trackingEnabled
} = getPerformanceOptions(nodeViewParams.view);
trackingEnabled && startMeasureReactNodeViewRendered({
nodeTypeName: currentNode.type.name
});
// We render the component while creating the node view
renderComponent();
trackingEnabled && stopMeasureReactNodeViewRendered({
nodeTypeName: currentNode.type.name,
dispatchAnalyticsEvent,
samplingRate,
slowThreshold
});
const extraNodeViewPropsWithStopEvent = {
...extraNodeViewProps
};
// https://prosemirror.net/docs/ref/#view.NodeView
const nodeView = {
get dom() {
return domRef;
},
update(nextNode, _decorations) {
// Let ProseMirror handle the update if node types are different.
// This prevents an issue where it was not possible to select the
// inline node view then replace it by entering text - the node
// view contents would be deleted but the node view itself would
// stay in the view and become uneditable.
if (currentNode.type !== nextNode.type) {
return false;
}
// On updates, we only set the new attributes if the type, attributes, and marks
// have changed on the node.
// NOTE: this could mean attrs changes aren't reflected in the dom,
// when an attribute key which was previously present is no longer
// present.
// ie.
// -> Original attributes { text: "hello world", color: "red" }
// -> Updated attributes { color: "blue" }
// in this case, the dom text attribute will not be cleared.
//
// This may not be an issue with any of our current node schemas.
if (!currentNode.sameMarkup(nextNode)) {
setDomAttrs(nextNode, domRef);
}
currentNode = nextNode;
renderComponent();
return true;
},
destroy() {
// When prosemirror destroys the node view, we need to clean up
// what we have previously rendered using the editor portal
// provider api.
pmPluginFactoryParams.portalProviderAPI.remove(key);
// @ts-expect-error Expect an error as domRef is expected to be
// of HTMLSpanElement type however once the node view has
// been destroyed no other consumers should still be using it.
domRef = undefined;
fallbackRef.current = null;
},
...extraNodeViewPropsWithStopEvent
};
return nodeView;
}
function createNodeViewVirtualized({
nodeViewParams,
pmPluginFactoryParams,
Component,
extraComponentProps,
extraNodeViewProps
}) {
// We set a variable for the current node which is
// used for comparisions when doing updates, before being
// overwritten to the updated node.
let currentNode = nodeViewParams.node;
const key = generateUniqueNodeKey();
// First we setup the dom element which will be rendered and "tracked" by prosemirror
// and also used as a "editor portal" (not react portal) target by the editor
// portal provider api, for rendering the Component passed.
let domRef = document.createElement('span');
domRef.contentEditable = 'false';
setDomAttrs(nodeViewParams.node, domRef);
const fallbackRef = {
current: null
};
// @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
domRef.classList.add(`${nodeViewParams.node.type.name}View-content-wrap`, `${inlineNodeViewClassname}`);
function onBeforeReactDomRender() {
if (!fallbackRef.current) {
return;
}
domRef.removeChild(fallbackRef.current);
fallbackRef.current = null;
}
// This util is shared for tracking rendering, and the ErrorBoundary that
// is setup to wrap the Component when rendering
// NOTE: This is not a prosemirror dispatch
function dispatchAnalyticsEvent(payload) {
pmPluginFactoryParams.eventDispatcher.emit(analyticsEventKey, {
payload
});
}
// This is called to render the Component into domRef which is inside the
// prosemirror View.
// Internally it uses the unstable_renderSubtreeIntoContainer api to render,
// to the passed dom element (domRef) which means it is automatically
// "cleaned up" when you do a "re render".
function renderComponent() {
pmPluginFactoryParams.portalProviderAPI.render(getPortalChildren({
dispatchAnalyticsEvent,
currentNode,
nodeViewParams,
Component,
extraComponentProps
}), domRef, key, onBeforeReactDomRender);
}
let didRenderComponentWithIntersectionObserver = false;
let destroyed = false;
let removeIntersectionObserver = () => {};
function renderFallback() {
var _currentNode$type, _currentNode$type$spe;
if (!canRenderFallback(currentNode) || typeof ((_currentNode$type = currentNode.type) === null || _currentNode$type === void 0 ? void 0 : (_currentNode$type$spe = _currentNode$type.spec) === null || _currentNode$type$spe === void 0 ? void 0 : _currentNode$type$spe.toDOM) !== 'function') {
return;
}
const fallback = DOMSerializer.renderSpec(document, currentNode.type.spec.toDOM(currentNode));
const dom = fallback.dom;
fallbackRef.current = dom;
domRef.replaceChildren(dom);
}
function attachNodeViewObserver() {
const observer = getOrCreateOnVisibleObserver(nodeViewParams.view);
if (domRef) {
removeIntersectionObserver = observer.observe(domRef, () => {
if (!didRenderComponentWithIntersectionObserver && !destroyed) {
renderComponent();
didRenderComponentWithIntersectionObserver = true;
}
});
}
}
renderFallback();
// allow the fallback to render first before attaching the observer.
// Will tweak this in a follow up PR to optimise rendering of visible
// nodes without fallback rendering.
setTimeout(() => {
attachNodeViewObserver();
}, 0);
const extraNodeViewPropsWithStopEvent = {
...extraNodeViewProps,
stopEvent: event => {
const maybeStopEvent = extraNodeViewProps === null || extraNodeViewProps === void 0 ? void 0 : extraNodeViewProps.stopEvent;
if (typeof maybeStopEvent === 'function') {
return maybeStopEvent(event);
}
return false;
}
};
// https://prosemirror.net/docs/ref/#view.NodeView
const nodeView = {
get dom() {
return domRef;
},
update(nextNode, _decorations) {
// Let ProseMirror handle the update if node types are different.
// This prevents an issue where it was not possible to select the
// inline node view then replace it by entering text - the node
// view contents would be deleted but the node view itself would
// stay in the view and become uneditable.
if (currentNode.type !== nextNode.type) {
return false;
}
// On updates, we only set the new attributes if the type, attributes, and marks
// have changed on the node.
// NOTE: this could mean attrs changes aren't reflected in the dom,
// when an attribute key which was previously present is no longer
// present.
// ie.
// -> Original attributes { text: "hello world", color: "red" }
// -> Updated attributes { color: "blue" }
// in this case, the dom text attribute will not be cleared.
//
// This may not be an issue with any of our current node schemas.
if (!currentNode.sameMarkup(nextNode)) {
setDomAttrs(nextNode, domRef);
}
currentNode = nextNode;
if (didRenderComponentWithIntersectionObserver) {
renderComponent();
}
return true;
},
destroy() {
removeIntersectionObserver();
destroyed = true;
// When prosemirror destroys the node view, we need to clean up
// what we have previously rendered using the editor portal
// provider api.
pmPluginFactoryParams.portalProviderAPI.remove(key);
// @ts-expect-error Expect an error as domRef is expected to be
// of HTMLSpanElement type however once the node view has
// been destroyed no other consumers should still be using it.
domRef = undefined;
fallbackRef.current = null;
},
...extraNodeViewPropsWithStopEvent
};
return nodeView;
}
/**
* Copies the attributes from a ProseMirror Node to a DOM node.
* @param node The Prosemirror Node from which to source the attributes
*/
function setDomAttrs(node, element) {
Object.keys(node.attrs || {}).forEach(attr => {
element.setAttribute(attr, node.attrs[attr]);
});
}
function getPortalChildren({
dispatchAnalyticsEvent,
currentNode,
nodeViewParams,
Component,
extraComponentProps
}) {
return function portalChildren() {
var _currentNode$type$nam, _currentNode$type2;
// All inline nodes use `display: inline` to allow for multi-line
// wrapping. This does produce an issue in Chrome where it is not
// possible to click select the position after the node,
// see: https://product-fabric.atlassian.net/browse/ED-12003
// however this is only a problem for node views that use
// `display: inline-block` somewhere within the Component.
// Looking at the below structure, spans with className
// `inlineNodeViewAddZeroWidthSpace` have pseudo elements that
// add a zero width space which fixes the problem.
// Without the additional zero width space before the Component,
// it is not possible to use the keyboard to range select in Safari.
//
// Zero width spaces on either side of the Component also prevent
// the cursor from appearing inside the node view on all browsers.
//
// Note:
// In future it is worth considering prohibiting the use of `display: inline-block`
// within inline node view Components however would require a sizable
// refactor. A test suite to catch any instances of this is ideal however
// the refactor required is currently out of scope for https://product-fabric.atlassian.net/browse/ED-14176
return jsx(ErrorBoundary, {
component: ACTION_SUBJECT.REACT_NODE_VIEW,
componentId: (_currentNode$type$nam = currentNode === null || currentNode === void 0 ? void 0 : (_currentNode$type2 = currentNode.type) === null || _currentNode$type2 === void 0 ? void 0 : _currentNode$type2.name) !== null && _currentNode$type$nam !== void 0 ? _currentNode$type$nam : ACTION_SUBJECT_ID.UNKNOWN_NODE,
dispatchAnalyticsEvent: dispatchAnalyticsEvent
}, jsx("span", {
className: `zeroWidthSpaceContainer`
}, jsx("span", {
className: `${inlineNodeViewClassname}AddZeroWidthSpace`
}), ZERO_WIDTH_SPACE), jsx(Component, _extends({
view: nodeViewParams.view
// TODO: ED-13910 - Remove the boolean to fix the prosemirror view type
// Ignored via go/ees005
// eslint-disable-next-line @typescript-eslint/no-explicit-any
,
getPos: nodeViewParams.getPos,
node: currentNode
// eslint-disable-next-line react/jsx-props-no-spreading -- Spreading props to pass through dynamic component props
}, extraComponentProps)), getBrowserInfo().android ?
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766
jsx("span", {
className: `zeroWidthSpaceContainer`,
contentEditable: "false"
}, jsx("span", {
className: `${inlineNodeViewClassname}AddZeroWidthSpace`
}), ZERO_WIDTH_SPACE) :
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766
jsx("span", {
className: `${inlineNodeViewClassname}AddZeroWidthSpace`
}));
};
}
// https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews
// The prosemirror EditorProps has a nodeViews key which has the rough shape:
// type nodeViews: {
// [nodeViewName: string]: (node, editorView, getPos, decorations, innerDecorations) => NodeView
// }
// So the value of the keys on the nodeViews object, are a function which should return a NodeView.
// The following type NodeViewProducer, refers to these functions which return a NodeView.
//
// So the above type could also be described as
// type NodeViewProducer = (node, editorView, getPos, decorations, innerDecorations) => NodeView
// nodeViews: {
// [nodeViewName: string]: NodeViewProducer
// }
const counterPerEditorViewMap = new WeakMap();
// This return of this function is intended to be the value of a key
// in a ProseMirror nodeViews object.
export function getInlineNodeViewProducer({
pmPluginFactoryParams,
Component,
extraComponentProps,
extraNodeViewProps
}) {
function nodeViewProducer(...nodeViewProducerParameters) {
var _node$type;
const view = nodeViewProducerParameters[1];
const node = nodeViewProducerParameters[0];
const parameters = {
nodeViewParams: {
node,
view,
getPos: nodeViewProducerParameters[2],
decorations: nodeViewProducerParameters[3]
},
pmPluginFactoryParams,
Component,
extraComponentProps,
extraNodeViewProps
};
const isNodeTypeAllowedToBeVirtualized = virtualizedNodeAllowlist.includes((node === null || node === void 0 ? void 0 : (_node$type = node.type) === null || _node$type === void 0 ? void 0 : _node$type.name) || '');
if (!isNodeTypeAllowedToBeVirtualized || isSSR()) {
return createNodeView(parameters);
}
if (fg('platform_editor_inline_node_virt_threshold_override')) {
return createNodeViewVirtualized(parameters);
}
let inlineNodeViewsVirtualizationCounter = counterPerEditorViewMap.get(view) || 0;
inlineNodeViewsVirtualizationCounter += 1;
counterPerEditorViewMap.set(view, inlineNodeViewsVirtualizationCounter);
// We never virtualize the 100th first elements
if (inlineNodeViewsVirtualizationCounter <= 100) {
return createNodeView(parameters);
}
return createNodeViewVirtualized(parameters);
}
return nodeViewProducer;
}