diff --git a/components/selection-toolbar/shared.tsx b/components/selection-toolbar/shared.tsx index b6b0fefc..e806d4f6 100644 --- a/components/selection-toolbar/shared.tsx +++ b/components/selection-toolbar/shared.tsx @@ -103,29 +103,56 @@ export function useCopyFeedback() { const copy = useCallback(async (text: string) => { let success = false; - const finalText = await shortenHighlightUrlsIn(text); - try { - await navigator.clipboard.writeText(finalText); - success = true; - } catch { - // Fallback for iOS Safari and older browsers - const ta = document.createElement("textarea"); - ta.value = finalText; - ta.style.position = "fixed"; - ta.style.top = "0"; - ta.style.left = "0"; - ta.style.opacity = "0"; - document.body.appendChild(ta); - ta.focus(); - ta.select(); + // Why we don't just `await shortenHighlightUrlsIn(text)` first: the URL + // shortening is a network round-trip, and awaiting it consumes the + // transient user activation that navigator.clipboard needs. The write + // would then silently fail on the *first* click and only succeed on the + // second (when the short link is cached and the shorten resolves + // synchronously). Instead we hand the clipboard a *Promise* of the text + // via ClipboardItem — the browser keeps the gesture alive while the + // async work finishes, so a single click copies the shortened URL. + if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { try { - document.execCommand("copy"); + const item = new ClipboardItem({ + "text/plain": shortenHighlightUrlsIn(text).then( + (s) => new Blob([s], { type: "text/plain" }), + ), + }); + await navigator.clipboard.write([item]); success = true; } catch { - // silent fail + // ClipboardItem-with-promise unsupported or rejected — fall through. + } + } + + if (!success) { + // Legacy path: now that ClipboardItem failed, await the text and try + // the synchronous fallbacks. (May still need a second click on very old + // browsers, but those lack ClipboardItem entirely.) + const finalText = await shortenHighlightUrlsIn(text); + try { + await navigator.clipboard.writeText(finalText); + success = true; + } catch { + // Fallback for older browsers without the async clipboard API. + const ta = document.createElement("textarea"); + ta.value = finalText; + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { + document.execCommand("copy"); + success = true; + } catch { + // silent fail + } + document.body.removeChild(ta); } - document.body.removeChild(ta); } if (success) {