mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-09-01 19:51:52 +02:00
372 lines
11 KiB
TypeScript
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;
|