feat(conversation): add copy text context menu action with preserved selection
This commit is contained in:
@@ -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 &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -828,6 +828,9 @@
|
||||
"reload": "إعادة تحميل",
|
||||
"newConversation": "محادثة جديدة",
|
||||
"closeConversation": "إغلاق المحادثة",
|
||||
"copyText": "نسخ النص",
|
||||
"copyTextSuccess": "تم النسخ",
|
||||
"copyTextFailed": "فشل النسخ",
|
||||
"forkSession": "تفريع الجلسة",
|
||||
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
|
||||
"forkSessionFailed": "فشل في تفريع الجلسة: {error}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -828,6 +828,9 @@
|
||||
"reload": "再読み込み",
|
||||
"newConversation": "新しい会話",
|
||||
"closeConversation": "会話を閉じる",
|
||||
"copyText": "テキストをコピー",
|
||||
"copyTextSuccess": "コピーしました",
|
||||
"copyTextFailed": "コピーに失敗しました",
|
||||
"forkSession": "セッションをフォーク",
|
||||
"forkSessionSuccess": "セッションのフォークに成功しました",
|
||||
"forkSessionFailed": "セッションのフォークに失敗しました:{error}",
|
||||
|
||||
@@ -828,6 +828,9 @@
|
||||
"reload": "다시 불러오기",
|
||||
"newConversation": "새 대화",
|
||||
"closeConversation": "대화 닫기",
|
||||
"copyText": "텍스트 복사",
|
||||
"copyTextSuccess": "복사됨",
|
||||
"copyTextFailed": "복사 실패",
|
||||
"forkSession": "세션 포크",
|
||||
"forkSessionSuccess": "세션 포크 성공",
|
||||
"forkSessionFailed": "세션 포크 실패: {error}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -828,6 +828,9 @@
|
||||
"reload": "重新加载",
|
||||
"newConversation": "新建会话",
|
||||
"closeConversation": "关闭会话",
|
||||
"copyText": "复制文本",
|
||||
"copyTextSuccess": "已复制",
|
||||
"copyTextFailed": "复制失败",
|
||||
"forkSession": "分叉会话",
|
||||
"forkSessionSuccess": "会话分叉成功",
|
||||
"forkSessionFailed": "会话分叉失败:{error}",
|
||||
|
||||
@@ -828,6 +828,9 @@
|
||||
"reload": "重新載入",
|
||||
"newConversation": "新增會話",
|
||||
"closeConversation": "關閉會話",
|
||||
"copyText": "複製文字",
|
||||
"copyTextSuccess": "已複製",
|
||||
"copyTextFailed": "複製失敗",
|
||||
"forkSession": "分叉會話",
|
||||
"forkSessionSuccess": "會話分叉成功",
|
||||
"forkSessionFailed": "會話分叉失敗:{error}",
|
||||
|
||||
Reference in New Issue
Block a user