diff --git a/src/globals.d.ts b/src/globals.d.ts new file mode 100644 index 0000000..e3aad0b --- /dev/null +++ b/src/globals.d.ts @@ -0,0 +1,3 @@ +interface Window { + addCleanup(callback: () => void): void; +} diff --git a/src/scripts/clipboard.inline.ts b/src/scripts/clipboard.inline.ts new file mode 100644 index 0000000..6d59a9f --- /dev/null +++ b/src/scripts/clipboard.inline.ts @@ -0,0 +1,39 @@ +const svgCopy = + ''; +const svgCheck = + ''; + +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); + } + } +}); diff --git a/src/styles.d.ts b/src/styles.d.ts new file mode 100644 index 0000000..0a31e5e --- /dev/null +++ b/src/styles.d.ts @@ -0,0 +1,4 @@ +declare module "*.scss" { + const content: string; + export default content; +} diff --git a/src/styles/clipboard.scss b/src/styles/clipboard.scss new file mode 100644 index 0000000..196b894 --- /dev/null +++ b/src/styles/clipboard.scss @@ -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; + } +} diff --git a/src/transformer.ts b/src/transformer.ts index bc3ca55..613dd94 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -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 { light: CodeTheme; @@ -10,6 +13,7 @@ interface Theme extends Record { 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> = ( 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 }; }, }; }; diff --git a/tsconfig.json b/tsconfig.json index 4a3e6c9..8b9c93e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "rootDir": ".", "outDir": "dist", "declaration": true, diff --git a/tsup.config.ts b/tsup.config.ts index 3b003aa..aef2c9e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -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", + }; + }); + }, + }, + ], });