Copy: redo visual copy and copy interception (Ctrl+c, etc) for Plain text and HTML

This commit is contained in:
Enrico Ros
2026-01-24 19:30:06 -08:00
parent 3c04a7dbac
commit 05d114be2f
3 changed files with 107 additions and 54 deletions
+2 -18
View File
@@ -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 (
<List role='chat-messages-list' sx={listSx} onCopy={handleCopyHTMLWithoutColors}>
<List role='chat-messages-list' sx={listSx} onCopy={clipboardInterceptCtrlCForCleanup}>
{props.isMessageSelectionMode && (
<MessagesSelectionHeader
@@ -44,7 +44,7 @@ import { Release } from '~/common/app.release';
import { TooltipOutlined } from '~/common/components/TooltipOutlined';
import { adjustContentScaling, themeScalingMap, themeZIndexChatBubble } from '~/common/app.theme';
import { avatarIconSx, makeMessageAvatarIcon, messageBackground, useMessageAvatarLabel } from '~/common/util/dMessageUtils';
import { copyToClipboard, copyToClipboardHtmlMinusColors } from '~/common/util/clipboardUtils';
import { clipboardCopyDOMSelectionOrFallback } from '~/common/util/clipboardUtils';
import { createTextContentFragment, DMessageFragment, DMessageFragmentId, updateFragmentWithEditedText } from '~/common/stores/chat/chat.fragments';
import { useFragmentBuckets } from '~/common/stores/chat/hooks/useFragmentBuckets';
import { useUIPreferencesStore } from '~/common/stores/store-ui';
@@ -315,13 +315,8 @@ export function ChatMessage(props: {
const handleCloseOpsMenu = React.useCallback(() => 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();
+103 -29
View File
@@ -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<ClipboardItem[] | null> {
console.warn('Failed to read clipboard: ', error);
return null;
}
}
}
// --- 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();
}
}