diff --git a/src/pages/blog/[slug].js b/src/pages/blog/[slug].js index 7d395cadf..b571112f7 100644 --- a/src/pages/blog/[slug].js +++ b/src/pages/blog/[slug].js @@ -5,10 +5,79 @@ import mdgh from "markdown-it-github-headings"; import mdhl from "markdown-it-highlightjs"; import Tags from "../../components/Tags"; import dateFormat from "dateformat"; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import Head from 'next/head'; +const COPY_ICON = + '' + + ''; + +const DONE_ICON = + ''; + +function renderArticle(content) { + const renderer = md({ html: true }) + .use(mdgh, { prefixHeadingIds: false }) + .use(mdhl); + + // Wrap every fenced block so the copy button has a positioning context. + // `pre` itself scrolls horizontally, so a button inside it would drift + // out of view as soon as the reader scrolls a long line. + const renderFence = renderer.renderer.rules.fence; + renderer.renderer.rules.fence = (tokens, idx, options, env, self) => { + const code = renderFence(tokens, idx, options, env, self); + return ( + '
' + + '" + + code + + "
" + ); + }; + + return renderer.render(content); +} + +async function copyText(text) { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch (err) { + // Permission denied or no clipboard access: fall through to the + // selection-based path below. + } + + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.top = "0"; + area.style.opacity = "0"; + document.body.appendChild(area); + area.select(); + + let copied = false; + try { + copied = document.execCommand("copy"); + } catch (err) { + copied = false; + } + document.body.removeChild(area); + return copied; +} + export default function Post({ frontmatter, content, slug }) { + const articleRef = useRef(null); + const html = useMemo(() => renderArticle(content), [content]); + useEffect(() => { // Check if MathJax object is available if (window.MathJax) { @@ -16,7 +85,42 @@ export default function Post({ frontmatter, content, slug }) { window.MathJax.typesetPromise(); } }, [content]); - + + useEffect(() => { + const article = articleRef.current; + if (!article) return; + + // The article is injected as raw HTML, so the buttons are not React + // nodes. One delegated listener covers every code block on the page. + const timers = new Map(); + + const onClick = async (event) => { + const button = event.target.closest(".code-copy"); + if (!button || !article.contains(button)) return; + + const code = button.parentElement.querySelector("pre code"); + if (!code) return; + + if (!(await copyText(code.textContent))) return; + + button.dataset.copied = "true"; + clearTimeout(timers.get(button)); + timers.set( + button, + setTimeout(() => { + delete button.dataset.copied; + timers.delete(button); + }, 2000) + ); + }; + + article.addEventListener("click", onClick); + return () => { + article.removeEventListener("click", onClick); + timers.forEach((id) => clearTimeout(id)); + }; + }, [html]); + return (
@@ -43,13 +147,9 @@ export default function Post({ frontmatter, content, slug }) {


diff --git a/src/styles/globals.css b/src/styles/globals.css index a98120ddf..1f272190b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -225,6 +225,69 @@ pre > code { padding-right: 0em; } +/* Copy button on fenced code blocks */ +.code-block { + position: relative; +} + +.code-block > pre { + padding-right: 2.75em; +} + +.code-copy { + position: absolute; + top: 0.4em; + right: 0.4em; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 0.35em; + color: #57606a; + background: #f6f8fa; + border: 1px solid #d0d7de; + border-radius: 6px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.code-block:hover > .code-copy, +.code-copy:focus-visible { + opacity: 1; +} + +.code-copy:hover { + color: #1d1d1f; +} + +/* No hover on touch devices, so the button has to stay visible. */ +@media (hover: none) { + .code-copy { + opacity: 1; + } +} + +.code-copy svg { + pointer-events: none; +} + +.code-copy .code-copy-icon-done { + display: none; +} + +.code-copy[data-copied="true"] { + color: #1a7f37; +} + +.code-copy[data-copied="true"] .code-copy-icon { + display: none; +} + +.code-copy[data-copied="true"] .code-copy-icon-done { + display: block; +} + .article img { margin-left: auto; width: 100%;