fix: preserve Markdown blocks when copying messages (#2944)
* fix: preserve Markdown blocks when copying messages * fix: preserve blank lines when copying code --------- Co-authored-by: Iuliia Ivashko <yulia.ivashko@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Iuliia Ivashko
parent
2a1c10cb3e
commit
7aae5a6634
@@ -21,7 +21,7 @@ import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { flattenAssistantTextParts, flattenUserTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
@@ -702,40 +702,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const messageTextContent = React.useMemo(() => {
|
||||
if (isUser) {
|
||||
const shellOutputs = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { output?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const output = part.shellAction?.output;
|
||||
return typeof output === 'string' ? output.trim() : '';
|
||||
})
|
||||
.filter((output) => output.length > 0);
|
||||
|
||||
if (shellOutputs.length > 0) {
|
||||
return shellOutputs.join('\n\n');
|
||||
}
|
||||
|
||||
const shellCommands = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; shellAction?: { command?: unknown } } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const command = part.shellAction?.command;
|
||||
return typeof command === 'string' ? command.trim() : '';
|
||||
})
|
||||
.filter((command) => command.length > 0);
|
||||
|
||||
if (shellCommands.length > 0) {
|
||||
return shellCommands.join('\n');
|
||||
}
|
||||
|
||||
const textParts = displayParts
|
||||
.filter((part): part is Part & { type: 'text'; text?: string; content?: string } => part.type === 'text')
|
||||
.map((part) => {
|
||||
const text = part.text || part.content || '';
|
||||
return text.trim();
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
|
||||
const combined = textParts.join('\n');
|
||||
return combined.replace(/\n\s*\n+/g, '\n');
|
||||
return flattenUserTextParts(displayParts);
|
||||
}
|
||||
|
||||
if (assistantErrorText && assistantErrorText.trim().length > 0) {
|
||||
|
||||
@@ -156,9 +156,8 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
row.setAttribute('data-md-code-line', '');
|
||||
|
||||
const number = document.createElement('span');
|
||||
number.setAttribute('data-md-code-line-number', '');
|
||||
number.setAttribute('data-md-code-line-number', String(index + 1));
|
||||
number.setAttribute('aria-hidden', 'true');
|
||||
number.textContent = String(index + 1);
|
||||
|
||||
const content = document.createElement('span');
|
||||
content.setAttribute('data-md-code-line-content', '');
|
||||
@@ -168,7 +167,6 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
|
||||
} else {
|
||||
content.textContent = sourceLine;
|
||||
}
|
||||
|
||||
row.append(number, content);
|
||||
fragment.appendChild(row);
|
||||
if (index < sourceLines.length - 1 || hasTrailingNewline) {
|
||||
@@ -543,6 +541,67 @@ const closeAllMenus = (container: HTMLElement): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const getContainingMarkdownCode = (node: Node): HTMLElement | null => {
|
||||
const element = node.nodeType === 1 ? node as Element : node.parentElement;
|
||||
return element?.closest<HTMLElement>('pre code[data-md-code-lines]') ?? null;
|
||||
};
|
||||
|
||||
const getMarkdownCodeSelectionText = (range: Range): string | null => {
|
||||
const code = getContainingMarkdownCode(range.startContainer);
|
||||
if (!code || code !== getContainingMarkdownCode(range.endContainer)) return null;
|
||||
// Line numbers are CSS-generated, so the DOM range is already the exact
|
||||
// source selection, including boundaries between rows and empty lines.
|
||||
return range.toString();
|
||||
};
|
||||
|
||||
type MarkdownCopyState = {
|
||||
registrations: number;
|
||||
handler: (event: ClipboardEvent) => void;
|
||||
menuHandler: (event: Event) => void;
|
||||
};
|
||||
|
||||
const markdownCopyStates = new WeakMap<Document, MarkdownCopyState>();
|
||||
|
||||
const registerMarkdownCodeCopy = (doc: Document): (() => void) => {
|
||||
let state = markdownCopyStates.get(doc);
|
||||
if (!state) {
|
||||
const getSelectedText = (): string | null => {
|
||||
const selection = doc.getSelection();
|
||||
if (!selection || selection.rangeCount !== 1 || selection.isCollapsed) return null;
|
||||
return getMarkdownCodeSelectionText(selection.getRangeAt(0));
|
||||
};
|
||||
const handler = (event: ClipboardEvent) => {
|
||||
if (!event.clipboardData) return;
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
};
|
||||
const menuHandler = (event: Event) => {
|
||||
const text = getSelectedText();
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
void copyTextToClipboard(text);
|
||||
};
|
||||
state = { registrations: 0, handler, menuHandler };
|
||||
markdownCopyStates.set(doc, state);
|
||||
doc.addEventListener('copy', handler, true);
|
||||
doc.defaultView?.addEventListener('openchamber:copy', menuHandler);
|
||||
}
|
||||
state.registrations += 1;
|
||||
|
||||
return () => {
|
||||
const current = markdownCopyStates.get(doc);
|
||||
if (!current) return;
|
||||
current.registrations -= 1;
|
||||
if (current.registrations > 0) return;
|
||||
doc.removeEventListener('copy', current.handler, true);
|
||||
doc.defaultView?.removeEventListener('openchamber:copy', current.menuHandler);
|
||||
markdownCopyStates.delete(doc);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a single delegated click listener for all in-markdown actions: code
|
||||
* copy, table copy/download menus, mermaid copy/download, loopback preview.
|
||||
@@ -552,6 +611,7 @@ export const attachMarkdownInteractions = (
|
||||
container: HTMLElement,
|
||||
ctx: DecorateContext,
|
||||
): (() => void) => {
|
||||
const unregisterCodeCopy = registerMarkdownCodeCopy(container.ownerDocument);
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
@@ -658,5 +718,8 @@ export const attachMarkdownInteractions = (
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
return () => container.removeEventListener('click', handleClick);
|
||||
return () => {
|
||||
unregisterCodeCopy();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user