Skip to content

Commit

Permalink
fix: add clipboard copy fallback for Safari (#2748)
Browse files Browse the repository at this point in the history
Fixes #2729

As of 2024-10-29, Safari does not support navigator.clipboard.writeText
when running localhost HTTP.

So this shows a prompt when copying fails so that the user can copy
themselves
  • Loading branch information
mscolnick authored Oct 29, 2024
1 parent 9aae5f4 commit 6ee8ffa
Show file tree
Hide file tree
Showing 13 changed files with 59 additions and 30 deletions.
5 changes: 3 additions & 2 deletions frontend/src/components/data-table/column-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { DATA_TYPE_ICON } from "../datasets/icons";
import { formattingExample } from "./column-formatting/feature";
import { PinLeftIcon, PinRightIcon } from "@radix-ui/react-icons";
import { NAMELESS_COLUMN_PREFIX } from "./columns";
import { copyToClipboard } from "@/utils/copy";

interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
Expand Down Expand Up @@ -245,8 +246,8 @@ export const DataTableColumnHeader = <TData, TValue>({
{renderSorts()}
{!column.id.startsWith(NAMELESS_COLUMN_PREFIX) && (
<DropdownMenuItem
onClick={() =>
navigator.clipboard.writeText(
onClick={async () =>
await copyToClipboard(
typeof header === "string" ? header : column.id,
)
}
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/editor/actions/useNotebookActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { useCopyNotebook } from "./useCopyNotebook";
import { isWasm } from "@/core/wasm/utils";
import { settingDialogAtom } from "@/components/app-config/app-config-button";
import { renderShortcut } from "@/components/shortcuts/renderShortcut";
import { copyToClipboard } from "@/utils/copy";

const NOOP_HANDLER = (event?: Event) => {
event?.preventDefault();
Expand Down Expand Up @@ -116,7 +117,7 @@ export function useNotebookActions() {
handle: async () => {
const code = await readCode();
const url = createShareableLink({ code: code.contents });
window.navigator.clipboard.writeText(url);
await copyToClipboard(url);
toast({
title: "Copied",
description: "Link copied to clipboard.",
Expand Down Expand Up @@ -300,7 +301,7 @@ export function useNotebookActions() {
hidden: !filename,
handle: async () => {
const code = await readCode();
navigator.clipboard.writeText(code.contents);
await copyToClipboard(code.contents);
toast({
title: "Copied",
description: "Code copied to clipboard.",
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/editor/code/readonly-python-code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useTheme } from "@/theme/useTheme";
import { cn } from "@/utils/cn";
import { customPythonLanguageSupport } from "@/core/codemirror/language/python";
import { sql } from "@codemirror/lang-sql";
import { copyToClipboard } from "@/utils/copy";

const pythonExtensions = [
customPythonLanguageSupport(),
Expand Down Expand Up @@ -69,8 +70,8 @@ export const ReadonlyCode = memo(
ReadonlyCode.displayName = "ReadonlyCode";

const CopyButton = (props: { text: string }) => {
const copy = Events.stopPropagation(() => {
navigator.clipboard.writeText(props.text);
const copy = Events.stopPropagation(async () => {
await copyToClipboard(props.text);
toast({ title: "Copied to clipboard" });
});

Expand Down
15 changes: 8 additions & 7 deletions frontend/src/components/editor/file-tree/file-explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { Spinner } from "@/components/icons/spinner";
import type { RequestingTree } from "./requesting-tree";
import type { FilePath } from "@/utils/paths";
import useEvent from "react-use-event-hook";
import { copyToClipboard } from "@/utils/copy";

const RequestingTreeContext = React.createContext<RequestingTree | null>(null);

Expand Down Expand Up @@ -444,37 +445,37 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onSelect={async () => {
await copyToClipboard(node.data.path);
toast({ title: "Copied to clipboard" });
navigator.clipboard.writeText(node.data.path);
}}
>
<CopyIcon {...iconProps} />
Copy path
</DropdownMenuItem>
{tree && (
<DropdownMenuItem
onSelect={() => {
toast({ title: "Copied to clipboard" });
navigator.clipboard.writeText(
onSelect={async () => {
await copyToClipboard(
tree.relativeFromRoot(node.data.path as FilePath),
);
toast({ title: "Copied to clipboard" });
}}
>
<CopyIcon {...iconProps} />
Copy relative path
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={() => {
onSelect={async () => {
toast({
title: "Copied to clipboard",
description:
"Code to open the file has been copied to your clipboard. You can also drag and drop this file into the editor",
});
const { path } = node.data;
const pythonCode = PYTHON_CODE_FOR_FILE_TYPE[fileType](path);
navigator.clipboard.writeText(pythonCode);
await copyToClipboard(pythonCode);
}}
>
<BracesIcon {...iconProps} />
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/editor/file-tree/file-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { hotkeysAtom } from "@/core/config/config";
import { useAtomValue } from "jotai";
import { ImageViewer, CsvViewer, AudioViewer, VideoViewer } from "./renderers";
import { isWasm } from "@/core/wasm/utils";
import { copyToClipboard } from "@/utils/copy";

interface Props {
file: FileInfo;
Expand Down Expand Up @@ -138,8 +139,8 @@ export const FileViewer: React.FC<Props> = ({ file, onOpenNotebook }) => {
<Tooltip content="Copy contents to clipboard">
<Button
size="small"
onClick={() => {
navigator.clipboard.writeText(internalValue);
onClick={async () => {
await copyToClipboard(internalValue);
}}
>
<CopyIcon />
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/icons/copy-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useState } from "react";
import { Tooltip } from "../ui/tooltip";
import { cn } from "@/utils/cn";
import { Events } from "@/utils/events";
import { copyToClipboard } from "@/utils/copy";

interface Props {
value: string;
Expand All @@ -19,8 +20,8 @@ export const CopyClipboardIcon: React.FC<Props> = ({
}) => {
const [isCopied, setIsCopied] = useState(false);

const handleCopy = Events.stopPropagation(() => {
navigator.clipboard.writeText(value).then(() => {
const handleCopy = Events.stopPropagation(async () => {
await copyToClipboard(value).then(() => {
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
});
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/static-html/share-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Tooltip } from "../ui/tooltip";
import { Constants } from "@/core/constants";
import { exportAsHTML } from "@/core/network/requests";
import { VirtualFileTracker } from "@/core/static/virtual-file-tracker";
import { copyToClipboard } from "@/utils/copy";

const BASE_URL = "https://static.marimo.app";

Expand Down Expand Up @@ -142,8 +143,8 @@ export const ShareStaticNotebookModal: React.FC<{
aria-label="Save"
variant="default"
type="submit"
onClick={() => {
navigator.clipboard.writeText(url);
onClick={async () => {
await copyToClipboard(url);
}}
>
Create
Expand All @@ -157,9 +158,9 @@ export const ShareStaticNotebookModal: React.FC<{
const CopyButton = (props: { text: string }) => {
const [copied, setCopied] = React.useState(false);

const copy = Events.stopPropagation((e) => {
const copy = Events.stopPropagation(async (e) => {
e.preventDefault();
navigator.clipboard.writeText(props.text);
await copyToClipboard(props.text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/static-html/static-banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../ui/dialog";
import { CopyIcon, DownloadIcon } from "lucide-react";
import { createShareableLink } from "@/core/wasm/share";
import { copyToClipboard } from "@/utils/copy";

export const StaticBanner: React.FC = () => {
if (!isStaticNotebook()) {
Expand Down Expand Up @@ -127,8 +128,8 @@ const StaticBannerDialog = ({ code }: { code: string }) => {
<Button
data-testid="copy-static-notebook-dialog-button"
variant="secondary"
onClick={() => {
window.navigator.clipboard.writeText(code);
onClick={async () => {
await copyToClipboard(code);
toast({ title: "Copied to clipboard" });
}}
>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/variables/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React from "react";
import { toast } from "../ui/use-toast";
import { Badge } from "../ui/badge";
import { copyToClipboard } from "@/utils/copy";

interface Props extends React.HTMLAttributes<HTMLDivElement> {
name: string;
Expand All @@ -20,12 +21,12 @@ export const VariableName: React.FC<Props> = ({
title={name}
variant={declaredBy.length > 1 ? "destructive" : "outline"}
className="rounded-sm text-ellipsis block overflow-hidden max-w-fit cursor-pointer font-medium"
onClick={(evt) => {
onClick={async (evt) => {
if (onClick) {
onClick(evt);
return;
}
navigator.clipboard.writeText(name);
await copyToClipboard(name);
toast({ title: "Copied to clipboard" });
}}
>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/core/codemirror/copilot/copilot-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { CheckIcon, CopyIcon, Loader2Icon, XIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/use-toast";
import { copyToClipboard } from "@/utils/copy";

type Step =
| "signedIn"
Expand Down Expand Up @@ -133,11 +134,11 @@ export const CopilotConfig = memo(() => {
<strong className="ml-2">{localData?.code}</strong>
<CopyIcon
className="ml-2 cursor-pointer opacity-60 hover:opacity-100 h-3 w-3"
onClick={() => {
onClick={async () => {
if (!localData) {
return;
}
navigator.clipboard.writeText(localData.code);
await copyToClipboard(localData.code);
toast({
description: "Copied to clipboard",
});
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/core/islands/components/output-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { isOutputEmpty } from "@/core/cells/outputs";
import type { CellRuntimeState } from "@/core/cells/types";
import { sendRun } from "@/core/network/requests";
import { useEventListener } from "@/hooks/useEventListener";
import { copyToClipboard } from "@/utils/copy";
import { Logger } from "@/utils/Logger";
import { useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
Expand Down Expand Up @@ -107,7 +108,7 @@ export const MarimoOutputWrapper: React.FC<Props> = ({
<IconButton
tooltip="Copy code"
icon={<CopyIcon className="size-3" />}
action={() => navigator.clipboard.writeText(codeCallback())}
action={() => copyToClipboard(codeCallback())}
/>
<IconButton
tooltip="Re-run cell"
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/plugins/impl/chat/chat-ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { ChatBubbleIcon } from "@radix-ui/react-icons";
import { renderHTML } from "@/plugins/core/RenderHTML";
import { Input } from "@/components/ui/input";
import { PopoverAnchor } from "@radix-ui/react-popover";
import { copyToClipboard } from "@/utils/copy";

interface Props {
prompts: string[];
Expand Down Expand Up @@ -246,8 +247,8 @@ export const Chatbot: React.FC<Props> = (props) => {
<div className="flex justify-end text-xs gap-2 invisible group-hover:visible">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(message.content);
onClick={async () => {
await copyToClipboard(message.content);
toast({
title: "Copied to clipboard",
});
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/utils/copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { Logger } from "./Logger";

/**
* Tries to copy text to the clipboard using the navigator.clipboard API.
* If that fails, it falls back to prompting the user to copy.
*
* As of 2024-10-29, Safari does not support navigator.clipboard.writeText
* when running localhost http.
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text).catch(async () => {
// Fallback to prompt
Logger.warn("Failed to copy to clipboard using navigator.clipboard");
window.prompt("Copy to clipboard: Ctrl+C, Enter", text);
});
}

0 comments on commit 6ee8ffa

Please sign in to comment.