Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
# `fallback/fallback.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Legacy clipboard fallback paths used when `navigator.clipboard` is
unavailable or rejects. Provides a hidden-textarea + `execCommand("copy")`
write path and a paste-event-based read path. Both return the same
`ClipboardResult` shape as the API path so the manager's call sites do
not branch on which mechanism actually ran. The paste-event fallback is
purpose-built for environments — older Safari, embedded WebViews, and
permission-denied contexts — where `navigator.clipboard.readText`
rejects but the user can paste manually.
## Features
- `copyTextFallback(text)` — synchronous textarea + `execCommand` copy.
- `readTextFallback(timeoutMs)` — focus + paste-event capture with a
configurable timeout.
- `dispatchSyntheticPaste(text)` — test helper that fires a synthetic
`paste` event on the most recent hidden host.
- SSR-safe — both copy and read paths return
`CLIPBOARD.DOCUMENT_UNAVAILABLE` when `document` is undefined.
- Off-screen, non-interactive host element so users never see the
textarea visibly steal focus.
## Usage: Basic
```ts
import { copyTextFallback } from "@suaveplan/clipboard";
const result = copyTextFallback("hello");
if (!result.success) {
console.warn("legacy copy failed:", result.code);
}
```
## Usage: Advanced
```ts
import {
readTextFallback,
dispatchSyntheticPaste,
} from "@suaveplan/clipboard";
async function readClipboardWithPasteFallback(): Promise<string | undefined> {
const result = await readTextFallback(2000);
return result.success ? result.data : undefined;
}
// Inside a test harness:
const promise = readTextFallback(500);
queueMicrotask(() => dispatchSyntheticPaste("from-test"));
const result = await promise;
```
## API Reference
### `copyTextFallback(text: string): ClipboardResult<string>`
Stages a hidden `<textarea>` carrying `text`, selects it, runs
`document.execCommand("copy")`, and removes the textarea on cleanup.
Returns `{ success: true, data, method: "fallback" }` on success and a
structured failure with `code: CLIPBOARD.FALLBACK_FAILED` (or
`CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser) otherwise.
### `readTextFallback(timeoutMs: number): Promise<ClipboardResult<string>>`
Creates a hidden textarea, focuses it, listens for the first `paste`
event, and resolves with the `text/plain` payload carried by the event.
Resolves with `code: CLIPBOARD.READ_FAILED` if the timeout elapses
first and `code: CLIPBOARD.DOCUMENT_UNAVAILABLE` outside the browser.
### `dispatchSyntheticPaste(text: string): boolean`
Test helper. Locates the most recent hidden host (`textarea[aria-hidden="true"]`)
and dispatches a `paste` ClipboardEvent carrying `text` as `text/plain`.
Returns `true` when a host was found, `false` otherwise.
## Implementation Notes
The hidden textarea is positioned at `-9999px` and given `opacity: 0`
plus `aria-hidden="true"` so assistive technologies skip it. The cleanup
runs in a `finally` block — even if `execCommand` throws, the textarea is
removed. The read fallback tracks a `settled` flag so a duplicate or
post-timeout `paste` event cannot resolve the promise twice. The paste
event may carry a null `clipboardData` on some browsers; the module
falls back to an empty string in that case rather than rejecting, since
"clipboard contained nothing" is a valid outcome. The synthetic-paste
helper is exported to keep tests deterministic — production code never
calls it.

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLIPBOARD_CODES } from "../errors/errors.js";
import {
copyTextFallback,
dispatchSyntheticPaste,
readTextFallback,
} from "./fallback.js";
function withExecCommand<T>(
impl: (() => boolean) | (() => never),
fn: () => T
): T {
const original = document.execCommand;
(document as unknown as { execCommand: () => boolean }).execCommand =
impl as () => boolean;
try {
return fn();
} finally {
document.execCommand = original;
}
}
describe("copyTextFallback", () => {
it("copies text when execCommand returns true", { timeout: 5000 }, () => {
const result = withExecCommand(
() => true,
() => copyTextFallback("hello")
);
expect(result).toEqual({
success: true,
data: "hello",
method: "fallback",
});
});
it("copyTextFallback reports FALLBACK_FAILED when execCommand returns false", {
timeout: 5000,
}, () => {
const result = withExecCommand(
() => false,
() => copyTextFallback("x")
);
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
expect(result.method).toBe("fallback");
});
it("copyTextFallback reports FALLBACK_FAILED when execCommand throws", {
timeout: 5000,
}, () => {
const result = withExecCommand(
() => {
throw new Error("denied");
},
() => copyTextFallback("x")
);
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.FALLBACK_FAILED);
expect(result.error).toBe("denied");
});
it("copyTextFallback reports DOCUMENT_UNAVAILABLE when document missing", {
timeout: 5000,
}, () => {
const originalDoc = globalThis.document;
(globalThis as unknown as { document?: Document }).document = undefined;
try {
const result = copyTextFallback("x");
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
}
});
});
describe("readTextFallback", () => {
it("readTextFallback returns pasted value when paste event fires", {
timeout: 5000,
}, async () => {
const promise = readTextFallback(500);
// Wait one tick for the host to be staged.
setTimeout(() => {
const ok = dispatchSyntheticPaste("pasted-text");
expect(ok).toBe(true);
}, 10);
const result = await promise;
expect(result).toEqual({
success: true,
data: "pasted-text",
method: "fallback",
});
});
it("readTextFallback returns empty string when paste lacks text/plain", {
timeout: 5000,
}, async () => {
const promise = readTextFallback(500);
setTimeout(() => {
const candidates = document.querySelectorAll(
'textarea[aria-hidden="true"]'
);
const host = candidates.item(candidates.length - 1);
const ev = new ClipboardEvent("paste", {
clipboardData: null as unknown as DataTransfer,
bubbles: true,
cancelable: true,
});
host?.dispatchEvent(ev);
}, 5);
const result = await promise;
expect(result.success).toBe(true);
expect(result.data).toBe("");
});
it("readTextFallback returns READ_FAILED on timeout", {
timeout: 5000,
}, async () => {
const result = await readTextFallback(20);
expect(result.success).toBe(false);
expect(result.code).toBe(CLIPBOARD_CODES.READ_FAILED);
expect(result.method).toBe("fallback");
});
it("readTextFallback reports DOCUMENT_UNAVAILABLE when document missing", {
timeout: 5000,
}, async () => {
const originalDoc = globalThis.document;
(globalThis as unknown as { document?: Document }).document = undefined;
try {
const result = await readTextFallback(100);
expect(result.code).toBe(CLIPBOARD_CODES.DOCUMENT_UNAVAILABLE);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
}
});
it("ignores duplicate paste events after settling", {
timeout: 5000,
}, async () => {
const promise = readTextFallback(500);
// Capture host reference inside setTimeout so it has been staged.
let host: Element | null = null;
const makeEvent = (text: string): ClipboardEvent => {
const dt = new DataTransfer();
dt.setData("text/plain", text);
return new ClipboardEvent("paste", {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
};
setTimeout(() => {
const candidates = document.querySelectorAll(
'textarea[aria-hidden="true"]'
);
host = candidates.item(candidates.length - 1);
// First event resolves the promise.
host?.dispatchEvent(makeEvent("first"));
// Second event hits the settled-guard branch — host has been
// detached from DOM by cleanup, but the listener remains attached
// to the orphan node and re-fires onPaste, which short-circuits.
host?.dispatchEvent(makeEvent("second"));
}, 10);
const result = await promise;
expect(result.data).toBe("first");
});
});
describe("readTextFallback timeout idempotence", () => {
it("timeout firing after paste does not double-resolve", {
timeout: 5000,
}, async () => {
// Short timeout so it fires synchronously after paste.
const promise = readTextFallback(30);
setTimeout(() => dispatchSyntheticPaste("real"), 5);
const first = await promise;
expect(first.data).toBe("real");
// Allow the original 30ms timeout to elapse — it must noop.
await new Promise<void>((r) => setTimeout(r, 50));
expect(first.success).toBe(true);
});
});
describe("dispatchSyntheticPaste", () => {
it("returns false when no host exists", { timeout: 5000 }, () => {
// Remove any leftover hosts.
for (const host of document.querySelectorAll(
'textarea[aria-hidden="true"]'
)) {
host.parentNode?.removeChild(host);
}
expect(dispatchSyntheticPaste("x")).toBe(false);
});
it("returns false when document missing", { timeout: 5000 }, () => {
const originalDoc = globalThis.document;
(globalThis as unknown as { document?: Document }).document = undefined;
try {
expect(dispatchSyntheticPaste("x")).toBe(false);
} finally {
(globalThis as unknown as { document?: Document }).document =
originalDoc;
}
});
});

View 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;
}

View File

@@ -0,0 +1,5 @@
export {
copyTextFallback,
dispatchSyntheticPaste,
readTextFallback,
} from "./fallback.js";