From 9314ca61f9bddc1a5e649698d2967b737c1f1b31 Mon Sep 17 00:00:00 2001 From: Rot4tion Date: Mon, 5 Jan 2026 19:54:59 +0700 Subject: [PATCH] feat: add clipboard fallback for HTTP/LAN environments --- .../components/copy-to-clipboard/index.tsx | 25 +++-- packages/scan/src/web/utils/clipboard.ts | 21 +++++ .../src/web/views/notifications/optimize.tsx | 5 +- .../notifications/other-visualization.tsx | 3 +- packages/website/components/cli.tsx | 9 +- packages/website/components/install-guide.tsx | 93 +++++++++++-------- packages/website/utils/clipboard.ts | 21 +++++ 7 files changed, 126 insertions(+), 51 deletions(-) create mode 100644 packages/scan/src/web/utils/clipboard.ts create mode 100644 packages/website/utils/clipboard.ts diff --git a/packages/scan/src/web/components/copy-to-clipboard/index.tsx b/packages/scan/src/web/components/copy-to-clipboard/index.tsx index 97bd5275..f37f1966 100644 --- a/packages/scan/src/web/components/copy-to-clipboard/index.tsx +++ b/packages/scan/src/web/components/copy-to-clipboard/index.tsx @@ -1,5 +1,6 @@ import { memo } from 'preact/compat'; import { useCallback, useEffect, useState } from 'preact/hooks'; +import { copyText } from '~web/utils/clipboard'; import { cn } from '~web/utils/helpers'; import { Icon } from '../icon'; @@ -38,15 +39,25 @@ export const CopyToClipboard = /* @__PURE__ */ memo( e.preventDefault(); e.stopPropagation(); - navigator.clipboard.writeText(text).then( - () => { + try { + const result = copyText(text); + if (result instanceof Promise) { + result.then( + () => { + setIsCopied(true); + onCopy?.(true, text); + }, + () => { + onCopy?.(false, text); + }, + ); + } else { setIsCopied(true); onCopy?.(true, text); - }, - () => { - onCopy?.(false, text); - }, - ); + } + } catch { + onCopy?.(false, text); + } }, [text, onCopy], ); diff --git a/packages/scan/src/web/utils/clipboard.ts b/packages/scan/src/web/utils/clipboard.ts new file mode 100644 index 00000000..185e4816 --- /dev/null +++ b/packages/scan/src/web/utils/clipboard.ts @@ -0,0 +1,21 @@ +/** + * Copies text to clipboard with fallback for HTTP / IP LAN environments + * where navigator.clipboard is not available. + */ +export function copyText(text: string): Promise | void { + if (navigator.clipboard?.writeText && window.isSecureContext) { + return navigator.clipboard.writeText(text); + } + + // Fallback for HTTP / IP LAN + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "fixed"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + + document.execCommand("copy"); + document.body.removeChild(ta); +} diff --git a/packages/scan/src/web/views/notifications/optimize.tsx b/packages/scan/src/web/views/notifications/optimize.tsx index 7b26c0bf..8a6a4e58 100644 --- a/packages/scan/src/web/views/notifications/optimize.tsx +++ b/packages/scan/src/web/views/notifications/optimize.tsx @@ -1,4 +1,6 @@ import { useState } from 'preact/hooks'; +import { iife } from '~core/notifications/performance-utils'; +import { copyText } from '~web/utils/clipboard'; import { cn } from '~web/utils/helpers'; import { GroupedFiberRender, @@ -6,7 +8,6 @@ import { getComponentName, getTotalTime, } from './data'; -import { iife } from '~core/notifications/performance-utils'; const formatReactData = (groupedFiberRenders: Array) => { let text = ''; @@ -506,7 +507,7 @@ export const Optimize = ({ onClick={async () => { const text = getLLMPrompt(activeTab, selectedEvent); - await navigator.clipboard.writeText(text); + await copyText(text); setCopying(true); setTimeout(() => setCopying(false), 1000); }} diff --git a/packages/scan/src/web/views/notifications/other-visualization.tsx b/packages/scan/src/web/views/notifications/other-visualization.tsx index 3de5ae66..037d1ef3 100644 --- a/packages/scan/src/web/views/notifications/other-visualization.tsx +++ b/packages/scan/src/web/views/notifications/other-visualization.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'preact/compat'; import { useContext, useEffect, useState } from 'preact/hooks'; import { getIsProduction } from '~core/index'; import { iife } from '~core/notifications/performance-utils'; +import { copyText } from '~web/utils/clipboard'; import { cn } from '~web/utils/helpers'; import { InteractionEvent, @@ -394,7 +395,7 @@ const CopyPromptButton = () => { return; } - await navigator.clipboard.writeText( + await copyText( getLLMPrompt('explanation', notificationState.selectedEvent), ); setCopying(true); diff --git a/packages/website/components/cli.tsx b/packages/website/components/cli.tsx index 4bb84252..d4554f9e 100644 --- a/packages/website/components/cli.tsx +++ b/packages/website/components/cli.tsx @@ -1,6 +1,7 @@ -'use client'; +"use client"; -import React, { useState } from 'react'; +import { copyText } from "@/utils/clipboard"; +import React, { useState } from "react"; const ClipboardIcon = ({ className }: { className: string }) => ( { - await navigator.clipboard.writeText(command); + await copyText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); }; @@ -67,7 +68,7 @@ export default function CLI({ command }: { command: string }) { {copied ? ( ) : ( - + )} diff --git a/packages/website/components/install-guide.tsx b/packages/website/components/install-guide.tsx index 53ddbd86..3c488c85 100644 --- a/packages/website/components/install-guide.tsx +++ b/packages/website/components/install-guide.tsx @@ -1,9 +1,10 @@ -'use client'; +"use client"; -import React, { useState, useEffect } from 'react'; +import { copyText } from "@/utils/clipboard"; +import hljs from "highlight.js"; +import "highlight.js/styles/github-dark.css"; import Image from "next/image"; -import hljs from 'highlight.js'; -import 'highlight.js/styles/github-dark.css'; +import React, { useEffect, useState } from "react"; const ClipboardIcon = ({ className }: { className: string }) => ( ( ); -const Tabs = ['script', 'nextjs-app', 'nextjs-pages', 'vite', 'remix'] as const; +const Tabs = ["script", "nextjs-app", "nextjs-pages", "vite", "remix"] as const; type Tab = (typeof Tabs)[number]; export default function InstallGuide() { const [copied, setCopied] = useState(false); - const [activeTab, setActiveTab] = useState('script'); - const [height, setHeight] = useState('auto'); + const [activeTab, setActiveTab] = useState("script"); + const [height, setHeight] = useState("auto"); const contentRef = React.useRef(null); useEffect(() => { @@ -61,21 +62,21 @@ export default function InstallGuide() { }; const copyToClipboard = async () => { - await navigator.clipboard.writeText(getCodeForTab(activeTab)); + await copyText(getCodeForTab(activeTab)); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const getCodeForTab = (tab: Tab) => { switch (tab) { - case 'script': + case "script": return ` `; - case 'nextjs-app': + case "nextjs-app": return `export default function RootLayout({ children, }: { @@ -94,7 +95,7 @@ export default function InstallGuide() { ) }`; - case 'nextjs-pages': + case "nextjs-pages": return `import { Html, Head, Main, NextScript } from 'next/document'; export default function Document() { @@ -114,7 +115,7 @@ export default function Document() { ); }`; - case 'vite': + case "vite": return ` @@ -128,7 +129,7 @@ export default function Document() { `; - case 'remix': + case "remix": return `import { Links, Meta, Outlet, Scripts } from "@remix-run/react"; export default function App() { @@ -157,7 +158,9 @@ export default function App() { } }; - const highlightedCode = hljs.highlight(getCodeForTab(activeTab), { language: 'javascript' }).value; + const highlightedCode = hljs.highlight(getCodeForTab(activeTab), { + language: "javascript", + }).value; return (
@@ -171,7 +174,12 @@ export default function App() {
- react-scan-logo + react-scan-logo React Scan
@@ -182,17 +190,23 @@ export default function App() { ))} @@ -200,12 +214,17 @@ export default function App() {
- {activeTab === 'script' ? 'index.html' : - activeTab === 'nextjs-app' ? 'app/layout.tsx' : - activeTab === 'nextjs-pages' ? 'pages/_document.tsx' : - activeTab === 'vite' ? 'index.html' : - activeTab === 'remix' ? 'app/root.tsx' : - ''} + {activeTab === "script" + ? "index.html" + : activeTab === "nextjs-app" + ? "app/layout.tsx" + : activeTab === "nextjs-pages" + ? "pages/_document.tsx" + : activeTab === "vite" + ? "index.html" + : activeTab === "remix" + ? "app/root.tsx" + : ""}
@@ -213,9 +232,7 @@ export default function App() { className="overflow-hidden transition-[height] duration-150 ease-in-out" style={{ height }} > -
+
${highlightedCode.split('\n').map((_, i) => i + 1).join('\n')
-                        }
+
${highlightedCode + .split("\n") + .map((_, i) => i + 1) + .join("\n")}
${highlightedCode}
- ` + `, }} />