Files
coco-app/src/components/Search/ContextMenu.tsx
ayangweb ac835c76aa fix: fix shortcut issue in windows context menu (#804)
* fix: fix shortcut issue in windows context menu

* docs: update changelog
2025-07-23 16:20:46 +08:00

372 lines
11 KiB
TypeScript

import { useClickAway, useCreation, useReactive } from "ahooks";
import clsx from "clsx";
import { isNil, lowerCase, noop } from "lodash-es";
import {
Copy,
Download,
Info,
Link,
Settings,
SquareArrowOutUpRight,
Trash2,
} from "lucide-react";
import { cloneElement, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@headlessui/react";
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
import { copyToClipboard } from "@/utils";
import { isMac } from "@/utils/platform";
import { CONTEXT_MENU_PANEL_ID } from "@/constants";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "../Common/VisibleKey";
import platformAdapter from "@/utils/platformAdapter";
import SearchEmpty from "../Common/SearchEmpty";
interface State {
activeMenuIndex: number;
}
interface ContextMenuProps {
formatUrl?: (item: any) => string;
}
const ContextMenu = ({ formatUrl }: ContextMenuProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { t, i18n } = useTranslation();
const state = useReactive<State>({
activeMenuIndex: 0,
});
const { setOpenPopover } = useShortcutsStore();
const [searchMenus, setSearchMenus] = useState<typeof menus>([]);
const searchInputRef = useRef<HTMLInputElement>(null);
const {
visibleContextMenu,
setVisibleContextMenu,
selectedSearchContent,
selectedExtension,
setVisibleExtensionDetail,
} = useSearchStore();
const title = useCreation(() => {
if (selectedExtension) {
return selectedExtension.name;
}
if (selectedSearchContent?.id === "Calculator") {
return t("search.contextMenu.title.calculator");
}
return selectedSearchContent?.title;
}, [selectedSearchContent, selectedExtension]);
const menus = useCreation(() => {
if (selectedExtension) {
return [
{
name: t("search.contextMenu.details"),
icon: <Info />,
keys: isMac ? ["↩︎"] : ["Enter"],
shortcut: "enter",
clickEvent() {
setVisibleExtensionDetail(true);
},
},
{
name: t("search.contextMenu.install"),
icon: <Download />,
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
shortcut: isMac ? "meta.enter" : "ctrl.enter",
hide: selectedExtension.installed,
clickEvent() {
platformAdapter.emitEvent("install-extension");
},
},
{
name: t("search.contextMenu.configureExtension"),
icon: <Settings />,
keys: isMac ? ["⌘", "/"] : ["Ctrl", "/"],
shortcut: isMac ? "meta.forwardslash" : "ctrl.forwardslash",
hide: !selectedExtension.installed,
clickEvent() {
platformAdapter.emitEvent("config-extension", selectedExtension.id);
},
},
{
name: t("search.contextMenu.uninstall"),
icon: <Trash2 />,
keys: isMac ? ["⌘", "X"] : ["Ctrl", "X"],
shortcut: isMac ? "meta.x" : "ctrl.x",
hide: !selectedExtension.installed,
color: "#fa4545",
clickEvent() {
platformAdapter.emitEvent("uninstall-extension");
},
},
];
}
if (isNil(selectedSearchContent)) {
return [];
}
const { id, url, category, type, payload } = selectedSearchContent;
const { query, result } = payload ?? {};
if (category === "AI Overview") {
return [];
}
return [
{
name: t("search.contextMenu.open"),
icon: <SquareArrowOutUpRight />,
keys: isMac ? ["↩︎"] : ["Enter"],
shortcut: "enter",
hide: category === "Calculator",
clickEvent: () => {
platformAdapter.openSearchItem(
selectedSearchContent as any,
formatUrl
);
},
},
{
name: t("search.contextMenu.copyLink"),
icon: <Link />,
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
shortcut: isMac ? "meta.l" : "ctrl.l",
hide:
category === "Calculator" ||
type === "AI Assistant" ||
id === "Extension Store",
clickEvent() {
copyToClipboard(
(formatUrl && formatUrl(selectedSearchContent)) || url
);
},
},
{
name: t("search.contextMenu.copyAnswer"),
icon: <Copy />,
keys: isMac ? ["↩︎"] : ["Enter"],
shortcut: "enter",
hide: category !== "Calculator",
clickEvent() {
copyToClipboard(result.value);
},
},
{
name: t("search.contextMenu.copyUppercaseAnswer"),
icon: <Copy />,
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
shortcut: isMac ? "meta.enter" : "ctrl.enter",
hide: category !== "Calculator",
clickEvent() {
copyToClipboard(i18n.language === "zh" ? result.toZh : result.toEn);
},
},
{
name: t("search.contextMenu.copyQuestionAndAnswer"),
icon: <Copy />,
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
shortcut: isMac ? "meta.l" : "ctrl.l",
hide: category !== "Calculator",
clickEvent() {
copyToClipboard(`${query.value} = ${result.value}`);
},
},
];
}, [selectedSearchContent, selectedExtension]);
useEffect(() => {
const filterMenus = menus.filter((item) => !item?.hide);
setSearchMenus(filterMenus);
}, [menus]);
const shortcuts = useCreation(() => {
return searchMenus.map((item) => item.shortcut);
}, [searchMenus]);
useEffect(() => {
state.activeMenuIndex = 0;
}, [visibleContextMenu, selectedSearchContent]);
useEffect(() => {
if (isNil(selectedSearchContent)) {
setVisibleContextMenu(false);
}
}, [selectedSearchContent]);
useOSKeyPress(["meta.k", "ctrl+k"], () => {
if (isNil(selectedSearchContent) && isNil(selectedExtension)) return;
setVisibleContextMenu(!visibleContextMenu);
});
useClickAway(() => {
setVisibleContextMenu(false);
}, containerRef);
useOSKeyPress(["uparrow", "downarrow"], (_, key) => {
if (!visibleContextMenu) return;
const index = state.activeMenuIndex;
const length = searchMenus.length;
switch (key) {
case "uparrow":
state.activeMenuIndex = index === 0 ? length - 1 : index - 1;
break;
case "downarrow":
state.activeMenuIndex = index === length - 1 ? 0 : index + 1;
break;
}
});
useOSKeyPress(shortcuts, (_, key) => {
if (!visibleContextMenu) return;
let matched;
if (key === "enter") {
matched = searchMenus.find((_, index) => index === state.activeMenuIndex);
} else {
matched = searchMenus.find((item) => item.shortcut === key);
}
handleClick(matched?.clickEvent);
});
useEffect(() => {
setOpenPopover(visibleContextMenu);
}, [visibleContextMenu]);
const handleClick = (click = noop) => {
click?.();
requestAnimationFrame(() => {
setVisibleContextMenu(false);
});
};
return (
menus.length > 0 && (
<>
{visibleContextMenu && (
<div
className="fixed inset-0"
onContextMenu={(event) => {
event?.preventDefault();
setVisibleContextMenu(false);
}}
/>
)}
<div
ref={containerRef}
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
className={clsx(
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg shadow-xs border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
{
"!scale-100": visibleContextMenu,
}
)}
>
<div className="text-[#999] dark:text-[#666] truncate">{title}</div>
{searchMenus.length > 0 ? (
<ul className="flex flex-col -mx-2 p-0">
{searchMenus.map((item, index) => {
const { name, icon, keys, color, clickEvent } = item;
return (
<li
key={name}
className={clsx(
"flex justify-between items-center gap-2 px-2 py-2 rounded-lg cursor-pointer",
{
"bg-[#EDEDED] dark:bg-[#202126]":
index === state.activeMenuIndex,
}
)}
onMouseEnter={() => {
state.activeMenuIndex = index;
}}
onClick={() => handleClick(clickEvent)}
>
<div className="flex items-center gap-2 text-black/80 dark:text-white/80">
{cloneElement(icon, {
className: "size-4",
style: { color },
})}
<span style={{ color }}>{name}</span>
</div>
<div className="flex gap-[4px] text-black/60 dark:text-white/60">
{keys.map((key) => (
<kbd
key={key}
className={clsx(
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
{
"px-1": key.length > 1,
}
)}
>
{key}
</kbd>
))}
</div>
</li>
);
})}
</ul>
) : (
<div className="py-4">
<SearchEmpty width={80} />
</div>
)}
<div className="-mx-3 p-2 border-t border-[#E6E6E6] dark:border-[#262626]">
{visibleContextMenu && (
<VisibleKey
shortcut="F"
shortcutClassName="left-3"
onKeyPress={() => {
searchInputRef.current?.focus();
}}
>
<Input
ref={searchInputRef}
autoFocus
autoCorrect="off"
placeholder={t("search.contextMenu.search")}
className="w-full bg-transparent"
onChange={(event) => {
const value = event.target.value.trim();
const nextMenus = menus
.filter((item) => !item.hide)
.filter((item) => {
return lowerCase(item.name).includes(lowerCase(value));
});
setSearchMenus(nextMenus);
}}
/>
</VisibleKey>
)}
</div>
</div>
</>
)
);
};
export default ContextMenu;