* -------------------------------------------------------------------------
* This file is part of the MindStudio project.
* Copyright (c) 2025 Huawei Technologies Co.,Ltd.
*
* MindStudio is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
* -------------------------------------------------------------------------
*/
import React, { useRef, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { Theme } from '@emotion/react';
import styled from '@emotion/styled';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react';
import type { Session } from '../entity/session';
import { EyeCloseOtuLine } from '@insight/lib/icon';
import type { ChartInteractorHandles, InteractorMouseState } from './charts/ChartInteractor/ChartInteractor';
import { unit } from '../entity/insight';
import {
actionClearBenchmarkSlice,
actionCollapseAllUnits,
actionEnableAutoUnitHeight,
actionExpandAllUnits,
actionFindInCommunication,
actionGenerateCurve,
actionGenerateBubbleCurve,
actionFitToScreen,
actionHideFlagEvents,
actionHidePythonCallStack,
actionHideUnits,
actionTimeRangeAnalysis,
actionRemoveTimeRangeAnalysis,
actionTimeRangeAnalysisAndZoomIn,
actionApplyTimeRangeAnalysis,
actionLockSelection,
actionRecoverDefaultOffset,
actionResetZoom,
actionSetBenchmarkSlice,
actionAlignByOperator,
actionShowFlagEvents,
actionShowHiddenUnits,
actionShowInEventsView,
actionShowPythonCallStack,
actionUndoZoom,
actionUnLockSelection,
actionUnpinAll,
actionZoomIntoSelection,
actionSetCardAlias,
actionPinByUnitName,
actionUnpinByUnitName,
actionParseCardsOfRelatedGroup,
actionMergeUnits,
actionUnmergeUnits,
actionSliceSelection,
actionJumpToLinkSlice,
} from '../actions';
import { Action } from '../actions/types';
import { getShortcutFromShortcutName, ShortcutName } from '../actions/shortcuts';
import { EmptyMetaData } from '../entity/data';
interface Position {
left: string;
top: string;
}
interface Props {
session: Session;
interactorMouseState: InteractorMouseState;
theme?: Theme;
chartInteractorRef: React.RefObject<ChartInteractorHandles>;
subMenus?: ContextMenuItem[];
style?: { [key: string]: any };
}
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
const MenuContainer = styled.div`
font-size: 12px;
padding: 3px 0;
min-width: 200px;
border-radius: ${(props): string => props.theme.borderRadiusBase};
background-color: ${(props): string => props.theme.contextMenuBgColor};
position: fixed;
z-index: 99999;
transition: all .1s ease;
box-shadow: ${(props): string => props.theme.boxShadowLight};
user-select: none;
`;
const MenuItem = styled.div`
display: grid;
grid-template-columns: 1fr 0.2fr;
align-items: center;
padding: 4px 16px 4px 20px;
color: ${(props): string => props.theme.textColorPrimary};
position: relative;
&:not(.disabled):hover{
background: ${(props): string => props.theme.primaryColorHover};
color: #ffffff;
}
&.disabled{
color: ${(props): string => props.theme.textColorDisabled};
}
&.checkmark::before {
position: absolute;
left: 6px;
margin-bottom: 1px;
content: "√";
}
.menu-item__label {
margin-right: 20px;
white-space: nowrap; /* 防止文本换行 */
overflow: hidden; /* 隐藏溢出的内容 */
text-overflow: ellipsis; /* 添加省略号 */
max-width: 300px; /* 设置一个固定宽度或根据需要调整 */
}
.menu-item__shortcut {
opacity: 0.6;
}
.menu-item__shortcut-area {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.menu-item__arrow {
display: inline-block;
width: 5px;
height: 5px;
border-top: 1.5px solid currentColor;
border-right: 1.5px solid currentColor;
transform: rotate(45deg);
opacity: 0.6;
}
`;
const SubMenuContainer = styled.div`
font-size: 12px;
padding: 3px 0;
min-width: 200px;
border-radius: ${(props): string => props.theme.borderRadiusBase};
background-color: ${(props): string => props.theme.contextMenuBgColor};
position: absolute;
z-index: 99999;
transition: all .1s ease;
box-shadow: ${(props): string => props.theme.boxShadowLight};
user-select: none;
max-height: 300px;
overflow-y: auto;
.menu-item__label {
grid-column: 1 / -1;
margin-right: 0 !important;
}
`;
const Separator = styled.hr`
border: none;
border-top: 1px solid ${(props): string => props.theme.borderColorLight};
`;
function closeMenu(session: Session): void {
runInAction(() => {
session.contextMenu.isVisible = false;
session.contextMenu.activeMenuKey = '';
});
}
function openMenu(session: Session): void {
if (session.selectedUnits.length === 0) {
return;
}
runInAction(() => {
session.contextMenu.isVisible = true;
});
}
export const EmptyUnit = unit<EmptyMetaData>({
name: 'Empty',
pinType: 'copied',
renderInfo: (session: Session, metadata: { count: number}) =>
<div>
<EyeCloseOtuLine style={{ width: '15px', height: '15px', top: '3px', position: 'relative' }}/>
<span style={{
marginLeft: 3,
overflow: 'hidden',
fontSize: 14,
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{metadata.count}{' unit'}{metadata.count > 1 ? 's' : ''}{' hidden'}
</span>
</div>,
});
function adjustMenuPosition({ menu, setPosition, xPos, yPos }: {
menu: HTMLDivElement;
setPosition: (_: Position) => void;
xPos: React.MutableRefObject<number>;
yPos: React.MutableRefObject<number>;
}): void {
const winWidth = document.documentElement.clientWidth || document.body.clientWidth;
const winHeight = document.documentElement.clientHeight || document.body.clientHeight;
if (xPos.current >= winWidth - menu.offsetWidth) {
xPos.current -= menu.offsetWidth;
}
if (yPos.current > winHeight - menu.offsetHeight) {
yPos.current -= menu.offsetHeight;
}
setPosition({ left: `${xPos.current + 1}px`, top: `${yPos.current}px` });
menu.focus();
}
export const CONTEXT_MENU_SEPARATOR = 'separator';
const contextMenuItems: ContextMenuItem[] = [
actionFindInCommunication,
actionGenerateCurve,
actionGenerateBubbleCurve,
actionSetCardAlias,
actionParseCardsOfRelatedGroup,
CONTEXT_MENU_SEPARATOR,
actionTimeRangeAnalysis,
actionTimeRangeAnalysisAndZoomIn,
actionRemoveTimeRangeAnalysis,
actionApplyTimeRangeAnalysis,
CONTEXT_MENU_SEPARATOR,
actionFitToScreen,
actionZoomIntoSelection,
actionLockSelection,
actionUnLockSelection,
actionUndoZoom,
actionResetZoom,
CONTEXT_MENU_SEPARATOR,
actionUnpinAll,
actionPinByUnitName,
actionUnpinByUnitName,
CONTEXT_MENU_SEPARATOR,
actionSetBenchmarkSlice,
actionClearBenchmarkSlice,
actionAlignByOperator,
actionRecoverDefaultOffset,
CONTEXT_MENU_SEPARATOR,
actionCollapseAllUnits,
actionExpandAllUnits,
CONTEXT_MENU_SEPARATOR,
actionMergeUnits,
actionUnmergeUnits,
actionHideUnits,
actionShowHiddenUnits,
CONTEXT_MENU_SEPARATOR,
actionShowPythonCallStack,
actionHidePythonCallStack,
actionHideFlagEvents,
actionShowFlagEvents,
CONTEXT_MENU_SEPARATOR,
actionEnableAutoUnitHeight,
CONTEXT_MENU_SEPARATOR,
actionShowInEventsView,
actionSliceSelection,
actionJumpToLinkSlice,
];
const SubMenu = (props: { session: Session; subMenus: ContextMenuItem[]; style: {[key: string]: any} }): JSX.Element => {
const { subMenus, style } = props;
const { t } = useTranslation();
return (
<SubMenuContainer className="sub-menu-container" style={style}>
{getMenuItems(props as Props, t, subMenus ?? [])}
</SubMenuContainer>
);
};
function mouseEnterEvent(event: React.MouseEvent<HTMLDivElement, MouseEvent>, session: Session, menu: Action, disabled: boolean): void {
runInAction(() => {
const SUB_MENU_MAX_WIDTH = 200;
const SUB_MENU_MAX_HEIGHT = 300;
const element = event.target as HTMLElement;
const style: { top?: string; bottom?: string; left?: string; right?: string } = {};
const clientWidth = window.innerWidth || document.documentElement.clientWidth;
const clientHeight = window.innerHeight || document.documentElement.clientHeight;
if (element.closest('.has-sub-menu')) {
const { bottom, right } = element.getBoundingClientRect();
if (clientHeight - bottom <= SUB_MENU_MAX_HEIGHT) {
style.bottom = '0';
} else {
style.top = '0';
}
if (clientWidth - right <= SUB_MENU_MAX_WIDTH) {
style.right = '100%';
} else {
style.left = '100%';
}
}
menu.style = style;
session.contextMenu.activeMenuKey = disabled && !menu.parentMenuKey ? '' : menu.parentMenuKey ?? menu.name;
});
}
function mouseLeaveEvent(session: Session, menu: Action): void {
runInAction(() => {
session.contextMenu.activeMenuKey = menu.parentMenuKey ? session.contextMenu.activeMenuKey : '';
});
}
const getMenuItems = (props: Props, t: TFunction, menuItems: ContextMenuItem[]): JSX.Element => {
const { session } = props;
if (!Array.isArray(session.selectedUnits) || session.selectedUnits.length === 0 || menuItems.length === 0) {
return <></>;
}
const filteredItems = menuItems.filter(menu => menu === CONTEXT_MENU_SEPARATOR || (menu.visible?.(session) ?? true));
if (!filteredItems.find(menuItem => menuItem !== CONTEXT_MENU_SEPARATOR)) { return <></>; }
if (filteredItems[filteredItems.length - 1] === CONTEXT_MENU_SEPARATOR) {
filteredItems.pop();
}
return <>
{
filteredItems.map((item, index) => {
const prevIsLine = !filteredItems[index - 1] || filteredItems[index - 1] === CONTEXT_MENU_SEPARATOR;
if (item === CONTEXT_MENU_SEPARATOR && prevIsLine) { return null; }
if (item === CONTEXT_MENU_SEPARATOR) { return <Separator key={index} />; }
const disabled = item.disabled?.(session) ?? false;
const label = typeof item.label === 'function' ? item.label(session, t) : t(item.label);
const subMenus = item.subMenus?.(session) ?? [];
const subMenuIsVisible = item.subMode && session.contextMenu.activeMenuKey === item.name && session.contextMenu.isVisible;
return <MenuItem
className={`menu-item ${disabled ? 'disabled' : ''} ${item.checked?.(session) ? 'checkmark' : ''} ${item.subMode ? 'has-sub-menu' : ''}`}
key={item.name}
title={label}
onClick={(e): void => {
if (disabled || item.subMode) { return; }
item.perform(session);
runInAction(() => {
session.contextMenu.isVisible = false;
});
}}
onMouseEnter={(event): void => { mouseEnterEvent(event, session, item, disabled); }}
onMouseLeave={(): void => { mouseLeaveEvent(session, item); }}
>
<div className="menu-item__label">{label}</div>
<div className="menu-item__shortcut-area">
<kbd className="menu-item__shortcut">{item.name ? getShortcutFromShortcutName(item.name as ShortcutName) : ''}</kbd>
{item.subMode && <span className="menu-item__arrow" />}
</div>
{subMenuIsVisible ? <SubMenu style={item.style ?? {}} session={session} subMenus={subMenus}></SubMenu> : <></>}
</MenuItem>;
})
}
</>;
};
const Menu = (props: Props): JSX.Element => {
const { session } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<Position>({ left: '0px', top: '0px' });
const xPos = useRef(0); const yPos = useRef(0);
const { t } = useTranslation();
useEffect(() => {
document.addEventListener('contextmenu', handleContextMenu);
window.addEventListener('wheel', handleCloseMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
window.removeEventListener('wheel', handleCloseMenu);
};
});
useEffect(() => {
const menu = menuRef.current;
if (session.contextMenu.isVisible && menu !== null) {
adjustMenuPosition({ menu, setPosition, xPos, yPos });
}
}, [session.contextMenu.isVisible]);
const handleContextMenu = (event: MouseEvent): void => {
const targetElement = event.target as HTMLElement;
if (targetElement?.closest('.laneWrapper') !== null) {
xPos.current = event.clientX; yPos.current = event.clientY;
setPosition({ left: `${xPos.current + 1}px`, top: `${yPos.current}px` });
openMenu(session);
}
};
const handleCloseMenu = (): void => {
closeMenu(session);
};
return (
session.contextMenu.isVisible
? <MenuContainer ref={menuRef} style={{ ...position }} tabIndex={-1} onBlur={(): void => {
closeMenu(session);
}} >
{getMenuItems(props, t, contextMenuItems)}
</MenuContainer>
: <></>
);
};
export const ContextMenu = observer(Menu);