Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 88 additions & 39 deletions src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,21 @@ export function getIcon(name: string): LucideIcon | undefined {
}

// 单个菜单项组件
function MenuItemComponent({ item, onClose }: { item: MenuItem; onClose: () => void }) {
function MenuItemComponent({
item,
onClose,
submenuDirection,
depth = 0,
}: {
item: MenuItem;
onClose: () => void;
submenuDirection: 'left' | 'right';
depth?: number;
}) {
const hasChildren = !!item.children?.length;

const handleClick = () => {
if (item.disabled || item.children) return;
if (item.disabled || hasChildren) return;
item.onClick?.();
onClose();
};
Comment on lines +106 to 112

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): 分隔符菜单项仍然被渲染为可交互按钮,而不是仅作为视觉分隔符。

MenuItemComponent 将所有条目都视为可点击项,但 MenuItem 是支持 divider 条目的(可参考在生成 key 时对 divider 的检查)。这些 divider 条目目前会被渲染为空的、可点击的行。

请在处理 hasChildren / handleClick 逻辑之前为 divider 条目添加一个提前返回,例如:

if (item.divider) {
  return <div className="my-1 h-px bg-border" role="separator" />;
}

对子级条目也应用同样的处理,以便嵌套的分隔符也被渲染为不可交互的分隔线。

Original comment in English

issue (bug_risk): Divider menu items are still rendered as interactive buttons instead of visual separators.

MenuItemComponent treats all items as clickable, but MenuItem supports divider items (see the divider checks when generating keys). These divider items are currently rendered as empty, clickable rows.

Add an early return for divider items (before hasChildren/handleClick), e.g.:

if (item.divider) {
  return <div className="my-1 h-px bg-border" role="separator" />;
}

Apply the same handling for child items so nested dividers are rendered as non-interactive separators as well.

Expand All @@ -106,44 +118,72 @@ function MenuItemComponent({ item, onClose }: { item: MenuItem; onClose: () => v
const Icon = item.icon;

return (
<button
onClick={handleClick}
disabled={item.disabled}
className={clsx(
'w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left rounded-md transition-colors',
item.disabled
? 'text-text-muted cursor-not-allowed'
: item.danger
? 'text-error hover:bg-error/10'
: 'text-text-primary hover:bg-bg-hover',
item.checked !== undefined && 'pl-2',
)}
>
{/* 选中状态图标 */}
{item.checked !== undefined && (
<span className="w-4 h-4 flex items-center justify-center">
{item.checked && <Check className="w-3.5 h-3.5 text-accent" />}
</span>
<div className="relative group/menu-item">
<button
onClick={handleClick}
disabled={item.disabled}
aria-haspopup={hasChildren ? 'menu' : undefined}
className={clsx(
'w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left rounded-md transition-colors',
item.disabled
? 'text-text-muted cursor-not-allowed'
: item.danger
? 'text-error hover:bg-error/10'
: 'text-text-primary hover:bg-bg-hover',
item.checked !== undefined && 'pl-2',
)}
>
{/* 选中状态图标 */}
{item.checked !== undefined && (
<span className="w-4 h-4 flex items-center justify-center">
{item.checked && <Check className="w-3.5 h-3.5 text-accent" />}
</span>
)}

{/* 图标 */}
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}

{/* 标签 */}
<span className="flex-1">{item.label}</span>

{/* 快捷键 */}
{item.shortcut && <span className="text-xs text-text-muted ml-4">{item.shortcut}</span>}

{/* 子菜单箭头 */}
{hasChildren && <ChevronRight className="w-4 h-4 text-text-muted" />}
</button>

{hasChildren && !item.disabled && (
<div
role="menu"
className={clsx(
'hidden group-hover/menu-item:block group-focus-within/menu-item:block',
'absolute top-0 min-w-[180px] max-w-[280px]',
'bg-bg-secondary border border-border rounded-lg shadow-lg',
'py-1 px-1',
submenuDirection === 'left' ? 'right-full mr-1' : 'left-full ml-1',
depth > 0 && 'z-[10000]',
)}
>
{item.children!.map((child, index) => (
<MenuItemComponent
key={child.divider ? `divider-${index}` : child.id}
item={child}
onClose={onClose}
submenuDirection={submenuDirection}
depth={depth + 1}
/>
))}
</div>
)}

{/* 图标 */}
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}

{/* 标签 */}
<span className="flex-1">{item.label}</span>

{/* 快捷键 */}
{item.shortcut && <span className="text-xs text-text-muted ml-4">{item.shortcut}</span>}

{/* 子菜单箭头 */}
{item.children && <ChevronRight className="w-4 h-4 text-text-muted" />}
</button>
</div>
);
}

// 右键菜单组件
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [submenuDirection, setSubmenuDirection] = useState<'left' | 'right'>('right');

// 点击外部关闭菜单
useEffect(() => {
Expand Down Expand Up @@ -196,6 +236,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
// 确保不小于 0
x = Math.max(8, x);
y = Math.max(8, y);
setSubmenuDirection(x + rect.width + 188 > viewportWidth ? 'left' : 'right');

menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
Expand All @@ -218,6 +259,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
key={item.divider ? `divider-${index}` : item.id}
item={item}
onClose={onClose}
submenuDirection={submenuDirection}
/>
))}
</div>,
Expand All @@ -229,24 +271,31 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
export function useContextMenu() {
const [state, setState] = useContextMenuState();

const show = useCallback(
(e: React.MouseEvent, items: MenuItem[]) => {
e.preventDefault();
e.stopPropagation();
const showAt = useCallback(
(position: MenuPosition, items: MenuItem[]) => {
setState({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
position,
items,
});
},
[setState],
);

const show = useCallback(
(e: React.MouseEvent, items: MenuItem[]) => {
e.preventDefault();
e.stopPropagation();
showAt({ x: e.clientX, y: e.clientY }, items);
},
[showAt],
);

const hide = useCallback(() => {
setState((prev) => ({ ...prev, isOpen: false }));
}, [setState]);

return { state, show, hide };
return { state, show, showAt, hide };
}

// 使用 React state 管理菜单状态
Expand Down
63 changes: 43 additions & 20 deletions src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import {
Bell,
History,
Share2,
FileText,
} from 'lucide-react';
import { useAppStore } from '@/stores/appStore';
import { ContextMenu, useContextMenu, type MenuItem } from './ContextMenu';
import { ConfirmDialog } from './ConfirmDialog';
import { getInterfaceLangKey } from '@/i18n';
import { exportWithToast } from '@/utils/tabExportImport';
import { exportFileWithToast, exportWithToast } from '@/utils/tabExportImport';
import clsx from 'clsx';

const LazyUpdatePanel = lazy(async () => {
Expand Down Expand Up @@ -58,7 +59,6 @@ export function TabBar() {
instances,
activeInstanceId,
createInstance,
removeInstance,
setActiveInstance,
renameInstance,
reorderInstances,
Expand Down Expand Up @@ -87,7 +87,7 @@ export function TabBar() {
const showUpdatePanel = showUpdateDialog;
const setShowUpdatePanel = setShowUpdateDialog;

const { state: menuState, show: showMenu, hide: hideMenu } = useContextMenu();
const { state: menuState, showAt: showMenuAt, hide: hideMenu } = useContextMenu();

// 当最近关闭列表为空时,自动关闭面板
useEffect(() => {
Expand Down Expand Up @@ -161,10 +161,18 @@ export function TabBar() {

// 右键菜单处理
const handleTabContextMenu = useCallback(
(e: React.MouseEvent, instanceId: string, instanceName: string) => {
async (e: React.MouseEvent, instanceId: string, instanceName: string) => {
e.preventDefault();
e.stopPropagation();
const position = { x: e.clientX, y: e.clientY };
const instanceIndex = instances.findIndex((i) => i.id === instanceId);
const isFirst = instanceIndex === 0;
const isLast = instanceIndex === instances.length - 1;
const inst = instances.find((i) => i.id === instanceId);
const projectName = projectInterface?.name;
const exportHint =
inst && projectName ? t('preset.exportShareHint', { projectName, tabName: inst.name }) : '';
const exportFooter = projectName ? t('preset.exportShareFooter', { projectName }) : '';

const menuItems: MenuItem[] = [
{
Expand All @@ -183,19 +191,35 @@ export function TabBar() {
id: 'export',
label: t('contextMenu.exportConfig'),
icon: Share2,
onClick: () => {
const inst = instances.find((i) => i.id === instanceId);
const projectName = projectInterface?.name;
if (inst && projectName) {
exportWithToast(
inst,
projectName,
t('preset.exportShareHint', { projectName, tabName: inst.name }),
t('preset.exportShareFooter', { projectName }),
{ success: t('preset.exportSuccess'), failed: t('preset.exportFailed') },
);
}
},
disabled: !inst || !projectName,
children: [
{
id: 'export-clipboard',
label: t('contextMenu.exportToClipboard'),
icon: Copy,
onClick: () => {
if (inst && projectName) {
exportWithToast(inst, projectName, exportHint, exportFooter, {
success: t('preset.exportSuccess'),
failed: t('preset.exportFailed'),
});
}
},
},
{
id: 'export-file',
label: t('contextMenu.exportToTxt'),
icon: FileText,
onClick: () => {
if (inst && projectName) {
exportFileWithToast(inst, projectName, exportHint, exportFooter, {
success: t('preset.exportFileSuccess'),
failed: t('preset.exportFileFailed'),
});
}
},
},
],
},
{
id: 'rename',
Expand Down Expand Up @@ -276,16 +300,15 @@ export function TabBar() {
},
];

showMenu(e, menuItems);
showMenuAt(position, menuItems);
},
[
instances,
t,
createInstance,
duplicateInstance,
removeInstance,
reorderInstances,
showMenu,
showMenuAt,
projectInterface,
confirmBeforeDelete,
startTabCloseAnimation,
Expand Down
Loading