From e1790c838508394736ec04e4efac8d3e089348f9 Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Sun, 22 Mar 2026 10:58:13 +0530 Subject: [PATCH 1/6] fix: Safari clipboard fallback for "Copy as Markdown" README button Fixes #2151 Safari requires navigator.clipboard.writeText() to be called synchronously within a user gesture. The async fetchReadmeMarkdown() breaks that chain, causing the clipboard write to silently fail. Added a document.execCommand('copy') fallback via a temporary textarea when the Clipboard API rejects. Also bypassed useClipboard's copy() to directly use navigator.clipboard.writeText() so the rejection is catchable. Co-Authored-By: Claude Opus 4.6 --- app/pages/package/[[org]]/[name].vue | 35 +++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 83b0f2b86b..7059bddef5 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -107,13 +107,46 @@ function prefetchReadmeMarkdown() { } } +// Fallback for Safari: navigator.clipboard.writeText() must be called +// synchronously within a user gesture. The async fetch breaks that chain, +// so we fall back to execCommand('copy') via a temporary textarea. +function copyViaExecCommand(text: string): boolean { + const textarea = document.createElement('textarea') + textarea.value = text + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + try { + return document.execCommand('copy') + } + finally { + document.body.removeChild(textarea) + } +} + async function copyReadmeHandler() { await fetchReadmeMarkdown() const markdown = readmeMarkdownData.value?.markdown if (!markdown) return - await copyReadme(markdown) + // Try the modern clipboard API first, then fall back to execCommand. + // Safari requires clipboard writes synchronously within a user gesture — + // the async fetch above breaks that chain, so writeText() will reject. + let success = false + try { + await navigator.clipboard.writeText(markdown) + success = true + } + catch { + success = copyViaExecCommand(markdown) + } + + if (success) { + copiedReadme.value = true + setTimeout(() => { copiedReadme.value = false }, 2000) + } } // Track active TOC item based on scroll position From 2e946dc2114b9f5d9ccd0969c064c9f73f4200a0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 05:29:43 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- app/pages/package/[[org]]/[name].vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 7059bddef5..abc913081d 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -119,8 +119,7 @@ function copyViaExecCommand(text: string): boolean { textarea.select() try { return document.execCommand('copy') - } - finally { + } finally { document.body.removeChild(textarea) } } @@ -138,14 +137,15 @@ async function copyReadmeHandler() { try { await navigator.clipboard.writeText(markdown) success = true - } - catch { + } catch { success = copyViaExecCommand(markdown) } if (success) { copiedReadme.value = true - setTimeout(() => { copiedReadme.value = false }, 2000) + setTimeout(() => { + copiedReadme.value = false + }, 2000) } } From df4dfe991d8f03a74a2ace3f058bea18b3e1dea1 Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:48:03 +0530 Subject: [PATCH 3/6] fix: replace read-only useClipboard ref with writable shallowRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copiedReadme from useClipboard's copied is a read-only computed — direct assignment fails type checking. Since we now bypass useClipboard's copy() entirely, replace with a writable shallowRef. Co-Authored-By: Claude Opus 4.6 --- app/pages/package/[[org]]/[name].vue | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index abc913081d..a02fa9d0b6 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -96,10 +96,7 @@ const { ) //copy README file as Markdown -const { copied: copiedReadme, copy: copyReadme } = useClipboard({ - source: () => '', - copiedDuring: 2000, -}) +const copiedReadme = shallowRef(false) function prefetchReadmeMarkdown() { if (readmeMarkdownStatus.value === 'idle') { From e3117a0af02e6440ee5bad5a3ae3e05da1c4a770 Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:56:16 +0530 Subject: [PATCH 4/6] fix: use ClipboardItem with Promise for Safari clipboard support Replace writeText() + execCommand fallback with the ClipboardItem Promise pattern. Passing the async fetch as a Promise into ClipboardItem keeps the clipboard.write() call synchronous within the user gesture, which is what Safari requires. Ref: https://wolfgangrittner.dev/how-to-use-clipboard-api-in-safari/ Co-Authored-By: Claude Opus 4.6 --- app/pages/package/[[org]]/[name].vue | 74 ++++++++++++++-------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index a02fa9d0b6..499e57b5eb 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -104,45 +104,47 @@ function prefetchReadmeMarkdown() { } } -// Fallback for Safari: navigator.clipboard.writeText() must be called -// synchronously within a user gesture. The async fetch breaks that chain, -// so we fall back to execCommand('copy') via a temporary textarea. -function copyViaExecCommand(text: string): boolean { - const textarea = document.createElement('textarea') - textarea.value = text - textarea.style.position = 'fixed' - textarea.style.opacity = '0' - document.body.appendChild(textarea) - textarea.select() - try { - return document.execCommand('copy') - } finally { - document.body.removeChild(textarea) - } -} - +// Safari requires clipboard writes synchronously within a user gesture. +// Passing a Promise into ClipboardItem lets clipboard.write() stay +// synchronous while the fetch resolves asynchronously inside the item. +// See: https://wolfgangrittner.dev/how-to-use-clipboard-api-in-safari/ async function copyReadmeHandler() { - await fetchReadmeMarkdown() - - const markdown = readmeMarkdownData.value?.markdown - if (!markdown) return - - // Try the modern clipboard API first, then fall back to execCommand. - // Safari requires clipboard writes synchronously within a user gesture — - // the async fetch above breaks that chain, so writeText() will reject. - let success = false try { - await navigator.clipboard.writeText(markdown) - success = true - } catch { - success = copyViaExecCommand(markdown) - } - - if (success) { + const item = new ClipboardItem({ + 'text/plain': (async () => { + await fetchReadmeMarkdown() + const markdown = readmeMarkdownData.value?.markdown + if (!markdown) throw new Error('No markdown') + return new Blob([markdown], { type: 'text/plain' }) + })(), + }) + await navigator.clipboard.write([item]) copiedReadme.value = true - setTimeout(() => { - copiedReadme.value = false - }, 2000) + setTimeout(() => { copiedReadme.value = false }, 2000) + } catch { + // Fallback for browsers without ClipboardItem Promise support + await fetchReadmeMarkdown() + const markdown = readmeMarkdownData.value?.markdown + if (!markdown) return + try { + await navigator.clipboard.writeText(markdown) + copiedReadme.value = true + setTimeout(() => { copiedReadme.value = false }, 2000) + } catch { + // last resort: execCommand + const textarea = document.createElement('textarea') + textarea.value = markdown + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + const ok = document.execCommand('copy') + document.body.removeChild(textarea) + if (ok) { + copiedReadme.value = true + setTimeout(() => { copiedReadme.value = false }, 2000) + } + } } } From 85e31f3f2b5454dd92eb6133a1cde1a2576d5e18 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:28:41 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- app/pages/package/[[org]]/[name].vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 499e57b5eb..1977f7095e 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -120,7 +120,9 @@ async function copyReadmeHandler() { }) await navigator.clipboard.write([item]) copiedReadme.value = true - setTimeout(() => { copiedReadme.value = false }, 2000) + setTimeout(() => { + copiedReadme.value = false + }, 2000) } catch { // Fallback for browsers without ClipboardItem Promise support await fetchReadmeMarkdown() @@ -129,7 +131,9 @@ async function copyReadmeHandler() { try { await navigator.clipboard.writeText(markdown) copiedReadme.value = true - setTimeout(() => { copiedReadme.value = false }, 2000) + setTimeout(() => { + copiedReadme.value = false + }, 2000) } catch { // last resort: execCommand const textarea = document.createElement('textarea') @@ -142,7 +146,9 @@ async function copyReadmeHandler() { document.body.removeChild(textarea) if (ok) { copiedReadme.value = true - setTimeout(() => { copiedReadme.value = false }, 2000) + setTimeout(() => { + copiedReadme.value = false + }, 2000) } } } From 61f74ca38e8692179083d6abe586e1375373e6b2 Mon Sep 17 00:00:00 2001 From: OM MISHRA <152969928+howwohmm@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:12:15 +0530 Subject: [PATCH 6/6] refactor: deduplicate copied-state handling in clipboard fallback chain Co-Authored-By: Claude Opus 4.6 --- app/pages/package/[[org]]/[name].vue | 50 +++++++++++++--------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index 1977f7095e..39d8679792 100644 --- a/app/pages/package/[[org]]/[name].vue +++ b/app/pages/package/[[org]]/[name].vue @@ -109,6 +109,8 @@ function prefetchReadmeMarkdown() { // synchronous while the fetch resolves asynchronously inside the item. // See: https://wolfgangrittner.dev/how-to-use-clipboard-api-in-safari/ async function copyReadmeHandler() { + let copied = false + try { const item = new ClipboardItem({ 'text/plain': (async () => { @@ -119,39 +121,35 @@ async function copyReadmeHandler() { })(), }) await navigator.clipboard.write([item]) - copiedReadme.value = true - setTimeout(() => { - copiedReadme.value = false - }, 2000) + copied = true } catch { // Fallback for browsers without ClipboardItem Promise support await fetchReadmeMarkdown() const markdown = readmeMarkdownData.value?.markdown - if (!markdown) return - try { - await navigator.clipboard.writeText(markdown) - copiedReadme.value = true - setTimeout(() => { - copiedReadme.value = false - }, 2000) - } catch { - // last resort: execCommand - const textarea = document.createElement('textarea') - textarea.value = markdown - textarea.style.position = 'fixed' - textarea.style.opacity = '0' - document.body.appendChild(textarea) - textarea.select() - const ok = document.execCommand('copy') - document.body.removeChild(textarea) - if (ok) { - copiedReadme.value = true - setTimeout(() => { - copiedReadme.value = false - }, 2000) + if (markdown) { + try { + await navigator.clipboard.writeText(markdown) + copied = true + } catch { + // last resort: execCommand + const textarea = document.createElement('textarea') + textarea.value = markdown + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + copied = document.execCommand('copy') + document.body.removeChild(textarea) } } } + + if (copied) { + copiedReadme.value = true + setTimeout(() => { + copiedReadme.value = false + }, 2000) + } } // Track active TOC item based on scroll position