Merge pull request #51 from bizzkoot/fix/copy-button-web

fix: copy button functionality in web browsers
This commit is contained in:
Shantur Rathore
2026-01-04 13:52:41 +00:00
committed by GitHub
5 changed files with 92 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full" import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown" import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard"
const inlineLoadedLanguages = new Set<string>() const inlineLoadedLanguages = new Set<string>()
@@ -61,10 +62,12 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
} }
const copyCode = async () => { const copyCode = async () => {
await navigator.clipboard.writeText(props.code) const success = await copyToClipboard(props.code)
if (success) {
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
}
return ( return (
<Show <Show

View File

@@ -2,6 +2,7 @@ import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown" import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session") const log = getLogger("session")
const markdownRenderCache = new Map<string, RenderCache>() const markdownRenderCache = new Map<string, RenderCache>()
@@ -125,13 +126,20 @@ export function Markdown(props: MarkdownProps) {
const code = copyButton.getAttribute("data-code") const code = copyButton.getAttribute("data-code")
if (code) { if (code) {
const decodedCode = decodeURIComponent(code) const decodedCode = decodeURIComponent(code)
await navigator.clipboard.writeText(decodedCode) const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text") const copyText = copyButton.querySelector(".copy-text")
if (copyText) { if (copyText) {
if (success) {
copyText.textContent = "Copied!" copyText.textContent = "Copied!"
setTimeout(() => { setTimeout(() => {
copyText.textContent = "Copy" copyText.textContent = "Copy"
}, 2000) }, 2000)
} else {
copyText.textContent = "Failed"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
}
} }
} }
} }

View File

@@ -3,6 +3,7 @@ import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -155,8 +156,8 @@ interface MessageItemProps {
const handleCopy = async () => { const handleCopy = async () => {
const content = getRawContent() const content = getRawContent()
if (!content) return if (!content) return
await navigator.clipboard.writeText(content) const success = await copyToClipboard(content)
setCopied(true) setCopied(success)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }

View File

@@ -10,6 +10,7 @@ import { formatShortcut } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { deleteSession, loading, renameSession } from "../stores/sessions" import { deleteSession, loading, renameSession } from "../stores/sessions"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session") const log = getLogger("session")
@@ -74,12 +75,12 @@ const SessionList: Component<SessionListProps> = (props) => {
event.stopPropagation() event.stopPropagation()
try { try {
if (typeof navigator === "undefined" || !navigator.clipboard) { const success = await copyToClipboard(sessionId)
throw new Error("Clipboard API unavailable") if (success) {
}
await navigator.clipboard.writeText(sessionId)
showToastNotification({ message: "Session ID copied", variant: "success" }) showToastNotification({ message: "Session ID copied", variant: "success" })
} else {
showToastNotification({ message: "Unable to copy session ID", variant: "error" })
}
} catch (error) { } catch (error) {
log.error(`Failed to copy session ID ${sessionId}:`, error) log.error(`Failed to copy session ID ${sessionId}:`, error)
showToastNotification({ message: "Unable to copy session ID", variant: "error" }) showToastNotification({ message: "Unable to copy session ID", variant: "error" })

View 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
}
}