mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Copy: redo visual copy and copy interception (Ctrl+c, etc) for Plain text and HTML
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user