Skip to content

Added copy button #2279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
27 changes: 24 additions & 3 deletions src/components/code-example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { clsx } from "clsx";
import dedent from "dedent";
import { createHighlighter } from "shiki";
import theme from "./syntax-highlighter/theme.json";
import { CopyButton } from "./copy-button";

import { highlightClasses } from "./highlight-classes";
import atApplyInjection from "./syntax-highlighter/at-apply.json";
import atRulesInjection from "./syntax-highlighter/at-rules.json";
import themeFnInjection from "./syntax-highlighter/theme-fn.json";
import { stripShikiComments } from "./shiki";

export function js(strings: TemplateStringsArray, ...args: any[]) {
return { lang: "js", code: dedent(strings, ...args) };
Expand Down Expand Up @@ -49,7 +51,18 @@ export async function CodeExample({
}) {
return (
<CodeExampleWrapper className={className}>
{filename ? <CodeExampleFilename filename={filename} /> : null}
<div className="relative">
{filename ? <CodeExampleFilename filename={filename} /> : null}
<CopyButton
className={clsx(
"absolute z-10 transition duration-150 group-hover/code-block:opacity-100",
filename
? "-top-1 right-0 text-white/50 hover:text-white/75"
: "top-2 right-2 rounded border border-black/15 bg-black/50 text-white/75 opacity-0 backdrop-blur-md hover:text-white",
)}
value={stripShikiComments(example.code)}
/>
</div>
<HighlightedCode example={example} />
</CodeExampleWrapper>
);
Expand All @@ -61,6 +74,7 @@ export function CodeExampleWrapper({ className, children }: { className?: string
<div
className={clsx(
"rounded-xl p-1 text-sm scheme-dark in-data-stack:rounded-none dark:bg-white/5 dark:inset-ring dark:inset-ring-white/10 in-data-stack:dark:inset-ring-0",
"group/code-block",
className,
)}
>
Expand Down Expand Up @@ -95,7 +109,7 @@ export function CodeExampleGroup({
<div className="rounded-xl bg-gray-950 in-[figure]:-mx-1 in-[figure]:-mb-1">
<div
className={clsx(
"rounded-xl p-1 text-sm scheme-dark dark:bg-white/5 dark:inset-ring dark:inset-ring-white/10",
"relative rounded-xl p-1 text-sm scheme-dark dark:bg-white/5 dark:inset-ring dark:inset-ring-white/10",
className,
)}
>
Expand All @@ -120,6 +134,13 @@ export function CodeExampleGroup({
export function CodeBlock({ example }: { example: { lang: string; code: string } }) {
return (
<TabPanel>
<CopyButton
className={clsx(
"absolute z-10 transition duration-150 group-hover/code-block:opacity-100",
"top-0 right-0 z-10 text-white/50 hover:text-white/75",
)}
value={stripShikiComments(example.code)}
/>
<HighlightedCode example={example} />
</TabPanel>
);
Expand Down Expand Up @@ -192,7 +213,7 @@ function CodeExampleFilename({ filename }: { filename: string }) {
return <div className="px-3 pt-0.5 pb-1.5 text-xs/5 text-gray-400 dark:text-white/50">{filename}</div>;
}

const highlighter = await createHighlighter({
let highlighter = await createHighlighter({
themes: [theme],
langs: [
atApplyInjection as any,
Expand Down
55 changes: 55 additions & 0 deletions src/components/copy-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import clsx from "clsx";
import { useState } from "react";

export function CopyButton({ value, className }: { value: string; className?: string }) {
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
if (copied) return;

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

return (
<button
onClick={handleCopy}
className={clsx("flex size-8 items-center justify-center", className)}
title="Copy to clipboard"
>
<div className="grid size-4">
<svg
viewBox="0 0 24 24"
strokeWidth={1.5}
className={clsx(
"col-start-1 row-start-1 fill-none stroke-current text-sky-400 transition-opacity duration-300 ease-in-out",
!copied && "opacity-0",
)}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<svg
viewBox="0 0 24 24"
strokeWidth={1.5}
className={clsx(
"col-start-1 row-start-1 fill-none stroke-current transition-opacity duration-300 ease-in-out",
copied && "opacity-0",
)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
/>
</svg>
</div>
</button>
);
}
15 changes: 15 additions & 0 deletions src/components/multi-cursor/animation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import clsx from "clsx";
import { createContext, useContext, useEffect, useState } from "react";
import { CopyButton } from "../copy-button";

export interface AnimationContextBag {
/** Where the cursor is at */
Expand Down Expand Up @@ -250,3 +251,17 @@ export function TypingAnimation(props: Record<string, any>) {
</span>
));
}

export function AnimationCopyButton({ code }: { code: string }) {
let { className } = useContext(AnimationContext);

return (
<CopyButton
className={clsx(
"absolute z-10 transition duration-150 group-hover/code-block:opacity-100",
"top-2 right-2 rounded border border-black/15 bg-black/50 text-white/75 opacity-0 backdrop-blur-md hover:text-white",
)}
value={code.replaceAll("__CLASS__", className)}
/>
);
}
100 changes: 53 additions & 47 deletions src/components/multi-cursor/example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import clsx from "clsx";
import { ThemedToken } from "shiki";
import { html } from "../code-example";
import { HighlightedCode } from "../highlight";
import { TypingAnimation } from "./animation";
import { AnimationCopyButton, TypingAnimation } from "./animation";
import { stripShikiComments } from "../shiki";

export function MultiCursorCode() {
let code = html`
Expand All @@ -24,62 +25,67 @@ export function MultiCursorCode() {
`;

return (
<HighlightedCode
className={clsx(
"*:flex *:*:max-w-none *:*:shrink-0 *:*:grow *:overflow-auto *:rounded-lg *:bg-white/10! *:p-5 *:inset-ring *:inset-ring-white/10 dark:*:bg-white/5! dark:*:inset-ring-white/5",
"**:[.line]:isolate **:[.line]:block **:[.line]:not-last:min-h-[1lh]",
)}
example={code}
transformers={[
{
tokens(tokens) {
let count = 0;
<>
<div className="relative">
<AnimationCopyButton code={stripShikiComments(code.code)} />
</div>
<HighlightedCode
className={clsx(
"*:flex *:*:max-w-none *:*:shrink-0 *:*:grow *:overflow-auto *:rounded-lg *:bg-white/10! *:p-5 *:inset-ring *:inset-ring-white/10 dark:*:bg-white/5! dark:*:inset-ring-white/5",
"**:[.line]:isolate **:[.line]:block **:[.line]:not-last:min-h-[1lh]",
)}
example={code}
transformers={[
{
tokens(tokens) {
let count = 0;

return tokens.map((line, idx) => {
let tokens: ThemedToken[] = [];
return tokens.map((line, idx) => {
let tokens: ThemedToken[] = [];

for (let token of line) {
let classIndex = token.content.indexOf("__CLASS__");
for (let token of line) {
let classIndex = token.content.indexOf("__CLASS__");

if (classIndex === -1) {
tokens.push(token);
continue;
}
if (classIndex === -1) {
tokens.push(token);
continue;
}

let before = token.content.slice(0, classIndex);
let after = token.content.slice(classIndex + 9);
let before = token.content.slice(0, classIndex);
let after = token.content.slice(classIndex + 9);

if (before) {
tokens.push({ ...token, content: before });
}
if (before) {
tokens.push({ ...token, content: before });
}

tokens.push({
...token,
content: "__CLASS__",
htmlAttrs: {
index: `${count++}`,
},
});
tokens.push({
...token,
content: "__CLASS__",
htmlAttrs: {
index: `${count++}`,
},
});

if (after) {
tokens.push({ ...token, content: after });
if (after) {
tokens.push({ ...token, content: after });
}
}
}

return tokens;
});
return tokens;
});
},
},
},
]}
components={{
span: (props) => {
if (props.children === "__CLASS__") {
return <TypingAnimation {...props} index={Number(props.index)} />;
}
]}
components={{
span: (props) => {
if (props.children === "__CLASS__") {
return <TypingAnimation {...props} index={Number(props.index)} />;
}

return <span {...props} />;
},
}}
/>
return <span {...props} />;
},
}}
/>
</>
);
}
112 changes: 112 additions & 0 deletions src/components/shiki.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Note: can run these tests with `bun test`

import { describe, test } from "node:test";
import { stripShikiComments } from "./shiki";
import dedent from "dedent";

describe("comment removal", () => {
test("at end of line", (t) => {
let input = dedent`
keep me # [!code highlight]
keep me /* [!code highlight] */
keep me // [!code highlight]
keep me <!-- [!code highlight] -->
`;

t.assert.equal(
stripShikiComments(input),
dedent`
keep me \n\
keep me \n\
keep me \n\
keep me \
`,
);
});

test("on separate lines", (t) => {
let input = dedent`
# [!code highlight]
/* [!code highlight] */
// [!code highlight]
<!-- [!code highlight] -->
<div class="flex"></div>
`;

t.assert.equal(
stripShikiComments(input),
dedent`
<div class="flex"></div>
`,
);
});
});

// [!code --] and [!code --:N] handling
describe("line removal", () => {
test("at end of line", (t) => {
let input = dedent`
keep me
remove me 1 # [!code --]
keep me
`;

t.assert.equal(
stripShikiComments(input),
dedent`
keep me
keep me
`,
);
});

test("on separate lines", (t) => {
let input = dedent`
keep me
# [!code --:3]
remove me 1
remove me 2
keep me
`;

t.assert.equal(
stripShikiComments(input),
dedent`
keep me
keep me
`,
);
});

test("an invalid number still removes the line its on", (t) => {
let input = dedent`
keep me
remove me # [!code --:foo]
keep me
`;

t.assert.equal(
stripShikiComments(input),
dedent`
keep me
keep me
`,
);
});

test("an invalid number is ignored on separate lines", (t) => {
let input = dedent`
keep me
# [!code --:foo]
keep me
`;

t.assert.equal(
stripShikiComments(input),
dedent`
keep me
keep me
`,
);
});
});
Loading