Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion frontend/src/utils/__tests__/copy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,67 @@
/* Copyright 2026 Marimo. All rights reserved. */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { copyImageToClipboard, isSafari } from "../copy";
import { copyImageToClipboard, copyToClipboard, isSafari } from "../copy";

describe("copyToClipboard", () => {
let execCommandMock: ReturnType<typeof vi.fn>;
let promptMock: ReturnType<typeof vi.fn>;

beforeEach(() => {
execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;
promptMock = vi.fn();
vi.stubGlobal("prompt", promptMock);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("uses navigator.clipboard.writeText when available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });

await copyToClipboard("hello");

expect(writeText).toHaveBeenCalledWith("hello");
expect(execCommandMock).not.toHaveBeenCalled();
expect(promptMock).not.toHaveBeenCalled();
});

it("falls back to execCommand when writeText rejects (Firefox, #3912)", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
Object.assign(navigator, { clipboard: { writeText } });

await copyToClipboard("permalink-url");

expect(writeText).toHaveBeenCalledWith("permalink-url");
expect(execCommandMock).toHaveBeenCalledWith("copy");
expect(promptMock).not.toHaveBeenCalled();
});

it("falls back to execCommand when navigator.clipboard is undefined", async () => {
Object.assign(navigator, { clipboard: undefined });

await copyToClipboard("text");

expect(execCommandMock).toHaveBeenCalledWith("copy");
expect(promptMock).not.toHaveBeenCalled();
});

it("prompts the user when both writeText and execCommand fail", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
Object.assign(navigator, { clipboard: { writeText } });
execCommandMock.mockReturnValue(false);

await copyToClipboard("text");

expect(promptMock).toHaveBeenCalledWith(
"Copy to clipboard: Ctrl+C, Enter",
"text",
);
});
});

describe("isSafari", () => {
afterEach(() => {
Expand Down
81 changes: 70 additions & 11 deletions frontend/src/utils/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import { Logger } from "./Logger";
*
* As of 2024-10-29, Safari does not support navigator.clipboard.writeText
* when running localhost http.
*
* Falls back to a hidden textarea + `document.execCommand("copy")` when the
* async Clipboard API is unavailable or rejects. This happens in Firefox when
* the copy follows an awaited async operation, since the transient user
* activation required by `navigator.clipboard.writeText` has been consumed by
* then (see #3912). `execCommand` has more lenient activation requirements.
*/
export async function copyToClipboard(text: string, html?: string) {
if (navigator.clipboard === undefined) {
Logger.warn("navigator.clipboard is not supported");
window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
return;
}

if (html && navigator.clipboard.write) {
// Rich text requires the async Clipboard API; only attempt it when both the
// API and an `html` payload are present.
if (html && navigator.clipboard?.write) {
try {
const item = new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
Expand All @@ -29,10 +31,67 @@ export async function copyToClipboard(text: string, html?: string) {
}
}

await navigator.clipboard.writeText(text).catch(() => {
Logger.warn("Failed to copy to clipboard using navigator.clipboard");
window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
});
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
Logger.warn(
"navigator.clipboard.writeText failed, falling back to execCommand",
);
}
}

if (copyWithExecCommand(text)) {
return;
}

// Last resort: let the user copy manually.
Logger.warn("Failed to copy to clipboard, prompting user");
window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
}

/**
* Synchronously copies `text` using a hidden textarea and the legacy
* `document.execCommand("copy")`. Returns whether the copy succeeded.
*
* The previous selection is preserved and restored so this is invisible to
* the user.
*/
function copyWithExecCommand(text: string): boolean {
if (typeof document === "undefined" || !document.body) {
return false;
}

const selection = document.getSelection();
const previousRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;

const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
// Position off-screen so focusing it doesn't scroll the page.
textArea.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0;";
document.body.append(textArea);
textArea.focus();
textArea.select();

let success = false;
try {
success = document.execCommand("copy");
} catch {
success = false;
}

textArea.remove();

// Restore the user's prior selection, if any.
if (selection && previousRange) {
selection.removeAllRanges();
selection.addRange(previousRange);
}

return success;
}

/**
Expand Down
Loading