@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
1,036 lines (1,009 loc) • 53.3 kB
JavaScript
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