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.
64 lines
2.7 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|