From af14386875df941a821542f8e6995f8980b9678b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Mar 2026 01:18:40 +0000 Subject: [PATCH 1/6] feat(viewer): add artifact copy button and shared clipboard helper - Toolbar Copy uses getArtifactBody (same raw text as Download) with feedback states - Extract copyTextToClipboard for link creator and shell (clipboard API + fallback) - Document shell actions; add Playwright coverage for copy Co-authored-by: Aanish Bhirud --- AGENTS.md | 1 + README.md | 4 +-- docs/architecture.md | 4 +-- docs/payload-format.md | 4 +-- docs/testing.md | 2 +- src/components/home/link-creator.tsx | 26 ++-------------- src/components/viewer-shell.tsx | 45 +++++++++++++++++++++++++++- src/lib/copy-text.ts | 26 ++++++++++++++++ tests/e2e/viewer.spec.ts | 23 ++++++++++++++ 9 files changed, 103 insertions(+), 32 deletions(-) create mode 100644 src/lib/copy-text.ts diff --git a/AGENTS.md b/AGENTS.md index 7dc32b6..fb7fd50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ Describe and preserve what is already true in the repo today. - The empty state explains the product and exposes sample fragment presets. - A built-in link creator can generate fragment-based links locally in the browser. - When a valid fragment is present, the app switches to a viewer-first artifact layout. +- The artifact stage toolbar exposes copy-to-clipboard, file download, and (for markdown) browser print-to-PDF. - `activeArtifactId` controls which artifact opens first. - Internal diff file navigation stays in UI state and does not repurpose the fragment. diff --git a/README.md b/README.md index c9af540..16bc017 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,12 @@ Built for the OpenClaw ecosystem, `agent-render` focuses on fragment-based shari - Markdown, code, diff, CSV, and JSON all render in the static shell - Fragment transport supports `plain`, `lz`, `deflate`, and `arx`, with automatic shortest-fragment selection across packed/non-packed wire formats - The `arx` substitution dictionary is served at `/arx-dictionary.json` so agents can fetch it for local compression -- Markdown supports download plus browser print-to-PDF +- The viewer toolbar copies artifact bodies to the clipboard, downloads them as files, and (for markdown) supports browser print-to-PDF - Deployment target: static hosting, including Cloudflare Pages ## Included Renderers -- `markdown` - GFM rendering with safe sanitization, download, print flow, and premium code fences that reuse the CodeMirror viewer stack +- `markdown` - GFM rendering with safe sanitization, copy/download/print flows from the shell, and premium code fences that reuse the CodeMirror viewer stack - `code` - read-only CodeMirror view with line numbers, wrap toggle, syntax-tree-aware rainbow brackets, and maintained indentation markers - `diff` - review-style multi-file git patch viewer with unified and split modes - `csv` - parsed table view with sticky headers and horizontal overflow handling diff --git a/docs/architecture.md b/docs/architecture.md index 39954d9..df3b6c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ GitHub Pages is strongest when the application behaves like a static shell inste ## Renderer implementation -- `markdown` - formatted document view with download and print-to-PDF flow plus embedded premium code fences +- `markdown` - formatted document view with shell copy, download, and print-to-PDF flows plus embedded premium code fences - `code` - read-only CodeMirror view with syntax-aware rendering and code affordances - `diff` - review-style diff view with unified and split modes - `csv` - table-focused data grid built from parsed rows and dynamic columns @@ -27,7 +27,7 @@ GitHub Pages is strongest when the application behaves like a static shell inste The viewer shell now routes all five artifact kinds through dynamically imported client-only renderers so the landing shell stays light and static-host friendly. -When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The landing/samples experience is only the empty state. +When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, and markdown print actions. The landing/samples experience is only the empty state. Diff file navigation is intentionally internal UI state now. The URL fragment remains reserved for payload transport and active-artifact selection instead of being reused as an in-page file anchor system. diff --git a/docs/payload-format.md b/docs/payload-format.md index 80eb17e..892566f 100644 --- a/docs/payload-format.md +++ b/docs/payload-format.md @@ -137,7 +137,7 @@ Two sample envelopes live in `src/lib/payload/examples.ts` for local development } ``` -Markdown artifacts use the `content` field and currently support client-side download and browser print-to-PDF from the viewer shell. +Markdown artifacts use the `content` field and currently support client-side clipboard copy, file download, and browser print-to-PDF from the viewer shell. ### Code artifact example @@ -160,7 +160,7 @@ Markdown artifacts use the `content` field and currently support client-side dow } ``` -Code artifacts use the same `content` transport, plus optional `language` and `filename` hints for syntax-aware rendering and download naming. +Code artifacts use the same `content` transport, plus optional `language` and `filename` hints for syntax-aware rendering, download naming, and clipboard copy of the source text. ### Diff artifact example diff --git a/docs/testing.md b/docs/testing.md index d141992..19fcab0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -39,7 +39,7 @@ Playwright visual tests live in `tests/e2e/visual.spec.ts`. The suite is intentionally split by responsibility: -- browser tests protect exported-app behavior, fragment-driven rendering, downloads, print flow, themes, and layout hierarchy +- browser tests protect exported-app behavior, fragment-driven rendering, downloads, clipboard copy, print flow, themes, and layout hierarchy - visual tests protect empty state, artifact views, theme presentation, and compact-content spacing - component tests protect selector/disclosure UI contracts - unit tests protect transport codecs, envelope validation, diff parsing, and language inference diff --git a/src/components/home/link-creator.tsx b/src/components/home/link-creator.tsx index 1358402..a8964b7 100644 --- a/src/components/home/link-creator.tsx +++ b/src/components/home/link-creator.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import type { LucideIcon } from "lucide-react"; import { ArrowUpRight, Check, Copy, ExternalLink, FileCode2, FileDiff, FileJson2, FileSpreadsheet, FileText, Link2 } from "lucide-react"; +import { copyTextToClipboard } from "@/lib/copy-text"; import { createGeneratedArtifactLinkAsync, defaultLinkCreatorDraft, getBodyFieldLabel, type GeneratedArtifactLink, type LinkCreatorDraft } from "@/lib/payload/link-creator"; import { artifactKinds, codecs, type ArtifactKind } from "@/lib/payload/schema"; import { cn } from "@/lib/utils"; @@ -35,29 +36,6 @@ const fieldPlaceholders: Record = { json: '{\n "status": "ready",\n "artifacts": 1\n}', }; -async function copyText(value: string) { - if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(value); - return; - } - - const textarea = document.createElement("textarea"); - textarea.value = value; - textarea.setAttribute("readonly", "true"); - textarea.style.position = "absolute"; - textarea.style.left = "-9999px"; - document.body.appendChild(textarea); - textarea.select(); - - try { - if (!document.execCommand("copy")) { - throw new Error("Copy command was rejected."); - } - } finally { - document.body.removeChild(textarea); - } -} - function getBaseUrl() { if (typeof window === "undefined") { return undefined; @@ -121,7 +99,7 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) { } try { - await copyText(generatedLink.url); + await copyTextToClipboard(generatedLink.url); setCopyState("copied"); } catch { setCopyState("failed"); diff --git a/src/components/viewer-shell.tsx b/src/components/viewer-shell.tsx index 1809d69..329bcf6 100644 --- a/src/components/viewer-shell.tsx +++ b/src/components/viewer-shell.tsx @@ -7,6 +7,8 @@ import type { CSSProperties } from "react"; import type { LucideIcon } from "lucide-react"; import { ArrowUpRight, + Check, + Copy, Download, FileCode2, FileDiff, @@ -34,6 +36,7 @@ import { type MarkdownArtifact, type PayloadEnvelope, } from "@/lib/payload/schema"; +import { copyTextToClipboard } from "@/lib/copy-text"; import { cn } from "@/lib/utils"; import { LinkCreator } from "@/components/home/link-creator"; import { ArtifactSelector } from "@/components/viewer/artifact-selector"; @@ -232,13 +235,14 @@ function getAnimationStyle(delay: number): CSSProperties { * Render the main viewer shell for decoding and displaying artifact fragments from the URL hash. * * Manages fragment decoding and ARX dictionary loading, synchronizes component state with the browser hash, - * and provides UI and handlers for selecting, downloading, printing, and navigating artifacts or clearing the fragment. + * and provides UI and handlers for selecting, copying, downloading, printing, and navigating artifacts or clearing the fragment. * * @returns The root React element for the viewer shell UI */ export function ViewerShell() { const [hash, setHash] = useState(""); const [rendererReady, setRendererReady] = useState(true); + const [artifactCopyState, setArtifactCopyState] = useState<"idle" | "copied" | "failed">("idle"); useEffect(() => { const syncHash = () => { @@ -301,6 +305,24 @@ export function ViewerShell() { setRendererReady(true); }, []); + useEffect(() => { + setArtifactCopyState("idle"); + }, [activeArtifact?.id]); + + useEffect(() => { + if (artifactCopyState !== "copied") { + return; + } + + const timer = window.setTimeout(() => { + setArtifactCopyState("idle"); + }, 2000); + + return () => { + window.clearTimeout(timer); + }; + }, [artifactCopyState]); + const setFragmentHash = useCallback((nextHash: string) => { if (window.location.hash === nextHash) { return; @@ -329,6 +351,19 @@ export function ViewerShell() { [envelope, setFragmentHash], ); + const handleArtifactCopy = useCallback(async () => { + if (!activeArtifact) { + return; + } + + try { + await copyTextToClipboard(getArtifactBody(activeArtifact)); + setArtifactCopyState("copied"); + } catch { + setArtifactCopyState("failed"); + } + }, [activeArtifact]); + const handleArtifactDownload = useCallback(() => { if (!activeArtifact) { return; @@ -459,6 +494,14 @@ export function ViewerShell() { Download + {markdownArtifact ? ( + {markdownArtifact ? (