UNPKG

@atlaskit/editor-core

Version:

A package contains Atlassian editor core functionality

1,036 lines (1,009 loc) 53.3 kB
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState, useEffect } from 'react'; import { injectIntl } from 'react-intl'; // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead import uuid from 'uuid/v4'; import { ACTION, ACTION_SUBJECT, EVENT_TYPE, fireAnalyticsEvent, PLATFORMS } from '@atlaskit/editor-common/analytics'; import { isSSR } from '@atlaskit/editor-common/core-utils'; import { createDispatch, EventDispatcher } from '@atlaskit/editor-common/event-dispatcher'; import { useConstructor, usePreviousState } from '@atlaskit/editor-common/hooks'; import { isPerformanceAPIAvailable } from '@atlaskit/editor-common/is-performance-api-available'; import { nodeVisibilityManager } from '@atlaskit/editor-common/node-visibility'; import { getEnabledFeatureFlagKeys } from '@atlaskit/editor-common/normalize-feature-flags'; import { measureRender } from '@atlaskit/editor-common/performance/measure-render'; import { getRequestToResponseTime, getResponseEndTime } from '@atlaskit/editor-common/performance/navigation'; import { profileSSROperation, SSRRenderMeasure } from '@atlaskit/editor-common/performance/ssr-measures'; import { EditorPluginInjectionAPI } from '@atlaskit/editor-common/preset'; import { processRawValue, processRawValueWithoutValidation } from '@atlaskit/editor-common/process-raw-value'; import { ReactEditorViewContext } from '@atlaskit/editor-common/ui-react'; import { analyticsEventKey, getAnalyticsEventSeverity } from '@atlaskit/editor-common/utils/analytics'; import { isEmptyDocument } from '@atlaskit/editor-common/utils/document'; import { Node as PMNode } from '@atlaskit/editor-prosemirror/model'; import { EditorState, Selection, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { EditorView } from '@atlaskit/editor-prosemirror/view'; import { EditorSSRRenderer } from '@atlaskit/editor-ssr-renderer'; import { fg } from '@atlaskit/platform-feature-flags'; import { getInteractionId } from '@atlaskit/react-ufo/interaction-id-context'; import { abortAll, getActiveInteraction } from '@atlaskit/react-ufo/interaction-metrics'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure'; import { useProviders } from '../composable-editor/hooks/useProviders'; import { createFeatureFlagsFromProps } from '../utils/feature-flags-from-props'; import { getNodesCount } from '../utils/getNodesCount'; import { getNodesCountWithExtensionKeys } from '../utils/getNodesCountWithExtensionKeys'; import { getNodesVisibleInViewport } from '../utils/getNodesVisibleInViewport'; import { isChromeless } from '../utils/is-chromeless'; import { isFullPage } from '../utils/is-full-page'; import { RenderTracking } from '../utils/performance/components/RenderTracking'; import measurements from '../utils/performance/measure-enum'; import { PROSEMIRROR_RENDERED_DEGRADED_SEVERITY_THRESHOLD, PROSEMIRROR_RENDERED_NORMAL_SEVERITY_THRESHOLD } from './consts'; import { createErrorReporter, createPMPlugins, processPluginsList } from './create-editor'; import createPluginsList from './create-plugins-list'; import { createSchema } from './create-schema'; import { filterPluginsForReconfigure } from './filter-plugins-for-reconfigure'; import { editorMessages } from './messages'; import { focusEditorElement } from './ReactEditorView/focusEditorElement'; import { getUAPrefix } from './ReactEditorView/getUAPrefix'; import { handleEditorFocus } from './ReactEditorView/handleEditorFocus'; import { useDispatchTransaction } from './ReactEditorView/useDispatchTransaction'; import { useFireFullWidthEvent } from './ReactEditorView/useFireFullWidthEvent'; const EDIT_AREA_ID = 'ak-editor-textarea'; const SSR_TRACE_SEGMENT_NAME = 'reactEditorView'; const bootStartTime = isPerformanceAPIAvailable() ? performance.now() : undefined; // `markdown↔rich` toggles drop different node/mark sets, so the unique // name set is enough to detect when a destructive rebuild is needed. function sameNames(a, b) { const setA = new Set(a); const setB = new Set(b); if (setA.size !== setB.size) { return false; } for (const name of setA) { if (!setB.has(name)) { return false; } } return true; } function schemaShapeChanged(current, next) { return !sameNames(Object.keys(current.nodes), next.nodes.map(n => n.name)) || !sameNames(Object.keys(current.marks), next.marks.map(m => m.name)); } export function ReactEditorView(props) { var _pluginInjectionAPI$c, _pluginInjectionAPI$c2, _pluginInjectionAPI$c3, _media, _linking, _linking$smartLinks, _document$querySelect, _props$render, _props$render2; // Should be always the first statement in the component const firstRenderStartTimestampRef = useRef(performance.now()); const { preset, editorProps: { onSSRMeasure, appearance: nextAppearance, disabled, featureFlags: editorPropFeatureFlags, errorReporterHandler, defaultValue, shouldFocus, __livePage }, onEditorCreated, onEditorDestroyed } = props; const ssrEditorStateRef = useRef(undefined); const editorRef = useRef(null); const viewRef = useRef(); const focusTimeoutId = useRef(); // ProseMirror is instantiated prior to the initial React render cycle, // so we allow transactions by default, to avoid discarding the initial one. const canDispatchTransactions = useRef(true); // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead const editorId = useRef(uuid()); const eventDispatcher = useMemo(() => new EventDispatcher(), []); const config = useRef({ nodes: [], marks: [], pmPlugins: [], contentComponents: [], pluginHooks: [], primaryToolbarComponents: [], secondaryToolbarComponents: [], onEditorViewStateUpdatedCallbacks: [] }); const contentTransformer = useRef(undefined); const featureFlags = useMemo(() => createFeatureFlagsFromProps(editorPropFeatureFlags), [editorPropFeatureFlags]); const getEditorState = useCallback(() => { var _ssrEditorStateRef$cu, _viewRef$current; return (_ssrEditorStateRef$cu = ssrEditorStateRef.current) !== null && _ssrEditorStateRef$cu !== void 0 ? _ssrEditorStateRef$cu : (_viewRef$current = viewRef.current) === null || _viewRef$current === void 0 ? void 0 : _viewRef$current.state; }, []); const getEditorView = useCallback(() => viewRef.current, []); const dispatch = useMemo(() => createDispatch(eventDispatcher), [eventDispatcher]); const errorReporter = useMemo(() => createErrorReporter(errorReporterHandler), [errorReporterHandler]); const handleAnalyticsEvent = useCallback(payload => { fireAnalyticsEvent(props.createAnalyticsEvent)(payload); }, [props.createAnalyticsEvent]); const dispatchAnalyticsEvent = useCallback(payload => { const dispatch = createDispatch(eventDispatcher); dispatch(analyticsEventKey, { payload }); }, [eventDispatcher]); const pluginInjectionAPI = useRef(new EditorPluginInjectionAPI({ getEditorState: getEditorState, getEditorView: getEditorView, fireAnalyticsEvent: handleAnalyticsEvent, appearance: nextAppearance })); const parseDoc = useCallback((schema, api, options) => { if (!options.doc) { return undefined; } // if the collabEdit API is set, skip this validation due to potential pm validation errors // from docs that end up with invalid marks after processing (See #hot-111702 for more details) if (isSSR() || (api === null || api === void 0 ? void 0 : api.collabEdit) !== undefined || options.props.editorProps.skipValidation) { return processRawValueWithoutValidation(schema, options.doc, dispatchAnalyticsEvent); } else { return processRawValue(schema, options.doc, options.props.providerFactory, options.props.editorProps.sanitizePrivateContent, contentTransformer.current, dispatchAnalyticsEvent); } }, [dispatchAnalyticsEvent]); const createEditorState = useCallback(options => { var _api$editorViewMode; let schema; if (viewRef.current) { if (options.resetting) { /** * ReactEditorView currently does NOT handle dynamic schema, * We are reusing the existing schema, and rely on #reconfigureState * to update `this.config` */ schema = viewRef.current.state.schema; } else { /** * There's presently a number of issues with changing the schema of a * editor inflight. A significant issue is that we lose the ability * to keep track of a user's history as the internal plugin state * keeps a list of Steps to undo/redo (which are tied to the schema). * Without a good way to do work around this, we prevent this for now. */ // eslint-disable-next-line no-console console.warn('The editor does not support changing the schema dynamically.'); return viewRef.current.state; } } else { config.current = processPluginsList(createPluginsList(options.props.preset, 'allowBlockType' in props.editorProps ? props.editorProps : {}, pluginInjectionAPI.current)); if (expValEquals('platform_editor_appearance_shared_state', 'isEnabled', true)) { config.current.pmPlugins.push(...pluginInjectionAPI.current.getInternalPMPlugins()); } schema = createSchema(config.current); } const { contentTransformerProvider } = options.props.editorProps; const plugins = createPMPlugins({ schema, dispatch: dispatch, errorReporter: errorReporter, editorConfig: config.current, eventDispatcher: eventDispatcher, providerFactory: options.props.providerFactory, portalProviderAPI: props.portalProviderAPI, nodeViewPortalProviderAPI: props.nodeViewPortalProviderAPI, dispatchAnalyticsEvent: dispatchAnalyticsEvent, featureFlags, getIntl: () => props.intl, onEditorStateUpdated: pluginInjectionAPI.current.onEditorViewUpdated }); contentTransformer.current = contentTransformerProvider ? contentTransformerProvider(schema) : undefined; const api = pluginInjectionAPI.current.api(); // If we have a doc prop, we need to process it into a PMNode const doc = parseDoc(schema, api, options); const isViewMode = (api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : _api$editorViewMode.sharedState.currentState().mode) === 'view'; let selection; if (doc) { if (isViewMode) { const emptySelection = new TextSelection(doc.resolve(0)); return EditorState.create({ schema, plugins: plugins, doc, selection: emptySelection }); } else { selection = options.selectionAtStart ? Selection.atStart(doc) : Selection.atEnd(doc); } } // Workaround for ED-3507: When media node is the last element, scrollIntoView throws an error const patchedSelection = selection ? Selection.findFrom(selection.$head, -1, true) || undefined : undefined; return EditorState.create({ schema, plugins: plugins, doc, selection: patchedSelection }); }, [errorReporter, featureFlags, parseDoc, props.intl, props.portalProviderAPI, props.nodeViewPortalProviderAPI, props.editorProps, dispatchAnalyticsEvent, eventDispatcher, dispatch]); const initialEditorState = useMemo(() => { if (isSSR()) { // We don't need to create initial state in SSR, it would be done by EditorSSRRenderer, // so we can save some CPU time here. return undefined; } return createEditorState({ props, doc: defaultValue, // ED-4759: Don't set selection at end for full-page editor - should be at start. selectionAtStart: isFullPage(nextAppearance) }); }, // This is only used for the initial state - afterwards we will have `viewRef` available for use // eslint-disable-next-line react-hooks/exhaustive-deps []); const getCurrentEditorState = useCallback(() => { var _viewRef$current$stat, _viewRef$current2; return (_viewRef$current$stat = (_viewRef$current2 = viewRef.current) === null || _viewRef$current2 === void 0 ? void 0 : _viewRef$current2.state) !== null && _viewRef$current$stat !== void 0 ? _viewRef$current$stat : initialEditorState; }, [initialEditorState]); const blur = useCallback(() => { if (!viewRef.current) { return; } if (viewRef.current.dom instanceof HTMLElement && viewRef.current.hasFocus()) { viewRef.current.dom.blur(); } // The selectionToDOM method uses the document selection to determine currently selected node // We need to mimic blurring this as it seems doing the above is not enough. // @ts-expect-error const sel = viewRef.current.root.getSelection(); if (sel) { sel.removeAllRanges(); } }, []); const resetEditorState = useCallback(({ doc, shouldScrollToBottom }) => { var _props$editorProps$on, _props$editorProps; if (!viewRef.current) { return; } // We cannot currently guarantee when all the portals will have re-rendered during a reconfigure // so we blur here to stop ProseMirror from trying to apply selection to detached nodes or // nodes that haven't been re-rendered to the document yet. blur(); const newEditorState = createEditorState({ props: props, doc: doc, resetting: true, selectionAtStart: !shouldScrollToBottom }); viewRef.current.updateState(newEditorState); (_props$editorProps$on = (_props$editorProps = props.editorProps).onChange) === null || _props$editorProps$on === void 0 ? void 0 : _props$editorProps$on.call(_props$editorProps, viewRef.current, { source: 'local', isDirtyChange: false }); }, [blur, createEditorState, props]); // Initialise phase // Using constructor hook so we setup and dispatch analytics before anything else useConstructor(() => { var _props$intl; // This needs to be before initialising editorState because // we dispatch analytics events in plugin initialisation eventDispatcher.on(analyticsEventKey, handleAnalyticsEvent); eventDispatcher.on('resetEditorState', resetEditorState); dispatchAnalyticsEvent({ action: ACTION.STARTED, actionSubject: ACTION_SUBJECT.EDITOR, attributes: { platform: PLATFORMS.WEB, featureFlags: featureFlags ? getEnabledFeatureFlagKeys(featureFlags) : [], accountLocale: (_props$intl = props.intl) === null || _props$intl === void 0 ? void 0 : _props$intl.locale, browserLocale: window.navigator.language }, eventType: EVENT_TYPE.UI }); }); useLayoutEffect(() => { if (isSSR()) { return; } // Transaction dispatching is already enabled by default prior to // mounting, but we reset it here, just in case the editor view // instance is ever recycled (mounted again after unmounting) with // the same key. // AND since React 18 effects may run multiple times so we need to ensure // this is reset so that transactions are still allowed. // Although storing mounted state is an anti-pattern in React, // we do so here so that we can intercept and abort asynchronous // ProseMirror transactions when a dismount is imminent. canDispatchTransactions.current = true; return () => { // We can ignore any transactions from this point onwards. // This serves to avoid potential runtime exceptions which could arise // from an async dispatched transaction after it's unmounted. canDispatchTransactions.current = false; }; }, []); // Cleanup useLayoutEffect(() => { if (isSSR()) { // No cleanup in SSR should happened because SSR doesn't render a real editor. return; } return () => { const focusTimeoutIdCurrent = focusTimeoutId.current; if (focusTimeoutIdCurrent) { clearTimeout(focusTimeoutIdCurrent); } if (viewRef.current) { // Destroy the state if the Editor is being unmounted const editorState = viewRef.current.state; editorState.plugins.forEach(plugin => { const state = plugin.getState(editorState); if (state && state.destroy) { state.destroy(); } }); } eventDispatcher.destroy(); // this.view will be destroyed when React unmounts in handleEditorViewRef }; }, [eventDispatcher]); // Bumped after `reconfigureState` so the render prop re-reads the // in-place-mutated `config.current` (contentComponents / toolbar // components from the rebuilt preset). const [, bumpConfigVersion] = useState(0); // Preset reference last processed by reconfigureState. Used to skip the // destructive work (plugin filter, schema rebuild) when reconfigure is // called with the same preset. const lastProcessedPresetRef = useRef(null); const reconfigureState = useCallback(props => { if (!viewRef.current) { return; } // We cannot currently guarantee when all the portals will have re-rendered during a reconfigure // so we blur here to stop ProseMirror from trying to apply selection to detached nodes or // nodes that haven't been re-rendered to the document yet. blur(); // Snapshot plugin names registered before createPluginsList runs, so // we can tell which plugins are newly added by the new preset vs. // which ones already coexisted with the current schema. const previousPluginNames = new Set(pluginInjectionAPI.current.getRegisteredPluginNames()); let editorPlugins = createPluginsList(props.preset, 'allowBlockType' in props.editorProps ? props.editorProps : {}, pluginInjectionAPI.current); // Capture once, before either downstream block updates the ref — // both the filter and the schema rebuild are destructive and only // want to run when the preset has actually changed. const presetChanged = lastProcessedPresetRef.current !== props.preset; // Build a candidate config from the *unfiltered* plugin list so we can // decide whether the schema rebuild path will run. Both the rebuild // decision and the drop-filter decision below depend on this answer, // so it has to be computed up-front. const buildConfig = plugins => { const c = processPluginsList(plugins); if (expValEquals('platform_editor_appearance_shared_state', 'isEnabled', true)) { c.pmPlugins.push(...pluginInjectionAPI.current.getInternalPMPlugins()); } return c; }; let nextConfig = buildConfig(editorPlugins); // `state.reconfigure` preserves the original schema, so a preset // toggle that should change schema (markdown↔rich) needs a fresh // `EditorState`. Resets all plugin state including undo history. // // Compare schema *shape* (node + mark name sets) rather than preset // identity: consumers commonly recreate the preset object on every // parent re-render, and a destructive rebuild on a no-op identity // change tears down all plugin state (e.g. unmounts the AI palette). const shouldRebuildSchema = presetChanged && schemaShapeChanged(viewRef.current.state.schema, nextConfig) && expValEqualsNoExposure('cc-markdown-mode', 'isEnabled', true); // `state.reconfigure` keeps the original schema, so switching presets // can leave the editor inconsistent in two ways: // 1. The new preset may add plugins that reference schema nodes or // marks the original schema doesn't have. // 2. Plugins registered by a previous preset can linger in the // injection API even when the new preset doesn't re-register // them, so listeners still fire against a state that no longer // has their pmPlugin. // // When the schema is being rebuilt below, the new schema is built // from the *unfiltered* plugin list — so dropping plugins whose // nodes/marks the OLD schema lacks would wrongly remove the very // plugins the rebuild is meant to admit. Skip the drop step in that // case (purpose 1) but always reconcile the injection API // (purpose 2). When NOT rebuilding, run both — even under the // `cc-markdown-mode` experiment, otherwise no-op preset identity // changes would silently leave a broken plugin/schema mismatch. if (presetChanged && fg('platform_editor_reconfigure_filter_plugins')) { let dropped = []; if (!shouldRebuildSchema) { const result = filterPluginsForReconfigure(editorPlugins, viewRef.current.state.schema, previousPluginNames); if (result.dropped.length > 0) { editorPlugins = result.kept; // Plugin list changed — rebuild candidate config to match. nextConfig = buildConfig(editorPlugins); } dropped = result.dropped; } const keptPluginNames = new Set(editorPlugins.map(p => p === null || p === void 0 ? void 0 : p.name).filter(n => Boolean(n))); const evictedFromApi = pluginInjectionAPI.current.retainPlugins(keptPluginNames); if (dropped.length > 0 || evictedFromApi.length > 0) { // eslint-disable-next-line no-console console.warn('[reconfigureState] Cleanup summary:', { dropped, evictedFromApi }); } } config.current = nextConfig; const state = viewRef.current.state; let newState; if (shouldRebuildSchema) { const newSchema = createSchema(config.current); let newDoc; try { newDoc = PMNode.fromJSON(newSchema, state.doc.toJSON()); } catch (e) { // eslint-disable-next-line no-console console.error('[reconfigureState] Failed to migrate doc to new schema; resetting to empty doc', e); const empty = newSchema.topNodeType.createAndFill(); if (!empty) { throw new Error('reconfigureState: doc migration failed and new schema cannot create an empty top node'); } newDoc = empty; } let newSelection; try { newSelection = Selection.fromJSON(newDoc, state.selection.toJSON()); } catch { // Old selection's positions / node types may not map onto the new schema. newSelection = Selection.atStart(newDoc); } const plugins = createPMPlugins({ schema: newSchema, dispatch: dispatch, errorReporter: errorReporter, editorConfig: config.current, eventDispatcher: eventDispatcher, providerFactory: props.providerFactory, portalProviderAPI: props.portalProviderAPI, nodeViewPortalProviderAPI: props.nodeViewPortalProviderAPI, dispatchAnalyticsEvent: dispatchAnalyticsEvent, featureFlags, getIntl: () => props.intl, onEditorStateUpdated: pluginInjectionAPI.current.onEditorViewUpdated }); newState = EditorState.create({ schema: newSchema, doc: newDoc, selection: newSelection, plugins: plugins }); } else { const plugins = createPMPlugins({ schema: state.schema, dispatch: dispatch, errorReporter: errorReporter, editorConfig: config.current, eventDispatcher: eventDispatcher, providerFactory: props.providerFactory, portalProviderAPI: props.portalProviderAPI, nodeViewPortalProviderAPI: props.nodeViewPortalProviderAPI, dispatchAnalyticsEvent: dispatchAnalyticsEvent, featureFlags, getIntl: () => props.intl, onEditorStateUpdated: pluginInjectionAPI.current.onEditorViewUpdated }); newState = state.reconfigure({ plugins: plugins }); } if (presetChanged) { lastProcessedPresetRef.current = props.preset; } // need to update the state first so when the view builds the nodeviews it is // using the latest plugins viewRef.current.updateState(newState); const result = viewRef.current.update({ ...viewRef.current.props, state: newState }); // The new collab-edit plugin instance starts with `isReady=false`. // The rebind path in editor-plugin-collab-edit's initialize.ts is // gated on `provider.getInitPayload`, which the Confluence NCS // provider does not implement, so the placeholder spinner would // never clear. Re-seeding here is safe: the prior state must have // had `isReady=true` for the user to have triggered the toggle. // // Must run AFTER `view.update({ state: newState })`: that call resets // the view's state to the captured `newState` reference, so a // dispatch placed before it would advance `view.state` to a value // that `update` then silently overwrites — discarding the meta and // leaving `isReady=false`. if (shouldRebuildSchema) { // `state.collabEditPlugin$` is the property PM derives from the // collab plugin's PluginKey; cast through `unknown` to read it. const collabState = viewRef.current.state.collabEditPlugin$; if (collabState && collabState.isReady !== true) { viewRef.current.dispatch(viewRef.current.state.tr.setMeta('collabInitialised', true)); } } // EDITOR-6702: gated until we have a broader gate; reconfigure is a // low-level path so use NoExposure. if (expValEqualsNoExposure('cc-markdown-mode', 'isEnabled', true)) { // Force a render so PluginSlot picks up the new preset's content // components against the new state. bumpConfigVersion(v => v + 1); } return result; }, [blur, dispatchAnalyticsEvent, eventDispatcher, dispatch, errorReporter, featureFlags]); const onEditorViewUpdated = useCallback(({ originalTransaction, transactions, oldEditorState, newEditorState }) => { var _config$current; (_config$current = config.current) === null || _config$current === void 0 ? void 0 : _config$current.onEditorViewStateUpdatedCallbacks.forEach(entry => { entry.callback({ originalTransaction, transactions, oldEditorState, newEditorState }); }); }, []); const dispatchTransaction = useDispatchTransaction({ onChange: props.editorProps.onChange, dispatchAnalyticsEvent, onEditorViewUpdated, isRemoteReplaceDocumentTransaction: (_pluginInjectionAPI$c = pluginInjectionAPI.current.api()) === null || _pluginInjectionAPI$c === void 0 ? void 0 : (_pluginInjectionAPI$c2 = _pluginInjectionAPI$c.collabEdit) === null || _pluginInjectionAPI$c2 === void 0 ? void 0 : (_pluginInjectionAPI$c3 = _pluginInjectionAPI$c2.actions) === null || _pluginInjectionAPI$c3 === void 0 ? void 0 : _pluginInjectionAPI$c3.isRemoteReplaceDocumentTransaction }); // Ignored via go/ees007 // eslint-disable-next-line @atlaskit/editor/enforce-todo-comment-format // TODO: Remove these when we deprecate these props from editor-props - smartLinks is unfortunately still used in some places, we can sidestep this problem if we move everyone across to ComposableEditor and deprecate Editor const UNSAFE_cards = props.editorProps.UNSAFE_cards; const smartLinks = props.editorProps.smartLinks; // Temporary to replace provider factory while migration to `ComposableEditor` occurs useProviders({ editorApi: pluginInjectionAPI.current.api(), contextIdentifierProvider: props.editorProps.contextIdentifierProvider, mediaProvider: (_media = props.editorProps.media) === null || _media === void 0 ? void 0 : _media.provider, mentionProvider: props.editorProps.mentionProvider, cardProvider: ((_linking = props.editorProps.linking) === null || _linking === void 0 ? void 0 : (_linking$smartLinks = _linking.smartLinks) === null || _linking$smartLinks === void 0 ? void 0 : _linking$smartLinks.provider) || smartLinks && smartLinks.provider || UNSAFE_cards && UNSAFE_cards.provider, emojiProvider: props.editorProps.emojiProvider, autoformattingProvider: props.editorProps.autoformattingProvider, taskDecisionProvider: props.editorProps.taskDecisionProvider }); const getDirectEditorProps = useCallback(state => { const stateToUse = state !== null && state !== void 0 ? state : getCurrentEditorState(); if (!stateToUse) { // This should not be happened, because initialState is only inavailable in SSR, // but in SSR this function should never be called. // In SSR we should use EditorSSRRenderer instead usual ProseMirror editor. throw new Error('No editor state found'); } return { state: stateToUse, dispatchTransaction: tr => { // Block stale transactions: // Prevent runtime exceptions from async transactions that would attempt to // update the DOM after React has unmounted the Editor. if (canDispatchTransactions.current) { dispatchTransaction(viewRef.current, tr); } }, // Disables the contentEditable attribute of the editor if the editor is disabled editable: _state => !disabled, attributes: { 'data-gramm': 'false' } }; }, [dispatchTransaction, disabled, getCurrentEditorState]); const createEditorView = useCallback(node => { // Creates the editor-view from this.editorState. If an editor has been mounted // previously, this will contain the previous state of the editor. const view = new EditorView({ mount: node }, getDirectEditorProps()); viewRef.current = view; measureRender(measurements.PROSEMIRROR_RENDERED, ({ duration, startTime, distortedDuration }) => { const proseMirrorRenderedSeverity = getAnalyticsEventSeverity(duration, PROSEMIRROR_RENDERED_NORMAL_SEVERITY_THRESHOLD, PROSEMIRROR_RENDERED_DEGRADED_SEVERITY_THRESHOLD); if (viewRef.current) { var _nodesAndExtensionKey, _pluginInjectionAPI$c4; const nodesAndExtensionKeys = expValEquals('platform_editor_prosemirror_rendered_data', 'isEnabled', true) ? getNodesCountWithExtensionKeys(viewRef.current.state.doc) : undefined; const nodes = (_nodesAndExtensionKey = nodesAndExtensionKeys === null || nodesAndExtensionKeys === void 0 ? void 0 : nodesAndExtensionKeys.nodes) !== null && _nodesAndExtensionKey !== void 0 ? _nodesAndExtensionKey : getNodesCount(viewRef.current.state.doc); const ttfb = getResponseEndTime(); const requestToResponseTime = getRequestToResponseTime(); const contextIdentifier = (_pluginInjectionAPI$c4 = pluginInjectionAPI.current.api().base) === null || _pluginInjectionAPI$c4 === void 0 ? void 0 : _pluginInjectionAPI$c4.sharedState.currentState(); const nodesInViewport = getNodesVisibleInViewport(viewRef.current.dom); const nodeSize = viewRef.current.state.doc.nodeSize; const { totalNodes, nodeSizeBucket } = expValEquals('cc_editor_insm_doc_size_stats', 'isEnabled', true) ? { totalNodes: Object.values(nodes).reduce((acc, curr) => acc + curr, 0), // Computed on client for dimension bucketing in Statsig nodeSizeBucket: (() => { switch (true) { case nodeSize < 10000: return '<10000'; case nodeSize < 20000: return '<20000'; case nodeSize < 30000: return '<30000'; case nodeSize < 40000: return '<40000'; case nodeSize < 50000: return '<50000'; default: return '50000+'; } })() } : {}; if (expValEquals('platform_editor_prosemirror_rendered_data', 'isEnabled', true)) { var _nodesAndExtensionKey2; const extensionKeys = (_nodesAndExtensionKey2 = nodesAndExtensionKeys === null || nodesAndExtensionKeys === void 0 ? void 0 : nodesAndExtensionKeys.extensionKeys) !== null && _nodesAndExtensionKey2 !== void 0 ? _nodesAndExtensionKey2 : {}; const interaction = getActiveInteraction(); const pageLoadType = interaction === null || interaction === void 0 ? void 0 : interaction.type; const pageType = interaction === null || interaction === void 0 ? void 0 : interaction.routeName; const timings = (() => { if (requestToResponseTime === undefined && bootStartTime === undefined) { return undefined; } const timingValues = {}; if (requestToResponseTime !== undefined) { timingValues['requestStart->responseEnd'] = Math.round(requestToResponseTime); } if (bootStartTime !== undefined) { timingValues.bootToRender = Math.round(startTime - bootStartTime); } return timingValues; })(); const attributes = { duration, startTime, nodes, nodesInViewport, nodeSize, nodeSizeBucket, totalNodes, ttfb, severity: proseMirrorRenderedSeverity, objectId: contextIdentifier === null || contextIdentifier === void 0 ? void 0 : contextIdentifier.objectId, distortedDuration, pageLoadType, pageType, timings, extensionKeys, ufoInteractionId: getInteractionId().current }; dispatchAnalyticsEvent({ action: ACTION.PROSEMIRROR_RENDERED, actionSubject: ACTION_SUBJECT.EDITOR, attributes, eventType: EVENT_TYPE.OPERATIONAL }); } else { const attributes = { duration, startTime, nodes, nodesInViewport, nodeSize, nodeSizeBucket, totalNodes, ttfb, severity: proseMirrorRenderedSeverity, objectId: contextIdentifier === null || contextIdentifier === void 0 ? void 0 : contextIdentifier.objectId, distortedDuration }; dispatchAnalyticsEvent({ action: ACTION.PROSEMIRROR_RENDERED, actionSubject: ACTION_SUBJECT.EDITOR, attributes, eventType: EVENT_TYPE.OPERATIONAL }); } } }); pluginInjectionAPI.current.onEditorViewUpdated({ newEditorState: viewRef.current.state, oldEditorState: undefined }); return view; }, [getDirectEditorProps, dispatchAnalyticsEvent]); const [editorView, setEditorView] = useState(undefined); // Detects if the editor is nested inside an extension - ie. it is a Legacy Content Extension (LCE) const isNestedEditor = useRef(null); const isNestedEditorCalculated = useRef(false); if (editorRef.current !== null && !isNestedEditorCalculated.current) { var _editorRef$current; isNestedEditor.current = !!((_editorRef$current = editorRef.current) !== null && _editorRef$current !== void 0 && _editorRef$current.closest('.extension-editable-area')); isNestedEditorCalculated.current = true; } const originalScrollToRestore = React.useRef(!isNestedEditor.current && isFullPage(props.editorProps.appearance) ? (_document$querySelect = document.querySelector('[data-editor-scroll-container]')) === null || _document$querySelect === void 0 ? void 0 : _document$querySelect.scrollTop : undefined); const mitigateScrollJump = // The feature gate here is being used to avoid potential bugs with the scroll restoration code // moving it to the end of the expression negates the point of the feature gate // eslint-disable-next-line @atlaskit/platform/no-preconditioning isFullPage(props.editorProps.appearance) && originalScrollToRestore.current && originalScrollToRestore.current !== 0; useLayoutEffect(() => { var _editorView$props$edi, _editorView$props; if (isSSR()) { // We don't need to focus anything in SSR. return; } if (shouldFocus && editorView !== null && editorView !== void 0 && (_editorView$props$edi = (_editorView$props = editorView.props).editable) !== null && _editorView$props$edi !== void 0 && _editorView$props$edi.call(_editorView$props, editorView.state)) { if (!mitigateScrollJump) { const liveDocWithContent = (__livePage || expValEquals('platform_editor_no_cursor_on_edit_page_init', 'isEnabled', true)) && !isEmptyDocument(editorView.state.doc); if (!liveDocWithContent) { focusTimeoutId.current = handleEditorFocus(editorView); } if (isChromeless(props.editorProps.appearance)) { focusTimeoutId.current = handleEditorFocus(editorView); } if (expValEquals('platform_editor_no_cursor_on_edit_page_init', 'isEnabled', true) && fg('cc_editor_focus_before_editor_on_load')) { if (!disabled && shouldFocus && !isEmptyDocument(editorView.state.doc)) { focusEditorElement(editorId.current); } } } } }, [editorView, shouldFocus, __livePage, mitigateScrollJump, disabled, props.editorProps.appearance]); const scrollElement = React.useRef(); const possibleListeners = React.useRef([]); useEffect(() => { if (isSSR()) { // No event listeners should be attached to scroll element in SSR. return; } return () => { if (scrollElement.current) { // eslint-disable-next-line react-hooks/exhaustive-deps for (const possibleListener of possibleListeners.current) { var _scrollElement$curren; // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners (_scrollElement$curren = scrollElement.current) === null || _scrollElement$curren === void 0 ? void 0 : _scrollElement$curren.removeEventListener(...possibleListener); } } scrollElement.current = null; }; }, []); const handleEditorViewRef = useCallback(node => { if (node) { // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage scrollElement.current = document.querySelector('[data-editor-scroll-container]'); const cleanupListeners = () => { // eslint-disable-next-line react-hooks/exhaustive-deps for (const possibleListener of possibleListeners.current) { var _scrollElement$curren2; // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners (_scrollElement$curren2 = scrollElement.current) === null || _scrollElement$curren2 === void 0 ? void 0 : _scrollElement$curren2.removeEventListener(...possibleListener); } }; if (scrollElement.current) { const wheelAbortHandler = () => { const activeInteraction = getActiveInteraction(); if (activeInteraction && ['edit-page', 'live-edit'].includes(activeInteraction.ufoName)) { abortAll('new_interaction', `wheel-on-editor-element`); } cleanupListeners(); }; // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners scrollElement.current.addEventListener('wheel', wheelAbortHandler); possibleListeners.current.push(['wheel', wheelAbortHandler]); const scrollAbortHandler = () => { const activeInteraction = getActiveInteraction(); if (activeInteraction && ['edit-page', 'live-edit'].includes(activeInteraction.ufoName)) { abortAll('new_interaction', `scroll-on-editor-element`); } cleanupListeners(); }; // eslint-disable-next-line @repo/internal/dom-events/no-unsafe-event-listeners scrollElement.current.addEventListener('scroll', scrollAbortHandler); possibleListeners.current.push(['scroll', scrollAbortHandler]); } } if (!viewRef.current && node) { nodeVisibilityManager(node).initialiseNodeObserver(); const view = createEditorView(node); if (mitigateScrollJump) { const scrollElement = document.querySelector('[data-editor-scroll-container]'); scrollElement === null || scrollElement === void 0 ? void 0 : scrollElement.scrollTo({ top: originalScrollToRestore.current, behavior: 'instant' }); } onEditorCreated({ view, config: config.current, eventDispatcher: eventDispatcher, transformer: contentTransformer.current }); React.startTransition(() => { // Force React to re-render so consumers get a reference to the editor view setEditorView(view); }); } else if (viewRef.current && !node) { // When the appearance is changed, React will call handleEditorViewRef with node === null // to destroy the old EditorView, before calling this method again with node === div to // create the new EditorView onEditorDestroyed({ view: viewRef.current, config: config.current, eventDispatcher: eventDispatcher, transformer: contentTransformer.current }); const wasAnalyticsDisconnected = !eventDispatcher.has(analyticsEventKey, handleAnalyticsEvent); // If we disabled event listening for some reason we should re-enable it temporarily while we destroy // the view for any analytics that occur there. if (wasAnalyticsDisconnected) { eventDispatcher.on(analyticsEventKey, handleAnalyticsEvent); viewRef.current.destroy(); // Destroys the dom node & all node views eventDispatcher.off(analyticsEventKey, handleAnalyticsEvent); } else { viewRef.current.destroy(); // Destroys the dom node & all node views } nodeVisibilityManager(viewRef.current.dom).disconnect(); viewRef.current = undefined; } }, [createEditorView, onEditorCreated, eventDispatcher, onEditorDestroyed, handleAnalyticsEvent, mitigateScrollJump]); const isPageAppearance = isFullPage(nextAppearance) || nextAppearance === 'max'; const createEditor = useCallback((assistiveLabel, assistiveDescribedBy) => { return /*#__PURE__*/React.createElement(React.Fragment, null, fg('cc_editor_focus_before_editor_on_load') && /*#__PURE__*/React.createElement("div", { tabIndex: -1, "data-focus-id": editorId.current, "data-testid": "react-editor-view-inital-focus-element" }), /*#__PURE__*/React.createElement("div", { // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766 className: `ProseMirror ${getUAPrefix()}`, key: "ProseMirror", ref: handleEditorViewRef, "aria-label": assistiveLabel || (isPageAppearance ? props.intl.formatMessage(editorMessages.fullPageEditorAssistiveLabel) : props.intl.formatMessage(editorMessages.editorAssistiveLabel)) // setting aria-multiline to true when not mobile appearance. // because somehow mobile tests are failing when it set. // don't know why that is happening. // Created https://product-fabric.atlassian.net/jira/servicedesk/projects/DTR/queues/issue/DTR-1675 // to investigate further. , "aria-multiline": true, role: "textbox", id: EDIT_AREA_ID, "aria-describedby": assistiveDescribedBy, "data-editor-id": editorId.current, "data-vc-ignore-if-no-layout-shift": true, "data-ssr-placeholder": "editor-view", "data-ssr-placeholder-replace": "editor-view" // eslint-disable-next-line react/no-danger -- needed for SSR and hydration so react keeps the HTML untouched , dangerouslySetInnerHTML: { __html: '' } })); }, [handleEditorViewRef, isPageAppearance, props.intl]); const previousPreset = usePreviousState(preset); useLayoutEffect(() => { if (isSSR()) { // No state reconfiguration is supported in SSR. return; } if (previousPreset && previousPreset !== preset) { reconfigureState(props); } }, [reconfigureState, previousPreset, preset, props]); const previousDisabledState = usePreviousState(disabled); useLayoutEffect(() => { if (isSSR()) { // We don't need to focus anything in SSR. return; } if (viewRef.current && previousDisabledState !== disabled) { // Disables the contentEditable attribute of the editor if the editor is disabled viewRef.current.setProps({ editable: _state => !disabled }); const isLivePageWithContent = (__livePage || expValEquals('platform_editor_no_cursor_on_edit_page_init', 'isEnabled', true)) && !isEmptyDocument(viewRef.current.state.doc); if (!disabled && shouldFocus && !isLivePageWithContent) { focusTimeoutId.current = handleEditorFocus(viewRef.current); } if (expValEquals('platform_editor_no_cursor_on_edit_page_init', 'isEnabled', true) && fg('cc_editor_focus_before_editor_on_load')) { if (!disabled && shouldFocus && !isEmptyDocument(viewRef.current.state.doc)) { focusEditorElement(editorId.current); } } } }, [disabled, shouldFocus, previousDisabledState, __livePage]); useLayoutEffect(() => { if (expValEquals('platform_editor_appearance_shared_state', 'isEnabled', true)) { var _pluginInjectionAPI$c5, _pluginInjectionAPI$c6, _pluginInjectionAPI$c7; (_pluginInjectionAPI$c5 = pluginInjectionAPI.current.api()) === null || _pluginInjectionAPI$c5 === void 0 ? void 0 : (_pluginInjectionAPI$c6 = _pluginInjectionAPI$c5.core) === null || _pluginInjectionAPI$c6 === void 0 ? void 0 : (_pluginInjectionAPI$c7 = _pluginInjectionAPI$c6.actions) === null || _pluginInjectionAPI$c7 === void 0 ? void 0 : _pluginInjectionAPI$c7.updateAppearance(nextAppearance); } }, [nextAppearance]); useFireFullWidthEvent(nextAppearance, dispatchAnalyticsEvent); // This function uses as prop as `<EditorSSRRenderer>` so, that should be memoized, // to avoid extra rerenders. const buildDoc = useCallback(schema => { return parseDoc(schema, undefined, { // Don't pass all props here, use only what you need to keep hook dependencies more stable. // Check what `parseDoc` consumes and pass only needed data. props: { providerFactory: props.providerFactory, editorProps: { sanitizePrivateContent: props.editorProps.sanitizePrivateContent } }, doc: defaultValue }); }, [defaultValue, parseDoc, props.editorProps.sanitizePrivateContent, props.providerFactory]); // We need to check `allowBlockType` in props, because it is now exist in EditorNextProps type. const { allowBlockType } = 'allowBlockType' in props.editorProps ? props.editorProps : { allowBlockType: undefined }; // In separate memo, because some props like `props.intl` that need only for rendering // changes many times, but we don't want to process plugins and ADF document for each unnecessary changes. const ssrDeps = useMemo(() => { if (!isSSR()) { return null; } const doCreatePluginList = () => createPluginsList(props.preset, // Don't pass props.editorProps directly, because editoProps in the dependency will lead to // multiple repaints, because props.editorPros is not stable object. { allowBlockType }, pluginInjectionAPI.current); const plugins = profileSSROperation(`${SSR_TRACE_SEGMENT_NAME}/createPluginsList`, doCreatePluginList, onSSRMeasure); const doCreateSchema = () => createSchema(processPluginsList(plugins)); const schema = profileSSROperation(`${SSR_TRACE_SEGMENT_NAME}/createSchema`, doCreateSchema, onSSRMeasure); const doBuildDoc = () => buildDoc(schema); const doc = profileSSROperation(`${SSR_TRACE_SEGMENT_NAME}/buildDoc`, doBuildDoc, onSSRMeasure); return { plugins, schema, doc }; }, [allowBlockType, buildDoc, props.preset, onSSRMeasure]); const { assistiveLabel, assistiveDescribedBy } = props.editorProps; const handleSsrEditorStateChanged = useCallback(state => { ssrEditorStateRef.current = state; // Notify listeners about the initial SSR state pluginInjectionAPI.current.onEditorViewUpdated({ newEditorState: state, oldEditorState: undefined }); }, [pluginInjectionAPI]); const memoizedReactEditorViewContext = useMemo(() => ({ editorRef, // Use a getter so that consumers always read the live viewRef.current at access // time, not a stale snapshot captured when this memo was created. get editorView() { return viewRef.current; }, popupsMountPoint: props.editorProps.popupsMountPoint }), // viewRef is intentionally omitted from the deps array — it's a stable ref object; the getter reads // .current lazily so there's no stale-closure risk. // eslint-disable-next-line react-hooks/exhaustive-deps [editorRef, props.editorProps.popupsMountPoint]); // eslint-disable-next-line @atlassian/perf-linting/no-inline-context-value, @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 const reactEditorViewContext = expValEquals('platform_editor_perf_lint_cleanup', 'isEnabled', true) ? memoizedReactEditorViewContext : { editorRef, editorView: viewRef.current, popupsMountPoint: props.editorProps.popupsMountPoint }; const ssrEditor = useMemo(() => { if (!ssrDeps) { return null; } return /*#__PURE__*/React.createElement(EditorSSRRenderer, { intl: props.intl, doc: ssrDeps.doc, schema: ssrDeps.schema, plugins: ssrDeps.plugins, portalProviderAPI: props.portalProviderAPI // IMPORTANT: Keep next props in sync with div that renders a real ProseMirror editor. // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766 , className: `ProseMirror ${getUAPrefix()}`, key: "ProseMirror", "a