@atlaskit/editor-plugin-block-menu
Version:
BlockMenu plugin for @atlaskit/editor-core
268 lines (263 loc) • 12.8 kB
JavaScript
/* block-menu.tsx generated by @compiled/babel-plugin v0.39.1 */
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';
import { BLOCK_MENU_TEST_ID } from '@atlaskit/editor-common/block-menu';
import { ErrorBoundary } from '@atlaskit/editor-common/error-boundary';
import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks';
import { DRAG_HANDLE_SELECTOR, DRAG_HANDLE_WIDTH } from '@atlaskit/editor-common/styles';
import { Popup } from '@atlaskit/editor-common/ui';
import { ArrowKeyNavigationProvider, ArrowKeyNavigationType } from '@atlaskit/editor-common/ui-menu';
import { OutsideClickTargetRefContext, withReactEditorViewOuterListeners } from '@atlaskit/editor-common/ui-react';
import { akEditorFloatingOverlapPanelZIndex } from '@atlaskit/editor-shared-styles';
import { Box } from '@atlaskit/primitives/compiled';
import { redo, undo } from '@atlaskit/prosemirror-history';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { useBlockMenu } from './block-menu-provider';
import { BlockMenuRenderer } from './block-menu-renderer/BlockMenuRenderer';
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 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;
};
return /*#__PURE__*/React.createElement(Box, {
testId: BLOCK_MENU_TEST_ID,
role: expValEquals('platform_editor_enghealth_a11y_jan_fixes', 'isEnabled', true) ? 'menu' : undefined,
ref: ref,
xcss: cx(styles.base, styles.maxWidthStyles, editorExperiment('platform_synced_block', true) && 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 _editorView$dom, _ref, _api$analytics2;
const {
menuTriggerBy,
isSelectedViaDragHandle,
isMenuOpen,
currentUserIntent,
openedViaKeyboard
} = useSharedPluginStateWithSelector(api, ['blockControls', 'userIntent'], states => {
var _states$blockControls, _states$blockControls2, _states$blockControls3, _states$userIntentSta, _states$blockControls4, _states$blockControls5;
return {
menuTriggerBy: (_states$blockControls = states.blockControlsState) === null || _states$blockControls === void 0 ? void 0 : _states$blockControls.menuTriggerBy,
isSelectedViaDragHandle: (_states$blockControls2 = states.blockControlsState) === null || _states$blockControls2 === void 0 ? void 0 : _states$blockControls2.isSelectedViaDragHandle,
isMenuOpen: (_states$blockControls3 = states.blockControlsState) === null || _states$blockControls3 === void 0 ? void 0 : _states$blockControls3.isMenuOpen,
currentUserIntent: (_states$userIntentSta = states.userIntentState) === null || _states$userIntentSta === void 0 ? void 0 : _states$userIntentSta.currentUserIntent,
openedViaKeyboard: (_states$blockControls4 = states.blockControlsState) === null || _states$blockControls4 === void 0 ? void 0 : (_states$blockControls5 = _states$blockControls4.blockMenuOptions) === null || _states$blockControls5 === void 0 ? void 0 : _states$blockControls5.openedViaKeyboard
};
});
const {
onDropdownOpenChanged
} = useBlockMenu();
const targetHandleRef = 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 prevIsMenuOpenRef = useRef(false);
const popupRef = useRef(undefined);
const [menuHeight, setMenuHeight] = React.useState(0);
const targetHandleHeightOffset = -((targetHandleRef === null || targetHandleRef === void 0 ? void 0 : targetHandleRef.clientHeight) || 0);
React.useLayoutEffect(() => {
var _popupRef$current;
if (!isMenuOpen) {
return;
}
setMenuHeight(((_popupRef$current = popupRef.current) === null || _popupRef$current === void 0 ? void 0 : _popupRef$current.clientHeight) || FALLBACK_MENU_HEIGHT);
}, [isMenuOpen]);
const hasFocus = (_ref = (editorView === null || editorView === void 0 ? void 0 : editorView.hasFocus()) ||
// 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)) !== null && _ref !== void 0 ? _ref : 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$blockControls, _api$blockControls$co;
// 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$blockControls = api.blockControls) === null || _api$blockControls === void 0 ? void 0 : (_api$blockControls$co = _api$blockControls.commands) === null || _api$blockControls$co === void 0 ? void 0 : _api$blockControls$co.handleKeyDownWithPreservedSelection(event));
};
const handleClickOutside = e => {
// check if the clicked element was another drag handle, if so don't close the menu
if (e.target instanceof HTMLElement && e.target.closest(DRAG_HANDLE_SELECTOR)) {
return;
}
closeMenu();
};
const closeMenu = () => {
api === null || api === void 0 ? void 0 : api.core.actions.execute(({
tr
}) => {
var _api$blockControls2, _api$userIntent2;
api === null || api === void 0 ? void 0 : (_api$blockControls2 = api.blockControls) === null || _api$blockControls2 === void 0 ? void 0 : _api$blockControls2.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;
});
};
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;
}
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,
zIndex: akEditorFloatingOverlapPanelZIndex,
fitWidth: DEFAULT_MENU_WIDTH,
fitHeight: menuHeight,
preventOverflow: true,
stick: true
// 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
}, /*#__PURE__*/React.createElement(BlockMenuContent, {
api: api,
setRef: setRef
})));
};
// eslint-disable-next-line @typescript-eslint/ban-types
const _default_1 = injectIntl(BlockMenu);
export default _default_1;