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"
|
const isConnecting = status === "connecting"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 pt-0">
|
<div
|
||||||
|
className="p-4 pt-0"
|
||||||
|
onContextMenu={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
{queue &&
|
{queue &&
|
||||||
queue.length > 0 &&
|
queue.length > 0 &&
|
||||||
onQueueReorder &&
|
onQueueReorder &&
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
import {
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react"
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileImage,
|
FileImage,
|
||||||
@@ -1221,6 +1230,72 @@ export function ConversationDetailPanel() {
|
|||||||
}))
|
}))
|
||||||
}, [activeConversationTab])
|
}, [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(() => {
|
const handleNewConversation = useCallback(() => {
|
||||||
if (!folder) return
|
if (!folder) return
|
||||||
openNewConversationTab(folder.path)
|
openNewConversationTab(folder.path)
|
||||||
@@ -1309,13 +1384,14 @@ export function ConversationDetailPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-full min-h-0 overflow-hidden",
|
"relative h-full min-h-0 overflow-hidden",
|
||||||
canTile && "flex flex-row"
|
canTile && "flex flex-row"
|
||||||
)}
|
)}
|
||||||
|
onPointerDown={handleContextMenuTriggerPointerDown}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, index) => {
|
{tabs.map((tab, index) => {
|
||||||
const active = tab.id === activeTabId
|
const active = tab.id === activeTabId
|
||||||
@@ -1352,6 +1428,14 @@ export function ConversationDetailPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
disabled={!contextMenuSelectedText}
|
||||||
|
onSelect={handleCopySelectedText}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
{t("copyText")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
disabled={!canReloadActiveConversation}
|
disabled={!canReloadActiveConversation}
|
||||||
onSelect={handleReloadActiveConversation}
|
onSelect={handleReloadActiveConversation}
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "إعادة تحميل",
|
"reload": "إعادة تحميل",
|
||||||
"newConversation": "محادثة جديدة",
|
"newConversation": "محادثة جديدة",
|
||||||
"closeConversation": "إغلاق المحادثة",
|
"closeConversation": "إغلاق المحادثة",
|
||||||
|
"copyText": "نسخ النص",
|
||||||
|
"copyTextSuccess": "تم النسخ",
|
||||||
|
"copyTextFailed": "فشل النسخ",
|
||||||
"forkSession": "تفريع الجلسة",
|
"forkSession": "تفريع الجلسة",
|
||||||
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
|
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
|
||||||
"forkSessionFailed": "فشل في تفريع الجلسة: {error}",
|
"forkSessionFailed": "فشل في تفريع الجلسة: {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "Neu laden",
|
"reload": "Neu laden",
|
||||||
"newConversation": "Neue Konversation",
|
"newConversation": "Neue Konversation",
|
||||||
"closeConversation": "Konversation schließen",
|
"closeConversation": "Konversation schließen",
|
||||||
|
"copyText": "Text kopieren",
|
||||||
|
"copyTextSuccess": "Kopiert",
|
||||||
|
"copyTextFailed": "Kopieren fehlgeschlagen",
|
||||||
"forkSession": "Sitzung forken",
|
"forkSession": "Sitzung forken",
|
||||||
"forkSessionSuccess": "Sitzung erfolgreich geforkt",
|
"forkSessionSuccess": "Sitzung erfolgreich geforkt",
|
||||||
"forkSessionFailed": "Sitzung konnte nicht geforkt werden: {error}",
|
"forkSessionFailed": "Sitzung konnte nicht geforkt werden: {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "Reload",
|
"reload": "Reload",
|
||||||
"newConversation": "New Conversation",
|
"newConversation": "New Conversation",
|
||||||
"closeConversation": "Close Conversation",
|
"closeConversation": "Close Conversation",
|
||||||
|
"copyText": "Copy Text",
|
||||||
|
"copyTextSuccess": "Copied",
|
||||||
|
"copyTextFailed": "Copy failed",
|
||||||
"forkSession": "Fork Session",
|
"forkSession": "Fork Session",
|
||||||
"forkSessionSuccess": "Session forked successfully",
|
"forkSessionSuccess": "Session forked successfully",
|
||||||
"forkSessionFailed": "Failed to fork session: {error}",
|
"forkSessionFailed": "Failed to fork session: {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "Recargar",
|
"reload": "Recargar",
|
||||||
"newConversation": "Nueva conversación",
|
"newConversation": "Nueva conversación",
|
||||||
"closeConversation": "Cerrar conversación",
|
"closeConversation": "Cerrar conversación",
|
||||||
|
"copyText": "Copiar texto",
|
||||||
|
"copyTextSuccess": "Copiado",
|
||||||
|
"copyTextFailed": "Error al copiar",
|
||||||
"forkSession": "Bifurcar sesión",
|
"forkSession": "Bifurcar sesión",
|
||||||
"forkSessionSuccess": "Sesión bifurcada exitosamente",
|
"forkSessionSuccess": "Sesión bifurcada exitosamente",
|
||||||
"forkSessionFailed": "Error al bifurcar la sesión: {error}",
|
"forkSessionFailed": "Error al bifurcar la sesión: {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "Recharger",
|
"reload": "Recharger",
|
||||||
"newConversation": "Nouvelle conversation",
|
"newConversation": "Nouvelle conversation",
|
||||||
"closeConversation": "Fermer la conversation",
|
"closeConversation": "Fermer la conversation",
|
||||||
|
"copyText": "Copier le texte",
|
||||||
|
"copyTextSuccess": "Copié",
|
||||||
|
"copyTextFailed": "Échec de la copie",
|
||||||
"forkSession": "Dupliquer la session",
|
"forkSession": "Dupliquer la session",
|
||||||
"forkSessionSuccess": "Session dupliquée avec succès",
|
"forkSessionSuccess": "Session dupliquée avec succès",
|
||||||
"forkSessionFailed": "Échec de la duplication de la session : {error}",
|
"forkSessionFailed": "Échec de la duplication de la session : {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "再読み込み",
|
"reload": "再読み込み",
|
||||||
"newConversation": "新しい会話",
|
"newConversation": "新しい会話",
|
||||||
"closeConversation": "会話を閉じる",
|
"closeConversation": "会話を閉じる",
|
||||||
|
"copyText": "テキストをコピー",
|
||||||
|
"copyTextSuccess": "コピーしました",
|
||||||
|
"copyTextFailed": "コピーに失敗しました",
|
||||||
"forkSession": "セッションをフォーク",
|
"forkSession": "セッションをフォーク",
|
||||||
"forkSessionSuccess": "セッションのフォークに成功しました",
|
"forkSessionSuccess": "セッションのフォークに成功しました",
|
||||||
"forkSessionFailed": "セッションのフォークに失敗しました:{error}",
|
"forkSessionFailed": "セッションのフォークに失敗しました:{error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "다시 불러오기",
|
"reload": "다시 불러오기",
|
||||||
"newConversation": "새 대화",
|
"newConversation": "새 대화",
|
||||||
"closeConversation": "대화 닫기",
|
"closeConversation": "대화 닫기",
|
||||||
|
"copyText": "텍스트 복사",
|
||||||
|
"copyTextSuccess": "복사됨",
|
||||||
|
"copyTextFailed": "복사 실패",
|
||||||
"forkSession": "세션 포크",
|
"forkSession": "세션 포크",
|
||||||
"forkSessionSuccess": "세션 포크 성공",
|
"forkSessionSuccess": "세션 포크 성공",
|
||||||
"forkSessionFailed": "세션 포크 실패: {error}",
|
"forkSessionFailed": "세션 포크 실패: {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "Recarregar",
|
"reload": "Recarregar",
|
||||||
"newConversation": "Nova conversa",
|
"newConversation": "Nova conversa",
|
||||||
"closeConversation": "Fechar conversa",
|
"closeConversation": "Fechar conversa",
|
||||||
|
"copyText": "Copiar texto",
|
||||||
|
"copyTextSuccess": "Copiado",
|
||||||
|
"copyTextFailed": "Falha ao copiar",
|
||||||
"forkSession": "Bifurcar sessão",
|
"forkSession": "Bifurcar sessão",
|
||||||
"forkSessionSuccess": "Sessão bifurcada com sucesso",
|
"forkSessionSuccess": "Sessão bifurcada com sucesso",
|
||||||
"forkSessionFailed": "Falha ao bifurcar a sessão: {error}",
|
"forkSessionFailed": "Falha ao bifurcar a sessão: {error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "重新加载",
|
"reload": "重新加载",
|
||||||
"newConversation": "新建会话",
|
"newConversation": "新建会话",
|
||||||
"closeConversation": "关闭会话",
|
"closeConversation": "关闭会话",
|
||||||
|
"copyText": "复制文本",
|
||||||
|
"copyTextSuccess": "已复制",
|
||||||
|
"copyTextFailed": "复制失败",
|
||||||
"forkSession": "分叉会话",
|
"forkSession": "分叉会话",
|
||||||
"forkSessionSuccess": "会话分叉成功",
|
"forkSessionSuccess": "会话分叉成功",
|
||||||
"forkSessionFailed": "会话分叉失败:{error}",
|
"forkSessionFailed": "会话分叉失败:{error}",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
"reload": "重新載入",
|
"reload": "重新載入",
|
||||||
"newConversation": "新增會話",
|
"newConversation": "新增會話",
|
||||||
"closeConversation": "關閉會話",
|
"closeConversation": "關閉會話",
|
||||||
|
"copyText": "複製文字",
|
||||||
|
"copyTextSuccess": "已複製",
|
||||||
|
"copyTextFailed": "複製失敗",
|
||||||
"forkSession": "分叉會話",
|
"forkSession": "分叉會話",
|
||||||
"forkSessionSuccess": "會話分叉成功",
|
"forkSessionSuccess": "會話分叉成功",
|
||||||
"forkSessionFailed": "會話分叉失敗:{error}",
|
"forkSessionFailed": "會話分叉失敗:{error}",
|
||||||
|
|||||||
Reference in New Issue
Block a user