-
Notifications
You must be signed in to change notification settings - Fork 0
feat(viewer): artifact copy button in toolbar #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
af14386
2da2f91
d153760
410d757
283fa0a
5c519b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,11 +2,13 @@ | |
|
|
||
| import dynamic from "next/dynamic"; | ||
| import Image from "next/image"; | ||
| import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
| 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,17 @@ 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"); | ||
| const activeArtifactRef = useRef<ArtifactPayload | null>(null); | ||
| /** Incremented on each copy click so stale async completions cannot overwrite state from a newer request. */ | ||
| const artifactCopyTokenRef = useRef(0); | ||
|
|
||
| useEffect(() => { | ||
| const syncHash = () => { | ||
|
|
@@ -272,6 +279,7 @@ export function ViewerShell() { | |
| const fragmentLength = hash.startsWith("#") ? hash.length - 1 : hash.length; | ||
| const envelope = parsed.ok ? parsed.envelope : null; | ||
| const activeArtifact = envelope ? getActiveArtifact(envelope) : null; | ||
| activeArtifactRef.current = activeArtifact; | ||
| const markdownArtifact: MarkdownArtifact | null = activeArtifact?.kind === "markdown" ? activeArtifact : null; | ||
| const codeArtifact: CodeArtifact | null = activeArtifact?.kind === "code" ? activeArtifact : null; | ||
| const diffArtifact: DiffArtifact | null = activeArtifact?.kind === "diff" ? activeArtifact : null; | ||
|
|
@@ -301,6 +309,24 @@ export function ViewerShell() { | |
| setRendererReady(true); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| setArtifactCopyState("idle"); | ||
| }, [activeArtifact?.id]); | ||
|
|
||
| useEffect(() => { | ||
| if (artifactCopyState !== "copied" && artifactCopyState !== "failed") { | ||
| 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 +355,30 @@ export function ViewerShell() { | |
| [envelope, setFragmentHash], | ||
| ); | ||
|
|
||
| const handleArtifactCopy = useCallback(async () => { | ||
| const artifact = activeArtifactRef.current; | ||
| if (!artifact) { | ||
| return; | ||
| } | ||
|
|
||
| const requestArtifactId = artifact.id; | ||
| const requestToken = ++artifactCopyTokenRef.current; | ||
| const body = getArtifactBody(artifact); | ||
|
|
||
| try { | ||
| await copyTextToClipboard(body); | ||
| if (activeArtifactRef.current?.id !== requestArtifactId || artifactCopyTokenRef.current !== requestToken) { | ||
| return; | ||
| } | ||
| setArtifactCopyState("copied"); | ||
|
Comment on lines
+368
to
+373
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because clipboard writes are async, a user can click Copy on artifact A and switch to artifact B before the promise resolves. When A's copy eventually succeeds, this unconditionally sets the shared toolbar state to Useful? React with 👍 / 👎. |
||
| } catch { | ||
| if (activeArtifactRef.current?.id !== requestArtifactId || artifactCopyTokenRef.current !== requestToken) { | ||
| return; | ||
| } | ||
| setArtifactCopyState("failed"); | ||
| } | ||
| }, []); | ||
|
|
||
| const handleArtifactDownload = useCallback(() => { | ||
| if (!activeArtifact) { | ||
| return; | ||
|
|
@@ -455,6 +505,14 @@ export function ViewerShell() { | |
| </div> | ||
|
|
||
| <div className="viewer-toolbar"> | ||
| <button | ||
| type="button" | ||
| className={cn("artifact-action", artifactCopyState === "copied" && "is-primary")} | ||
| onClick={handleArtifactCopy} | ||
| > | ||
| {artifactCopyState === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} | ||
| {artifactCopyState === "copied" ? "Copied" : artifactCopyState === "failed" ? "Copy failed" : "Copy"} | ||
| </button> | ||
| <button type="button" className="artifact-action is-primary" onClick={handleArtifactDownload}> | ||
| <Download className="h-3.5 w-3.5" /> | ||
| Download | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| /** | ||
| * Copies a string to the clipboard using the Async Clipboard API when available, | ||
| * with a `document.execCommand("copy")` fallback when `navigator.clipboard` is missing | ||
| * or `navigator.clipboard.writeText` rejects (e.g. permission errors). | ||
| */ | ||
| export async function copyTextToClipboard(value: string): Promise<void> { | ||
| if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { | ||
| try { | ||
| await navigator.clipboard.writeText(value); | ||
| return; | ||
| } catch { | ||
| // Fall through to document.execCommand("copy") below. | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.