UNPKG

@atlaskit/editor-plugin-floating-toolbar

Version:

Floating toolbar plugin for @atlaskit/editor-core

592 lines (578 loc) 25.9 kB
import React from 'react'; import camelCase from 'lodash/camelCase'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, CONTENT_COMPONENT, EVENT_TYPE } from '@atlaskit/editor-common/analytics'; import { isSSR } from '@atlaskit/editor-common/core-utils'; import { ErrorBoundary } from '@atlaskit/editor-common/error-boundary'; import { // Deprecated API - Look at removing this sometime in the future useSharedPluginState } from '@atlaskit/editor-common/hooks'; import { WithProviders } from '@atlaskit/editor-common/provider-factory'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { areToolbarFlagsEnabled } from '@atlaskit/editor-common/toolbar-flag-check'; import { Popup } from '@atlaskit/editor-common/ui'; import { AllSelection, PluginKey, TextSelection } from '@atlaskit/editor-prosemirror/state'; import { findDomRefAtPos, findSelectedNodeOfType } from '@atlaskit/editor-prosemirror/utils'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { copyNode } from './pm-plugins/commands'; import forceFocusPlugin, { forceFocusSelector } from './pm-plugins/force-focus'; import { hideConfirmDialog } from './pm-plugins/toolbar-data/commands'; import { createPlugin as floatingToolbarDataPluginFactory } from './pm-plugins/toolbar-data/plugin'; import { pluginKey as dataPluginKey } from './pm-plugins/toolbar-data/plugin-key'; import { findNode } from './pm-plugins/utils'; import { ConfirmationModal } from './ui/ConfirmationModal'; import Toolbar from './ui/Toolbar'; import { consolidateOverflowDropdownItems } from './ui/utils'; const SUPPRESS_TOOLBAR_USER_INTENTS = ['dragging', 'tableContextualMenuPopupOpen', 'tableDragMenuPopupOpen', 'commenting', 'resizing', 'blockMenuOpen', 'statusPickerOpen']; // TODO: AFP-2532 - Fix automatic suppressions below export const getRelevantConfig = (selection, configs) => { // node selections always take precedence, see if let configPair; configs.find(config => { const node = findSelectedNodeOfType(config.nodeType)(selection); if (node) { configPair = { node: node.node, pos: node.pos, config }; } return !!node; }); if (configPair) { return configPair; } // create mapping of node type name to configs const configByNodeType = {}; configs.forEach(config => { if (Array.isArray(config.nodeType)) { config.nodeType.forEach(nodeType => { configByNodeType[nodeType.name] = config; }); } else { configByNodeType[config.nodeType.name] = config; } }); // search up the tree from selection const { $from } = selection; for (let i = $from.depth; i > 0; i--) { const node = $from.node(i); const matchedConfig = configByNodeType[node.type.name]; if (matchedConfig) { return { config: matchedConfig, node: node, pos: $from.pos }; } } // if it is AllSelection (can be result of Cmd+A) - use first node if (selection instanceof AllSelection) { const docNode = $from.node(0); let matchedConfig = null; const firstChild = findNode(docNode, node => { matchedConfig = configByNodeType[node.type.name]; return !!matchedConfig; }); if (firstChild && matchedConfig) { return { config: matchedConfig, node: firstChild, pos: $from.pos }; } } return; }; const getDomRefFromSelection = (view, dispatchAnalyticsEvent) => { try { // Ignored via go/ees005 // eslint-disable-next-line @atlaskit/editor/no-as-casting return findDomRefAtPos(view.state.selection.from, view.domAtPos.bind(view)); // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error) { // eslint-disable-next-line no-console console.warn(error); if (dispatchAnalyticsEvent) { const payload = { action: ACTION.ERRORED, actionSubject: ACTION_SUBJECT.CONTENT_COMPONENT, eventType: EVENT_TYPE.OPERATIONAL, attributes: { component: CONTENT_COMPONENT.FLOATING_TOOLBAR, selection: view.state.selection.toJSON(), position: view.state.selection.from, docSize: view.state.doc.nodeSize, error: error.toString(), // @ts-expect-error - Object literal may only specify known properties, 'errorStack' does not exist in type // This error was introduced after upgrading to TypeScript 5 errorStack: error.stack || undefined } }; dispatchAnalyticsEvent(payload); } } }; function filterUndefined(x) { return !!x; } export const floatingToolbarPlugin = ({ api }) => { return { name: 'floatingToolbar', pmPlugins(floatingToolbarHandlers = []) { const plugins = [{ // Should be after all toolbar plugins name: 'floatingToolbar', plugin: ({ providerFactory, getIntl }) => floatingToolbarPluginFactory({ api, floatingToolbarHandlers, providerFactory, getIntl }) }, { name: 'floatingToolbarData', plugin: ({ dispatch }) => floatingToolbarDataPluginFactory(dispatch) }, { name: 'forceFocus', plugin: () => forceFocusPlugin() }]; return plugins; }, actions: { forceFocusSelector }, commands: { copyNode: (nodeType, inputMethod) => { var _api$analytics; return copyNode(nodeType, api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions, inputMethod); } }, getSharedState(editorState) { var _api$interaction, _api$interaction$shar, _pluginKey$getState$g, _pluginKey$getState, _pluginKey$getState$g2; if (!editorState) { return undefined; } const interactionState = api === null || api === void 0 ? void 0 : (_api$interaction = api.interaction) === null || _api$interaction === void 0 ? void 0 : (_api$interaction$shar = _api$interaction.sharedState.currentState()) === null || _api$interaction$shar === void 0 ? void 0 : _api$interaction$shar.interactionState; const configWithNodeInfo = interactionState !== 'hasNotHadInteraction' ? (_pluginKey$getState$g = (_pluginKey$getState = pluginKey.getState(editorState)) === null || _pluginKey$getState === void 0 ? void 0 : (_pluginKey$getState$g2 = _pluginKey$getState.getConfigWithNodeInfo) === null || _pluginKey$getState$g2 === void 0 ? void 0 : _pluginKey$getState$g2.call(_pluginKey$getState, editorState)) !== null && _pluginKey$getState$g !== void 0 ? _pluginKey$getState$g : undefined : undefined; return { configWithNodeInfo, floatingToolbarData: dataPluginKey.getState(editorState) }; }, contentComponent({ popupsMountPoint, popupsBoundariesElement, popupsScrollableElement, editorView, providerFactory, dispatchAnalyticsEvent }) { if (!editorView) { return null; } return /*#__PURE__*/React.createElement(ContentComponent, { editorView: editorView, pluginInjectionApi: api, popupsMountPoint: popupsMountPoint, popupsBoundariesElement: popupsBoundariesElement, popupsScrollableElement: popupsScrollableElement, providerFactory: providerFactory, dispatchAnalyticsEvent: dispatchAnalyticsEvent }); } }; }; /** * React component that renders the floating toolbar UI for the editor. * * This component manages the display of floating toolbars based on the current editor state, * selection, and configuration. It handles visibility conditions, positioning, toolbar items * consolidation, and confirmation dialogs. * * @param props - Component properties * @param props.pluginInjectionApi - Plugin injection API for accessing other plugin states * @param props.editorView - ProseMirror EditorView instance * @param props.popupsMountPoint - DOM element where popups should be mounted * @param props.popupsBoundariesElement - Element that defines popup boundaries * @param props.popupsScrollableElement - Scrollable container element for popups * @param props.providerFactory - Factory for creating various providers * @param props.dispatchAnalyticsEvent - Function to dispatch analytics events * @returns JSX element representing the floating toolbar or null if not visible */ export function ContentComponent({ pluginInjectionApi, editorView, popupsMountPoint, popupsBoundariesElement, popupsScrollableElement, providerFactory, dispatchAnalyticsEvent }) { var _configWithNodeInfo$c, _configWithNodeInfo$c2, _items, _pluginInjectionApi$c, _pluginInjectionApi$d, _confirmButtonItem, _confirmButtonItem2, _confirmButtonItem3; const { floatingToolbarState, editorDisabledState, editorViewModeState, userIntentState } = useSharedPluginState(pluginInjectionApi, ['floatingToolbar', 'editorDisabled', 'editorViewMode', 'userIntent']); const { configWithNodeInfo, floatingToolbarData } = floatingToolbarState !== null && floatingToolbarState !== void 0 ? floatingToolbarState : {}; if (isSSR()) { return null; } if (!configWithNodeInfo || !configWithNodeInfo.config || typeof ((_configWithNodeInfo$c = configWithNodeInfo.config) === null || _configWithNodeInfo$c === void 0 ? void 0 : _configWithNodeInfo$c.visible) !== 'undefined' && !((_configWithNodeInfo$c2 = configWithNodeInfo.config) !== null && _configWithNodeInfo$c2 !== void 0 && _configWithNodeInfo$c2.visible)) { return null; } if (userIntentState !== null && userIntentState !== void 0 && userIntentState.currentUserIntent && SUPPRESS_TOOLBAR_USER_INTENTS.includes(userIntentState === null || userIntentState === void 0 ? void 0 : userIntentState.currentUserIntent)) { return null; } const { config, node } = configWithNodeInfo; // When the new inline editor-toolbar is enabled, suppress floating toolbar for text selections. if (Boolean(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.toolbar)) { const selection = editorView.state.selection; const isCellSelection = '$anchorCell' in selection && !selection.empty; const isTextSelected = selection instanceof TextSelection && !selection.empty; // don't dismiss table toolbar when a cell selection is caused by clicking the drag handle (which has it's own userIntent) if (isTextSelected && config.className !== 'hyperlink-floating-toolbar' || isCellSelection && (userIntentState === null || userIntentState === void 0 ? void 0 : userIntentState.currentUserIntent) === 'default') { return null; } } let { items } = config; const { groupLabel } = config; const { title, getDomRef = getDomRefFromSelection, align = 'center', className = '', height, width, zIndex, offset = [0, 12], forcePlacement, preventPopupOverflow, onPositionCalculated, absoluteOffset = { top: 0, left: 0, right: 0, bottom: 0 }, focusTrap, mediaAssistiveMessage = '', stick = true } = config; const targetRef = getDomRef(editorView, dispatchAnalyticsEvent); const isEditorDisabled = editorDisabledState && editorDisabledState.editorDisabled; const isInViewMode = (editorViewModeState === null || editorViewModeState === void 0 ? void 0 : editorViewModeState.mode) === 'view'; if (!targetRef || isEditorDisabled && !isInViewMode) { return null; } // TODO: MODES-3950 - Update this view mode specific logic once we refactor view mode. // We should inverse the responsibility here: A blacklist for toolbar items in view mode, rather than this white list. // Also consider moving this logic to the more specific toolbar plugins (media and selection). const iterableItems = Array.isArray(items) ? items : (_items = items) === null || _items === void 0 ? void 0 : _items(node); if (isInViewMode) { // Typescript note: Not all toolbar item types have the `supportsViewMode` prop. const toolbarItemViewModeProp = 'supportsViewMode'; // eslint-disable-next-line @atlassian/perf-linting/no-expensive-computations-in-render -- Ignored via go/ees017 (to be fixed) items = iterableItems.filter(item => toolbarItemViewModeProp in item && !!item[toolbarItemViewModeProp]); } if (areToolbarFlagsEnabled(Boolean(pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : pluginInjectionApi.toolbar))) { var _items2; // Consolidate floating toolbar items const toolbarItemsArray = Array.isArray(items) ? items : (_items2 = items) === null || _items2 === void 0 ? void 0 : _items2(node); // eslint-disable-next-line @atlassian/perf-linting/no-expensive-computations-in-render -- Ignored via go/ees017 (to be fixed) const overflowDropdownItems = toolbarItemsArray.filter(item => item.type === 'overflow-dropdown'); if (overflowDropdownItems.length > 1) { const consolidatedOverflowDropdown = consolidateOverflowDropdownItems(overflowDropdownItems); // eslint-disable-next-line @atlassian/perf-linting/no-expensive-computations-in-render -- Ignored via go/ees017 (to be fixed) const otherItems = toolbarItemsArray.filter(item => item.type !== 'overflow-dropdown'); if (otherItems.length > 0) { // remove the last separators while (((_otherItems$at = otherItems.at(-1)) === null || _otherItems$at === void 0 ? void 0 : _otherItems$at.type) === 'separator') { var _otherItems$at; otherItems.pop(); } } items = [...otherItems, { type: 'separator', fullHeight: true, supportsViewMode: true }, consolidatedOverflowDropdown]; } // Apply analytics to dropdown if (overflowDropdownItems.length > 0 && dispatchAnalyticsEvent) { var _items3; const currentItems = Array.isArray(items) ? items : (_items3 = items) === null || _items3 === void 0 ? void 0 : _items3(node); const updatedItems = currentItems.map(item => { if (item.type !== 'overflow-dropdown') { return item; } const originalOnClick = item.onClick; return { ...item, onClick: () => { var _pluginInjectionApi$e, _pluginInjectionApi$e2; const editorContentMode = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e = pluginInjectionApi.editorViewMode) === null || _pluginInjectionApi$e === void 0 ? void 0 : (_pluginInjectionApi$e2 = _pluginInjectionApi$e.sharedState.currentState()) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.mode; dispatchAnalyticsEvent({ action: ACTION.CLICKED, actionSubject: ACTION_SUBJECT.BUTTON, actionSubjectId: ACTION_SUBJECT_ID.FLOATING_TOOLBAR_OVERFLOW, eventType: EVENT_TYPE.UI, attributes: { editorContentMode } }); // Call original onClick if it exists originalOnClick === null || originalOnClick === void 0 ? void 0 : originalOnClick(); } }; }); items = updatedItems; } } let customPositionCalculation; const toolbarItems = pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c = pluginInjectionApi.copyButton) === null || _pluginInjectionApi$c === void 0 ? void 0 : _pluginInjectionApi$c.actions.processCopyButtonItems(editorView.state)(Array.isArray(items) ? items : items(node), pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$d = pluginInjectionApi.decorations) === null || _pluginInjectionApi$d === void 0 ? void 0 : _pluginInjectionApi$d.actions.hoverDecoration); if (onPositionCalculated) { customPositionCalculation = nextPos => { return onPositionCalculated(editorView, nextPos); }; } const dispatchCommand = fn => fn && fn(editorView.state, editorView.dispatch, editorView); // Confirm dialog let confirmButtonItem; const { confirmDialogForItem, confirmDialogForItemOption } = floatingToolbarData || {}; const matchingItem = confirmDialogForItem ? toolbarItems === null || toolbarItems === void 0 ? void 0 : toolbarItems[confirmDialogForItem] : undefined; if ((matchingItem === null || matchingItem === void 0 ? void 0 : matchingItem.type) === 'button') { confirmButtonItem = matchingItem; } if ((matchingItem === null || matchingItem === void 0 ? void 0 : matchingItem.type) === 'overflow-dropdown' && confirmDialogForItemOption !== undefined) { const matchingItemOption = matchingItem.options[confirmDialogForItemOption]; // OverflowDropdownOption is the only member of the union that does not have a 'type' property if (!('type' in matchingItemOption)) { confirmButtonItem = matchingItemOption; } } const scrollable = config.scrollable; const confirmDialogOptions = typeof ((_confirmButtonItem = confirmButtonItem) === null || _confirmButtonItem === void 0 ? void 0 : _confirmButtonItem.confirmDialog) === 'function' ? (_confirmButtonItem2 = confirmButtonItem) === null || _confirmButtonItem2 === void 0 ? void 0 : _confirmButtonItem2.confirmDialog() : (_confirmButtonItem3 = confirmButtonItem) === null || _confirmButtonItem3 === void 0 ? void 0 : _confirmButtonItem3.confirmDialog; return /*#__PURE__*/React.createElement(ErrorBoundary, { component: ACTION_SUBJECT.FLOATING_TOOLBAR_PLUGIN, componentId: camelCase(title), dispatchAnalyticsEvent: dispatchAnalyticsEvent, fallbackComponent: null }, /*#__PURE__*/React.createElement(Popup, { ariaLabel: title, role: 'toolbar', offset: offset, target: targetRef, alignY: "bottom", forcePlacement: forcePlacement, fitHeight: height, fitWidth: width, absoluteOffset: absoluteOffset, alignX: align, stick: stick, zIndex: zIndex, mountTo: popupsMountPoint, boundariesElement: popupsBoundariesElement, scrollableElement: popupsScrollableElement, onPositionCalculated: customPositionCalculation // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766 , style: scrollable ? { maxWidth: '100%' } : {}, focusTrap: focusTrap, preventOverflow: preventPopupOverflow }, /*#__PURE__*/React.createElement(WithProviders, { providerFactory: providerFactory // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , providers: ['extensionProvider'] // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , renderNode: providers => { return /*#__PURE__*/React.createElement(Toolbar, { target: targetRef // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion , items: toolbarItems, groupLabel: groupLabel, node: node, dispatchCommand: dispatchCommand, editorView: editorView // eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop -- Ignored via go/DSP-18766 , className: className // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , focusEditor: () => editorView.focus(), providerFactory: providerFactory, popupsMountPoint: popupsMountPoint, popupsBoundariesElement: popupsBoundariesElement, popupsScrollableElement: popupsScrollableElement, dispatchAnalyticsEvent: dispatchAnalyticsEvent, extensionsProvider: providers.extensionProvider, scrollable: scrollable, api: pluginInjectionApi, mediaAssistiveMessage: mediaAssistiveMessage }); } })), /*#__PURE__*/React.createElement(ConfirmationModal, { testId: "ak-floating-toolbar-confirmation-modal", options: confirmDialogOptions // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onConfirm: (isChecked = false) => { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion if (!!confirmDialogOptions.onConfirm) { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion dispatchCommand(confirmDialogOptions.onConfirm(isChecked)); } else { // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion dispatchCommand(confirmButtonItem.onClick); } } // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , onClose: () => { dispatchCommand(hideConfirmDialog()); // Need to set focus to Editor here, // As when the Confirmation dialog pop up, and user interacts with the dialog, Editor loses focus. // So when Confirmation dialog is closed, Editor does not have the focus, then cursor goes to the position 1 of the doc, // instead of the cursor position before the dialog pop up. if (!editorView.hasFocus()) { editorView.focus(); } } })); } /** * * ProseMirror Plugin * */ // We throttle update of this plugin with RAF. // So from other plugins you will always get the previous state. export const pluginKey = new PluginKey('floatingToolbarPluginKey'); /** * Clean up floating toolbar configs from undesired properties. */ function sanitizeFloatingToolbarConfig(config) { // Cleanup from non existing node types if (Array.isArray(config.nodeType)) { return { ...config, nodeType: config.nodeType.filter(filterUndefined) }; } return config; } /** * Creates a floating toolbar plugin for the ProseMirror editor. * * This factory function creates a SafePlugin that manages floating toolbars in the editor. * It processes an array of floating toolbar handlers and determines which toolbar configuration * should be active based on the current editor state and selection. * * @param options - Configuration object for the floating toolbar plugin * @param options.floatingToolbarHandlers - Array of handlers that return toolbar configurations * @param options.getIntl - Function that returns the IntlShape instance for internationalization * @param options.providerFactory - Factory for creating various providers used by the editor * @returns A SafePlugin instance that manages floating toolbar state and behavior */ export function floatingToolbarPluginFactory(options) { const { floatingToolbarHandlers, providerFactory, getIntl, api } = options; const intl = getIntl(); const getConfigWithNodeInfo = editorState => { let activeConfigs = []; for (let index = 0; index < floatingToolbarHandlers.length; index++) { const handler = floatingToolbarHandlers[index]; const config = handler(editorState, intl, providerFactory, activeConfigs); if (config) { var _api$userIntent, _api$userIntent$share; if (SUPPRESS_TOOLBAR_USER_INTENTS.includes((api === null || api === void 0 ? void 0 : (_api$userIntent = api.userIntent) === null || _api$userIntent === void 0 ? void 0 : (_api$userIntent$share = _api$userIntent.sharedState.currentState()) === null || _api$userIntent$share === void 0 ? void 0 : _api$userIntent$share.currentUserIntent) || '')) { activeConfigs = undefined; break; } activeConfigs.push(sanitizeFloatingToolbarConfig(config)); } } const relevantConfig = activeConfigs && getRelevantConfig(editorState.selection, activeConfigs); return relevantConfig; }; const getIsToolbarSuppressed = () => { return false; }; const apply = () => { const newPluginState = { getConfigWithNodeInfo }; return newPluginState; }; return new SafePlugin({ key: pluginKey, state: { init: () => { return { getConfigWithNodeInfo }; }, apply: expValEquals('platform_editor_lovability_suppress_toolbar_event', 'isEnabled', true) ? (_tr, _pluginState, __oldEditorState) => { const suppressedToolbar = getIsToolbarSuppressed(); const newPluginState = { getConfigWithNodeInfo, suppressedToolbar }; return newPluginState; } : apply }, view: expValEquals('platform_editor_lovability_suppress_toolbar_event', 'isEnabled', true) ? () => { return { update: (view, prevState) => { const pluginState = pluginKey.getState(view.state); const prevPluginState = pluginKey.getState(prevState); if (pluginState !== null && pluginState !== void 0 && pluginState.suppressedToolbar && !(prevPluginState !== null && prevPluginState !== void 0 && prevPluginState.suppressedToolbar)) { var _api$analytics2, _api$analytics2$actio; api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : (_api$analytics2$actio = _api$analytics2.actions) === null || _api$analytics2$actio === void 0 ? void 0 : _api$analytics2$actio.fireAnalyticsEvent({ action: ACTION.SUPPRESSED, actionSubject: ACTION_SUBJECT.FLOATING_TOOLBAR_PLUGIN, actionSubjectId: ACTION_SUBJECT_ID.FLOATING_TOOLBAR, eventType: EVENT_TYPE.TRACK }); } } }; } : undefined }); }