mirror of
https://github.com/nature-of-code/noc-book-2
synced 2024-11-16 07:47:48 +01:00
feat: implement the fill-in-blank feature and more
- reveal / hide answer - copy raw code
This commit is contained in:
parent
e7515880e4
commit
f5d214784a
6 changed files with 182 additions and 5 deletions
73
gatsby/lib/blank-span.mjs
Normal file
73
gatsby/lib/blank-span.mjs
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
84
src/components/Codesplit.js
Normal file
84
src/components/Codesplit.js
Normal 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;
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue