(front) add code copy button

add code copy button in code block
This commit is contained in:
Eléonore Voisin
2025-11-14 17:20:19 +01:00
parent 22ce90488c
commit e823d21418
4 changed files with 94 additions and 7 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(front) add code copy button
- ✨(RAG) add generic collection RAG tools #159
### Fixed
@@ -8,6 +8,7 @@ import { Modal, ModalSize } from '@openfun/cunningham-react';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ChangeEvent, FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownHooks } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
@@ -26,6 +27,7 @@ import {
useLLMConfiguration,
} from '@/features/chat/api/useLLMConfiguration';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { CodeBlock } from '@/features/chat/components/CodeBlock';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import { InputChat } from '@/features/chat/components/InputChat';
import { SourceItemList } from '@/features/chat/components/SourceItemList';
@@ -153,7 +155,7 @@ export const Chat = ({
const [initialConversationMessages, setInitialConversationMessages] =
useState<Message[] | undefined>(undefined);
const [pendingFirstMessage, setPendingFirstMessage] = useState<{
event: React.FormEvent<HTMLFormElement>;
event: FormEvent<HTMLFormElement>;
attachments?: Attachment[];
forceWebSearch?: boolean;
} | null>(null);
@@ -244,7 +246,7 @@ export const Chat = ({
void stopGeneration();
};
const handleSubmitWrapper = (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmitWrapper = (event: FormEvent<HTMLFormElement>) => {
void handleSubmit(event);
};
@@ -361,7 +363,7 @@ export const Chat = ({
if (initialConversationId !== conversationId) {
handleInputChange({
target: { value: '' },
} as React.ChangeEvent<HTMLTextAreaElement>);
} as ChangeEvent<HTMLTextAreaElement>);
setHasInitialized(false); // Réinitialiser pour permettre le scroll au prochain chargement
}
}, [initialConversationId, conversationId, handleInputChange]);
@@ -375,7 +377,7 @@ export const Chat = ({
if (pendingInput) {
const syntheticEvent = {
target: { value: pendingInput },
} as React.ChangeEvent<HTMLInputElement>;
} as ChangeEvent<HTMLInputElement>;
handleInputChange(syntheticEvent);
}
if (pendingFiles) {
@@ -397,7 +399,7 @@ export const Chat = ({
const syntheticFormEvent = {
preventDefault: () => {},
target: form,
} as unknown as React.FormEvent<HTMLFormElement>;
} as unknown as FormEvent<HTMLFormElement>;
void handleSubmit(syntheticFormEvent);
setShouldAutoSubmit(false);
}
@@ -457,7 +459,7 @@ export const Chat = ({
}, [hasInitialized, messages.length]);
// Custom handleSubmit to include attachments and handle chat creation
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
// Upload files to server and get URLs
@@ -677,6 +679,10 @@ export const Chat = ({
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, children, ...props }) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
}}
>
{message.content}
@@ -0,0 +1,80 @@
import { useRef } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
import { useClipboard } from '@/hook';
interface CopyCodeButtonProps {
onCopy: () => void;
}
const CopyCodeButton = ({ onCopy }: CopyCodeButtonProps) => {
const { t } = useTranslation();
return (
<Box
as="button"
onClick={onCopy}
$css={`
position: absolute;
top: 8px;
right: 8px;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: #fff;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
z-index: 10;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
width: fit-content;
&:hover {
background:rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.20);
}
`}
>
<Icon
iconName="content_copy"
$size="14px"
$theme="greyscale"
$variation="200"
/>
<Text $size="xs" $theme="greyscale" $variation="200">
{t('Copy code')}
</Text>
</Box>
);
};
interface CodeBlockProps {
children: ReactNode;
[key: string]: unknown;
}
export const CodeBlock = ({ children, ...props }: CodeBlockProps) => {
const preRef = useRef<HTMLPreElement>(null);
const copyToClipboard = useClipboard();
const handleCopy = () => {
const code = preRef.current?.querySelector('code');
copyToClipboard(code?.textContent || '');
};
return (
<>
<CopyCodeButton onCopy={handleCopy} />
<Box ref={preRef} $position="relative" as="pre" {...props}>
{children}
</Box>
</>
);
};
@@ -235,6 +235,7 @@ ul a:hover {
figure[data-rehype-pretty-code-figure] {
margin-left: 0;
margin-right: 0;
position: relative;
}
figure[data-rehype-pretty-code-figure] > pre {