diff --git a/web/hooks/useClipboard.ts b/web/hooks/useClipboard.ts new file mode 100644 index 0000000000..919fe91832 --- /dev/null +++ b/web/hooks/useClipboard.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useState } from 'react' + +export function useClipboard({ timeout = 2000 } = {}) { + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + const [copyTimeout, setCopyTimeout] = useState(null) + + const handleCopyResult = (value: boolean) => { + window.clearTimeout(copyTimeout!) + setCopyTimeout(window.setTimeout(() => setCopied(false), timeout)) + setCopied(value) + } + + const copy = (valueToCopy: any) => { + if ('clipboard' in navigator) { + navigator.clipboard + .writeText(valueToCopy) + .then(() => handleCopyResult(true)) + .catch((err) => setError(err)) + } else { + setError(new Error('useClipboard: navigator.clipboard is not supported')) + } + } + + const reset = () => { + setCopied(false) + setError(null) + window.clearTimeout(copyTimeout!) + } + + return { copy, reset, error, copied } +} diff --git a/web/screens/Chat/MessageToolbar/index.tsx b/web/screens/Chat/MessageToolbar/index.tsx index c6214aef37..87004cc3b2 100644 --- a/web/screens/Chat/MessageToolbar/index.tsx +++ b/web/screens/Chat/MessageToolbar/index.tsx @@ -6,12 +6,11 @@ import { } from '@janhq/core' import { ConversationalExtension } from '@janhq/core' import { useAtomValue, useSetAtom } from 'jotai' -import { RefreshCcw, Copy, Trash2Icon } from 'lucide-react' +import { RefreshCcw, CopyIcon, Trash2Icon, CheckIcon } from 'lucide-react' import { twMerge } from 'tailwind-merge' -import { toaster } from '@/containers/Toast' - +import { useClipboard } from '@/hooks/useClipboard' import useSendChatMessage from '@/hooks/useSendChatMessage' import { extensionManager } from '@/extension' @@ -26,6 +25,7 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => { const thread = useAtomValue(activeThreadAtom) const messages = useAtomValue(getCurrentChatMessagesAtom) const { resendChatMessage } = useSendChatMessage() + const clipboard = useClipboard({ timeout: 1000 }) const onDeleteClick = async () => { deleteMessage(message.id ?? '') @@ -63,13 +63,14 @@ const MessageToolbar = ({ message }: { message: ThreadMessage }) => {
{ - navigator.clipboard.writeText(message.content[0]?.text?.value ?? '') - toaster({ - title: 'Copied to clipboard', - }) + clipboard.copy(message.content[0]?.text?.value ?? '') }} > - + {clipboard.copied ? ( + + ) : ( + + )}
- ${ - escaped ? code : decodeURIComponent(code) - } - ` - }, - }, - } -) - const SimpleTextMessage: React.FC = (props) => { let text = '' if (props.content && props.content.length > 0) { text = props.content[0]?.text?.value ?? '' } + const clipboard = useClipboard({ timeout: 1000 }) + + const marked = new Marked( + markedHighlight({ + langPrefix: 'hljs', + highlight(code, lang) { + if (lang === undefined || lang === '') { + return hljs.highlightAuto(code).value + } + try { + return hljs.highlight(code, { language: lang }).value + } catch (err) { + return hljs.highlight(code, { language: 'javascript' }).value + } + }, + }), + { + renderer: { + code(code, lang, escaped) { + return ` +
+ +
+              ${
+                escaped ? code : decodeURIComponent(code)
+              }
+            
+
+ ` + }, + }, + } + ) const parsedText = marked.parse(text) const isUser = props.role === ChatCompletionRole.User @@ -66,6 +77,27 @@ const SimpleTextMessage: React.FC = (props) => { const [tokenSpeed, setTokenSpeed] = useState(0) const messages = useAtomValue(getCurrentChatMessagesAtom) + const codeBlockCopyEvent = useRef((e: Event) => { + const target: HTMLElement = e.target as HTMLElement + if (typeof target.className !== 'string') return null + const isCopyActionClassName = target?.className.includes('copy-action') + const isCodeBlockParent = + target.parentElement?.parentElement?.className.includes('code-block') + + if (isCopyActionClassName || isCodeBlockParent) { + const content = target?.parentNode?.querySelector('code')?.innerText ?? '' + clipboard.copy(content) + } + }) + + useEffect(() => { + document.addEventListener('click', codeBlockCopyEvent.current) + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + document.removeEventListener('click', codeBlockCopyEvent.current) + } + }, []) + useEffect(() => { if (props.status === MessageStatus.Ready) { return