diff --git a/src/apps/chat/components/ChatMessageList.tsx b/src/apps/chat/components/ChatMessageList.tsx index 92f7d8574..e4a0c327d 100644 --- a/src/apps/chat/components/ChatMessageList.tsx +++ b/src/apps/chat/components/ChatMessageList.tsx @@ -13,12 +13,12 @@ import type { ConversationHandler } from '~/common/chat-overlay/ConversationHand import type { DLLMContextTokens } from '~/common/stores/llms/llms.types'; import { DConversationId, excludeSystemMessages } from '~/common/stores/chat/chat.conversation'; import { ShortcutKey, useGlobalShortcuts } from '~/common/components/shortcuts/useGlobalShortcuts'; +import { clipboardInterceptCtrlCForCleanup } from '~/common/util/clipboardUtils'; import { convertFilesToDAttachmentFragments } from '~/common/attachment-drafts/attachment.pipeline'; import { createDMessageFromFragments, createDMessageTextContent, DMessage, DMessageId, DMessageUserFlag, DMetaReferenceItem, MESSAGE_FLAG_AIX_SKIP, messageHasUserFlag } from '~/common/stores/chat/chat.message'; import { createTextContentFragment, DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments'; import { openFileForAttaching } from '~/common/components/ButtonAttachFiles'; import { optimaOpenPreferences } from '~/common/layout/optima/useOptima'; -import { stripHtmlColors } from '~/common/util/clipboardUtils'; import { useChatOverlayStore } from '~/common/chat-overlay/store-perchat_vanilla'; import { useChatStore } from '~/common/stores/chat/store-chats'; import { useScrollToBottom } from '~/common/scroll-to-bottom/useScrollToBottom'; @@ -288,22 +288,6 @@ export function ChatMessageList(props: { }, [conversationId, notifyBooting]); - // "ctrl + c" copy handler - strip theme-dependent colors from copied content (keep formatting like font sizes) - // similar to ChatMessage.handleOpsCopy - const handleCopyHTMLWithoutColors = React.useCallback((event: React.ClipboardEvent) => { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) return; - - const div = document.createElement('div'); - div.appendChild(selection.getRangeAt(0).cloneContents()); - stripHtmlColors(div); - - event.clipboardData?.setData('text/html', div.innerHTML); - event.clipboardData?.setData('text/plain', selection.toString()); - event.preventDefault(); - }, []); - - // style memo const listSx: SxProps = React.useMemo(() => ({ p: 0, @@ -340,7 +324,7 @@ export function ChatMessageList(props: { ); return ( - + {props.isMessageSelectionMode && ( setOpsMenuAnchor(null), []); const handleOpsCopy = (e: React.MouseEvent) => { - const html = blocksRendererRef.current?.innerHTML; - if (html) { - // same as ChatMessageList.handleCopyHTMLWithoutColors - copyToClipboardHtmlMinusColors(html, textSubject, 'Message'); - } else - copyToClipboard(textSubject, 'Text'); e.preventDefault(); + clipboardCopyDOMSelectionOrFallback(blocksRendererRef.current, textSubject, 'Message'); handleCloseOpsMenu(); closeContextMenu(); closeBubble(); diff --git a/src/common/util/clipboardUtils.ts b/src/common/util/clipboardUtils.ts index c347312e2..720c5108c 100644 --- a/src/common/util/clipboardUtils.ts +++ b/src/common/util/clipboardUtils.ts @@ -1,35 +1,9 @@ +import type { ClipboardEvent as ReactClipboardEvent } from 'react'; + import { addSnackbar } from '../components/snackbar/useSnackbarsStore'; import { Is, isBrowser } from './pwaUtils'; -/** Strip theme-dependent colors from an HTML element tree (in-place) */ -export function stripHtmlColors(element: HTMLElement) { - element.querySelectorAll('*').forEach((el) => { - if (el instanceof HTMLElement) - ['color', 'background', 'background-color'].forEach(p => el.style.removeProperty(p)); - }); -} - -/** - * Copy HTML to clipboard with theme-dependent colors stripped (keeps formatting like font sizes). - * Falls back to plain text if HTML clipboard write fails. - */ -export function copyToClipboardHtmlMinusColors(html: string, plainText: string, typeLabel: string) { - if (!isBrowser) return; - - const div = document.createElement('div'); - div.innerHTML = html; - stripHtmlColors(div); - - const blob = new Blob([div.innerHTML], { type: 'text/html' }); - const textBlob = new Blob([plainText], { type: 'text/plain' }); - - navigator.clipboard.write([new ClipboardItem({ 'text/html': blob, 'text/plain': textBlob })]) - .then(() => addSnackbar({ key: 'copy-to-clipboard', message: `${typeLabel} copied to clipboard`, type: 'success', closeButton: false, overrides: { autoHideDuration: 2000 } })) - .catch(() => copyToClipboard(plainText, typeLabel)); // fallback to plain text -} - - export function copyToClipboard(text: string, typeLabel: string) { if (!isBrowser) return; @@ -96,4 +70,104 @@ export async function getClipboardItems(): Promise { console.warn('Failed to read clipboard: ', error); return null; } -} \ No newline at end of file +} + + +// --- HTML copy (from DOM Elements / Selection) with cleaning --- + +/** + * Copy selection (if within container) or entire container content to clipboard. + * Strips theme colors and no-copy elements. Shows snackbar notification. + */ +export function clipboardCopyDOMSelectionOrFallback(containerElement: HTMLElement | null, fallbackText: string, typeLabel: string) { + if (!isBrowser) return; + + const selection = window.getSelection(); + const hasSelectionInContainer = selection && !selection.isCollapsed && containerElement?.contains(selection.anchorNode); + + // Clone content: selection or full container + const div = document.createElement('div'); + if (hasSelectionInContainer) { + div.appendChild(selection.getRangeAt(0).cloneContents()); + } else if (containerElement) { + div.innerHTML = containerElement.innerHTML; + } else { + copyToClipboard(fallbackText, typeLabel); + return; + } + + _cleanElementForCopy(div); + const cleanedHtml = div.innerHTML; + const cleanedText = _getInnerTextFromFloatingElement(div, fallbackText); + + // Write both HTML and plain text to clipboard + const htmlBlob = new Blob([cleanedHtml], { type: 'text/html' }); + const textBlob = new Blob([cleanedText], { type: 'text/plain' }); + + navigator.clipboard.write([new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob })]) + .then(() => addSnackbar({ key: 'copy-to-clipboard', message: `${hasSelectionInContainer ? 'Selection' : typeLabel} copied to clipboard`, type: 'success', closeButton: false, overrides: { autoHideDuration: 2000 } })) + .catch(() => copyToClipboard(cleanedText, typeLabel)); +} + +/** + * Intercept copy event (Ctrl+C) to clean HTML before copying. + * Call this from onCopy handlers. Returns true if handled. + */ +export function clipboardInterceptCtrlCForCleanup(event: ReactClipboardEvent): boolean { + if (!isBrowser) return false; + + // require a valid selection + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || !event.clipboardData) return false; + + // clone selection content and clean it + const div = document.createElement('div'); + div.appendChild(selection.getRangeAt(0).cloneContents()); + _cleanElementForCopy(div); + + // get formatted text (innerText respects block elements for line breaks) + const cleanedHtml = div.innerHTML; + const cleanedText = _getInnerTextFromFloatingElement(div, selection.toString()); + + // set cleaned data to clipboard + event.clipboardData?.setData('text/html', cleanedHtml); + event.clipboardData?.setData('text/plain', cleanedText); + event.preventDefault(); + return true; +} + + +function _cleanElementForCopy(element: HTMLElement) { + // remove elements marked with data-agi-no-copy (buttons, reasoning, citations, etc.) + element.querySelectorAll('[data-agi-no-copy]').forEach((el) => el.remove()); + + // clean all elements + [element, ...element.querySelectorAll('*')].forEach((el) => { + if (!(el instanceof HTMLElement)) return; + + // strip theme-dependent colors, but keeps formatting like font sizes + ['color', 'background', 'background-color'].forEach(p => el.style.removeProperty(p)); + + // remove framework/accessibility cruft + el.removeAttribute('class'); + el.removeAttribute('tabindex'); + el.removeAttribute('role'); + [...el.attributes].filter(a => a.name.startsWith('aria-')).forEach(a => el.removeAttribute(a.name)); + }); + + // remove empty divs (wrapper cruft) + element.querySelectorAll('div:empty').forEach((el) => el.remove()); +} + +/** Get properly formatted text from element (with line breaks for block elements) */ +function _getInnerTextFromFloatingElement(element: HTMLElement, fallback: string): string { + // innerText requires element to be in DOM to respect CSS layout + // Note: can't use visibility:hidden as innerText won't return text from hidden elements + element.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;overflow:hidden'; + document.body.appendChild(element); + try { + return element.innerText || fallback; + } finally { + element.remove(); + } +}