Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -766,9 +766,41 @@ <h2 class="section-title">Up and running in seconds</h2>
</footer>

<script>
function fallbackCopyText(text) {
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try {
if (document.execCommand('copy')) {
resolve();
} else {
reject(new Error('copy failed'));
}
} catch (e) {
reject(e);
} finally {
document.body.removeChild(ta);
}
});
}

function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).catch(function() {
return fallbackCopyText(text);
});
}
return fallbackCopyText(text);
}

function copyCode(btn) {
const code = btn.nextElementSibling.innerText;
navigator.clipboard.writeText(code).then(() => {
copyText(code).then(() => {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => {
Expand Down
34 changes: 33 additions & 1 deletion public/index.zh.html
Original file line number Diff line number Diff line change
Expand Up @@ -766,9 +766,41 @@ <h2 class="section-title">几秒钟内启动运行</h2>
</footer>

<script>
function fallbackCopyText(text) {
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try {
if (document.execCommand('copy')) {
resolve();
} else {
reject(new Error('copy failed'));
}
} catch (e) {
reject(e);
} finally {
document.body.removeChild(ta);
}
});
}

function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).catch(function() {
return fallbackCopyText(text);
});
}
return fallbackCopyText(text);
}

function copyCode(btn) {
const code = btn.nextElementSibling.innerText;
navigator.clipboard.writeText(code).then(() => {
copyText(code).then(() => {
btn.textContent = '已复制!';
btn.classList.add('copied');
setTimeout(() => {
Expand Down
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"copy-to-clipboard": "^3.3.3",
"dompurify": "^3.4.8",
"highlight.js": "^11.11.1",
"katex": "^0.17.0",
Expand Down
15 changes: 1 addition & 14 deletions web/src/components/OnboardingDevice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,7 @@
import { useEffect, useRef, useState } from "react"
import { deviceStart, devicePoll, getOnboarding, type OnboardingStatus } from "../api"
import { PersonalRepoPanel } from "./dialog/PersonalRepoPanel"

// navigator.clipboard is undefined over http://<ip> (non-secure context); fall
// back to a hidden textarea + execCommand so copy works there too.
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true }
} catch {}
try {
const ta = document.createElement("textarea")
ta.value = text; ta.style.position = "fixed"; ta.style.left = "-9999px"
document.body.appendChild(ta); ta.focus(); ta.select()
const ok = document.execCommand("copy"); document.body.removeChild(ta); return ok
} catch { return false }
}
import { copyText } from "@/lib/clipboard"

export function OnboardingDevice({
show,
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/OnboardingInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import { useState } from "react"
import { getOnboarding, type OnboardingStatus } from "../api"
import { copyText } from "@/lib/clipboard"

export function OnboardingInfo({
show,
Expand Down Expand Up @@ -44,7 +45,7 @@ export function OnboardingInfo({
<div className="flex items-start gap-2">
<code className="flex-1 min-w-0 break-all bg-gray-50 border border-gray-200 rounded px-2 py-1.5 text-[10px] font-mono">{v.value}</code>
<button
onClick={() => { navigator.clipboard.writeText(v.value); setCopied(v.label); setTimeout(() => setCopied(null), 1500) }}
onClick={() => { if (copyText(v.value)) { setCopied(v.label); setTimeout(() => setCopied(null), 1500) } }}
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-800 px-1.5 py-1"
>
{copied === v.label ? "copied" : "copy"}
Expand Down
7 changes: 4 additions & 3 deletions web/src/components/ShareArtifactDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { getServeConfig, checkAliasAvailable, getAvailablePort, checkPortAvailable, getCurrentSharePort, type LoopMeta, type ServeConfig } from "../api"
import { Globe, Copy, Check, AlertCircle, Shuffle, RefreshCw } from "lucide-react"
import { copyText } from "@/lib/clipboard"

type ShareMode = "static" | "port" | "direct" | "ephemeral"
type TabKey = "standard" | "direct" | "ephemeral"
Expand Down Expand Up @@ -205,9 +206,9 @@ export function ShareArtifactDialog({ loop, open, onClose, onSaved }: { loop: Lo
}
}

const copyUrl = async () => {
try { await navigator.clipboard.writeText(shareUrl); setCopied(true); setTimeout(() => setCopied(false), 1500) }
catch { window.prompt("Copy this URL:", shareUrl) }
const copyUrl = () => {
if (copyText(shareUrl)) { setCopied(true); setTimeout(() => setCopied(false), 1500) }
else window.prompt("Copy this URL:", shareUrl)
}

const canSave =
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/assistant-ui/markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";

import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
import { copyText } from "@/lib/clipboard";

const MarkdownTextImpl = () => {
return (
Expand Down Expand Up @@ -57,10 +58,10 @@ const useCopyToClipboard = ({
const copyToClipboard = (value: string) => {
if (!value) return;

navigator.clipboard.writeText(value).then(() => {
if (copyText(value)) {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
}
};

return { isCopied, copyToClipboard };
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/chat/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CheckIcon, CopyIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { copyText } from "@/lib/clipboard";

interface CopyButtonProps {
content: string;
Expand All @@ -15,10 +16,10 @@ export default function CopyButton({ content, className, iconClassName }: CopyBu

const handleCopy = () => {
if (!content || copied) return;
navigator.clipboard.writeText(content).then(() => {
if (copyText(content)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
};

return (
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/chat/MarkdownBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import remarkCjkFriendly from "remark-cjk-friendly";
import { remarkAlert } from "@/lib/remarkAlert";
import { copyText } from "@/lib/clipboard";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import type { PluggableList } from "unified";
Expand Down Expand Up @@ -194,10 +195,10 @@ function useClipboard({ copiedDuration = 2000 } = {}) {

const copyToClipboard = (value: string) => {
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
if (copyText(value)) {
setIsCopied(true);
setTimeout(() => setIsCopied(false), copiedDuration);
});
}
};

return { isCopied, copyToClipboard };
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/chat/SvgRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ExternalLinkIcon, LinkIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { sanitizeSvg } from "@/lib/sanitizeSvg";
import { copyText } from "@/lib/clipboard";

type SvgRendererProps = React.SVGProps<SVGSVGElement> & { node?: unknown };

Expand Down Expand Up @@ -132,7 +133,7 @@ export const SvgRenderer = ({ node, children, ...rest }: SvgRendererProps) => {
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(menu.url);
copyText(menu.url);
setMenu(null);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-gray-700 hover:bg-gray-100"
Expand Down
15 changes: 2 additions & 13 deletions web/src/components/chat/TableWithToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { copyRich } from "@/lib/clipboard";

type TableProps = React.ComponentProps<"table"> & { node?: unknown };

Expand Down Expand Up @@ -49,21 +50,9 @@ export const TableWithToolbar = ({ node, className, ...props }: TableProps) => {
const grid = readGrid(table);
const markdown = gridToMarkdown(grid);
const html = table.outerHTML;
try {
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([markdown], { type: "text/plain" }),
"text/html": new Blob([html], { type: "text/html" }),
}),
]);
} else {
await navigator.clipboard.writeText(markdown);
}
if (await copyRich({ text: markdown, html })) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard access can be denied; fail quietly.
}
};

Expand Down
37 changes: 4 additions & 33 deletions web/src/components/dialog/PersonalRepoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,7 @@ import {
type PersonalStatus,
} from "@/api"
import { ArrowUp, ArrowDown, AlertTriangle, Check, X } from "lucide-react"

/**
* Robust clipboard copy. navigator.clipboard is undefined in non-secure
* contexts (e.g. dev:host accessed over http://<ip>:port), which silently
* broke the "copy" buttons. Fall back to a hidden textarea + execCommand,
* which works there too.
*/
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
return true
}
} catch {}
try {
const ta = document.createElement("textarea")
ta.value = text
ta.style.position = "fixed"
ta.style.left = "-9999px"
document.body.appendChild(ta)
ta.focus()
ta.select()
const ok = document.execCommand("copy")
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
import { copyText } from "@/lib/clipboard"

/**
* Personal-repo deploy-key flow, rendered as a settings panel.
Expand Down Expand Up @@ -1098,13 +1070,12 @@ function ExportKeyFlow({ onDone }: { onDone: () => void }) {
}
}

const copy = async () => {
const copy = () => {
if (!cryptKey) return
try {
await navigator.clipboard.writeText(cryptKey)
if (copyText(cryptKey)) {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}
}

if (cryptKey) {
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/settings/A2ASection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
import { useEffect, useState } from "react"
import { getA2A, saveA2A, regenA2AKey, type A2ASettings } from "../../api"
import { copyText } from "@/lib/clipboard"

function CopyRow({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
Expand All @@ -13,7 +14,7 @@ function CopyRow({ label, value }: { label: string; value: string }) {
<span className="text-[11px] text-gray-500 w-20 shrink-0">{label}</span>
<code className="flex-1 min-w-0 truncate bg-white border border-gray-200 rounded px-2 py-1 text-[10px] font-mono">{value}</code>
<button
onClick={() => { navigator.clipboard.writeText(value); setCopied(true); setTimeout(() => setCopied(false), 1500) }}
onClick={() => { if (copyText(value)) { setCopied(true); setTimeout(() => setCopied(false), 1500) } }}
className="text-[11px] text-gray-500 hover:text-gray-800 shrink-0"
>
{copied ? "copied" : "copy"}
Expand Down
48 changes: 48 additions & 0 deletions web/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import copy from "copy-to-clipboard"

/**
* Single clipboard entry point for the web app.
*
* Previously copy was open-coded everywhere: ~10 sites called
* `navigator.clipboard.writeText` with no fallback (silently broken over
* http://<ip>, a non-secure context where `navigator.clipboard` is
* undefined), plus two components carried their own duplicated
* textarea+execCommand fallback. This centralizes both.
*
* `copy-to-clipboard` works in non-secure contexts: it copies via a hidden
* element + synchronous `execCommand("copy")`, so it does not depend on the
* async Clipboard API or a secure origin.
*/

/** Copy plain text. Synchronous; returns whether the copy succeeded. */
export function copyText(text: string): boolean {
if (!text) return false
try {
return copy(text)
} catch {
return false
}
}

/**
* Copy rich content: registers both `text/html` and `text/plain` so pasting
* into a rich target keeps formatting while plain targets get the text.
* Falls back to plain-text {@link copyText} when `ClipboardItem` / the async
* Clipboard API is unavailable (older browsers, non-secure contexts).
*/
export async function copyRich({ text, html }: { text: string; html: string }): Promise<boolean> {
try {
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
await navigator.clipboard.write([
new ClipboardItem({
"text/plain": new Blob([text], { type: "text/plain" }),
"text/html": new Blob([html], { type: "text/html" }),
}),
])
return true
}
} catch {
// Clipboard write can be denied / unsupported — fall back to plain text.
}
return copyText(text)
}
Loading