Initial Commit
This commit is contained in:
175
packages/foundation/data/clipboard/src/fallback/fallback.ts
Normal file
175
packages/foundation/data/clipboard/src/fallback/fallback.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user