| 1 | import { Fragment, memo, useEffect, useMemo, useState } from "react"; |
| 2 | import type { EditorProps } from "../CodeViewer"; |
| 3 | import { |
| 4 | escapeHtml, |
| 5 | highlightToHtml, |
| 6 | IDLE_HIGHLIGHT_MIN_BYTES, |
| 7 | shouldHighlightSource, |
| 8 | resolveLang, |
| 9 | } from "../../lib/highlight"; |
| 10 | import { splitHighlightedCodeLines } from "../../lib/highlightLines"; |
| 11 | import { scheduleIdleTask } from "../../lib/idleTask"; |
| 12 | import { CopyButton } from "../CopyButton"; |
| 13 | |
| 14 | // HljsCode is the syntax-highlighted default behind the code editor seam. It |
| 15 | // renders highlight.js token markup into a <pre>; token colors live in CodeSyntax.css |
| 16 | // (.hljs-*). To upgrade to a full editor, point CodeViewer.tsx's lazy import at a |
| 17 | // Monaco/CodeMirror module honoring the same EditorProps. |
| 18 | // |
| 19 | // Large blocks (≥ IDLE_HIGHLIGHT_MIN_BYTES, still under the skip caps) mount |
| 20 | // the COMPLETE plain text immediately and swap in highlighted HTML from an |
| 21 | // idle callback; the skip caps (MAX_HIGHLIGHT_BYTES / MAX_HIGHLIGHT_LINES) |
| 22 | // remain the plain-forever policy. Either way nothing is ever truncated. |
| 23 | const CodeLine = memo(function CodeLine({ html }: { html: string }) { |
| 24 | return <span className="code-block__line" dangerouslySetInnerHTML={{ __html: html }} />; |
| 25 | }); |
| 26 | |
| 27 | const HljsCode = memo(function HljsCode({ value, copyValue, language, scrollMode, maxHeight, sourceSize, showHeader }: EditorProps) { |
| 28 | const syntaxHighlight = useMemo( |
| 29 | () => Boolean(resolveLang(language)) && shouldHighlightSource(value, sourceSize), |
| 30 | [language, sourceSize, value], |
| 31 | ); |
| 32 | const deferToIdle = syntaxHighlight && (sourceSize ?? value.length) >= IDLE_HIGHLIGHT_MIN_BYTES; |
| 33 | const [idleResult, setIdleResult] = useState<{ value: string; language?: string; html: string } | null>(null); |
| 34 | |
| 35 | useEffect(() => { |
| 36 | if (!deferToIdle) return; |
| 37 | let cancelled = false; |
| 38 | const cancelIdle = scheduleIdleTask(() => { |
| 39 | if (cancelled) return; |
| 40 | const html = highlightToHtml(value, language); |
| 41 | setIdleResult({ value, language, html }); |
| 42 | }); |
| 43 | return () => { |
| 44 | cancelled = true; |
| 45 | cancelIdle(); |
| 46 | }; |
| 47 | }, [deferToIdle, language, value]); |
| 48 | |
| 49 | const idleHtml = idleResult && idleResult.value === value && idleResult.language === language |
| 50 | ? idleResult.html |
| 51 | : null; |
| 52 | const html = useMemo(() => { |
| 53 | if (!deferToIdle) return highlightToHtml(value, syntaxHighlight ? language : undefined); |
| 54 | // While an appended revision waits for idle, retain its already-highlighted |
| 55 | // prefix. Replacements and language changes must never reuse stale markup. |
| 56 | if (idleHtml === null && idleResult && idleResult.language === language && value.startsWith(idleResult.value)) { |
| 57 | return idleResult.html + escapeHtml(value.slice(idleResult.value.length)); |
| 58 | } |
| 59 | return idleHtml ?? escapeHtml(value); |
| 60 | }, [deferToIdle, idleHtml, idleResult, language, syntaxHighlight, value]); |
| 61 | const retainLines = showHeader && syntaxHighlight; |
| 62 | const lines = useMemo(() => retainLines ? splitHighlightedCodeLines(html) : [], [html, retainLines]); |
| 63 | const highlighted = syntaxHighlight && (!deferToIdle || idleHtml !== null); |
| 64 | const bounded = scrollMode === "bounded" || (scrollMode !== "expand" && maxHeight != null); |
| 65 | return ( |
| 66 | <div className="code-block__wrap"> |
| 67 | <pre |
| 68 | className={`code hljs${bounded ? " code--scroll-y" : ""}`} |
| 69 | data-nested-scroll={bounded ? "" : undefined} |
| 70 | data-highlight-mode={highlighted ? "syntax" : "plain"} |
| 71 | data-lang={language} |
| 72 | style={maxHeight != null ? { maxHeight } : undefined} |
| 73 | > |
| 74 | {retainLines ? <code>{lines.map((line, index) => <Fragment key={index}> |
| 75 | {index > 0 && "\n"}<CodeLine html={line} /> |
| 76 | </Fragment>)}</code> : <code dangerouslySetInnerHTML={{ __html: html }} />} |
| 77 | </pre> |
| 78 | {!showHeader && <CopyButton text={copyValue ?? value} className="code-block__copy" />} |
| 79 | </div> |
| 80 | ); |
| 81 | }); |
| 82 | |
| 83 | export default HljsCode; |
| 84 |