mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
a4fb90a9f3
commit
2473ab9567
3 changed files with 335 additions and 19 deletions
|
|
@ -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 }}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
134
e2e/tests/file-viewer-markdown-copy.test.tsx
Normal file
134
e2e/tests/file-viewer-markdown-copy.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue