UNPKG

@atlaskit/editor-plugin-block-menu

Version:

BlockMenu plugin for @atlaskit/editor-core

424 lines (417 loc) • 20.5 kB
/* block-menu.tsx generated by @compiled/babel-plugin v3.0.2 */ import "./block-menu.compiled.css"; import { ax, ix } from "@compiled/react/runtime"; import React, { useContext, useEffect, useRef } from 'react'; import { injectIntl } from 'react-intl'; import { cx } from '@atlaskit/css'; import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics/types/enums'; import { surfaceDragHandleElementStore } from '@atlaskit/editor-common/block-controls/surface-drag-handle-element'; import { BLOCK_MENU_TEST_ID } from '@atlaskit/editor-common/block-menu/key'; import { ErrorBoundary } from '@atlaskit/editor-common/error-boundary'; import Popup from '@atlaskit/editor-common/Popup'; import { DRAG_HANDLE_SELECTOR, NESTED_DROPDOWN_MENU, DRAG_HANDLE_WIDTH } from '@atlaskit/editor-common/styles/drag-handle'; import { ArrowKeyNavigationProvider } from '@atlaskit/editor-common/ui-menu/ArrowKeyNavigationProvider'; import { ArrowKeyNavigationType } from '@atlaskit/editor-common/ui-menu/ArrowKeyNavigationProvider/types'; import { OutsideClickTargetRefContext } from '@atlaskit/editor-common/ui-react'; import withReactEditorViewOuterListeners from '@atlaskit/editor-common/ui-react/with-react-editor-view-outer-listeners'; import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/useSharedPluginStateWithSelector'; import { akEditorFloatingOverlapPanelZIndex } from '@atlaskit/editor-shared-styles/constants'; import { ToolbarMenuContainer } from '@atlaskit/editor-toolbar/toolbar-menu-container'; import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled'; import { Box } from '@atlaskit/primitives/compiled/box'; import { redo } from '@atlaskit/prosemirror-history/redo'; import { undo } from '@atlaskit/prosemirror-history/undo'; import { useBlockMenu } from './block-menu-provider'; import { BlockMenuRenderer } from './block-menu-renderer/BlockMenuRenderer'; import { BlockMenuTargetVisibilityProvider } from './block-menu-target-visibility-context'; const styles = { base: "_2rko12b0 _bfhk1bhr _16qs130s", maxWidthStyles: "_p12fnklw", emptyMenuSectionStyles: "_1cc0glyw _1k2yglyw" }; const DEFAULT_MENU_WIDTH = 230; const DRAG_HANDLE_OFFSET_PADDING = 5; const FALLBACK_MENU_HEIGHT = 300; const PopupWithListeners = withReactEditorViewOuterListeners(Popup); const useConditionalBlockMenuEffect = ({ api, isMenuOpen, menuTriggerBy, selectedByShortcutOrDragHandle, hasFocus, openedViaKeyboard, prevIsMenuOpenRef }) => { /** * NOTE: do not add `currentUserIntent` to dependency array as it causes unnecessary re-renders and messes with the user intent state */ useEffect(() => { var _api$userIntent; if (!isMenuOpen || !menuTriggerBy || !selectedByShortcutOrDragHandle || !hasFocus) { return; } // Fire analytics event when block menu opens (only on first transition from closed to open) if (!prevIsMenuOpenRef.current && isMenuOpen) { var _api$analytics; api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({ action: ACTION.OPENED, actionSubject: ACTION_SUBJECT.BLOCK_MENU, eventType: EVENT_TYPE.UI, attributes: { inputMethod: openedViaKeyboard ? INPUT_METHOD.KEYBOARD : INPUT_METHOD.MOUSE } }); } // Update the previous state prevIsMenuOpenRef.current = isMenuOpen; api === null || api === void 0 ? void 0 : api.core.actions.execute(api === null || api === void 0 ? void 0 : (_api$userIntent = api.userIntent) === null || _api$userIntent === void 0 ? void 0 : _api$userIntent.commands.setCurrentUserIntent('blockMenuOpen')); }, [api, isMenuOpen, menuTriggerBy, selectedByShortcutOrDragHandle, hasFocus, openedViaKeyboard, prevIsMenuOpenRef]); }; const isSelectionWithinCodeBlock = state => { const { $from, $to } = state.selection; return $from.sameParent($to) && $from.parent.type === state.schema.nodes.codeBlock; }; const useCloseBlockMenuOnResize = ({ isEnabled, mountTo, boundariesElement, scrollableElement, closeMenu }) => { useEffect(() => { if (!isEnabled || typeof ResizeObserver === 'undefined') { return; } const observedElements = Array.from(new Set([mountTo, boundariesElement, scrollableElement].filter(element => Boolean(element)))); if (observedElements.length === 0) { return; } const pendingInitialResizeElements = new Set(observedElements); const resizeObserver = new ResizeObserver(entries => { const hasResizeAfterInitialObservation = entries.some(({ target }) => { if (target instanceof HTMLElement && pendingInitialResizeElements.has(target)) { pendingInitialResizeElements.delete(target); return false; } return true; }); if (hasResizeAfterInitialObservation) { closeMenu(); } }); observedElements.forEach(element => resizeObserver.observe(element)); return () => { resizeObserver.disconnect(); }; }, [boundariesElement, closeMenu, isEnabled, mountTo, scrollableElement]); }; const BlockMenuContent = ({ api, setRef }) => { var _api$blockMenu; const blockMenuComponents = api === null || api === void 0 ? void 0 : (_api$blockMenu = api.blockMenu) === null || _api$blockMenu === void 0 ? void 0 : _api$blockMenu.actions.getBlockMenuComponents(); const setOutsideClickTargetRef = useContext(OutsideClickTargetRefContext); const ref = el => { setOutsideClickTargetRef(el); setRef === null || setRef === void 0 ? void 0 : setRef(el); }; const shouldDisableArrowKeyNavigation = event => { if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') { return false; } const target = event.target; if (!(target instanceof HTMLElement)) { return false; } return target.closest('[data-toolbar-nested-dropdown-menu]') !== null; }; if (isExperimentEnabled('platform_editor_menu_radius_update')) { return /*#__PURE__*/React.createElement(ToolbarMenuContainer, { testId: BLOCK_MENU_TEST_ID, ref: ref, xcss: styles.maxWidthStyles }, /*#__PURE__*/React.createElement(ArrowKeyNavigationProvider, { type: ArrowKeyNavigationType.MENU // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , handleClose: e => e.preventDefault(), disableArrowKeyNavigation: shouldDisableArrowKeyNavigation }, /*#__PURE__*/React.createElement(BlockMenuRenderer, { allRegisteredComponents: blockMenuComponents || [] }))); } return /*#__PURE__*/React.createElement(Box, { testId: BLOCK_MENU_TEST_ID, role: "menu", ref: ref, xcss: cx(styles.base, styles.maxWidthStyles, styles.emptyMenuSectionStyles) }, /*#__PURE__*/React.createElement(ArrowKeyNavigationProvider, { type: ArrowKeyNavigationType.MENU // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , handleClose: e => e.preventDefault(), disableArrowKeyNavigation: shouldDisableArrowKeyNavigation }, /*#__PURE__*/React.createElement(BlockMenuRenderer, { allRegisteredComponents: blockMenuComponents || [] }))); }; const BlockMenu = ({ editorView, api, mountTo, boundariesElement, scrollableElement }) => { var _openMenuHandleRef$cu, _editorView$dom, _ref2, _api$analytics2; const isPopupTargetVisibilityEnabled = Boolean(mountTo) && isExperimentEnabled('platform_editor_popup_target_visibility'); const { menuTriggerBy, menuTriggerByNode, isSelectedViaDragHandle, isMenuOpen, currentUserIntent, openedViaKeyboard } = useSharedPluginStateWithSelector(api, ['blockControls', 'userIntent'], states => { var _states$blockControls, _states$blockControls2, _states$blockControls3, _states$blockControls4, _states$userIntentSta, _states$blockControls5, _states$blockControls6; return { menuTriggerBy: (_states$blockControls = states.blockControlsState) === null || _states$blockControls === void 0 ? void 0 : _states$blockControls.menuTriggerBy, menuTriggerByNode: isPopupTargetVisibilityEnabled ? (_states$blockControls2 = states.blockControlsState) === null || _states$blockControls2 === void 0 ? void 0 : _states$blockControls2.menuTriggerByNode : undefined, isSelectedViaDragHandle: (_states$blockControls3 = states.blockControlsState) === null || _states$blockControls3 === void 0 ? void 0 : _states$blockControls3.isSelectedViaDragHandle, isMenuOpen: (_states$blockControls4 = states.blockControlsState) === null || _states$blockControls4 === void 0 ? void 0 : _states$blockControls4.isMenuOpen, currentUserIntent: (_states$userIntentSta = states.userIntentState) === null || _states$userIntentSta === void 0 ? void 0 : _states$userIntentSta.currentUserIntent, openedViaKeyboard: (_states$blockControls5 = states.blockControlsState) === null || _states$blockControls5 === void 0 ? void 0 : (_states$blockControls6 = _states$blockControls5.blockMenuOptions) === null || _states$blockControls6 === void 0 ? void 0 : _states$blockControls6.openedViaKeyboard }; }); const { onDropdownOpenChanged } = useBlockMenu(); const isMenuOpenRef = React.useRef(isMenuOpen); isMenuOpenRef.current = isMenuOpen; const [surfaceDragHandle, setSurfaceDragHandle] = React.useState(null); React.useLayoutEffect(() => { if (!isExperimentEnabled('platform_editor_block_control_migration')) { return; } const readElement = () => setSurfaceDragHandle(surfaceDragHandleElementStore.get(editorView)); readElement(); return surfaceDragHandleElementStore.subscribe(editorView, () => { if (!isMenuOpenRef.current) { readElement(); } }); }, [editorView]); const openMenuHandleRef = React.useRef(null); const anchoredToRef = React.useRef(undefined); if (!isMenuOpen) { openMenuHandleRef.current = null; anchoredToRef.current = undefined; } else if (!openMenuHandleRef.current || anchoredToRef.current !== menuTriggerBy) { var _ref, _surfaceDragHandleEle; openMenuHandleRef.current = (_ref = (_surfaceDragHandleEle = surfaceDragHandleElementStore.get(editorView)) !== null && _surfaceDragHandleEle !== void 0 ? _surfaceDragHandleEle : openMenuHandleRef.current) !== null && _ref !== void 0 ? _ref : surfaceDragHandle; anchoredToRef.current = menuTriggerBy; } const targetHandleRef = isExperimentEnabled('platform_editor_block_control_migration') ? (_openMenuHandleRef$cu = openMenuHandleRef.current) !== null && _openMenuHandleRef$cu !== void 0 ? _openMenuHandleRef$cu : surfaceDragHandle : editorView === null || editorView === void 0 ? void 0 : (_editorView$dom = editorView.dom) === null || _editorView$dom === void 0 ? void 0 : _editorView$dom.querySelector(DRAG_HANDLE_SELECTOR); const menuTargetNode = menuTriggerByNode && (editorView === null || editorView === void 0 ? void 0 : editorView.nodeDOM(menuTriggerByNode.pos)); const visibilityTarget = menuTargetNode instanceof HTMLElement ? menuTargetNode : undefined; const closeMenu = React.useCallback(() => { api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => { var _api$blockControls, _api$userIntent2; api === null || api === void 0 ? void 0 : (_api$blockControls = api.blockControls) === null || _api$blockControls === void 0 ? void 0 : _api$blockControls.commands.toggleBlockMenu({ closeMenu: true })({ tr }); onDropdownOpenChanged(false); api === null || api === void 0 ? void 0 : (_api$userIntent2 = api.userIntent) === null || _api$userIntent2 === void 0 ? void 0 : _api$userIntent2.commands.setCurrentUserIntent(currentUserIntent === 'blockMenuOpen' ? 'default' : currentUserIntent || 'default')({ tr }); return tr; }); }, [api, currentUserIntent, onDropdownOpenChanged]); useCloseBlockMenuOnResize({ isEnabled: Boolean(isMenuOpen), mountTo, boundariesElement, scrollableElement, closeMenu }); const prevIsMenuOpenRef = useRef(false); const popupRef = useRef(undefined); const [menuHeight, setMenuHeight] = React.useState(0); const [targetVisibility, setTargetVisibility] = React.useState({ isVisible: undefined, restoreFocusTo: null }); const { isVisible: targetVisible, restoreFocusTo } = targetVisibility; // CSS visibility can move focus to the document body. Preserve the active menu item so the // existing focus-loss guard does not close the menus during a temporary scroll transition. const handleTargetVisibilityChanged = React.useCallback(isVisible => { var _popupRef$current; if (isVisible) { setTargetVisibility(current => ({ isVisible: true, restoreFocusTo: current.isVisible === false ? current.restoreFocusTo : null })); return; } // Capture focus before scheduling the state update so the updater stays pure. // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage const activeElement = document.activeElement; const isFocusInMenu = activeElement instanceof HTMLElement && (((_popupRef$current = popupRef.current) === null || _popupRef$current === void 0 ? void 0 : _popupRef$current.contains(activeElement)) || activeElement.closest(NESTED_DROPDOWN_MENU) !== null); setTargetVisibility({ isVisible: false, restoreFocusTo: isFocusInMenu ? activeElement : null }); }, []); React.useLayoutEffect(() => { if (!isPopupTargetVisibilityEnabled || !targetVisible || !restoreFocusTo) { return; } const { activeElement, body, documentElement } = restoreFocusTo.ownerDocument; const focusWasLost = activeElement === null || activeElement === body || activeElement === documentElement; if (restoreFocusTo.isConnected && focusWasLost) { restoreFocusTo.focus({ preventScroll: true }); } setTargetVisibility(current => ({ ...current, restoreFocusTo: null })); }, [isPopupTargetVisibilityEnabled, restoreFocusTo, targetVisible]); const targetHandleHeightOffset = -((targetHandleRef === null || targetHandleRef === void 0 ? void 0 : targetHandleRef.clientHeight) || 0); React.useLayoutEffect(() => { var _popupRef$current2; if (!isMenuOpen) { return; } onDropdownOpenChanged(true); setMenuHeight(((_popupRef$current2 = popupRef.current) === null || _popupRef$current2 === void 0 ? void 0 : _popupRef$current2.clientHeight) || FALLBACK_MENU_HEIGHT); }, [isMenuOpen, onDropdownOpenChanged]); // Nested dropdowns are portaled outside the block-menu popup. Sparse surface reconciliation // dispatches after the menu opens, so a re-render can occur while focus is inside that portal. const hasFocus = (_ref2 = (editorView === null || editorView === void 0 ? void 0 : editorView.hasFocus()) || isPopupTargetVisibilityEnabled && (targetVisible === false || restoreFocusTo !== null) || // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage document.activeElement === targetHandleRef || popupRef.current && ( // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage popupRef.current.contains(document.activeElement) || // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage popupRef.current === document.activeElement) || isExperimentEnabled('platform_editor_block_control_migration') && // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage document.activeElement instanceof HTMLElement && // eslint-disable-next-line @atlaskit/platform/no-direct-document-usage document.activeElement.closest(NESTED_DROPDOWN_MENU) !== null) !== null && _ref2 !== void 0 ? _ref2 : false; const selectedByShortcutOrDragHandle = !!isSelectedViaDragHandle || !!openedViaKeyboard; // Use conditional hook based on feature flag useConditionalBlockMenuEffect({ api, isMenuOpen, menuTriggerBy, selectedByShortcutOrDragHandle, hasFocus, openedViaKeyboard, prevIsMenuOpenRef }); if (!isMenuOpen) { return null; } const handleKeyDown = event => { var _api$core, _api$blockControls2, _api$blockControls2$c; // When the editor view has focus, the keydown will be handled by the // selection preservation plugin – exit early to avoid double handling // Also exit if selection is within a code block to avoid double handling when code block got focus when the node after it is deleted if (!editorView || editorView !== null && editorView !== void 0 && editorView.hasFocus() || isSelectionWithinCodeBlock(editorView.state)) { return; } const key = event.key.toLowerCase(); const isMetaCtrl = event.metaKey || event.ctrlKey; const isDelete = ['backspace', 'delete'].includes(key); const isUndo = isMetaCtrl && key === 'z' && !event.shiftKey; const isRedo = isMetaCtrl && (key === 'y' || key === 'z' && event.shiftKey); // Necessary to prevent the editor from handling the delete natively if (isDelete || isUndo || isRedo) { event.preventDefault(); event.stopPropagation(); } if (isUndo) { undo(editorView.state, editorView.dispatch); } else if (isRedo) { redo(editorView.state, editorView.dispatch); } api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(api === null || api === void 0 ? void 0 : (_api$blockControls2 = api.blockControls) === null || _api$blockControls2 === void 0 ? void 0 : (_api$blockControls2$c = _api$blockControls2.commands) === null || _api$blockControls2$c === void 0 ? void 0 : _api$blockControls2$c.handleKeyDownWithPreservedSelection(event)); }; const handleClickOutside = e => { // check if the clicked element was another drag handle or nested dropdown menu, if so don't close the menu if (e.target instanceof HTMLElement && (e.target.closest(DRAG_HANDLE_SELECTOR) || e.target.closest(NESTED_DROPDOWN_MENU))) { return; } closeMenu(); }; if (!menuTriggerBy || !selectedByShortcutOrDragHandle || !hasFocus || ['resizing', 'dragging'].includes(currentUserIntent || '')) { closeMenu(); return null; } const setRef = el => { if (el) { popupRef.current = el; } }; if (!(targetHandleRef instanceof HTMLElement)) { return null; } const blockMenuContent = /*#__PURE__*/React.createElement(BlockMenuContent, { api: api, setRef: setRef }); return /*#__PURE__*/React.createElement(ErrorBoundary, { component: ACTION_SUBJECT.BLOCK_MENU, dispatchAnalyticsEvent: api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions.fireAnalyticsEvent, fallbackComponent: null }, /*#__PURE__*/React.createElement(PopupWithListeners, { alignX: 'right', alignY: 'start', handleClickOutside: handleClickOutside, handleEscapeKeydown: closeMenu, handleKeyDown: handleKeyDown, mountTo: mountTo, boundariesElement: boundariesElement, scrollableElement: scrollableElement, target: targetHandleRef, visibilityTarget: visibilityTarget, zIndex: akEditorFloatingOverlapPanelZIndex, fitWidth: DEFAULT_MENU_WIDTH, fitHeight: menuHeight, preventOverflow: true, stick: true, hideWhenTargetOutOfView: isPopupTargetVisibilityEnabled, onTargetVisibilityChanged: isPopupTargetVisibilityEnabled ? handleTargetVisibilityChanged : undefined // eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) , offset: [DRAG_HANDLE_WIDTH + DRAG_HANDLE_OFFSET_PADDING, targetHandleHeightOffset], focusTrap: openedViaKeyboard ? // Only enable focus trap when opened via keyboard to make sure the focus is on the first focusable menu item { initialFocus: undefined } : undefined }, isPopupTargetVisibilityEnabled ? /*#__PURE__*/React.createElement(BlockMenuTargetVisibilityProvider, { value: targetVisible }, blockMenuContent) : blockMenuContent)); }; // eslint-disable-next-line @typescript-eslint/no-restricted-types const _default_1 = injectIntl(BlockMenu); export default _default_1;