💄 style: support ContextMenu on ChatItem (#9034)

* feat: add chatitem right click contextMenu

* fix: soft key fixed

* feat: add contextMenu used box

* feat: add commons contextMenuMode settings config

* feat: add i18n

* feat: update contextmenu use

* fix: add lost merge files

* fix: add lost className

* fix: lint fixed

* feat: add expand & collapse fc in contextMenu

* fix: delete the onShare callback

* fix: refactor contextMenu

* feat: update i18n
This commit is contained in:
Shinji-Li
2025-11-24 15:19:15 +08:00
committed by GitHub
parent 1fb7b292ca
commit 27c1154210
30 changed files with 779 additions and 51 deletions
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "أنيق",
"title": "حركة الاستجابة"
},
"contextMenuMode": {
"default": "افتراضي",
"desc": "اختر طريقة عرض قائمة النقر بزر الماوس الأيمن لرسائل الدردشة",
"disabled": "عدم الاستخدام",
"title": "خطة قائمة النقر بزر الماوس الأيمن"
},
"neutralColor": {
"desc": "تخصيص تدرجات الرمادي ذات الاتجاهات اللونية المختلفة",
"title": "لون محايد"
@@ -777,4 +783,4 @@
},
"title": "أدوات الامتداد"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Елегантно",
"title": "Анимация на отговор"
},
"contextMenuMode": {
"default": "По подразбиране",
"desc": "Изберете начина на показване на контекстното меню за чат съобщения",
"disabled": "Не използвай",
"title": "Схема на контекстното меню"
},
"neutralColor": {
"desc": "Персонализиране на сивата скала с различни цветови нюанси",
"title": "Неутрални цветове"
@@ -777,4 +783,4 @@
},
"title": "Инструменти за разширение"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegant",
"title": "Reaktionsanimation"
},
"contextMenuMode": {
"default": "Standard",
"desc": "Wählen Sie das Anzeigeformat des Kontextmenüs für Chatnachrichten",
"disabled": "Nicht verwenden",
"title": "Kontextmenü-Option"
},
"neutralColor": {
"desc": "Anpassung der Graustufen mit unterschiedlichen Farbneigungen",
"title": "Neutrale Farben"
@@ -777,4 +783,4 @@
},
"title": "Erweiterungswerkzeuge"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegant",
"title": "Response Animation"
},
"contextMenuMode": {
"default": "Default",
"desc": "Select the display mode for the chat message right-click menu",
"disabled": "Disabled",
"title": "Right-Click Menu Mode"
},
"neutralColor": {
"desc": "Custom grayscale with different color tendencies",
"title": "Neutral Color"
@@ -777,4 +783,4 @@
},
"title": "Extension Tools"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegante",
"title": "Animación de respuesta"
},
"contextMenuMode": {
"default": "Predeterminado",
"desc": "Seleccione el esquema de visualización del menú contextual en los mensajes de chat",
"disabled": "No usar",
"title": "Esquema del menú contextual"
},
"neutralColor": {
"desc": "Personalización de escalas de grises con diferentes inclinaciones de color",
"title": "Color Neutro"
@@ -777,4 +783,4 @@
},
"title": "Herramientas de extensión"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "ظریف",
"title": "انیمیشن پاسخ"
},
"contextMenuMode": {
"default": "پیش‌فرض",
"desc": "انتخاب طرح نمایش منوی کلیک راست پیام‌های چت",
"disabled": "استفاده نشود",
"title": "طرح منوی کلیک راست"
},
"neutralColor": {
"desc": "سفارشی‌سازی مقیاس خاکستری با تم‌های رنگی مختلف",
"title": "رنگ‌های خنثی"
@@ -777,4 +783,4 @@
},
"title": "افزونه‌های گسترش"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Élégant",
"title": "Animation de réponse"
},
"contextMenuMode": {
"default": "Par défaut",
"desc": "Choisissez le mode d'affichage du menu contextuel des messages de chat",
"disabled": "Désactivé",
"title": "Mode du menu contextuel"
},
"neutralColor": {
"desc": "Personnalisation des nuances de gris selon les tendances de couleur",
"title": "Couleur neutre"
@@ -777,4 +783,4 @@
},
"title": "Outils supplémentaires"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegante",
"title": "Animazione di risposta"
},
"contextMenuMode": {
"default": "Predefinito",
"desc": "Seleziona il tipo di menu contestuale per i messaggi di chat",
"disabled": "Disabilitato",
"title": "Tipo di menu contestuale"
},
"neutralColor": {
"desc": "Personalizzazione dei grigi con diverse inclinazioni di colore",
"title": "Colore Neutro"
@@ -777,4 +783,4 @@
},
"title": "Strumenti aggiuntivi"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "エレガント",
"title": "応答アニメーション"
},
"contextMenuMode": {
"default": "デフォルト",
"desc": "チャットメッセージの右クリックメニューの表示方法を選択してください",
"disabled": "使用しない",
"title": "右クリックメニューの設定"
},
"neutralColor": {
"desc": "異なる色彩傾向のグレースケールカスタマイズ",
"title": "ニュートラルカラー"
@@ -777,4 +783,4 @@
},
"title": "拡張ツール"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "우아함",
"title": "반응 애니메이션"
},
"contextMenuMode": {
"default": "기본값",
"desc": "채팅 메시지 우클릭 메뉴 표시 방식을 선택하세요",
"disabled": "사용 안 함",
"title": "우클릭 메뉴 방식"
},
"neutralColor": {
"desc": "다양한 색상 경향의 회색조 사용자 정의",
"title": "중립 색상"
@@ -777,4 +783,4 @@
},
"title": "확장 도구"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegant",
"title": "Reactie-animatie"
},
"contextMenuMode": {
"default": "Standaard",
"desc": "Kies de weergaveoptie voor het contextmenu van chatberichten",
"disabled": "Niet gebruiken",
"title": "Contextmenu-optie"
},
"neutralColor": {
"desc": "Aangepaste grijstinten met verschillende kleurvoorkeuren",
"title": "Neutrale kleur"
@@ -777,4 +783,4 @@
},
"title": "Uitbreidingsgereedschap"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegancki",
"title": "Animacja reakcji"
},
"contextMenuMode": {
"default": "Domyślnie",
"desc": "Wybierz sposób wyświetlania menu kontekstowego wiadomości czatu",
"disabled": "Nie używaj",
"title": "Schemat menu kontekstowego"
},
"neutralColor": {
"desc": "Dostosowanie odcieni szarości w różnych kolorach",
"title": "Kolor neutralny"
@@ -777,4 +783,4 @@
},
"title": "Narzędzia rozszerzeń"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Elegante",
"title": "Animação de Resposta"
},
"contextMenuMode": {
"default": "Padrão",
"desc": "Escolha o modo de exibição do menu de contexto ao clicar com o botão direito nas mensagens de chat",
"disabled": "Desativado",
"title": "Modo do menu de contexto"
},
"neutralColor": {
"desc": "Personalização de escala de cinza com diferentes inclinações de cor",
"title": "Cor Neutra"
@@ -777,4 +783,4 @@
},
"title": "Ferramentas de Extensão"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Элегантный",
"title": "Анимация отклика"
},
"contextMenuMode": {
"default": "По умолчанию",
"desc": "Выберите способ отображения контекстного меню сообщений чата",
"disabled": "Не использовать",
"title": "Схема контекстного меню"
},
"neutralColor": {
"desc": "Настройка градаций серого с различными цветовыми наклонами",
"title": "Нейтральный цвет"
@@ -777,4 +783,4 @@
},
"title": "Дополнительные инструменты"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Zarif",
"title": "Yanıt Animasyonu"
},
"contextMenuMode": {
"default": "Varsayılan",
"desc": "Sohbet mesajları sağ tıklama menüsünün görüntüleme seçeneğini seçin",
"disabled": "Kullanma",
"title": "Sağ Tıklama Menüsü Seçeneği"
},
"neutralColor": {
"desc": "Farklı renk eğilimlerine sahip gri tonları özelleştirme",
"title": "Nötr Renk"
@@ -777,4 +783,4 @@
},
"title": "Uzantı Araçları"
}
}
}
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "Thanh lịch",
"title": "Hoạt ảnh phản hồi"
},
"contextMenuMode": {
"default": "Mặc định",
"desc": "Chọn phương án hiển thị menu chuột phải cho tin nhắn trò chuyện",
"disabled": "Không sử dụng",
"title": "Phương án menu chuột phải"
},
"neutralColor": {
"desc": "Tùy chỉnh thang độ xám với các xu hướng màu sắc khác nhau",
"title": "Màu trung tính"
@@ -777,4 +783,4 @@
},
"title": "Công cụ mở rộng"
}
}
}
+6
View File
@@ -293,6 +293,12 @@
"elegant": "优雅",
"title": "响应动画"
},
"contextMenuMode": {
"default": "默认",
"desc": "选择聊天消息右键菜单的显示方案",
"disabled": "不使用",
"title": "右键菜单方案"
},
"neutralColor": {
"desc": "不同色彩倾向的灰阶自定义",
"title": "中性色"
+7 -1
View File
@@ -293,6 +293,12 @@
"elegant": "優雅",
"title": "回應動畫"
},
"contextMenuMode": {
"default": "預設",
"desc": "選擇聊天訊息右鍵選單的顯示方案",
"disabled": "不使用",
"title": "右鍵選單方案"
},
"neutralColor": {
"desc": "不同色彩傾向的灰階自訂",
"title": "中性色"
@@ -777,4 +783,4 @@
},
"title": "擴展工具"
}
}
}
+1
View File
@@ -2,6 +2,7 @@ import { UserGeneralConfig } from '@lobechat/types';
export const DEFAULT_COMMON_SETTINGS: UserGeneralConfig = {
animationMode: 'agile',
// contextMenuMode not set default value, use env to calc
fontSize: 14,
highlighterTheme: 'lobe-theme',
mermaidTheme: 'lobe-theme',
@@ -4,8 +4,11 @@ import type { ResponseAnimationStyle } from '../../aiProvider';
export type AnimationMode = 'disabled' | 'agile' | 'elegant';
export type ContextMenuMode = 'disabled' | 'default';
export interface UserGeneralConfig {
animationMode?: AnimationMode;
contextMenuMode?: ContextMenuMode;
fontSize: number;
highlighterTheme?: HighlighterProps['theme'];
mermaidTheme?: MermaidProps['theme'];
@@ -4,7 +4,7 @@ import { Form, type FormGroupItemType, Icon, ImageSelect, InputPassword } from '
import { Select } from '@lobehub/ui';
import { Segmented, Skeleton } from 'antd';
import isEqual from 'fast-deep-equal';
import { Ban, Gauge, Loader2Icon, Monitor, Moon, Sun, Waves } from 'lucide-react';
import { Ban, Gauge, Loader2Icon, Monitor, Moon, Mouse, Sun, Waves } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -113,6 +113,28 @@ const Common = memo(() => {
minWidth: undefined,
name: 'animationMode',
},
{
children: (
<Segmented
options={[
{
icon: <Icon icon={Ban} size={16} />,
label: t('settingAppearance.contextMenuMode.disabled'),
value: 'disabled',
},
{
icon: <Icon icon={Mouse} size={16} />,
label: t('settingAppearance.contextMenuMode.default'),
value: 'default',
},
]}
/>
),
desc: t('settingAppearance.contextMenuMode.desc'),
label: t('settingAppearance.contextMenuMode.title'),
minWidth: undefined,
name: 'contextMenuMode',
},
{
children: (
@@ -5,6 +5,7 @@ import { type ReactNode, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { MessageContentClassName } from '@/features/ChatList/Messages/Default';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
@@ -83,7 +84,7 @@ const MessageContent = memo<MessageContentProps>(
return (
<Flexbox
className={cx(styles.message, editing && styles.editingContainer, className)}
className={cx(styles.message, className, editing && styles.editingContainer, MessageContentClassName)}
onDoubleClick={onDoubleClick}
>
{messageContent}
@@ -21,6 +21,7 @@ interface AssistantActionsProps {
id: string;
index: number;
}
export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, index }) => {
const { error, tools } = data;
const [isThreadMode, hasThread, isRegenerating, isCollapsed] = useChatStore((s) => [
@@ -75,7 +75,7 @@ export const useStyles = createStyles(
justify-content: ${placement === 'left' ? 'flex-end' : 'flex-start'};
`,
editing &&
css`
css`
pointer-events: none !important;
opacity: 0 !important;
`,
@@ -6,6 +6,8 @@ import { LOADING_FLAT } from '@/const/message';
import { useChatStore } from '@/store/chat';
import { messageStateSelectors } from '@/store/chat/selectors';
export const MessageContentClassName = 'msg_content_flag'
export const DefaultMessage = memo<
UIChatMessage & {
addIdOnDOM?: boolean;
+80 -31
View File
@@ -3,24 +3,28 @@
import { isDesktop } from '@lobechat/const';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { MouseEvent, ReactNode, memo, use, useCallback, useEffect, useRef } from 'react';
import { Flexbox } from 'react-layout-kit';
import {
removeVirtuaVisibleItem,
upsertVirtuaVisibleItem,
} from '@/features/ChatList/components/VirtualizedList/VirtuosoContext';
import { getChatStoreState, useChatStore } from '@/store/chat';
import { displayMessageSelectors, messageStateSelectors } from '@/store/chat/selectors';
import ContextMenu from '../components/ContextMenu';
import History from '../components/History';
import { InPortalThreadContext } from '../context/InPortalThreadContext';
import { useChatItemContextMenu } from '../hooks/useChatItemContextMenu';
import AssistantMessage from './Assistant';
import GroupMessage from './Group';
import SupervisorMessage from './Supervisor';
import ToolMessage from './Tool';
import UserMessage from './User';
import {
VirtuaContext,
removeVirtuaVisibleItem,
upsertVirtuaVisibleItem,
} from '@/features/ChatList/components/VirtualizedList/VirtuosoContext';
import { getChatStoreState, useChatStore } from '@/store/chat';
import { displayMessageSelectors, messageStateSelectors } from '@/store/chat/selectors';
const useStyles = createStyles(({ css, prefixCls }) => ({
loading: css`
opacity: 0.6;
@@ -58,17 +62,41 @@ const Item = memo<ChatListItemProps>(
}) => {
const { styles, cx } = useStyles();
const containerRef = useRef<HTMLDivElement | null>(null);
const virtuaRef = use(VirtuaContext);
const searchParams = useSearchParams();
const topic = searchParams.get('topic');
const [role, isMessageCreating] = useChatStore((s) => [
displayMessageSelectors.getDisplayMessageById(id)(s)?.role,
messageStateSelectors.isMessageCreating(id)(s),
]);
const [role, editing, isMessageCreating] = useChatStore(
(s) => {
const item = displayMessageSelectors.getDisplayMessageById(id)(s);
return [
item?.role,
messageStateSelectors.isMessageEditing(id)(s),
messageStateSelectors.isMessageCreating(id)(s),
];
},
isEqual,
);
const {
containerRef: contextMenuContainerRef,
contextMenuState,
handleContextMenu,
hideContextMenu,
} = useChatItemContextMenu({
editing,
onActionClick: () => {},
});
const setContainerRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
contextMenuContainerRef.current = node;
},
[contextMenuContainerRef],
);
// ======================= Performance Optimization ======================= //
// these useMemo/useCallback are all for the performance optimization
// maybe we can remove it in React 19
// ======================================================================== //
useEffect(() => {
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') return;
@@ -107,22 +135,32 @@ const Item = memo<ChatListItemProps>(
};
}, [index]);
const onContextMenu = useCallback(async () => {
const item = displayMessageSelectors.getDisplayMessageById(id)(getChatStoreState());
const onContextMenu = useCallback(
async (event: MouseEvent<HTMLDivElement>) => {
if (!role || (role !== 'user' && role !== 'assistant')) return;
if (isDesktop && item) {
const { electronSystemService } = await import('@/services/electron/system');
const item = displayMessageSelectors.getDisplayMessageById(id)(getChatStoreState());
if (!item) return;
electronSystemService.showContextMenu('chat', {
content: item.content,
hasError: !!item.error,
messageId: id,
role: item.role,
});
}
}, [id]);
if (isDesktop) {
const { electronSystemService } = await import('@/services/electron/system');
const renderContent = useMemo(() => {
electronSystemService.showContextMenu('chat', {
content: item.content,
hasError: !!item.error,
messageId: id,
role: item.role,
});
return;
}
handleContextMenu(event);
},
[handleContextMenu, id, role],
);
const renderContent = useCallback(() => {
switch (role) {
case 'user': {
return <UserMessage disableEditing={disableEditing} id={id} index={index} />;
@@ -171,11 +209,22 @@ const Item = memo<ChatListItemProps>(
className={cx(styles.message, className, isMessageCreating && styles.loading)}
data-index={index}
onContextMenu={onContextMenu}
ref={containerRef}
ref={setContainerRef}
>
{renderContent}
{renderContent()}
{endRender}
</Flexbox>
<ContextMenu
id={id}
index={index}
inPortalThread={inPortalThread}
onClose={hideContextMenu}
position={contextMenuState.position}
selectedText={contextMenuState.selectedText}
topic={topic}
virtuaRef={virtuaRef}
visible={contextMenuState.visible}
/>
</InPortalThreadContext.Provider>
);
},
@@ -0,0 +1,392 @@
'use client';
import { UIChatMessage } from '@lobechat/types';
import { ActionIcon } from '@lobehub/ui';
import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
import { Dropdown, type MenuProps } from 'antd';
import { App } from 'antd';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { isValidElement, memo, useCallback, useMemo, useState, type RefObject } from 'react';
import type { ComponentType, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { VListHandle } from 'virtua';
import ShareMessageModal from './ShareMessageModal';
import { useChatListActionsBar } from '../hooks/useChatListActionsBar';
import { getChatStoreState, useChatStore } from '@/store/chat';
import {
displayMessageSelectors,
messageStateSelectors,
threadSelectors,
} from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
interface ActionMenuItem extends ActionIconGroupItemType {
children?: { key: string; label: ReactNode }[];
disable?: boolean;
popupClassName?: string;
}
type MenuItem = ActionMenuItem | { type: 'divider' };
type ContextMenuEvent = ActionIconGroupEvent & { selectedText?: string };
const useStyles = createStyles(({ css }) => ({
contextMenu: css`
position: fixed;
z-index: 1000;
min-width: 160px;
.ant-dropdown-menu {
border: none;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
}
`,
trigger: css`
pointer-events: none;
position: fixed;
width: 1px;
height: 1px;
opacity: 0;
`,
}));
interface ContextMenuProps {
id: string;
index: number;
inPortalThread: boolean;
position: { x: number; y: number };
selectedText?: string;
topic: string | null;
virtuaRef?: RefObject<VListHandle | null> | null;
visible: boolean;
onClose: () => void;
}
const ContextMenu = memo<ContextMenuProps>(
({ visible, position, selectedText, id, index, inPortalThread, topic, virtuaRef, onClose }) => {
const { styles } = useStyles();
const { message } = App.useApp();
const { t } = useTranslation('common');
const [shareMessage, setShareMessage] = useState<UIChatMessage | null>(null);
const [isShareModalOpen, setShareModalOpen] = useState(false);
const [role, error, isCollapsed, hasThread, isRegenerating] = useChatStore(
(s) => {
const item = displayMessageSelectors.getDisplayMessageById(id)(s);
return [
item?.role,
item?.error,
messageStateSelectors.isMessageCollapsed(id)(s),
threadSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
];
},
isEqual,
);
const isThreadMode = useChatStore((s) => !!s.activeThreadId);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const actionsBar = useChatListActionsBar({ hasThread, isRegenerating });
const inThread = isThreadMode || inPortalThread;
const [
toggleMessageEditing,
deleteMessage,
regenerateUserMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
copyMessage,
openThreadCreator,
resendThreadMessage,
delAndResendThreadMessage,
toggleMessageCollapsed,
] = useChatStore((s) => [
s.toggleMessageEditing,
s.deleteMessage,
s.regenerateUserMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
s.copyMessage,
s.openThreadCreator,
s.resendThreadMessage,
s.delAndResendThreadMessage,
s.toggleMessageCollapsed,
]);
const getMessage = useCallback(
() => displayMessageSelectors.getDisplayMessageById(id)(getChatStoreState()),
[id],
);
const menuItems = useMemo<MenuItem[]>(() => {
if (!role) return [];
const {
branching,
collapse,
copy,
del,
delAndRegenerate,
divider,
edit,
expand,
regenerate,
share,
translate,
tts,
} = actionsBar;
if (role === 'assistant') {
if (error) {
return [edit, copy, divider, del, divider, regenerate].filter(Boolean) as MenuItem[];
}
const collapseAction = isCollapsed ? expand : collapse;
const list: MenuItem[] = [edit, copy, collapseAction];
if (!inThread && !isGroupSession) list.push(branching);
list.push(
divider,
tts,
translate,
divider,
share,
divider,
regenerate,
delAndRegenerate,
del,
);
return list.filter(Boolean) as MenuItem[];
}
if (role === 'user') {
const list: MenuItem[] = [edit, copy];
if (!inThread) list.push(branching);
list.push(divider, tts, translate, divider, regenerate, del);
return list.filter(Boolean) as MenuItem[];
}
return [];
}, [actionsBar, error, inThread, isCollapsed, isGroupSession, role]);
const handleShare = useCallback(() => {
const item = getMessage();
if (!item || item.role !== 'assistant') return;
setShareMessage(item);
setShareModalOpen(true);
}, [getMessage]);
const handleShareClose = useCallback(() => {
setShareModalOpen(false);
setShareMessage(null);
}, []);
const handleAction = useCallback(
async (action: ContextMenuEvent) => {
const item = getMessage();
if (!item) return;
if (action.key === 'edit') {
toggleMessageEditing(id, true);
virtuaRef?.current?.scrollToIndex(index, { align: 'start' });
}
switch (action.key) {
case 'copy': {
await copyMessage(id, item.content);
message.success(t('copySuccess'));
break;
}
case 'expand':
case 'collapse': {
toggleMessageCollapsed(id);
break;
}
case 'branching': {
if (!topic) {
message.warning(t('branchingRequiresSavedTopic'));
break;
}
openThreadCreator(id);
break;
}
case 'del': {
deleteMessage(id);
break;
}
case 'regenerate': {
if (inPortalThread) {
resendThreadMessage(id);
} else if (role === 'assistant') {
regenerateAssistantMessage(id);
} else {
regenerateUserMessage(id);
}
if (item.error) deleteMessage(id);
break;
}
case 'delAndRegenerate': {
if (inPortalThread) {
delAndResendThreadMessage(id);
} else {
delAndRegenerateMessage(id);
}
break;
}
case 'tts': {
ttsMessage(id);
break;
}
case 'share': {
handleShare();
break;
}
}
if (action.keyPath?.at(-1) === 'translate') {
const lang = action.keyPath[0];
translateMessage(id, lang);
}
},
[
copyMessage,
deleteMessage,
delAndRegenerateMessage,
delAndResendThreadMessage,
getMessage,
handleShare,
id,
index,
inPortalThread,
message,
openThreadCreator,
regenerateAssistantMessage,
regenerateUserMessage,
resendThreadMessage,
role,
t,
toggleMessageCollapsed,
toggleMessageEditing,
topic,
translateMessage,
ttsMessage,
virtuaRef,
],
);
const renderIcon = useCallback((iconComponent: ActionIconGroupItemType['icon']) => {
if (!iconComponent) return null;
if (isValidElement(iconComponent)) {
return <ActionIcon icon={iconComponent} size={'small'} />;
}
const IconComponent = iconComponent as ComponentType<{ size?: number }>;
return <ActionIcon icon={<IconComponent size={16} />} size={'small'} />;
}, []);
const dropdownMenuItems = useMemo(() => {
return (menuItems ?? []).filter(Boolean).map((item) => {
if ('type' in item && item.type === 'divider') return { type: 'divider' as const };
const actionItem = item as ActionMenuItem;
const children = actionItem.children?.map((child) => ({
key: child.key,
label: child.label,
}));
const disabled =
actionItem.disabled ??
(typeof actionItem.disable === 'boolean' ? actionItem.disable : undefined);
return {
children,
danger: actionItem.danger,
disabled,
icon: renderIcon(actionItem.icon),
key: actionItem.key,
label: actionItem.label,
popupClassName: actionItem.popupClassName,
};
});
}, [menuItems, renderIcon]);
const handleMenuClick = useCallback(
(info: Parameters<NonNullable<MenuProps['onClick']>>[0]) => {
const event = {
...info,
selectedText,
} as ContextMenuEvent;
handleAction(event);
onClose();
},
[handleAction, onClose, selectedText],
);
if (!visible || menuItems.length === 0) return null;
return (
<>
{createPortal(
<>
<div
className={styles.trigger}
style={{
left: position.x,
top: position.y,
}}
/>
<Dropdown
menu={{
items: dropdownMenuItems,
onClick: handleMenuClick,
}}
open={visible}
placement="bottomLeft"
trigger={[]}
>
<div
className={styles.contextMenu}
style={{
left: position.x,
top: position.y,
}}
/>
</Dropdown>
</>,
document.body,
)}
{shareMessage && (
<ShareMessageModal
message={shareMessage}
onCancel={handleShareClose}
open={isShareModalOpen}
/>
)}
</>
);
},
);
ContextMenu.displayName = 'ContextMenu';
export default ContextMenu;
@@ -0,0 +1,135 @@
import { type ActionIconGroupEvent } from '@lobehub/ui';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
import { MessageContentClassName } from '../Messages/Default';
interface ContextMenuState {
position: { x: number; y: number };
selectedText?: string;
visible: boolean;
}
interface UseChatItemContextMenuProps {
editing?: boolean;
id: string;
onActionClick: (action: ActionIconGroupEvent) => void;
}
export const useChatItemContextMenu = ({
onActionClick,
editing,
}: Omit<UseChatItemContextMenuProps, 'id'>) => {
const contextMenuMode = useUserStore(userGeneralSettingsSelectors.contextMenuMode);
const [contextMenuState, setContextMenuState] = useState<ContextMenuState>({
position: { x: 0, y: 0 },
visible: false,
});
const containerRef = useRef<HTMLDivElement>(null);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
// Don't show context menu if disabled in settings
if (contextMenuMode === 'disabled') {
return;
}
// Don't show context menu in editing mode
if (editing) {
return;
}
// Check if the clicked element or its parents have an id containing "msg_"
let target = event.target as HTMLElement;
let hasMessageId = false;
while (target && target !== document.body) {
if (target.className.includes(MessageContentClassName)) {
hasMessageId = true;
break;
}
target = target.parentElement as HTMLElement;
}
if (!hasMessageId) {
return;
}
event.preventDefault();
event.stopPropagation();
// Get selected text
const selection = window.getSelection();
const selectedText = selection?.toString().trim() || '';
setContextMenuState({
position: { x: event.clientX, y: event.clientY },
selectedText,
visible: true,
});
},
[contextMenuMode, editing],
);
const hideContextMenu = useCallback(() => {
setContextMenuState((prev) => ({ ...prev, visible: false }));
}, []);
const handleMenuClick = useCallback(
(action: ActionIconGroupEvent) => {
if (action.key === 'quote' && contextMenuState.selectedText) {
// Handle quote action - this will be integrated with ChatInput
onActionClick({
...action,
selectedText: contextMenuState.selectedText,
} as ActionIconGroupEvent & { selectedText: string });
} else {
onActionClick(action);
}
hideContextMenu();
},
[contextMenuState.selectedText, onActionClick, hideContextMenu],
);
// Close context menu when clicking outside
useEffect(() => {
const handleClickOutside = () => {
if (contextMenuState.visible) {
hideContextMenu();
}
};
const handleScroll = (event: Event) => {
if (contextMenuState.visible) {
// Check if the scroll event is from a dropdown sub-menu
const target = event.target as HTMLElement;
if (target && target.classList && target.classList.contains('ant-dropdown-menu-sub')) {
return; // Don't hide the context menu when scrolling within sub-menu
}
hideContextMenu();
}
};
if (contextMenuState.visible) {
document.addEventListener('click', handleClickOutside);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [contextMenuState.visible, hideContextMenu]);
return {
containerRef,
contextMenuState,
handleContextMenu,
handleMenuClick,
hideContextMenu,
};
};
+6
View File
@@ -297,6 +297,12 @@ export default {
elegant: '优雅',
title: '响应动画',
},
contextMenuMode: {
default: '默认',
desc: '选择聊天消息右键菜单的显示方案',
disabled: '不使用',
title: '右键菜单方案',
},
neutralColor: {
desc: '不同色彩倾向的灰阶自定义',
title: '中性色',
@@ -1,3 +1,5 @@
import { isDesktop } from '@lobechat/const';
import { UserStore } from '../../../store';
import { currentSettings } from './settings';
@@ -10,10 +12,16 @@ const highlighterTheme = (s: UserStore) => generalConfig(s).highlighterTheme;
const mermaidTheme = (s: UserStore) => generalConfig(s).mermaidTheme;
const transitionMode = (s: UserStore) => generalConfig(s).transitionMode;
const animationMode = (s: UserStore) => generalConfig(s).animationMode;
const contextMenuMode = (s: UserStore) => {
const config = generalConfig(s).contextMenuMode;
if (config !== undefined) return config;
return isDesktop ? 'default' : 'disabled';
};
export const userGeneralSettingsSelectors = {
animationMode,
config: generalConfig,
contextMenuMode,
fontSize,
highlighterTheme,
mermaidTheme,