Compare commits

...

1 Commits

Author SHA1 Message Date
Laurent Paoletti 402f7a7487 (front) copy button preserves formatting when pasting into Word
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-24 18:48:39 +01:00
3 changed files with 30 additions and 4 deletions
+1
View File
@@ -16,6 +16,7 @@ and this project adheres to
### Changed
- ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
- ✨(front) rich text copy for Word/Docs paste
### Fixed
@@ -2,7 +2,7 @@ import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Loader, Text } from '@/components';
import { Box, Icon, Loader, Text, useToast } from '@/components';
import { AttachmentList } from '@/features/chat/components/AttachmentList';
import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons';
import {
@@ -185,6 +185,8 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
getMetadata,
}) => {
const { t } = useTranslation();
const { showToast } = useToast();
const contentRef = React.useRef<HTMLDivElement>(null);
const shouldApplyStreamingHeight =
isLastAssistantMessage &&
@@ -240,8 +242,26 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
}, [isCurrentlyStreaming, message.content]);
const handleCopy = React.useCallback(() => {
onCopyToClipboard(message.content);
}, [onCopyToClipboard, message.content]);
const html = contentRef.current?.innerHTML;
if (
html &&
typeof ClipboardItem !== 'undefined' &&
navigator.clipboard.write
) {
// Write both formats: Word/Docs use text/html (preserving formatting),
// while code editors and plain-text apps use text/plain (raw markdown).
const item = new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([message.content], { type: 'text/plain' }),
});
navigator.clipboard.write([item]).then(
() => showToast('success', t('Copied'), 'content_copy', 3000),
() => onCopyToClipboard(message.content),
);
} else {
onCopyToClipboard(message.content);
}
}, [contentRef, message.content, onCopyToClipboard, showToast, t]);
const handleCopyKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
@@ -310,6 +330,7 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
{/* Message content */}
{message.content && (
<Box
ref={contentRef}
className="mainContent-chat"
data-testid={
message.role === 'assistant'
@@ -4,6 +4,8 @@ import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Suspense } from 'react';
import { ToastProvider } from '@/components/ToastProvider';
import {
MessageItem,
splitIntoBlocks,
@@ -367,7 +369,9 @@ describe('MessageItem', () => {
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<CunninghamProvider>
<Suspense fallback={null}>{ui}</Suspense>
<ToastProvider>
<Suspense fallback={null}>{ui}</Suspense>
</ToastProvider>
</CunninghamProvider>,
);
};