feat: add clipboard copy button for code blocks

Move clipboard script and styles from Quartz core Body.tsx into the
syntax-highlighting plugin. Injected via externalResources() hook with
a new clipboard option (defaults to true). Adds esbuild text-loader
for .inline.ts and .scss files.
This commit is contained in:
saberzero1 2026-02-14 00:32:49 +01:00
parent 7255e37de4
commit fada634ccd
No known key found for this signature in database
7 changed files with 135 additions and 4 deletions

3
src/globals.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
interface Window {
addCleanup(callback: () => void): void;
}

View file

@ -0,0 +1,39 @@
const svgCopy =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"></path><path fill-rule="evenodd" d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"></path></svg>';
const svgCheck =
'<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true"><path fill-rule="evenodd" fill="rgb(63, 185, 80)" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"></path></svg>';
document.addEventListener("nav", () => {
const els = document.getElementsByTagName("pre");
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (!el) continue;
const codeBlock = el.getElementsByTagName("code")[0];
if (codeBlock) {
const source = (
codeBlock.dataset.clipboard ? JSON.parse(codeBlock.dataset.clipboard) : codeBlock.innerText
).replace(/\n\n/g, "\n");
const button = document.createElement("button");
button.className = "clipboard-button";
button.type = "button";
button.innerHTML = svgCopy;
button.ariaLabel = "Copy source";
function onClick() {
navigator.clipboard.writeText(source).then(
() => {
button.blur();
button.innerHTML = svgCheck;
setTimeout(() => {
button.innerHTML = svgCopy;
button.style.borderColor = "";
}, 2000);
},
(error) => console.error(error),
);
}
button.addEventListener("click", onClick);
window.addCleanup(() => button.removeEventListener("click", onClick));
el.prepend(button);
}
}
});

4
src/styles.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.scss" {
const content: string;
export default content;
}

36
src/styles/clipboard.scss Normal file
View file

@ -0,0 +1,36 @@
.clipboard-button {
position: absolute;
display: flex;
float: right;
right: 0;
padding: 0.4rem;
margin: 0.3rem;
color: var(--gray);
border-color: var(--dark);
background-color: var(--light);
border: 1px solid;
border-radius: 5px;
opacity: 0;
transition: 0.2s;
& > svg {
fill: var(--light);
filter: contrast(0.3);
}
&:hover {
cursor: pointer;
border-color: var(--secondary);
}
&:focus {
outline: 0;
}
}
pre {
&:hover > .clipboard-button {
opacity: 1;
transition: 0.2s;
}
}

View file

@ -1,6 +1,9 @@
import rehypePrettyCode from "rehype-pretty-code";
import type { Options as CodeOptions, Theme as CodeTheme } from "rehype-pretty-code";
import type { QuartzTransformerPlugin } from "@quartz-community/types";
import type { QuartzTransformerPlugin, JSResource, CSSResource } from "@quartz-community/types";
// @ts-ignore
import clipboardScript from "./scripts/clipboard.inline";
import clipboardStyle from "./styles/clipboard.scss";
interface Theme extends Record<string, CodeTheme> {
light: CodeTheme;
@ -10,6 +13,7 @@ interface Theme extends Record<string, CodeTheme> {
export interface SyntaxHighlightingOptions {
theme?: Theme;
keepBackground?: boolean;
clipboard?: boolean;
}
const defaultOptions: SyntaxHighlightingOptions = {
@ -18,16 +22,37 @@ const defaultOptions: SyntaxHighlightingOptions = {
dark: "github-dark",
},
keepBackground: false,
clipboard: true,
};
export const SyntaxHighlighting: QuartzTransformerPlugin<Partial<SyntaxHighlightingOptions>> = (
userOpts,
) => {
const opts: CodeOptions = { ...defaultOptions, ...userOpts };
const opts = { ...defaultOptions, ...userOpts };
const { clipboard, ...codeOpts } = opts;
return {
name: "SyntaxHighlighting",
htmlPlugins() {
return [[rehypePrettyCode, opts]];
return [[rehypePrettyCode, codeOpts as CodeOptions]];
},
externalResources() {
const js: JSResource[] = [];
const css: CSSResource[] = [];
if (clipboard) {
js.push({
script: clipboardScript,
loadTime: "afterDOMReady",
contentType: "inline",
});
css.push({
content: clipboardStyle,
inline: true,
});
}
return { js, css };
},
};
};

View file

@ -3,7 +3,7 @@
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"rootDir": ".",
"outDir": "dist",
"declaration": true,

View file

@ -12,4 +12,28 @@ export default defineConfig({
target: "es2022",
splitting: false,
outDir: "dist",
esbuildPlugins: [
{
name: "text-loader",
setup(build) {
build.onLoad({ filter: /\.scss$/ }, async (args) => {
const fs = await import("fs");
const text = await fs.promises.readFile(args.path, "utf8");
return {
contents: text,
loader: "text",
};
});
build.onLoad({ filter: /\.inline\.ts$/ }, async (args) => {
const fs = await import("fs");
const text = await fs.promises.readFile(args.path, "utf8");
return {
contents: text,
loader: "text",
};
});
},
},
],
});