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 { h } from 'hastscript';
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
import { toHtml } from 'hast-util-to-html';
|
import { toHtml } from 'hast-util-to-html';
|
||||||
|
import { toString } from 'hast-util-to-string';
|
||||||
import { fromHtml } from 'hast-util-from-html';
|
import { fromHtml } from 'hast-util-from-html';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,6 +23,7 @@ export const rehypeCodesplit = () => (tree) => {
|
||||||
node.properties.className &&
|
node.properties.className &&
|
||||||
node.properties.className.includes('codesplit')
|
node.properties.className.includes('codesplit')
|
||||||
) {
|
) {
|
||||||
|
const raw = toString(node);
|
||||||
const lines = toHtml(node.children).split('\n');
|
const lines = toHtml(node.children).split('\n');
|
||||||
const lang = node.properties.dataCodeLanguage;
|
const lang = node.properties.dataCodeLanguage;
|
||||||
|
|
||||||
|
@ -114,11 +116,16 @@ export const rehypeCodesplit = () => (tree) => {
|
||||||
|
|
||||||
return h('div', { className }, [
|
return h('div', { className }, [
|
||||||
h('pre', [h('code', { class: ['code', `language-${lang}`] }, code)]),
|
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.properties.className = ['codesplit', 'callout', 'not-prose'];
|
||||||
node.children = children;
|
node.children = children;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import rehypeHighlight from 'rehype-highlight';
|
||||||
import { toString } from 'hast-util-to-string';
|
import { toString } from 'hast-util-to-string';
|
||||||
|
|
||||||
import { rehypeCodesplit } from './codesplit.mjs';
|
import { rehypeCodesplit } from './codesplit.mjs';
|
||||||
|
import { preserveCustomSpans, restoreCustomSpans } from './blank-span.mjs';
|
||||||
|
|
||||||
function isHeading(node) {
|
function isHeading(node) {
|
||||||
return node.type === 'element' && /^h[1-6]$/i.test(node.tagName);
|
return node.type === 'element' && /^h[1-6]$/i.test(node.tagName);
|
||||||
|
@ -178,7 +179,9 @@ export function parseContent(html) {
|
||||||
.use(replaceMedia)
|
.use(replaceMedia)
|
||||||
.use(externalLinkInNewTab)
|
.use(externalLinkInNewTab)
|
||||||
.use(rehypeCodesplit)
|
.use(rehypeCodesplit)
|
||||||
|
.use(preserveCustomSpans)
|
||||||
.use(rehypeHighlight)
|
.use(rehypeHighlight)
|
||||||
|
.use(restoreCustomSpans)
|
||||||
.use(rehypeSlug)
|
.use(rehypeSlug)
|
||||||
.use(rehypeKatex)
|
.use(rehypeKatex)
|
||||||
.runSync(ast);
|
.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 Image from '../components/Image';
|
||||||
import Example from '../components/Example';
|
import Example from '../components/Example';
|
||||||
import VideoLink from '../components/VideoLink';
|
import VideoLink from '../components/VideoLink';
|
||||||
|
import Codesplit from '../components/Codesplit';
|
||||||
|
|
||||||
const renderAst = ({ ast, images }) => {
|
const renderAst = ({ ast, images }) => {
|
||||||
visit(ast, { tagName: 'img' }, (node) => {
|
visit(ast, { tagName: 'img' }, (node) => {
|
||||||
|
@ -31,6 +32,7 @@ const renderAst = ({ ast, images }) => {
|
||||||
'gatsby-image': Image,
|
'gatsby-image': Image,
|
||||||
'embed-example': Example,
|
'embed-example': Example,
|
||||||
'video-link': VideoLink,
|
'video-link': VideoLink,
|
||||||
|
'codesplit': Codesplit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.snip-above > .codesplit::before {
|
.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 {
|
.snip-below > .codesplit {
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.snip-below > .codesplit::after {
|
.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 {
|
.pair {
|
||||||
|
@ -25,13 +25,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.pair.split {
|
.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 {
|
.pair code.hljs {
|
||||||
@apply bg-transparent p-0 text-sm;
|
@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 {
|
.comment {
|
||||||
@apply flex flex-grow text-sm text-gray-600 lg:justify-end;
|
@apply flex flex-grow text-sm text-gray-600 lg:justify-end;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue