UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

291 lines (281 loc) 13.5 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import React from 'react'; import { fg } from '@atlaskit/platform-feature-flags'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { isSSR } from '../core-utils'; import ReactNodeView from '../react-node-view'; import { Extension } from './Extension'; import { ExtensionNodeWrapper } from './ExtensionNodeWrapper'; /** * Allowlists of extension keys + layout * Currently, only toc with default layout allows to skip React render. * Extensions NOT in this list always follow the normal React render path. */ const ssrHydrationExtensionAllowlist = ['toc']; const ssrHydrationLayoutAllowlist = ['default']; const isSSRHydrationEligible = node => { var _node$attrs; if (node.type.name !== 'extension') { return false; } const { extensionKey, layout } = (_node$attrs = node.attrs) !== null && _node$attrs !== void 0 ? _node$attrs : {}; if (!ssrHydrationExtensionAllowlist.includes(extensionKey)) { return false; } // Treat a missing layout attr as `default` (the schema default). const effectiveLayout = layout !== null && layout !== void 0 ? layout : 'default'; return ssrHydrationLayoutAllowlist.includes(effectiveLayout); }; /** * Per-(EditorView, extensionKey + localId) record of which extension identities * have already had their initial-hydration init pass. SSR DOM reuse is only valid * the very first time an ExtensionNode init runs for a given identity in a given * editor — that is the only moment a real SSR-rendered element for that identity * can exist in the editor DOM. * * After the first init, any element matching the SSR selector is the previous * node view's React-rendered domRef that ProseMirror has not yet detached (e.g. * during DnD / layout resize, where ProseMirror constructs the new node view * BEFORE destroying the old one). Reusing it as if it were SSR DOM causes the * new node view to skip React rendering and leaves the extension invisible * (EDITOR-6613). */ const consumedHydrationIdentitiesByEditor = new WeakMap(); const getHydrationIdentityKey = (extensionKey, localId) => { if (typeof extensionKey !== 'string' || typeof localId !== 'string') { return null; } if (extensionKey === '' || localId === '') { return null; } return `${extensionKey}::${localId}`; }; const hasHydrationIdentityBeenConsumed = (view, identityKey) => { const consumed = consumedHydrationIdentitiesByEditor.get(view); return consumed ? consumed.has(identityKey) : false; }; const markHydrationIdentityAsConsumed = (view, identityKey) => { let consumed = consumedHydrationIdentitiesByEditor.get(view); if (!consumed) { consumed = new Set(); consumedHydrationIdentitiesByEditor.set(view, consumed); } consumed.add(identityKey); }; // getInlineNodeViewProducer is a new api to use instead of ReactNodeView // when creating inline node views, however, it is difficult to test the impact // on selections when migrating inlineExtension to use the new api. // The ReactNodeView api will be visited in the second phase of the selections // project whilst investigating block nodes. We will revisit the Extension node view there too. export class ExtensionNode extends ReactNodeView { constructor(...args) { super(...args); /** True between SSR DOM adoption in `createDomRef` and the SSR→React handoff in `update`. */ _defineProperty(this, "didReuseSsrDom", false); } ignoreMutation(mutation) { // Extensions can perform async operations that will change the DOM. // To avoid having their tree rebuilt, we need to ignore the mutation // for atom based extensions if its not a layout, we need to give // children a chance to recalc return this.node.type.isAtom || mutation.type !== 'selection' && mutation.attributeName !== 'data-layout'; } /** See {@link consumedHydrationIdentitiesByEditor}. Null when attrs are missing → SSR reuse skipped. */ getHydrationIdentityKey() { var _this$node$attrs, _this$node$attrs2; return getHydrationIdentityKey((_this$node$attrs = this.node.attrs) === null || _this$node$attrs === void 0 ? void 0 : _this$node$attrs.extensionKey, (_this$node$attrs2 = this.node.attrs) === null || _this$node$attrs2 === void 0 ? void 0 : _this$node$attrs2.localId); } /** True only for the first ExtensionNode of this identity in this editor. See {@link consumedHydrationIdentitiesByEditor}. */ isInInitialHydrationWindow() { const identityKey = this.getHydrationIdentityKey(); if (identityKey === null) { return false; } return !hasHydrationIdentityBeenConsumed(this.view, identityKey); } // Reserve height by setting a minimum height for the extension node view element createDomRef() { if (!fg('confluence_connect_macro_preset_height')) { // SSR DOM reuse — see {@link consumedHydrationIdentitiesByEditor}. if (!isSSR() && isSSRHydrationEligible(this.node) && this.isInInitialHydrationWindow() && expValEquals('platform_editor_hydration_skip_react_portal', 'isEnabled', true)) { const ssrElement = this.findSSRElement(); if (ssrElement) { this.didReuseSsrDom = true; return ssrElement; } } return super.createDomRef(); } if (!this.node.isInline) { var _this$reactComponentP, _this$reactComponentP2, _this$reactComponentP3; const htmlElement = document.createElement('div'); const extensionHeight = (_this$reactComponentP = this.reactComponentProps) === null || _this$reactComponentP === void 0 ? void 0 : (_this$reactComponentP2 = _this$reactComponentP.extensionNodeViewOptions) === null || _this$reactComponentP2 === void 0 ? void 0 : (_this$reactComponentP3 = _this$reactComponentP2.getExtensionHeight) === null || _this$reactComponentP3 === void 0 ? void 0 : _this$reactComponentP3.call(_this$reactComponentP2, this.node); if (extensionHeight) { htmlElement.style.setProperty('min-height', `${extensionHeight}px`); } return htmlElement; } const htmlElement = document.createElement('span'); return htmlElement; } /** * Cache for SSR element lookup to avoid repeated DOM queries. * undefined = not yet searched, null = searched but not found, HTMLElement = found */ /** * Attempts to find an existing SSR'd DOM element for this extension node by extensionKey and localId * which should uniquely identify the * extension node within the editor content. * * @returns The SSR'd element if found, otherwise null */ findSSRElement() { if (this.cachedSsrElement !== undefined) { return this.cachedSsrElement || null; } const extensionKey = this.node.attrs.extensionKey; const localId = this.node.attrs.localId; const editorDom = this.view.dom; if (extensionKey && localId) { const selector = `[extensionkey="${extensionKey}"][localid="${localId}"]`; const element = editorDom.querySelector(selector); if (element && element instanceof HTMLElement) { this.cachedSsrElement = element; return element; } } this.cachedSsrElement = null; return null; } /** Skip React Portal render on first init when reusing SSR DOM. See {@link consumedHydrationIdentitiesByEditor}. */ init() { if (!expValEquals('platform_editor_hydration_skip_react_portal', 'isEnabled', true)) { super.init(); } else { const isEligibleForSsrReuse = !isSSR() && isSSRHydrationEligible(this.node); if (isEligibleForSsrReuse && this.isInInitialHydrationWindow()) { const ssrElement = this.findSSRElement(); const shouldSkipInitRender = ssrElement !== null; super.init(shouldSkipInitRender); const identityKey = this.getHydrationIdentityKey(); if (identityKey !== null) { // Close the hydration window — see {@link consumedHydrationIdentitiesByEditor}. markHydrationIdentityAsConsumed(this.view, identityKey); } } else { super.init(); } } return this; } update(node, decorations, _innerDecorations, validUpdate = () => true) { // Remove extensionNodeWrapper aka span.relative if we previously reused SSR DOM // control is back to React afterwards if (this.didReuseSsrDom && expValEquals('platform_editor_hydration_skip_react_portal', 'isEnabled', true)) { const ssrElement = this.findSSRElement(); if (ssrElement) { const extensionNodeWrapper = ssrElement.querySelector('[data-testid="extension-node-wrapper"]'); if (extensionNodeWrapper) { extensionNodeWrapper.remove(); } this.didReuseSsrDom = false; } } return super.update(node, decorations, _innerDecorations, validUpdate); } /** * When interacting with input elements inside an extension's body, the events * bubble up to the editor and get handled by it. This almost always gets in the way * of being able to actually interact with said input in the extension, i.e. * typing inside a text field (in an extension body) will print the text in the editor * content area instead. This change prevents the editor view from trying to handle these events, * when the target of the event is an input element, so the extension can. */ stopEvent(event) { if (fg('forge-ui-extensionnodeview-stop-event-for-textarea')) { return event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement; } return event.target instanceof HTMLInputElement; } getContentDOM() { var _this$reactComponentP4, _this$reactComponentP5; if (this.node.isInline) { return; } if (this.didReuseSsrDom) { return; } const contentDomWrapper = document.createElement('div'); contentDomWrapper.className = `${this.node.type.name}-content-dom-wrapper`; const isBodiedExtension = this.node.type.name === 'bodiedExtension'; const showMacroInteractionDesignUpdates = (_this$reactComponentP4 = this.reactComponentProps) === null || _this$reactComponentP4 === void 0 ? void 0 : (_this$reactComponentP5 = _this$reactComponentP4.macroInteractionDesignFeatureFlags) === null || _this$reactComponentP5 === void 0 ? void 0 : _this$reactComponentP5.showMacroInteractionDesignUpdates; if (isBodiedExtension && showMacroInteractionDesignUpdates && expValEquals('platform_editor_bodiedextension_layoutshift_fix', 'isEnabled', true)) { // Create outer wrapper to hold space for the lozenge const outerWrapper = document.createElement('div'); outerWrapper.className = `${this.node.type.name}-content-outer-wrapper`; // Create inner wrapper to position inner content const innerWrapper = document.createElement('div'); innerWrapper.className = `${this.node.type.name}-content-inner-wrapper`; innerWrapper.appendChild(contentDomWrapper); outerWrapper.appendChild(innerWrapper); return { dom: outerWrapper, contentDOM: contentDomWrapper }; } return { dom: contentDomWrapper }; } render(props, forwardRef) { var _props$extensionNodeV; // While sitting on SSR DOM, skip the React portal — see {@link didReuseSsrDom}. if (this.didReuseSsrDom) { return null; } return /*#__PURE__*/React.createElement(ExtensionNodeWrapper, { intl: props.intl, nodeType: this.node.type.name, macroInteractionDesignFeatureFlags: props.macroInteractionDesignFeatureFlags }, /*#__PURE__*/React.createElement(Extension, { editorView: this.view, node: this.node, eventDispatcher: this.eventDispatcher // The getPos arg is always a function when used with nodes // the version of the types we use has a union with the type // for marks. // This has been fixed in later versions of the definitly typed // types (and also in prosmirror-views inbuilt types). // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/57384 , getPos: this.getPos, providerFactory: props.providerFactory, handleContentDOMRef: forwardRef, extensionHandlers: props.extensionHandlers, editorAppearance: (_props$extensionNodeV = props.extensionNodeViewOptions) === null || _props$extensionNodeV === void 0 ? void 0 : _props$extensionNodeV.appearance, pluginInjectionApi: props.pluginInjectionApi, macroInteractionDesignFeatureFlags: props.macroInteractionDesignFeatureFlags, showLivePagesBodiedMacrosRendererView: props.showLivePagesBodiedMacrosRendererView, showUpdatedLivePages1PBodiedExtensionUI: props.showUpdatedLivePages1PBodiedExtensionUI, rendererExtensionHandlers: props.rendererExtensionHandlers })); } } export default function ExtensionNodeView(portalProviderAPI, eventDispatcher, providerFactory, extensionHandlers, extensionNodeViewOptions, pluginInjectionApi, macroInteractionDesignFeatureFlags, showLivePagesBodiedMacrosRendererView, showUpdatedLivePages1PBodiedExtensionUI, rendererExtensionHandlers, intl) { return (node, view, getPos) => { return new ExtensionNode(node, view, getPos, portalProviderAPI, eventDispatcher, { providerFactory, extensionHandlers, extensionNodeViewOptions, pluginInjectionApi, macroInteractionDesignFeatureFlags, showLivePagesBodiedMacrosRendererView, showUpdatedLivePages1PBodiedExtensionUI, rendererExtensionHandlers, intl }).init(); }; }