Archived
fix: support clipboard copy on private HTTP origins
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Allow chat copy buttons to work from HTTP private-network addresses by falling back when the browser Clipboard API is unavailable.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { writeClipboardText } from "./clipboard";
|
||||
|
||||
describe("writeClipboardText", () => {
|
||||
it("uses the synchronous fallback directly in insecure contexts", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: false, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("uses the async Clipboard API in secure contexts", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
expect(fallbackWriteText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when the async Clipboard API is unavailable", async () => {
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("falls back when the async Clipboard API rejects", async () => {
|
||||
const writeText = vi.fn(() => Promise.reject(new Error("denied")));
|
||||
const fallbackWriteText = vi.fn(() => true);
|
||||
|
||||
const copied = await writeClipboardText("hello", { isSecureContext: true, writeText, fallbackWriteText });
|
||||
|
||||
expect(copied).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("hello");
|
||||
expect(fallbackWriteText).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
export interface ClipboardTextWriteHost {
|
||||
readonly isSecureContext: boolean;
|
||||
readonly writeText?: (text: string) => Promise<void>;
|
||||
readonly fallbackWriteText: (text: string) => boolean;
|
||||
}
|
||||
|
||||
export async function writeClipboardText(text: string, host: ClipboardTextWriteHost = browserClipboardTextWriteHost()): Promise<boolean> {
|
||||
if (!host.isSecureContext) return host.fallbackWriteText(text);
|
||||
|
||||
if (host.writeText !== undefined) {
|
||||
try {
|
||||
await host.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return host.fallbackWriteText(text);
|
||||
}
|
||||
}
|
||||
|
||||
return host.fallbackWriteText(text);
|
||||
}
|
||||
|
||||
function browserClipboardTextWriteHost(): ClipboardTextWriteHost {
|
||||
const fallbackWriteText = (text: string) => writeClipboardTextWithSelectionFallback(text);
|
||||
const writeText = browserClipboardWriteText();
|
||||
return writeText === undefined
|
||||
? { isSecureContext: browserIsSecureContext(), fallbackWriteText }
|
||||
: { isSecureContext: browserIsSecureContext(), writeText, fallbackWriteText };
|
||||
}
|
||||
|
||||
function browserIsSecureContext(): boolean {
|
||||
return typeof window !== "undefined" && window.isSecureContext;
|
||||
}
|
||||
|
||||
function browserClipboardWriteText(): ((text: string) => Promise<void>) | undefined {
|
||||
if (typeof navigator === "undefined" || !("clipboard" in navigator)) return undefined;
|
||||
return navigator.clipboard.writeText.bind(navigator.clipboard);
|
||||
}
|
||||
|
||||
function writeClipboardTextWithSelectionFallback(text: string): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const selection = document.getSelection();
|
||||
const selectedRanges = selection === null ? [] : selectionRanges(selection);
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.readOnly = true;
|
||||
textarea.setAttribute("aria-hidden", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.width = "1px";
|
||||
textarea.style.height = "1px";
|
||||
textarea.style.padding = "0";
|
||||
textarea.style.border = "0";
|
||||
textarea.style.opacity = "0";
|
||||
textarea.style.pointerEvents = "none";
|
||||
|
||||
document.body.append(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated -- Required for HTTP/private-network pages where navigator.clipboard is unavailable.
|
||||
return document.execCommand("copy");
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
textarea.remove();
|
||||
restoreSelection(selection, selectedRanges);
|
||||
restoreFocus(activeElement);
|
||||
}
|
||||
}
|
||||
|
||||
function selectionRanges(selection: Selection): Range[] {
|
||||
const ranges: Range[] = [];
|
||||
for (let index = 0; index < selection.rangeCount; index += 1) {
|
||||
ranges.push(selection.getRangeAt(index));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function restoreSelection(selection: Selection | null, ranges: readonly Range[]): void {
|
||||
if (selection === null) return;
|
||||
try {
|
||||
selection.removeAllRanges();
|
||||
for (const range of ranges) selection.addRange(range);
|
||||
} catch {
|
||||
// Restoring the prior selection is best-effort; the copy result should remain authoritative.
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFocus(element: Element | null): void {
|
||||
if (typeof HTMLElement === "undefined" || !(element instanceof HTMLElement)) return;
|
||||
try {
|
||||
element.focus({ preventScroll: true });
|
||||
} catch {
|
||||
element.focus();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { ChatDisclosureController } from "../chatDisclosure";
|
||||
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||
@@ -438,22 +439,14 @@ export class ChatView extends LitElement {
|
||||
|
||||
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const ok = await this.writeClipboard(this.messageCopyText(message));
|
||||
if (!ok) return;
|
||||
const copied = await writeClipboardText(this.messageCopyText(message));
|
||||
if (!copied) return;
|
||||
this.copiedMessageKey = key;
|
||||
window.setTimeout(() => {
|
||||
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
private async writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
|
||||
const cached = this.messageMetaCache.get(message);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import { toSafeMarkdownHtml } from "../formatting/markdown";
|
||||
import { formattedTextStyles } from "./shared";
|
||||
|
||||
@@ -49,8 +50,8 @@ export class FormattedText extends LitElement {
|
||||
};
|
||||
|
||||
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
||||
const ok = await writeClipboard(text);
|
||||
this.setCopyButtonState(button, ok ? "copied" : "failed");
|
||||
const copied = await writeClipboardText(text);
|
||||
this.setCopyButtonState(button, copied ? "copied" : "failed");
|
||||
window.setTimeout(() => {
|
||||
this.setCopyButtonState(button, "idle");
|
||||
}, 1200);
|
||||
@@ -67,11 +68,3 @@ export class FormattedText extends LitElement {
|
||||
static override styles = formattedTextStyles;
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { writeClipboardText } from "../clipboard";
|
||||
import type { ToolExecutionPart } from "./shared";
|
||||
|
||||
const MAX_COLLAPSED_DIFF_LINES = 180;
|
||||
@@ -115,13 +116,13 @@ export class ToolExecutionView extends LitElement {
|
||||
}
|
||||
|
||||
private async copyDiff(diff: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(diff);
|
||||
this.copied = true;
|
||||
window.setTimeout(() => { this.copied = false; }, 1200);
|
||||
} catch {
|
||||
const copied = await writeClipboardText(diff);
|
||||
if (!copied) {
|
||||
this.copied = false;
|
||||
return;
|
||||
}
|
||||
this.copied = true;
|
||||
window.setTimeout(() => { this.copied = false; }, 1200);
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
|
||||
Reference in New Issue
Block a user