Files
siftlode/frontend/src/lib/clipboard.ts
T
peter 42ae451c1b fix(share): robust clipboard copy with an iOS-safe fallback
navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the
download share-link copy 'often does neither'). Add lib/clipboard.ts
copyText(): a gesture-safe execCommand path FIRST (before any await can
spend the transient user activation), async Clipboard API as fallback.
Migrate all five copy call sites to it.
2026-07-19 05:38:42 +02:00

62 lines
2.3 KiB
TypeScript

// Copy text to the clipboard, robust across iOS Safari and non-secure origins.
//
// The synchronous `execCommand("copy")` path (a hidden, selected textarea) is the
// battle-tested, user-gesture-safe primitive: it works on iOS Safari and every
// desktop browser. We run it FIRST, inside the click gesture, before any `await`
// can spend the page's transient user activation — awaiting a rejected async
// `navigator.clipboard.writeText` first is exactly what makes the fallback fail on
// iOS. Only if the sync path fails (some sandboxed / permission-locked contexts
// disable execCommand) do we fall back to the async Clipboard API. Returns true on
// success so callers can toast.
export async function copyText(text: string): Promise<boolean> {
if (legacyCopy(text)) return true;
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// both paths failed
}
}
return false;
}
function legacyCopy(text: string): boolean {
const active = document.activeElement as HTMLElement | null;
// Preserve any text the user had selected on the page — focusing our textarea
// collapses it, so snapshot the range and restore it afterwards.
const sel = document.getSelection();
const savedRange = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", ""); // suppress the iOS keyboard
// Hidden but still selectable: iOS won't copy from `display:none`/`opacity:0`, so
// clip it out of view while keeping a live, user-selectable node.
ta.style.position = "fixed";
ta.style.top = "0";
ta.style.left = "0";
ta.style.width = "1px";
ta.style.height = "1px";
ta.style.padding = "0";
ta.style.border = "none";
ta.style.clip = "rect(0, 0, 0, 0)";
ta.style.userSelect = "text";
ta.style.setProperty("-webkit-user-select", "text");
document.body.appendChild(ta);
try {
ta.focus();
ta.select();
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(ta);
active?.focus?.(); // restore focus we borrowed for the copy
if (savedRange) {
sel!.removeAllRanges();
sel!.addRange(savedRange);
}
}
}