|
|
@@ -1,15 +1,55 @@
|
|
|
-import type { JSX, ReactNode } from 'react';
|
|
|
-import { PrismAsyncLight } from 'react-syntax-highlighter';
|
|
|
-import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
|
|
+import type { ComponentType, CSSProperties, JSX, ReactNode } from 'react';
|
|
|
+import { startTransition, useEffect, useState } from 'react';
|
|
|
|
|
|
import styles from './CodeBlock.module.scss';
|
|
|
|
|
|
-// remove font-family
|
|
|
-Object.entries<object>(oneDark).forEach(([key, value]) => {
|
|
|
- if ('fontFamily' in value) {
|
|
|
- delete oneDark[key].fontFamily;
|
|
|
+// Hardcoded container styles from the oneDark Prism theme.
|
|
|
+// fontFamily is intentionally omitted so the page's default monospace font is used.
|
|
|
+const preStyle: CSSProperties = {
|
|
|
+ background: 'hsl(220, 13%, 18%)',
|
|
|
+ color: 'hsl(220, 14%, 71%)',
|
|
|
+ textShadow: '0 1px rgba(0, 0, 0, 0.3)',
|
|
|
+ direction: 'ltr',
|
|
|
+ textAlign: 'left',
|
|
|
+ whiteSpace: 'pre',
|
|
|
+ wordSpacing: 'normal',
|
|
|
+ wordBreak: 'normal',
|
|
|
+ lineHeight: '1.5',
|
|
|
+ tabSize: 2,
|
|
|
+ hyphens: 'none',
|
|
|
+ padding: '1em',
|
|
|
+ margin: '0.5em 0',
|
|
|
+ overflow: 'auto',
|
|
|
+ borderRadius: '0.3em',
|
|
|
+};
|
|
|
+
|
|
|
+const codeStyle: CSSProperties = {
|
|
|
+ background: 'hsl(220, 13%, 18%)',
|
|
|
+ color: 'hsl(220, 14%, 71%)',
|
|
|
+ textShadow: '0 1px rgba(0, 0, 0, 0.3)',
|
|
|
+ direction: 'ltr',
|
|
|
+ textAlign: 'left',
|
|
|
+ whiteSpace: 'pre',
|
|
|
+ wordSpacing: 'normal',
|
|
|
+ wordBreak: 'normal',
|
|
|
+ lineHeight: '1.5',
|
|
|
+ tabSize: 2,
|
|
|
+ hyphens: 'none',
|
|
|
+};
|
|
|
+
|
|
|
+type PrismHighlighterProps = { lang: string; children: ReactNode };
|
|
|
+
|
|
|
+// Cache the loaded module so all CodeBlock instances share a single import
|
|
|
+let prismModulePromise: Promise<ComponentType<PrismHighlighterProps>> | null =
|
|
|
+ null;
|
|
|
+function loadPrismHighlighter(): Promise<ComponentType<PrismHighlighterProps>> {
|
|
|
+ if (prismModulePromise == null) {
|
|
|
+ prismModulePromise = import('./PrismHighlighter').then(
|
|
|
+ (mod) => mod.PrismHighlighter,
|
|
|
+ );
|
|
|
}
|
|
|
-});
|
|
|
+ return prismModulePromise;
|
|
|
+}
|
|
|
|
|
|
type InlineCodeBlockProps = {
|
|
|
children: ReactNode;
|
|
|
@@ -52,6 +92,22 @@ function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
|
|
|
return String(children).replace(/\n$/, '');
|
|
|
}
|
|
|
|
|
|
+function LightweightCodeBlock({
|
|
|
+ lang,
|
|
|
+ children,
|
|
|
+}: {
|
|
|
+ lang: string;
|
|
|
+ children: ReactNode;
|
|
|
+}): JSX.Element {
|
|
|
+ return (
|
|
|
+ <div style={preStyle}>
|
|
|
+ <code className={`language-${lang}`} style={codeStyle}>
|
|
|
+ {children}
|
|
|
+ </code>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
function CodeBlockSubstance({
|
|
|
lang,
|
|
|
children,
|
|
|
@@ -59,35 +115,42 @@ function CodeBlockSubstance({
|
|
|
lang: string;
|
|
|
children: ReactNode;
|
|
|
}): JSX.Element {
|
|
|
+ const [Highlighter, setHighlighter] =
|
|
|
+ useState<ComponentType<PrismHighlighterProps> | null>(null);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ loadPrismHighlighter().then((comp) => {
|
|
|
+ startTransition(() => {
|
|
|
+ setHighlighter(() => comp);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }, []);
|
|
|
+
|
|
|
// return alternative element
|
|
|
// in order to fix "CodeBlock string is be [object Object] if searched"
|
|
|
// see: https://github.com/growilabs/growi/pull/7484
|
|
|
- //
|
|
|
- // Note: You can also remove this code if the user requests to see the code highlighted in Prism as-is.
|
|
|
-
|
|
|
const isSimpleString =
|
|
|
typeof children === 'string' ||
|
|
|
(Array.isArray(children) &&
|
|
|
children.length === 1 &&
|
|
|
typeof children[0] === 'string');
|
|
|
- if (!isSimpleString) {
|
|
|
+
|
|
|
+ const textContent = extractChildrenToIgnoreReactNode(children);
|
|
|
+
|
|
|
+ // SSR or loading or non-simple children: use lightweight container
|
|
|
+ // - SSR: Highlighter is null → styled container with content
|
|
|
+ // - Client hydration: matches SSR output (Highlighter still null)
|
|
|
+ // - After hydration: useEffect fires → import starts
|
|
|
+ // - Import done: startTransition swaps to Highlighter (single seamless transition)
|
|
|
+ if (Highlighter == null || !isSimpleString) {
|
|
|
return (
|
|
|
- <div style={oneDark['pre[class*="language-"]']}>
|
|
|
- <code
|
|
|
- className={`language-${lang}`}
|
|
|
- style={oneDark['code[class*="language-"]']}
|
|
|
- >
|
|
|
- {children}
|
|
|
- </code>
|
|
|
- </div>
|
|
|
+ <LightweightCodeBlock lang={lang}>
|
|
|
+ {isSimpleString ? textContent : children}
|
|
|
+ </LightweightCodeBlock>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- return (
|
|
|
- <PrismAsyncLight PreTag="div" style={oneDark} language={lang}>
|
|
|
- {extractChildrenToIgnoreReactNode(children)}
|
|
|
- </PrismAsyncLight>
|
|
|
- );
|
|
|
+ return <Highlighter lang={lang}>{textContent}</Highlighter>;
|
|
|
}
|
|
|
|
|
|
type CodeBlockProps = {
|