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 { repeat } from "lit/directives/repeat.js";
|
||||||
import { ChatDisclosureController } from "../chatDisclosure";
|
import { ChatDisclosureController } from "../chatDisclosure";
|
||||||
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
import { groupChatMessages, summarizeChatGroup, type ChatGroup } from "../chatGroups";
|
||||||
|
import { writeClipboardText } from "../clipboard";
|
||||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
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> {
|
private async copyMessage(message: ChatLine, key: string, event: MouseEvent): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const ok = await this.writeClipboard(this.messageCopyText(message));
|
const copied = await writeClipboardText(this.messageCopyText(message));
|
||||||
if (!ok) return;
|
if (!copied) return;
|
||||||
this.copiedMessageKey = key;
|
this.copiedMessageKey = key;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
|
if (this.copiedMessageKey === key) this.copiedMessageKey = undefined;
|
||||||
}, 1200);
|
}, 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 } {
|
private messageMetaLabel(message: ChatLine): { short: string; full: string } {
|
||||||
const cached = this.messageMetaCache.get(message);
|
const cached = this.messageMetaCache.get(message);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
|
import { writeClipboardText } from "../clipboard";
|
||||||
import { toSafeMarkdownHtml } from "../formatting/markdown";
|
import { toSafeMarkdownHtml } from "../formatting/markdown";
|
||||||
import { formattedTextStyles } from "./shared";
|
import { formattedTextStyles } from "./shared";
|
||||||
|
|
||||||
@@ -49,8 +50,8 @@ export class FormattedText extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
private async copyCode(text: string, button: HTMLButtonElement): Promise<void> {
|
||||||
const ok = await writeClipboard(text);
|
const copied = await writeClipboardText(text);
|
||||||
this.setCopyButtonState(button, ok ? "copied" : "failed");
|
this.setCopyButtonState(button, copied ? "copied" : "failed");
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
this.setCopyButtonState(button, "idle");
|
this.setCopyButtonState(button, "idle");
|
||||||
}, 1200);
|
}, 1200);
|
||||||
@@ -67,11 +68,3 @@ export class FormattedText extends LitElement {
|
|||||||
static override styles = formattedTextStyles;
|
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 { LitElement, css, html } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { writeClipboardText } from "../clipboard";
|
||||||
import type { ToolExecutionPart } from "./shared";
|
import type { ToolExecutionPart } from "./shared";
|
||||||
|
|
||||||
const MAX_COLLAPSED_DIFF_LINES = 180;
|
const MAX_COLLAPSED_DIFF_LINES = 180;
|
||||||
@@ -115,13 +116,13 @@ export class ToolExecutionView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async copyDiff(diff: string): Promise<void> {
|
private async copyDiff(diff: string): Promise<void> {
|
||||||
try {
|
const copied = await writeClipboardText(diff);
|
||||||
await navigator.clipboard.writeText(diff);
|
if (!copied) {
|
||||||
this.copied = true;
|
|
||||||
window.setTimeout(() => { this.copied = false; }, 1200);
|
|
||||||
} catch {
|
|
||||||
this.copied = false;
|
this.copied = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.copied = true;
|
||||||
|
window.setTimeout(() => { this.copied = false; }, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
|
|||||||
Reference in New Issue
Block a user