Skip to content
Open
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
116 changes: 108 additions & 8 deletions src/pages/blog/[slug].js
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,122 @@ 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 =
'<svg class="code-copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';

const DONE_ICON =
'<svg class="code-copy-icon-done" width="16" height="16" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';

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 (
'<div class="code-block">' +
'<button class="code-copy" type="button" aria-label="Copy code">' +
COPY_ICON +
DONE_ICON +
"</button>" +
code +
"</div>"
);
};

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) {
// Typeset / reprocess the math content
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 (
<div className="w-full flex justify-center py-5 pt-16 md:pt-5">
<Head>
Expand All @@ -43,13 +147,9 @@ export default function Post({ frontmatter, content, slug }) {
</p>
<hr />
<div
ref={articleRef}
className="pt-2 article"
dangerouslySetInnerHTML={{
__html: md({ html: true })
.use(mdgh, {prefixHeadingIds: false})
.use(mdhl)
.render(content),
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
Expand Down
63 changes: 63 additions & 0 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down