Просмотр исходного кода

feat: implement lightweight code block with dynamic Prism highlighter loading

Yuki Takei 1 месяц назад
Родитель
Сommit
0f53b8c2a6

+ 88 - 25
apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -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 = {

+ 22 - 0
apps/app/src/components/ReactMarkdownComponents/PrismHighlighter.tsx

@@ -0,0 +1,22 @@
+import type { JSX, ReactNode } from 'react';
+import { PrismAsyncLight } from 'react-syntax-highlighter';
+import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+// Remove font-family to use the page's default monospace font
+Object.entries<object>(oneDark).forEach(([key, value]) => {
+  if ('fontFamily' in value) {
+    delete oneDark[key].fontFamily;
+  }
+});
+
+export const PrismHighlighter = ({
+  lang,
+  children,
+}: {
+  lang: string;
+  children: ReactNode;
+}): JSX.Element => (
+  <PrismAsyncLight PreTag="div" style={oneDark} language={lang}>
+    {children}
+  </PrismAsyncLight>
+);