feat(conversation): add copy text context menu action with preserved selection

This commit is contained in:
xintaofei
2026-04-17 23:20:01 +08:00
parent e86682dc66
commit 32b4c88582
12 changed files with 120 additions and 3 deletions

View File

@@ -88,7 +88,10 @@ export const ChatInput = memo(function ChatInput({
const isConnecting = status === "connecting"
return (
<div className="p-4 pt-0">
<div
className="p-4 pt-0"
onContextMenu={(event) => event.stopPropagation()}
>
{queue &&
queue.length > 0 &&
onQueueReorder &&

View File

@@ -1,7 +1,16 @@
"use client"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react"
import {
Copy,
Download,
FileCode,
FileImage,
@@ -1221,6 +1230,72 @@ export function ConversationDetailPanel() {
}))
}, [activeConversationTab])
const [contextMenuSelectedText, setContextMenuSelectedText] = useState("")
const savedSelectionRangeRef = useRef<Range | null>(null)
const isContextMenuOpenRef = useRef(false)
const handleContextMenuOpenChange = useCallback((open: boolean) => {
isContextMenuOpenRef.current = open
if (!open) {
savedSelectionRangeRef.current = null
return
}
const selection = window.getSelection()
const text = selection?.toString() ?? ""
setContextMenuSelectedText(text)
savedSelectionRangeRef.current =
selection && selection.rangeCount > 0 && !selection.isCollapsed
? selection.getRangeAt(0).cloneRange()
: null
}, [])
const handleContextMenuTriggerPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 2) return
const selection = window.getSelection()
if (selection && !selection.isCollapsed) {
event.preventDefault()
}
},
[]
)
useEffect(() => {
const handler = () => {
if (!isContextMenuOpenRef.current) return
const range = savedSelectionRangeRef.current
if (!range) return
if (
!document.contains(range.startContainer) ||
!document.contains(range.endContainer)
) {
savedSelectionRangeRef.current = null
return
}
const selection = window.getSelection()
if (!selection) return
if (selection.toString().length > 0) return
selection.removeAllRanges()
selection.addRange(range)
}
document.addEventListener("selectionchange", handler)
return () => document.removeEventListener("selectionchange", handler)
}, [])
const handleCopySelectedText = useCallback(async () => {
if (!contextMenuSelectedText) return
if (!navigator.clipboard?.writeText) {
toast.error(t("copyTextFailed"))
return
}
try {
await navigator.clipboard.writeText(contextMenuSelectedText)
toast.success(t("copyTextSuccess"))
} catch {
toast.error(t("copyTextFailed"))
}
}, [contextMenuSelectedText, t])
const handleNewConversation = useCallback(() => {
if (!folder) return
openNewConversationTab(folder.path)
@@ -1309,13 +1384,14 @@ export function ConversationDetailPanel() {
}
return (
<ContextMenu>
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild>
<div
className={cn(
"relative h-full min-h-0 overflow-hidden",
canTile && "flex flex-row"
)}
onPointerDown={handleContextMenuTriggerPointerDown}
>
{tabs.map((tab, index) => {
const active = tab.id === activeTabId
@@ -1352,6 +1428,14 @@ export function ConversationDetailPanel() {
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
disabled={!contextMenuSelectedText}
onSelect={handleCopySelectedText}
>
<Copy className="h-4 w-4" />
{t("copyText")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
disabled={!canReloadActiveConversation}
onSelect={handleReloadActiveConversation}

View File

@@ -828,6 +828,9 @@
"reload": "إعادة تحميل",
"newConversation": "محادثة جديدة",
"closeConversation": "إغلاق المحادثة",
"copyText": "نسخ النص",
"copyTextSuccess": "تم النسخ",
"copyTextFailed": "فشل النسخ",
"forkSession": "تفريع الجلسة",
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
"forkSessionFailed": "فشل في تفريع الجلسة: {error}",

View File

@@ -828,6 +828,9 @@
"reload": "Neu laden",
"newConversation": "Neue Konversation",
"closeConversation": "Konversation schließen",
"copyText": "Text kopieren",
"copyTextSuccess": "Kopiert",
"copyTextFailed": "Kopieren fehlgeschlagen",
"forkSession": "Sitzung forken",
"forkSessionSuccess": "Sitzung erfolgreich geforkt",
"forkSessionFailed": "Sitzung konnte nicht geforkt werden: {error}",

View File

@@ -828,6 +828,9 @@
"reload": "Reload",
"newConversation": "New Conversation",
"closeConversation": "Close Conversation",
"copyText": "Copy Text",
"copyTextSuccess": "Copied",
"copyTextFailed": "Copy failed",
"forkSession": "Fork Session",
"forkSessionSuccess": "Session forked successfully",
"forkSessionFailed": "Failed to fork session: {error}",

View File

@@ -828,6 +828,9 @@
"reload": "Recargar",
"newConversation": "Nueva conversación",
"closeConversation": "Cerrar conversación",
"copyText": "Copiar texto",
"copyTextSuccess": "Copiado",
"copyTextFailed": "Error al copiar",
"forkSession": "Bifurcar sesión",
"forkSessionSuccess": "Sesión bifurcada exitosamente",
"forkSessionFailed": "Error al bifurcar la sesión: {error}",

View File

@@ -828,6 +828,9 @@
"reload": "Recharger",
"newConversation": "Nouvelle conversation",
"closeConversation": "Fermer la conversation",
"copyText": "Copier le texte",
"copyTextSuccess": "Copié",
"copyTextFailed": "Échec de la copie",
"forkSession": "Dupliquer la session",
"forkSessionSuccess": "Session dupliquée avec succès",
"forkSessionFailed": "Échec de la duplication de la session : {error}",

View File

@@ -828,6 +828,9 @@
"reload": "再読み込み",
"newConversation": "新しい会話",
"closeConversation": "会話を閉じる",
"copyText": "テキストをコピー",
"copyTextSuccess": "コピーしました",
"copyTextFailed": "コピーに失敗しました",
"forkSession": "セッションをフォーク",
"forkSessionSuccess": "セッションのフォークに成功しました",
"forkSessionFailed": "セッションのフォークに失敗しました:{error}",

View File

@@ -828,6 +828,9 @@
"reload": "다시 불러오기",
"newConversation": "새 대화",
"closeConversation": "대화 닫기",
"copyText": "텍스트 복사",
"copyTextSuccess": "복사됨",
"copyTextFailed": "복사 실패",
"forkSession": "세션 포크",
"forkSessionSuccess": "세션 포크 성공",
"forkSessionFailed": "세션 포크 실패: {error}",

View File

@@ -828,6 +828,9 @@
"reload": "Recarregar",
"newConversation": "Nova conversa",
"closeConversation": "Fechar conversa",
"copyText": "Copiar texto",
"copyTextSuccess": "Copiado",
"copyTextFailed": "Falha ao copiar",
"forkSession": "Bifurcar sessão",
"forkSessionSuccess": "Sessão bifurcada com sucesso",
"forkSessionFailed": "Falha ao bifurcar a sessão: {error}",

View File

@@ -828,6 +828,9 @@
"reload": "重新加载",
"newConversation": "新建会话",
"closeConversation": "关闭会话",
"copyText": "复制文本",
"copyTextSuccess": "已复制",
"copyTextFailed": "复制失败",
"forkSession": "分叉会话",
"forkSessionSuccess": "会话分叉成功",
"forkSessionFailed": "会话分叉失败:{error}",

View File

@@ -828,6 +828,9 @@
"reload": "重新載入",
"newConversation": "新增會話",
"closeConversation": "關閉會話",
"copyText": "複製文字",
"copyTextSuccess": "已複製",
"copyTextFailed": "複製失敗",
"forkSession": "分叉會話",
"forkSessionSuccess": "會話分叉成功",
"forkSessionFailed": "會話分叉失敗:{error}",