Files
siftlode/frontend/src/lib/clipboard.ts
T
peter 471846d784 build(frontend): close the lint gaps the E32 audit measured
eslint now also enforces:
- the React Compiler diagnostics that `recommended` leaves out (todo,
  memo-dependencies and ten zero-hit tripwires);
- a description on every suppression, and no blanket disables;
- no focused or skipped tests (vitest and playwright);
- the typed pair no-floating-promises and no-deprecated.

Lint also covers e2e/ and the TypeScript build configs, which `eslint .`
had been skipping in silence.

Every hit is fixed or suppressed inline with a named reason:
- The 31 try statements the compiler cannot lower yet carry a suppression
  until S3 rewrites them. Five other compiler skips were plain fixes:
  a module counter bumped inside a hook, a ref read in a default
  parameter, and a local named like the <section> it renders.
- The real defects behind floating promises are fixed. ChatDock threw at
  nobody when the device key store could not be opened. VideoEditor's
  play() rejection went unhandled. loadYouTubeApi never rejected, so a
  blocked IFrame API left the player on an empty stage forever; it now
  fails on a script error or after 15 s, shows its own error with the
  way out, and lets the next open try again.
- MutableRefObject, FormEvent and lucide's deprecated brand icon are
  replaced. The YouTube glyph now lives in lib/brandIcons.
- The lint-probe harness lets its virtual file names into TypeScript's
  default project, so the contract tests still run the real config.
2026-10-03 16:34:20 +02:00

64 lines
2.7 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";
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the iOS-tested hidden-but-selectable textarea (see the header); not re-styled blind
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
// eslint-disable-next-line @typescript-eslint/no-deprecated -- the only SYNCHRONOUS copy: iOS Safari needs it inside the click gesture, and a non-secure origin has no navigator.clipboard
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);
}
}
}