fix(web): add copy buttons for FileViewer code blocks (#471)

* fix(web): add copy buttons for FileViewer code blocks

* fix(web): harden FileViewer markdown copy controls

* fix(web): restore focus after clipboard fallback

* test(e2e): restore execCommand after markdown copy tests
This commit is contained in:
jiakeboge 2026-05-05 09:09:39 +08:00 committed by GitHub
parent a4fb90a9f3
commit 2473ab9567
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 335 additions and 19 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
import { useT } from '../i18n';
@ -43,6 +43,88 @@ type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) =>
type SlideState = { active: number; count: number };
const htmlPreviewSlideState = new Map<string, SlideState>();
const MARKDOWN_CODE_BLOCK_ATTR = 'data-markdown-code-block';
const MARKDOWN_COPY_BLOCK_ATTR = 'data-copy-code-block';
const MARKDOWN_COPY_BUTTON_CLASS = 'markdown-code-copy';
const MARKDOWN_COPY_TOAST_CLASS = 'markdown-code-toast';
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
return document.execCommand('copy');
} catch {
return false;
} finally {
document.body.removeChild(ta);
if (priorFocus?.isConnected) {
try {
priorFocus.focus({ preventScroll: true });
} catch {
priorFocus.focus();
}
}
}
}
}
function decorateMarkdownCodeBlocks(html: string): string {
let blockIndex = 0;
return html.replace(/<pre\b([^>]*)>([\s\S]*?)<\/pre>/g, (_match, attrs: string, content: string) => {
const blockId = String(blockIndex++);
return `<div class="markdown-code-block" ${MARKDOWN_CODE_BLOCK_ATTR}="${blockId}"><pre${attrs}>${content}</pre></div>`;
});
}
function setMarkdownCodeBlockCopiedState(block: HTMLElement, copied: boolean, t: TranslateFn) {
const button = block.querySelector<HTMLButtonElement>(`.${MARKDOWN_COPY_BUTTON_CLASS}`);
if (!button) return;
const label = copied ? t('fileViewer.copied') : t('fileViewer.copy');
button.textContent = label;
button.setAttribute('aria-label', label);
button.title = t('fileViewer.copyTitle');
const existingToast = block.querySelector(`.${MARKDOWN_COPY_TOAST_CLASS}`);
if (copied) {
if (existingToast instanceof HTMLElement) {
existingToast.textContent = t('fileViewer.copied');
return;
}
const toast = document.createElement('span');
toast.className = MARKDOWN_COPY_TOAST_CLASS;
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.textContent = t('fileViewer.copied');
button.insertAdjacentElement('afterend', toast);
return;
}
existingToast?.remove();
}
function ensureMarkdownCodeBlockControls(root: HTMLElement, t: TranslateFn) {
for (const block of root.querySelectorAll<HTMLElement>(`[${MARKDOWN_CODE_BLOCK_ATTR}]`)) {
let button = block.querySelector<HTMLButtonElement>(`.${MARKDOWN_COPY_BUTTON_CLASS}`);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = MARKDOWN_COPY_BUTTON_CLASS;
const blockId = block.getAttribute(MARKDOWN_CODE_BLOCK_ATTR) ?? '';
button.setAttribute(MARKDOWN_COPY_BLOCK_ATTR, blockId);
block.prepend(button);
}
setMarkdownCodeBlockCopiedState(block, false, t);
}
}
interface Props {
projectId: string;
@ -2110,12 +2192,20 @@ function MarkdownViewer({
const [text, setText] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [copied, setCopied] = useState(false);
const markdownArticleRef = useRef<HTMLElement | null>(null);
const copyBlockTimerRef = useRef<number | null>(null);
const copiedMarkdownBlockRef = useRef<HTMLElement | null>(null);
const status = file.artifactManifest?.status ?? 'complete';
const isStreaming = status === 'streaming';
const isError = status === 'error';
useEffect(() => {
setText(null);
copiedMarkdownBlockRef.current = null;
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
copyBlockTimerRef.current = null;
}
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((next) => {
if (!cancelled) setText(next ?? '');
@ -2125,35 +2215,67 @@ function MarkdownViewer({
};
}, [projectId, file.name, file.mtime, reloadKey]);
useEffect(() => {
return () => {
copiedMarkdownBlockRef.current = null;
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
}
};
}, []);
async function copy() {
if (text == null) return;
try {
await navigator.clipboard.writeText(text);
const didCopy = await copyTextToClipboard(text);
if (didCopy) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} finally {
document.body.removeChild(ta);
}
}
}
const html = useMemo(() => {
if (text === null) return null;
const renderPartial = MarkdownRenderer.renderPartial ?? renderMarkdownToSafeHtml;
return renderPartial(text);
return decorateMarkdownCodeBlocks(renderPartial(text));
}, [text]);
useEffect(() => {
const article = markdownArticleRef.current;
if (!article) return;
ensureMarkdownCodeBlockControls(article, t);
if (copiedMarkdownBlockRef.current?.isConnected) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, true, t);
}
}, [html, t]);
async function handleMarkdownBodyClick(event: ReactMouseEvent<HTMLElement>) {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest<HTMLButtonElement>(`button[${MARKDOWN_COPY_BLOCK_ATTR}]`);
if (!button) return;
const block = button.closest('.markdown-code-block');
if (!(block instanceof HTMLElement)) return;
const pre = block.querySelector('pre');
if (!pre) return;
const didCopy = await copyTextToClipboard(pre.textContent ?? '');
if (!didCopy) return;
if (copiedMarkdownBlockRef.current && copiedMarkdownBlockRef.current !== block) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
}
copiedMarkdownBlockRef.current = block;
setMarkdownCodeBlockCopiedState(block, true, t);
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
}
copyBlockTimerRef.current = window.setTimeout(() => {
if (copiedMarkdownBlockRef.current) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
}
copiedMarkdownBlockRef.current = null;
copyBlockTimerRef.current = null;
}, 1800);
}
return (
<div className="viewer text-viewer">
<div className="viewer-toolbar">
@ -2191,7 +2313,9 @@ function MarkdownViewer({
{isError ? <div className="markdown-status markdown-status-error">{t('fileViewer.markdownErrorStatus')}</div> : null}
{/* Safe by contract: renderMarkdownToSafeHtml escapes raw HTML and rejects unsafe link protocols. */}
<article
ref={markdownArticleRef}
className="markdown-rendered"
onClick={(event) => void handleMarkdownBodyClick(event)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</>

View file

@ -4366,12 +4366,70 @@ code {
color: var(--text-muted);
background: var(--bg-panel);
}
.markdown-code-block {
position: relative;
}
.markdown-code-copy {
position: absolute;
top: 18px;
right: 18px;
z-index: 1;
border: 1px solid var(--border-soft);
border-radius: 999px;
background: color-mix(in oklab, var(--bg-panel) 92%, black 8%);
color: var(--text-muted);
font: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 7px 10px;
cursor: pointer;
opacity: 0;
transform: translateY(-2px);
transition:
opacity 120ms ease,
transform 120ms ease,
color 120ms ease,
border-color 120ms ease,
background 120ms ease;
}
.markdown-code-block:hover .markdown-code-copy,
.markdown-code-block:focus-within .markdown-code-copy {
opacity: 1;
transform: translateY(0);
}
@media (hover: none) {
.markdown-code-copy {
opacity: 1;
transform: translateY(0);
}
}
.markdown-code-copy:hover,
.markdown-code-copy:focus-visible {
color: var(--text);
border-color: var(--border);
background: var(--bg-elevated, var(--bg));
}
.markdown-code-toast {
position: absolute;
top: 18px;
right: 82px;
z-index: 1;
border-radius: 999px;
background: color-mix(in oklab, var(--accent) 18%, var(--bg-panel));
color: var(--text);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 7px 10px;
box-shadow: 0 10px 26px color-mix(in oklab, var(--accent) 18%, transparent);
}
.markdown-rendered pre {
margin: 12px 0;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 12px;
padding: 40px 12px 12px;
overflow: auto;
}
.markdown-rendered code {

View file

@ -0,0 +1,134 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileViewer } from '../../apps/web/src/components/FileViewer';
import type { ProjectFile } from '../../apps/web/src/types';
import { fetchProjectFileText } from '../../apps/web/src/providers/registry';
vi.mock('../../apps/web/src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../apps/web/src/providers/registry')>(
'../../apps/web/src/providers/registry',
);
return {
...actual,
fetchProjectFileText: vi.fn(),
};
});
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
let writeTextMock: ReturnType<typeof vi.fn>;
let originalClipboard: PropertyDescriptor | undefined;
let originalExecCommand: PropertyDescriptor | undefined;
function baseFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
return {
name: 'notes.md',
path: 'notes.md',
type: 'file',
size: 256,
mtime: 1710000000,
kind: 'text',
mime: 'text/markdown',
artifactManifest: {
version: 1,
kind: 'markdown-document',
title: 'Notes',
entry: 'notes.md',
renderer: 'markdown',
exports: ['md'],
},
...overrides,
};
}
describe('FileViewer markdown code block copy', () => {
beforeEach(() => {
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand');
mockedFetchProjectFileText.mockResolvedValue('```ts\nconsole.log("copied")\n```');
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: writeTextMock,
},
});
});
afterEach(() => {
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
delete (navigator as { clipboard?: Clipboard }).clipboard;
}
if (originalExecCommand) {
Object.defineProperty(document, 'execCommand', originalExecCommand);
} else {
delete (document as Document & { execCommand?: typeof document.execCommand }).execCommand;
}
cleanup();
vi.clearAllMocks();
});
it('copies fenced code blocks from the markdown preview', async () => {
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
await waitFor(() => {
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
});
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
expect(copyButton.tagName).toBe('BUTTON');
copyButton.focus();
expect(copyButton).toBe(document.activeElement);
fireEvent.click(copyButton);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith('console.log("copied")');
});
expect(copyButton).toBe(document.activeElement);
await waitFor(() => {
expect(copyButton.getAttribute('aria-label')).toBe('Copied!');
});
expect(screen.getByRole('status').textContent).toBe('Copied!');
});
it('copies empty fenced code blocks instead of treating the button as broken', async () => {
mockedFetchProjectFileText.mockResolvedValue('```ts\n```');
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
await waitFor(() => {
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
});
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
fireEvent.click(copyButton);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith('');
});
});
it('restores focus when the Clipboard API fails and the execCommand fallback succeeds', async () => {
writeTextMock.mockRejectedValueOnce(new Error('clipboard unavailable'));
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(true),
});
const execCommandSpy = vi.mocked(document.execCommand);
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
await waitFor(() => {
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
});
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
copyButton.focus();
expect(copyButton).toBe(document.activeElement);
fireEvent.click(copyButton);
await waitFor(() => {
expect(execCommandSpy).toHaveBeenCalledWith('copy');
});
expect(copyButton).toBe(document.activeElement);
});
});