Skip to content
Merged
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
94 changes: 94 additions & 0 deletions frontend/src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { CopyIcon } from "@primer/octicons-react";

interface CopyButtonProps {
text: string | (() => string | Promise<string>);
title?: string;
size?: number;
className?: string;
}

export default function CopyButton({
text,
title = "Copy",
size = 16,
className,
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const [fade, setFade] = useState(false);

const fadeTimeoutRef = useRef<number | null>(null);
const resetTimeoutRef = useRef<number | null>(null);

const popup_lifetime_ms = 1200;
const popup_fade_duration_ms = 500;

const handleCopy = async () => {
try {
const value =
typeof text === "function"
? await Promise.resolve(text())
: text;

if (typeof value !== "string") {
throw new Error("CopyButton: text must resolve to a string");
}

await navigator.clipboard.writeText(value);
setCopied(true);
setFade(false);

if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);

fadeTimeoutRef.current = window.setTimeout(
() => setFade(true),
popup_lifetime_ms,
);
resetTimeoutRef.current = window.setTimeout(
() => setCopied(false),
popup_lifetime_ms + popup_fade_duration_ms,
);
} catch (err) {
console.error("Failed to copy text to clipboard:", err);
}
};

// cleanup
useEffect(() => {
return () => {
if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
};
}, []);

return (
<div className="relative inline-block">
<button
className={clsx(
"cursor-pointer rounded p-[0.5em] text-[var(--g1500)] hover:bg-[var(--g400)]",
className,
)}
aria-label={title}
title={title}
onClick={handleCopy}
>
<CopyIcon size={size} />
</button>

{copied && (
<span
className={clsx(
"rounded px-2 py-1",
"bg-[var(--accent)] text-[#eee] text-[0.9em]",
`transition-opacity duration-${popup_fade_duration_ms} ease-in`,
fade ? "opacity-5" : "opacity-100",
)}
>
Copied!
</span>
)}
</div>
);
}
17 changes: 0 additions & 17 deletions frontend/src/components/Diff/Diff.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -198,20 +198,3 @@
.highlightable {
cursor: pointer;
}

.copyButton {
cursor: pointer;
border-radius: 4px;
width: 2em;
height: 2em;

&:hover {
background-color: var(--a50);
}
}

.copyButton svg {
width: 1em;
height: 1em;
margin: 0.5em;
}
49 changes: 29 additions & 20 deletions frontend/src/components/Diff/Diff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
useState,
} from "react";

import { VersionsIcon, CopyIcon } from "@primer/octicons-react";
import { VersionsIcon } from "@primer/octicons-react";
import type { EditorView } from "codemirror";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList } from "react-window";
Expand All @@ -25,8 +25,9 @@ import styles from "./Diff.module.scss";
import * as AsmDiffer from "./DiffRowAsmDiffer";
import DragBar from "./DragBar";
import { useHighlighers } from "./Highlighter";
import CopyButton from "../CopyButton";

const copyDiffContentsToClipboard = (diff: api.DiffOutput, kind: string) => {
const diffContentsToString = (diff: api.DiffOutput, kind: string): string => {
// kind is either "base", "current", or "previous"
const contents = diff.rows.map((row) => {
let text = "";
Expand All @@ -40,22 +41,9 @@ const copyDiffContentsToClipboard = (diff: api.DiffOutput, kind: string) => {
return text;
});

navigator.clipboard.writeText(contents.join("\n"));
return contents.join("\n");
};

// Small component for the copy button
function CopyButton({ diff, kind }: { diff: api.DiffOutput; kind: string }) {
return (
<button
className={styles.copyButton} // Add a new style for the button
onClick={() => copyDiffContentsToClipboard(diff, kind)}
title="Copy content"
>
<CopyIcon size={16} />
</button>
);
}

// https://github.com/bvaughn/react-window#can-i-add-padding-to-the-top-and-bottom-of-a-list
const innerElementType = forwardRef<
HTMLUListElement,
Expand Down Expand Up @@ -269,11 +257,26 @@ export default function Diff({
<div className={styles.headers}>
<div className={styles.header}>
Target
<CopyButton diff={diff as api.DiffOutput} kind="base" />
<CopyButton
title="Copy content"
size={12}
text={() =>
diffContentsToString(diff as api.DiffOutput, "base")
}
/>
</div>
<div className={styles.header}>
Current
<CopyButton diff={diff as api.DiffOutput} kind="current" />
<CopyButton
title="Copy content"
size={12}
text={() =>
diffContentsToString(
diff as api.DiffOutput,
"current",
)
}
/>
{isCompiling && <LoadingSpinner className="size-6" />}
{!threeWayDiffEnabled && threeWayButton}
</div>
Expand All @@ -283,8 +286,14 @@ export default function Diff({
? "Saved"
: "Previous"}
<CopyButton
diff={diff as api.DiffOutput}
kind="previous"
title="Copy content"
size={12}
text={() =>
diffContentsToString(
diff as api.DiffOutput,
"previous",
)
}
/>
{threeWayButton}
</div>
Expand Down
17 changes: 0 additions & 17 deletions frontend/src/components/Scratch/panels/AboutPanel.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,6 @@
text-wrap: nowrap;
}

.copyIcon {
padding: 0px 5px;
cursor: pointer;
color: var(--g1400);

&:hover {
color: var(--g1700);
}
}
.copied {
background: var(--accent);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-size: 0.9em;
}

.scratchLink {
flex-shrink: 1;
overflow: hidden;
Expand Down
31 changes: 6 additions & 25 deletions frontend/src/components/Scratch/panels/AboutPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,17 @@ import Link from "next/link";

import useSWR from "swr";

import { CopyIcon } from "@primer/octicons-react";

import LoadingSpinner from "@/components/loading.svg";
import { PlatformIcon } from "@/components/PlatformSelect/PlatformIcon";
import PlatformName from "@/components/PlatformSelect/PlatformName";
import { getScoreText } from "@/components/ScoreBadge";
import TimeAgo from "@/components/TimeAgo";
import UserLink from "@/components/user/UserLink";
import CopyButton from "@/components/CopyButton";
import { type Scratch, type PresetBase, get, usePreset } from "@/lib/api";
import { presetUrl, scratchUrl, scratchParentUrl } from "@/lib/api/urls";

import styles from "./AboutPanel.module.scss";
import { useState } from "react";

function ScratchLink({ url }: { url: string }) {
const { data: scratch, error } = useSWR<Scratch>(url, get);
Expand Down Expand Up @@ -58,36 +56,19 @@ export type Props = {
export default function AboutPanel({ scratch, setScratch }: Props) {
const preset: PresetBase = usePreset(scratch.preset);

const [copied, setCopied] = useState(false);

const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy text to clipboard: ", err);
}
};

return (
<div className={styles.container}>
<div>
<div className={styles.horizontalField}>
<p className={styles.label}>Name</p>
<span>{scratch.name}</span>
<button
className={styles.copyIcon}
<CopyButton
title="Copy Scratch link to clipboard"
onClick={() =>
copyToClipboard(
`${window.location.origin}${scratchUrl(scratch)}`,
)
text={() =>
`${typeof window !== "undefined" ? window.location.origin : ""}${scratchUrl(scratch)}`
}
>
<CopyIcon />
</button>
{copied && <span className={styles.copied}>Copied!</span>}
className={"ml-1"}
/>
</div>
<div className={styles.horizontalField}>
<p className={styles.label}>Score</p>
Expand Down