💄 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:
@@ -293,6 +293,12 @@
|
||||
"elegant": "أنيق",
|
||||
"title": "حركة الاستجابة"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "افتراضي",
|
||||
"desc": "اختر طريقة عرض قائمة النقر بزر الماوس الأيمن لرسائل الدردشة",
|
||||
"disabled": "عدم الاستخدام",
|
||||
"title": "خطة قائمة النقر بزر الماوس الأيمن"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "تخصيص تدرجات الرمادي ذات الاتجاهات اللونية المختلفة",
|
||||
"title": "لون محايد"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "أدوات الامتداد"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "Елегантно",
|
||||
"title": "Анимация на отговор"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "По подразбиране",
|
||||
"desc": "Изберете начина на показване на контекстното меню за чат съобщения",
|
||||
"disabled": "Не използвай",
|
||||
"title": "Схема на контекстното меню"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "Персонализиране на сивата скала с различни цветови нюанси",
|
||||
"title": "Неутрални цветове"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "Инструменти за разширение"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "ظریف",
|
||||
"title": "انیمیشن پاسخ"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "پیشفرض",
|
||||
"desc": "انتخاب طرح نمایش منوی کلیک راست پیامهای چت",
|
||||
"disabled": "استفاده نشود",
|
||||
"title": "طرح منوی کلیک راست"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "سفارشیسازی مقیاس خاکستری با تمهای رنگی مختلف",
|
||||
"title": "رنگهای خنثی"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "افزونههای گسترش"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "エレガント",
|
||||
"title": "応答アニメーション"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "デフォルト",
|
||||
"desc": "チャットメッセージの右クリックメニューの表示方法を選択してください",
|
||||
"disabled": "使用しない",
|
||||
"title": "右クリックメニューの設定"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "異なる色彩傾向のグレースケールカスタマイズ",
|
||||
"title": "ニュートラルカラー"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "拡張ツール"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "우아함",
|
||||
"title": "반응 애니메이션"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "기본값",
|
||||
"desc": "채팅 메시지 우클릭 메뉴 표시 방식을 선택하세요",
|
||||
"disabled": "사용 안 함",
|
||||
"title": "우클릭 메뉴 방식"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "다양한 색상 경향의 회색조 사용자 정의",
|
||||
"title": "중립 색상"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "확장 도구"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ń"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "Элегантный",
|
||||
"title": "Анимация отклика"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "По умолчанию",
|
||||
"desc": "Выберите способ отображения контекстного меню сообщений чата",
|
||||
"disabled": "Не использовать",
|
||||
"title": "Схема контекстного меню"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "Настройка градаций серого с различными цветовыми наклонами",
|
||||
"title": "Нейтральный цвет"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "Дополнительные инструменты"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "优雅",
|
||||
"title": "响应动画"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "默认",
|
||||
"desc": "选择聊天消息右键菜单的显示方案",
|
||||
"disabled": "不使用",
|
||||
"title": "右键菜单方案"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "不同色彩倾向的灰阶自定义",
|
||||
"title": "中性色"
|
||||
|
||||
@@ -293,6 +293,12 @@
|
||||
"elegant": "優雅",
|
||||
"title": "回應動畫"
|
||||
},
|
||||
"contextMenuMode": {
|
||||
"default": "預設",
|
||||
"desc": "選擇聊天訊息右鍵選單的顯示方案",
|
||||
"disabled": "不使用",
|
||||
"title": "右鍵選單方案"
|
||||
},
|
||||
"neutralColor": {
|
||||
"desc": "不同色彩傾向的灰階自訂",
|
||||
"title": "中性色"
|
||||
@@ -777,4 +783,4 @@
|
||||
},
|
||||
"title": "擴展工具"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user