feat: implement the fill-in-blank feature and more

- reveal / hide answer
- copy raw code
This commit is contained in:
Yifei Gao 2024-09-12 23:48:50 +08:00
parent e7515880e4
commit f5d214784a
No known key found for this signature in database
6 changed files with 182 additions and 5 deletions

73
gatsby/lib/blank-span.mjs Normal file
View file

@ -0,0 +1,73 @@
import { toString } from 'hast-util-to-string';
import { visit } from 'unist-util-visit';
// Special characters for marking blanks and separators
const BLANK_MARKER = '\u2420';
const SEPARATOR = '\u2063';
export const preserveCustomSpans = () => (tree) => {
visit(tree, 'element', (node, index, parent) => {
if (isBlankSpan(node)) {
const reservedString = toString(node);
parent.properties['data-blanks'] = parent.properties['data-blanks']
? `${parent.properties['data-blanks']}${SEPARATOR}${reservedString}`
: reservedString;
parent.children.splice(index, 1, { type: 'text', value: BLANK_MARKER });
}
});
};
export const restoreCustomSpans = () => (tree) => {
visit(tree, 'element', (node) => {
if (node.tagName === 'codesplit') {
let containsBlank = false;
visit(node, 'element', (nodeInCodesplit) => {
if (
nodeInCodesplit.tagName === 'code' &&
nodeInCodesplit.properties['data-blanks']
) {
const blanks =
nodeInCodesplit.properties['data-blanks'].split(SEPARATOR);
restoreBlankSpans(nodeInCodesplit, blanks);
delete nodeInCodesplit.properties['data-blanks'];
containsBlank = true;
}
});
if (containsBlank) {
node.properties['data-contains-blank'] = true;
}
}
});
};
const isBlankSpan = (node) =>
node.tagName === 'span' &&
Array.isArray(node.properties.className) &&
node.properties.className.includes('blank');
const restoreBlankSpans = (node, blanks) => {
let blankIndex = 0;
visit(node, 'text', (textNode, index, parent) => {
if (textNode.value.includes(BLANK_MARKER)) {
const parts = textNode.value.split(BLANK_MARKER);
const newNodes = parts.flatMap((part, i) => {
const nodes = part ? [{ type: 'text', value: part }] : [];
if (i < parts.length - 1 && blankIndex < blanks.length) {
nodes.push({
type: 'element',
tagName: 'span',
properties: { className: ['blank'] },
children: [{ type: 'text', value: blanks[blankIndex++] }],
});
}
return nodes;
});
parent.children.splice(index, 1, ...newNodes);
return index + newNodes.length;
}
});
};

View file

@ -1,6 +1,7 @@
import { h } from 'hastscript';
import { visit } from 'unist-util-visit';
import { toHtml } from 'hast-util-to-html';
import { toString } from 'hast-util-to-string';
import { fromHtml } from 'hast-util-from-html';
/**
@ -22,6 +23,7 @@ export const rehypeCodesplit = () => (tree) => {
node.properties.className &&
node.properties.className.includes('codesplit')
) {
const raw = toString(node);
const lines = toHtml(node.children).split('\n');
const lang = node.properties.dataCodeLanguage;
@ -114,11 +116,16 @@ export const rehypeCodesplit = () => (tree) => {
return h('div', { className }, [
h('pre', [h('code', { class: ['code', `language-${lang}`] }, code)]),
h('div', { class: ['comment'] }, h('p', fromHtml(comments.join('\n'), { fragment: true }))),
h(
'div',
{ class: ['comment'] },
h('p', fromHtml(comments.join('\n'), { fragment: true })),
),
]);
});
node.tagName = 'div';
node.tagName = 'codesplit';
node.properties['data-raw'] = raw;
node.properties.className = ['codesplit', 'callout', 'not-prose'];
node.children = children;
}

View file

@ -10,6 +10,7 @@ import rehypeHighlight from 'rehype-highlight';
import { toString } from 'hast-util-to-string';
import { rehypeCodesplit } from './codesplit.mjs';
import { preserveCustomSpans, restoreCustomSpans } from './blank-span.mjs';
function isHeading(node) {
return node.type === 'element' && /^h[1-6]$/i.test(node.tagName);
@ -178,7 +179,9 @@ export function parseContent(html) {
.use(replaceMedia)
.use(externalLinkInNewTab)
.use(rehypeCodesplit)
.use(preserveCustomSpans)
.use(rehypeHighlight)
.use(restoreCustomSpans)
.use(rehypeSlug)
.use(rehypeKatex)
.runSync(ast);

View file

@ -0,0 +1,84 @@
import React from 'react';
import { HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi';
import { RiFileCopyLine } from 'react-icons/ri';
const LANGUAGE_NAME_MAP = {
javascript: 'JavaScript',
html: 'HTML',
};
const Codesplit = (props) => {
const [isAnswerVisible, setIsAnswerVisible] = React.useState(false);
const [isCopied, setIsCopied] = React.useState(false);
const toggleAnswerHiddenStatus = () => {
setIsAnswerVisible((lastState) => !lastState);
};
const copyRaw = () => {
if (isCopied) return;
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
navigator.clipboard.writeText(props['data-raw']);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 1000);
};
const displayedLanguageName =
LANGUAGE_NAME_MAP[props['data-code-language']] ||
props['data-code-language'];
const containsBlank = !!props['data-contains-blank'];
return (
<div
className={`pt-0 ${props.className} ${isAnswerVisible ? 'is-answer-visible' : ''}`}
>
<div className="flex items-start justify-between">
<div className="ml-4 rounded-b-md bg-noc-400 px-2 py-0.5 text-xs text-white">
{displayedLanguageName}
</div>
<div className="hidden sm:flex">
{containsBlank && (
<button
className="flex items-center rounded px-2.5 py-1.5 text-xs font-semibold hover:bg-gray-300"
onClick={toggleAnswerHiddenStatus}
>
{isAnswerVisible ? (
<>
<HiOutlineEyeOff className="h-4 w-4" />
<span className="ml-1">Hide Answer</span>
</>
) : (
<>
<HiOutlineEye className="h-4 w-4" />
<span className="ml-1">Reveal Answer</span>
</>
)}
</button>
)}
<button
className="relative flex items-center rounded px-2.5 py-1.5 text-xs font-semibold hover:bg-gray-300"
onClick={copyRaw}
>
<div
className={`absolute inset-0 flex items-center justify-center rounded bg-noc-400 text-white transition-opacity ${isCopied ? 'opacity-100' : 'opacity-0'}`}
>
Copied!
</div>
<RiFileCopyLine className="h-4 w-4" />
<span className="ml-1">Copy</span>
</button>
</div>
</div>
{props.children}
</div>
);
};
export default Codesplit;

View file

@ -9,6 +9,7 @@ import PrevNextButtons from '../components/PrevNextButtons';
import Image from '../components/Image';
import Example from '../components/Example';
import VideoLink from '../components/VideoLink';
import Codesplit from '../components/Codesplit';
const renderAst = ({ ast, images }) => {
visit(ast, { tagName: 'img' }, (node) => {
@ -31,6 +32,7 @@ const renderAst = ({ ast, images }) => {
'gatsby-image': Image,
'embed-example': Example,
'video-link': VideoLink,
'codesplit': Codesplit,
},
});

View file

@ -9,7 +9,7 @@
}
.snip-above > .codesplit::before {
@apply content-icon-scissors absolute -left-3.5 -top-3 h-5 w-5;
@apply absolute -left-3.5 -top-3 h-5 w-5 content-icon-scissors;
}
.snip-below > .codesplit {
@ -17,7 +17,7 @@
}
.snip-below > .codesplit::after {
@apply content-icon-scissors absolute -bottom-2.5 -left-3.5 h-5 w-5;
@apply absolute -bottom-2.5 -left-3.5 h-5 w-5 content-icon-scissors;
}
.pair {
@ -25,13 +25,21 @@
}
.pair.split {
@apply border-noc-200 my-1 flex flex-col-reverse flex-wrap justify-between gap-x-8 gap-y-2 border-l-2 bg-gray-200 py-2 lg:flex-row lg:flex-wrap-reverse;
@apply my-1 flex flex-col-reverse flex-wrap justify-between gap-x-8 gap-y-2 border-l-2 border-noc-200 bg-gray-200 py-2 lg:flex-row lg:flex-wrap-reverse;
}
.pair code.hljs {
@apply bg-transparent p-0 text-sm;
}
.pair span.blank {
@apply border-b border-gray-400 text-sm text-transparent;
}
.is-answer-visible .pair span.blank {
@apply text-[#24292e];
}
.comment {
@apply flex flex-grow text-sm text-gray-600 lg:justify-end;
}