22581b2f创建于 2025年12月16日历史提交
/*
 * -------------------------------------------------------------------------
 * 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, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useContextMenu } from '../../hooks/useContextMenu';
import { useViewport } from '../../hooks/useViewport';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import styled from '@emotion/styled';
import { useTranslation } from 'react-i18next';

const ContextMenuBox = styled.div<{visible: boolean}>`
    z-index: 99999999;
    position: fixed;
    min-width: 150px;
    padding: 4px 0;
    overflow: hidden;
    font-size: 12px;
    color: ${(props): string => props.theme.textColorPrimary};
    border-radius: 4px;
    background-color: ${(props): string => props.theme.bgColorCommon};
    box-shadow: ${(props): string => props.theme.boxShadow};

    .context-menu-item {
        padding: 4px 16px;
        user-select: none;

        &:not(.disabled):hover{
            background: ${(props): string => props.theme.primaryColorHover};
            color: #ffffff;
        }
        &.disabled{
            color: ${(props): string => props.theme.textColorDisabled};
        }
    }
`;

export interface MenuItem {
    name: string;
    label: string;
    visible?: boolean;
    disabled?: boolean;
    action?: () => void;
}

interface Position {
    x: number;
    y: number;
}

const usePosition = ({ coords, menuSize, vw, vh, menuEl }:
{
    coords: {x: number;y: number};
    menuSize: {width: number;height: number};
    vw: number;
    vh: number;
    menuEl: HTMLDivElement | null;
}): Position | null => {
    const [actualPos, setActualPos] = useState<Position | null>(null);

    useEffect(() => {
        if (!menuEl) {
            setActualPos(null);
            return;
        }

        let posX = coords.x;
        let posY = coords.y;
        const { width: menuWidth, height: menuHeight } = menuSize;

        if (posX > vw - menuWidth) {
            posX -= menuWidth;
        }
        if (posY > vh - menuHeight) {
            posY = vh - menuHeight;
        }

        setActualPos({ x: posX, y: posY });
    }, [coords, menuSize, vw, vh]);

    return actualPos;
};

interface Props {
    menuItems: MenuItem[];
    onSelect?: (item: MenuItem) => void;
    onShow?: (pos: {x: number;y: number}) => void;
    children: React.ReactNode;
}

export const ContextMenu: React.FC<Props> = ({ menuItems, onSelect, onShow, children }) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const [menuEl, setMenuEl] = useState<HTMLDivElement | null>(null);
    const { coords, coordsOffset, visible, setVisible } = useContextMenu(containerRef.current);
    const menuSize = useResizeObserver(menuEl);
    const { vw, vh } = useViewport();
    const pos = usePosition({ coords, menuSize, vw, vh, menuEl });
    const filteredMenuItems = menuItems.filter(item => item.visible);
    const { t } = useTranslation();

    const handleClick = (item: MenuItem): void => {
        if (item.disabled) {
            return;
        }
        setVisible(false);
        onSelect?.(item);
        item?.action?.();
    };

    const handleMenuRef = (el: HTMLDivElement | null): void => {
        if (el) {
            setMenuEl(el);
        }
    };

    useEffect(() => {
        if (visible) {
            onShow?.(coordsOffset);
        }
    }, [visible]);

    return (
        <div ref={containerRef}>
            {children}
            {
                visible &&
                filteredMenuItems.length > 0 &&
                createPortal(
                    <ContextMenuBox
                        ref={handleMenuRef}
                        visible={pos !== null}
                        style={{ left: pos?.x, top: pos?.y }}
                    >
                        {
                            filteredMenuItems.map(item => {
                                const { label, name, disabled } = item;
                                return <div
                                    className={`context-menu-item ${disabled ? 'disabled' : ''}`}
                                    key={name}
                                    onMouseDown={(e) => e.stopPropagation()}
                                    onClick={() => handleClick(item)}
                                >
                                    {t(label)}
                                </div>;
                            })
                        }
                    </ContextMenuBox>,
                    document.body,
                )}
        </div>
    );
};