fix: copy button functionality in web browsers
- Add clipboard utility with fallback for non-secure contexts - Implement modern Clipboard API with document.execCommand fallback - Update copy buttons in code blocks, markdown, messages, and session list - Add proper error handling and user feedback for copy operations Fixes issue where copy buttons did not work in web browsers served over HTTP or without Clipboard API support
This commit is contained in:
@@ -2,6 +2,7 @@ import { createSignal, onMount, Show, createEffect } from "solid-js"
|
||||
import type { Highlighter } from "shiki/bundle/full"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
const inlineLoadedLanguages = new Set<string>()
|
||||
|
||||
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
await navigator.clipboard.writeText(props.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
const success = await copyToClipboard(props.code)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
const log = getLogger("session")
|
||||
|
||||
const markdownRenderCache = new Map<string, RenderCache>()
|
||||
@@ -125,13 +126,20 @@ export function Markdown(props: MarkdownProps) {
|
||||
const code = copyButton.getAttribute("data-code")
|
||||
if (code) {
|
||||
const decodedCode = decodeURIComponent(code)
|
||||
await navigator.clipboard.writeText(decodedCode)
|
||||
const success = await copyToClipboard(decodedCode)
|
||||
const copyText = copyButton.querySelector(".copy-text")
|
||||
if (copyText) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
if (success) {
|
||||
copyText.textContent = "Copied!"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
} else {
|
||||
copyText.textContent = "Failed"
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copy"
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MessageInfo, ClientPart } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { MessageRecord } from "../stores/message-v2/types"
|
||||
import MessagePart from "./message-part"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
|
||||
interface MessageItemProps {
|
||||
record: MessageRecord
|
||||
@@ -15,9 +16,9 @@ interface MessageItemProps {
|
||||
onFork?: (messageId?: string) => void
|
||||
showAgentMeta?: boolean
|
||||
onContentRendered?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
export default function MessageItem(props: MessageItemProps) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const isUser = () => props.record.role === "user"
|
||||
@@ -155,8 +156,8 @@ interface MessageItemProps {
|
||||
const handleCopy = async () => {
|
||||
const content = getRawContent()
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
const success = await copyToClipboard(content)
|
||||
setCopied(success)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { formatShortcut } from "../lib/keyboard-utils"
|
||||
import { showToastNotification } from "../lib/notifications"
|
||||
import { deleteSession, loading, renameSession } from "../stores/sessions"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { copyToClipboard } from "../lib/clipboard"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -72,14 +73,14 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
|
||||
const copySessionId = async (event: MouseEvent, sessionId: string) => {
|
||||
event.stopPropagation()
|
||||
|
||||
|
||||
try {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
||||
throw new Error("Clipboard API unavailable")
|
||||
const success = await copyToClipboard(sessionId)
|
||||
if (success) {
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
} else {
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
showToastNotification({ message: "Session ID copied", variant: "success" })
|
||||
} catch (error) {
|
||||
log.error(`Failed to copy session ID ${sessionId}:`, error)
|
||||
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
|
||||
|
||||
61
packages/ui/src/lib/clipboard.ts
Normal file
61
packages/ui/src/lib/clipboard.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Clipboard utility with fallback for non-secure contexts
|
||||
* The modern Clipboard API requires HTTPS or localhost, but document.execCommand
|
||||
* works in HTTP contexts as a fallback.
|
||||
*/
|
||||
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with fallback for non-secure contexts
|
||||
* @param text - The text to copy
|
||||
* @returns Promise<boolean> - true if successful, false if failed
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
// Try modern Clipboard API first (requires secure context)
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
log.info("Copied text using Clipboard API")
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Clipboard API failed, trying fallback:", error)
|
||||
}
|
||||
|
||||
// Fallback for non-secure contexts (HTTP) using document.execCommand
|
||||
try {
|
||||
if (typeof document === "undefined") {
|
||||
log.error("Document not available for clipboard fallback")
|
||||
return false
|
||||
}
|
||||
|
||||
// Create temporary textarea element
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-9999px"
|
||||
textArea.style.top = "-9999px"
|
||||
textArea.style.opacity = "0"
|
||||
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const success = document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
if (success) {
|
||||
log.info("Copied text using execCommand fallback")
|
||||
return true
|
||||
} else {
|
||||
log.error("execCommand copy failed")
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Clipboard fallback failed:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user