Claude Code 实现 Clipboard API: 复制按钮、权限、fallback 与测试
用 Claude Code 安全实现 Clipboard API: 复制、粘贴、权限、fallback、React 和 Playwright。
Clipboard API 看起来只是给按钮写一行 navigator.clipboard.writeText()。但真正上线后,它会遇到 HTTPS、HTTP 预览地址、iframe、权限提示、移动端 Safari、复制失败提示、粘贴内容隐私、E2E 测试稳定性等问题。只在本机 Chrome 跑通,不等于用户真的能用。
这篇文章会用 Claude Code 的工作方式,完成一套可以直接放进 React/TypeScript 项目的复制与粘贴实现。Clipboard API 是“浏览器读写系统剪贴板的 Web API”。Async Clipboard 是 Promise 风格的异步接口。secure context 可以理解成浏览器认为安全的环境,例如 HTTPS 和 localhost。fallback 是现代 API 不可用时尝试的备用路径。
建议配合阅读 ClaudeCodeLab 的 Claude Code 无障碍改进、Claude Code Playwright 测试 和 Claude Code 表单验证。官方资料请看 MDN Clipboard API、MDN writeText、W3C Clipboard API and events、Playwright BrowserContext、WebKit Async Clipboard API 和 Claude Code docs。
先把成功条件写给 Claude Code
不要只说“做一个复制按钮”。Clipboard API 的风险主要在边界情况,所以 prompt 里要直接写清楚。
Goal: Implement Clipboard API copy and paste UX in React.
Scope: edit only src/lib/clipboard.ts, src/components/CopyButton.tsx, and matching tests.
Requirements:
- Use navigator.clipboard.writeText in secure contexts.
- Keep the write call inside a user click handler.
- Provide a textarea fallback for unsupported or HTTP pages.
- Never read clipboard on page load.
- Show accessible copied/error feedback.
- Add Playwright tests for copy success and paste normalization.
Do not stage, commit, or edit unrelated files.
整体流程如下。
flowchart TD
A["用户点击复制"] --> B{"Async Clipboard 可用吗"}
B -->|yes| C["writeText"]
B -->|no| D["textarea + execCommand fallback"]
C --> E{"成功了吗"}
D --> E
E -->|yes| F["用 aria-live 宣告复制成功"]
E -->|no| G["提示用户手动复制"]
H["用户明确执行粘贴"] --> I["readText 或 onPaste"]
I --> J["规范化、限长、验证"]
最重要的规则是: 不要在页面加载时读取剪贴板。剪贴板里可能有密码、地址、内部 URL、客户资料或代码片段。读取必须来自清晰可见的粘贴按钮,或者输入框的正常 onPaste 事件。
把复制逻辑放进独立工具函数
先写一个不依赖 React 的 copyText。这样 fallback、错误信息和测试都集中在一个地方,不会散落到多个组件里。
// src/lib/clipboard.ts
export type CopyResult =
| { ok: true; method: "async-clipboard" | "textarea-fallback" }
| { ok: false; method: "async-clipboard" | "textarea-fallback" | "unsupported"; error: string };
export async function copyText(text: string): Promise<CopyResult> {
if (!text) {
return { ok: false, method: "unsupported", error: "Copy text is empty." };
}
if (canUseAsyncClipboard()) {
try {
await navigator.clipboard.writeText(text);
return { ok: true, method: "async-clipboard" };
} catch (error) {
const fallback = fallbackCopyText(text);
if (fallback) return { ok: true, method: "textarea-fallback" };
return {
ok: false,
method: "async-clipboard",
error: error instanceof Error ? error.message : "Clipboard write was blocked.",
};
}
}
if (fallbackCopyText(text)) {
return { ok: true, method: "textarea-fallback" };
}
return {
ok: false,
method: "unsupported",
error: "Clipboard API is unavailable in this browser or context.",
};
}
function canUseAsyncClipboard(): boolean {
return (
typeof window !== "undefined" &&
window.isSecureContext &&
typeof navigator !== "undefined" &&
Boolean(navigator.clipboard?.writeText)
);
}
function fallbackCopyText(text: string): boolean {
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
const selection = document.getSelection();
const selectedRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
if (selection && selectedRange) {
selection.removeAllRanges();
selection.addRange(selectedRange);
}
}
}
document.execCommand("copy") 已经过时,不应该作为主路径。但在旧浏览器、受限制的 WebView、HTTP 预览页里,它仍然可以作为最后的备用方案。注意 fallback 也可能失败,尤其是复制动作没有发生在用户点击或触摸事件里时。
React hook 和无障碍按钮
下面的 hook 负责“复制中、已复制、失败”的状态。组件用 role="status" 和 aria-live 把结果告诉读屏软件。
// src/components/CopyButton.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import { copyText, type CopyResult } from "../lib/clipboard";
type ClipboardStatus = "idle" | "copying" | "copied" | "failed";
export function useClipboard(resetAfter = 2000) {
const [status, setStatus] = useState<ClipboardStatus>("idle");
const [message, setMessage] = useState("");
const timerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
};
}, []);
const copy = useCallback(
async (text: string): Promise<CopyResult> => {
if (timerRef.current) window.clearTimeout(timerRef.current);
setStatus("copying");
setMessage("Copying...");
const result = await copyText(text);
if (result.ok) {
setStatus("copied");
setMessage("Copied to clipboard.");
} else {
setStatus("failed");
setMessage("Copy failed. Select the text and copy it manually.");
}
timerRef.current = window.setTimeout(() => {
setStatus("idle");
setMessage("");
}, resetAfter);
return result;
},
[resetAfter],
);
return { copy, status, message };
}
type CopyButtonProps = {
text: string;
label?: string;
copiedLabel?: string;
className?: string;
};
export function CopyButton({
text,
label = "Copy",
copiedLabel = "Copied",
className = "",
}: CopyButtonProps) {
const { copy, status, message } = useClipboard();
const isCopying = status === "copying";
return (
<div className="inline-flex items-center gap-2">
<button
type="button"
className={className}
onClick={() => void copy(text)}
disabled={isCopying}
aria-label={status === "copied" ? copiedLabel : label}
>
{status === "copied" ? copiedLabel : label}
</button>
<span role="status" aria-live="polite" className="sr-only">
{message}
</span>
</div>
);
}
只改变按钮文字还不够。看不见 toast 的用户也需要知道操作结果。单独的 status 区域也方便 Playwright 稳定断言。
代码块复制不要引发布局抖动
技术文档和博客代码块是最常见场景。Masa 在 ClaudeCodeLab 的第一次实现里遇到过一个小坑: “Copy” 变成 “Copied” 后按钮变宽,代码区域被挤动。给按钮固定最小宽度可以避免这个问题。
// src/components/CodeBlockWithCopy.tsx
import { CopyButton } from "./CopyButton";
type CodeBlockWithCopyProps = {
code: string;
language?: string;
};
export function CodeBlockWithCopy({ code, language = "text" }: CodeBlockWithCopyProps) {
return (
<figure className="relative my-6 overflow-hidden rounded-md border border-slate-700 bg-slate-950">
<figcaption className="flex min-h-10 items-center justify-between border-b border-slate-800 px-3 text-xs text-slate-300">
<span>{language}</span>
<CopyButton
text={code}
label="Copy code"
copiedLabel="Copied"
className="min-w-24 rounded bg-slate-800 px-3 py-1.5 text-xs font-medium text-white hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-cyan-400 disabled:opacity-60"
/>
</figcaption>
<pre tabIndex={0} className="overflow-x-auto p-4 text-sm leading-6">
<code>{code}</code>
</pre>
</figure>
);
}
同一个按钮也可以复制 CLI 命令、邀请链接、优惠码、工单 ID 或生成好的 prompt。让 Claude Code 先做通用组件,再补使用例,比只为代码块写死逻辑更容易维护。
粘贴要当作敏感输入处理
在表单里优先使用浏览器正常的 onPaste。只有在用户点击明确的“从剪贴板粘贴”按钮时,才调用 navigator.clipboard.readText()。
// src/components/PasteImportBox.tsx
import { useState } from "react";
export function normalizePastedText(input: string): string {
return input
.replace(/\r\n?/g, "\n")
.replace(/\u0000/g, "")
.slice(0, 10_000);
}
export function PasteImportBox() {
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
async function pasteFromClipboard() {
if (!navigator.clipboard?.readText || !window.isSecureContext) {
setMessage("Use your browser paste shortcut instead.");
return;
}
try {
const text = await navigator.clipboard.readText();
setValue(normalizePastedText(text));
setMessage("Pasted from clipboard.");
} catch {
setMessage("Paste was blocked. Use Ctrl+V or Cmd+V in the text area.");
}
}
return (
<section aria-labelledby="paste-import-title">
<h2 id="paste-import-title">Import prompt</h2>
<button type="button" onClick={pasteFromClipboard}>
Paste from clipboard
</button>
<textarea
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onPaste={(event) => {
const text = event.clipboardData.getData("text/plain");
if (!text) return;
event.preventDefault();
setValue(normalizePastedText(text));
setMessage("Pasted text was normalized.");
}}
aria-describedby="paste-import-help"
/>
<p id="paste-import-help" role="status" aria-live="polite">
{message}
</p>
</section>
);
}
粘贴内容不能直接相信。至少要做长度限制、换行规范化、控制字符移除和格式校验。如果接收 HTML,渲染前要做 sanitize,不要把剪贴板 HTML 直接传给 dangerouslySetInnerHTML。
4 个实际用例和常见失败
| 用例 | 实现重点 | 常见失败 |
|---|---|---|
| 文档代码复制 | 只复制代码字符串,并提示成功 | 按钮文字变长导致布局抖动 |
| 后台订单 ID 复制 | 只复制 ID,不复制整行 | 客户姓名、地址被带进剪贴板 |
| 支持工具粘贴日志 | 限长、规范化、检测 secret | token 或 cookie 被无限制保存 |
| 邀请链接分享 | 复制带有效期的 URL,并显示过期时间 | 复制旧链接,失效后增加客服请求 |
ClaudeCodeLab 的产品导线也适用这个思路。免费 PDF 的命令、训练营的环境搭建步骤、咨询前的诊断模板,都应该让读者精准复制。但许可证、购买者邮箱、客户数据不应该被打包成一个“方便复制”的按钮。
HTTP、iframe 和移动 Safari 的注意点
现代浏览器的 Async Clipboard 需要 secure context。HTTPS 和 localhost 通常可以,普通 http://192.168.x.x 预览页可能根本没有 navigator.clipboard。这时先试 fallback,仍失败就给出手动复制提示。
iframe 中的剪贴板访问在 Chromium 系浏览器里可能需要 Permissions Policy 或 allow 属性。
<iframe
src="https://docs.example.com/embed"
allow="clipboard-read; clipboard-write"
title="Documentation preview"
></iframe>
Safari 和 iOS WebKit 对用户操作的要求更敏感。把复制调用放在点击或触摸处理函数里,不要在复制前等待 fetch、timer、动画或路由跳转。值可以提前准备好,用户点击后立刻复制,然后再做 UI 后续处理。关键路径不要只测桌面 Chrome,应在真实 iOS 设备上验证。
用 Playwright 测试复制和粘贴
测试时要给“实际访问的同一个 origin”授权。Playwright 官方也提醒,权限名称支持会随浏览器和版本变化。实际项目中可以让 Chromium 验证真实剪贴板内容,WebKit 重点验证 UI 状态。
// tests/clipboard.spec.ts
import { expect, test } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173";
test.describe("clipboard UX", () => {
test.beforeEach(async ({ context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"], {
origin: baseURL,
});
});
test("copies a code block", async ({ page }) => {
await page.goto(`${baseURL}/docs/install`);
await page.getByRole("button", { name: /copy code/i }).first().click();
await expect(page.getByRole("status")).toContainText(/copied/i);
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toContain("npm");
});
test("normalizes pasted text", async ({ page }) => {
await page.goto(`${baseURL}/support/import`);
await page.evaluate(() => navigator.clipboard.writeText("line1\r\nline2\u0000"));
await page.getByRole("button", { name: /paste from clipboard/i }).click();
await expect(page.getByRole("textbox")).toHaveValue("line1\nline2");
});
});
CI 失败时,先检查 origin。http://localhost:4173 和 http://127.0.0.1:4173 不是同一个 origin。然后检查浏览器项目是否支持授予的权限,以及翻译后的按钮文案是否让 role query 失效。
无障碍检查清单
- 使用真正的
button,不要用可点击的div。 - 用
role="status"和aria-live="polite"宣告成功或失败。 - 标签要具体,例如 “Copy code”“Copy invite link”“Copy order ID”。
- 键盘焦点必须可见。
- 按钮设置稳定的最小宽度,避免布局移动。
- 失败时告诉用户下一步怎么做。
- 不要只靠颜色表达成功状态。
- 只有在用户明确粘贴时读取剪贴板。
让 Claude Code 做针对性 review
实现后,不要只说“帮我检查”。给 Claude Code 一个窄范围 review prompt。
Review only clipboard-related changes.
Check:
1. Clipboard read is never triggered on page load.
2. writeText is called from a user action.
3. HTTP or unsupported browser fallback is handled.
4. copied/error feedback is accessible.
5. pasted text is normalized and size-limited.
6. Playwright tests grant permissions for the correct origin.
Return findings with file and line references.
ClaudeCodeLab 会在训练和咨询里用这种小型 Web API 作为练习,因为它能覆盖完整工程循环: 查规格、写实现、设计 fallback、做浏览器测试、检查无障碍、再沉淀成文档。需要模板和实战指南可以看 ClaudeCodeLab 产品,需要团队导入可以看 Claude Code 培训。
实际试用后的结果
Masa 在代码块复制 UI 上先遇到的是视觉问题: 成功后按钮变宽,代码区域被挤动。第二个问题出现在手机 HTTP 预览页,Async Clipboard 路径不可用。稳定版本把复制逻辑抽到工具函数里,加入 textarea fallback 和手动复制提示,固定按钮宽度,并让 Playwright 对测试的准确 origin 授权。Clipboard API 看起来很小,但只有把权限、隐私、无障碍和测试一起设计,才算真正可上线。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。