Initial Commit
This commit is contained in:
94
packages/foundation/data/clipboard/src/fallback/fallback.md
Normal file
94
packages/foundation/data/clipboard/src/fallback/fallback.md
Normal 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.
|
||||
208
packages/foundation/data/clipboard/src/fallback/fallback.test.ts
Normal file
208
packages/foundation/data/clipboard/src/fallback/fallback.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
5
packages/foundation/data/clipboard/src/fallback/index.ts
Normal file
5
packages/foundation/data/clipboard/src/fallback/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
copyTextFallback,
|
||||
dispatchSyntheticPaste,
|
||||
readTextFallback,
|
||||
} from "./fallback.js";
|
||||
Reference in New Issue
Block a user