176 lines
5.5 KiB
TypeScript
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;
|
|
}
|