Files
suaveplan/packages/foundation/data/clipboard/src/fallback/fallback.ts
2026-05-27 23:47:53 +01:00

176 lines
5.5 KiB
TypeScript

import {
asClipboardError,
CLIPBOARD_CODES,
ClipboardError,
} from "../errors/errors.js";
import type { ClipboardResult } from "../types/clipboard.types.js";
/**
* Stage a hidden textarea for the `execCommand` fallback. Off-screen and
* non-interactive so it can never steal focus visibly. Caller is
* responsible for removing it via the returned cleanup.
*/
function stageTextarea(value: string): {
textarea: HTMLTextAreaElement;
cleanup: () => void;
} {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
return {
textarea,
cleanup: (): void => {
textarea.remove();
},
};
}
/**
* Copy `text` via the legacy `<textarea>` + `document.execCommand("copy")`
* path. Returns a structured {@link ClipboardResult} with method
* `"fallback"` and (on failure) a `CLIPBOARD.*` code.
*/
export function copyTextFallback(text: string): ClipboardResult<string> {
if (typeof document === "undefined") {
const error = new ClipboardError(
CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE,
"document is undefined; cannot run clipboard fallback."
);
return {
success: false,
error: error.message,
code: error.code,
method: "fallback",
};
}
const { cleanup } = stageTextarea(text);
try {
const ok = document.execCommand("copy");
if (ok) {
return { success: true, data: text, method: "fallback" };
}
return {
success: false,
error: "document.execCommand('copy') returned false.",
code: CLIPBOARD_CODES.FALLBACK_FAILED,
method: "fallback",
};
} catch (cause) {
const error = asClipboardError(
CLIPBOARD_CODES.FALLBACK_FAILED,
cause,
"document.execCommand('copy') threw."
);
return {
success: false,
error: error.message,
code: error.code,
method: "fallback",
};
} finally {
cleanup();
}
}
/**
* Read plain text from the clipboard via a paste-event fallback. Creates a
* hidden contenteditable element, focuses it, and resolves with the value
* carried by the first `paste` event. Times out with
* `CLIPBOARD.READ_FAILED` after `timeoutMs` milliseconds.
*/
export function readTextFallback(
timeoutMs: number
): Promise<ClipboardResult<string>> {
if (typeof document === "undefined") {
const error = new ClipboardError(
CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE,
"document is undefined; cannot run paste-event fallback."
);
return Promise.resolve({
success: false,
error: error.message,
code: error.code,
method: "fallback",
});
}
return new Promise<ClipboardResult<string>>((resolve) => {
const host = document.createElement("textarea");
host.setAttribute("aria-hidden", "true");
host.style.position = "fixed";
host.style.left = "-9999px";
host.style.top = "-9999px";
host.style.opacity = "0";
document.body.appendChild(host);
let settled = false;
const cleanup = (): void => {
// Keep the listener attached after first paste; the `settled`
// guard below prevents double-resolve when a host that's already
// been detached fires a duplicate paste event.
host.remove();
};
const onPaste = (ev: Event): void => {
if (settled) {
return;
}
settled = true;
const data =
(ev as ClipboardEvent).clipboardData?.getData("text/plain") ??
"";
cleanup();
resolve({ success: true, data, method: "fallback" });
};
host.addEventListener("paste", onPaste);
host.focus();
setTimeout(() => {
if (settled) {
return;
}
settled = true;
cleanup();
const error = new ClipboardError(
CLIPBOARD_CODES.READ_FAILED,
"Paste-event fallback timed out before any paste event fired."
);
resolve({
success: false,
error: error.message,
code: error.code,
method: "fallback",
});
}, timeoutMs);
});
}
/**
* Dispatch a synthetic `paste` event on the most recent hidden host (the
* one staged by `readTextFallback`). Intended for tests: the host is the
* last child of `document.body` whose `aria-hidden` attribute is `"true"`.
*/
export function dispatchSyntheticPaste(text: string): boolean {
if (typeof document === "undefined") {
return false;
}
const candidates = document.querySelectorAll(
'textarea[aria-hidden="true"]'
);
const host = candidates.item(candidates.length - 1);
if (host === null) {
return false;
}
const dt = new DataTransfer();
dt.setData("text/plain", text);
const ev = new ClipboardEvent("paste", {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
host.dispatchEvent(ev);
return true;
}