diff --git a/components/Book/Book.tsx b/components/Book/Book.tsx index ef7dac1..b5175f5 100644 --- a/components/Book/Book.tsx +++ b/components/Book/Book.tsx @@ -23,6 +23,7 @@ import { SidenoteContext } from "@/components/Book/Sidenote"; import { useHasMounted } from "@/hooks/useHasMounted"; import { usePublicProvider } from "@/hooks/usePublicProvider"; import { ChapterDef, LinkDesc, UnlockChaptersOnAnswersType } from "@/types"; +import { initCopyButtons } from "@/utils/copyToClipboard"; const Chapters = ({chapters: allChapters, bookId, chapterNumbers, unlockChaptersOnAnswers, env, setIsChapterIndexVisible, allAnswers}: @@ -241,6 +242,27 @@ export const Book = ( } }, [layout]); + // Expressive code insert scripts into mdx, which are not executed, + // so we imitate what they would do. + React.useEffect(() => { + const run = () => { + initCopyButtons(document); + + document.querySelectorAll('.expressive-code pre').forEach((el) => { + // reading these forces layout flush + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + el.scrollWidth; + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + el.clientWidth; + }) + window.dispatchEvent(new Event('resize')); + } + run(); + const obs = new MutationObserver(() => { run(); }) + obs.observe(document.body, { childList: true, subtree: true }); + return () => obs.disconnect(); + }, []); + // If there are no chapters, ensure enough space for any side notes in intro. // Doing this in presence of chapters would increase the left margin too much; // let us assume that books with chapters don't have side notes in intro. diff --git a/ingest/md-helpers.ts b/ingest/md-helpers.ts index 4887332..2ee6453 100644 --- a/ingest/md-helpers.ts +++ b/ingest/md-helpers.ts @@ -8,7 +8,14 @@ import rehypeKatex from "rehype-katex"; import rehypeExpressiveCode from "rehype-expressive-code"; import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections"; -import { addRelativeDir, forbiddenComponents, rewriteQuestions, replacer, constructReplacer } from "./plugins"; +import { + addRelativeDir, + forbiddenComponents, + rewriteQuestions, + replacer, + constructReplacer, + removeTerminalFrames +} from "./plugins"; import { getImageSize } from "./getImageSize"; import { isDirectory, joinedPath, readPublicDir } from "./paths"; import {ChapterFrontmatter, CollectionFrontmatter, RawBookFrontmatter} from "@/types"; @@ -187,7 +194,8 @@ export const serializedContent = async ( addRelativeDir( { relativeDir }), getImageSize, rewriteQuestions, - [rehypeExpressiveCode, rehypeExpressiveCodeOptions] + [rehypeExpressiveCode, rehypeExpressiveCodeOptions], + removeTerminalFrames ], }); return compiled.value as string; diff --git a/ingest/plugins.ts b/ingest/plugins.ts index 87ca6d0..254c3f3 100644 --- a/ingest/plugins.ts +++ b/ingest/plugins.ts @@ -145,3 +145,26 @@ export const rewriteQuestions = () => (tree: Root) => { } }); }; + +/* Life's too short to try to convince expressive code + to stop adding frames to terminal windows, so let's just remove them. */ +export const removeTerminalFrames = () => { + return (tree: Root) => { + visit(tree, 'element', (node: any) => { + if (!node.properties?.className) { + return; + } + const className = node.properties.className + if (Array.isArray(className)) { + node.properties.className = className.filter( + (c) => c !== 'is-terminal' + ); + } else if (typeof className === 'string') { + node.properties.className = className + .split(' ') + .filter((c) => c !== 'is-terminal') + .join(' ') + } + }) + } +} diff --git a/styles/content-index/content-index.scss b/styles/content-index/content-index.scss index 666ea29..aed2a30 100644 --- a/styles/content-index/content-index.scss +++ b/styles/content-index/content-index.scss @@ -15,6 +15,7 @@ position: absolute; top: 24px; display: none; + max-width: 500px; } .header-content-index:hover .content-index-at-header { diff --git a/utils/copyToClipboard.ts b/utils/copyToClipboard.ts new file mode 100644 index 0000000..984a38f --- /dev/null +++ b/utils/copyToClipboard.ts @@ -0,0 +1,47 @@ +const fallbackCopy = (text: string): boolean => { + const el = document.createElement('textarea'); + el.value = text; + + // prevent scroll jump + el.style.position = 'fixed'; + el.style.top = '0'; + el.style.left = '0'; + el.style.opacity = '0'; + el.style.pointerEvents = 'none'; + + document.body.appendChild(el); + el.focus(); + el.select(); + + let success = false; + try { + success = document.execCommand('copy'); + } catch { + success = false; + } + + document.body.removeChild(el); + return success; +} + +export const initCopyButtons = (root = document) => { + root.querySelectorAll('.expressive-code .copy button').forEach((btn) => { + if ((btn as any)._wired) { + return; + } + (btn as any)._wired = true + + btn.addEventListener('click', async () => { + const code = btn.getAttribute('data-code')?.replace(/\u007f/g, '\n'); + if (!code) { + return; + } + + try { + await navigator.clipboard.writeText(code); + } catch { + fallbackCopy(code); + } + }) + }) +}