Add code block language header and improve styles (#2403)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sun, 27 Jul 2025 12:21:09 +0000 (17:51 +0530)
committerGitHub <noreply@github.com>
Sun, 27 Jul 2025 12:21:09 +0000 (22:21 +1000)
* add code block language header and improve styles

* improve codeblock fallback text

* move floating expand button to code block header

* reduce code font size

src/app/components/editor/Elements.tsx
src/app/plugins/react-custom-html-parser.tsx
src/app/styles/CustomHtml.css.ts

index 6a6659b9a7deef76b8a686edc1ff684e8154859b..675c4542ef074e1b100cb8fd20244d72d65a7afa 100644 (file)
@@ -157,12 +157,12 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
         <Text as="pre" className={css.CodeBlock} {...attributes}>
           <Scroll
             direction="Horizontal"
-            variant="Secondary"
+            variant="SurfaceVariant"
             size="300"
             visibility="Hover"
             hideTrack
           >
-            <div className={css.CodeBlockInternal()}>{children}</div>
+            <div className={css.CodeBlockInternal}>{children}</div>
           </Scroll>
         </Text>
       );
index 04ebacd495edef38df1f4325671ca74cde3e9edd..ba40c97876f723d135e41e92637773ece1ab8e88 100644 (file)
@@ -4,7 +4,6 @@ import React, {
   ReactEventHandler,
   Suspense,
   lazy,
-  useCallback,
   useMemo,
   useState,
 } from 'react';
@@ -17,7 +16,7 @@ import {
 } from 'html-react-parser';
 import { MatrixClient } from 'matrix-js-sdk';
 import classNames from 'classnames';
-import { Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
 import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
 import Linkify from 'linkify-react';
 import { ErrorBoundary } from 'react-error-boundary';
@@ -205,79 +204,108 @@ export const highlightText = (
     );
   });
 
-export function CodeBlock(children: ChildNode[], opts: HTMLReactParserOptions) {
-  const LINE_LIMIT = 14;
+/**
+ * Recursively extracts and concatenates all text content from an array of ChildNode objects.
+ *
+ * @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
+ * @returns {string} The concatenated plain text content of all descendant text nodes.
+ */
+const extractTextFromChildren = (nodes: ChildNode[]): string => {
+  let text = '';
+
+  nodes.forEach((node) => {
+    if (node.type === 'text') {
+      text += node.data;
+    } else if (node instanceof Element && node.children) {
+      text += extractTextFromChildren(node.children);
+    }
+  });
 
-  /**
-   * Recursively extracts and concatenates all text content from an array of ChildNode objects.
-   *
-   * @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from.
-   * @returns {string} The concatenated plain text content of all descendant text nodes.
-   */
-  const extractTextFromChildren = useCallback((nodes: ChildNode[]): string => {
-    let text = '';
-
-    nodes.forEach((node) => {
-      if (node.type === 'text') {
-        text += node.data;
-      } else if (node instanceof Element && node.children) {
-        text += extractTextFromChildren(node.children);
-      }
-    });
+  return text;
+};
 
-    return text;
-  }, []);
+export function CodeBlock({
+  children,
+  opts,
+}: {
+  children: ChildNode[];
+  opts: HTMLReactParserOptions;
+}) {
+  const code = children[0];
+  const languageClass =
+    code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
+  const language =
+    languageClass && languageClass.startsWith('language-')
+      ? languageClass.replace('language-', '')
+      : languageClass;
 
-  const [copied, setCopied] = useTimeoutToggle();
-  const collapsible = useMemo(
+  const LINE_LIMIT = 14;
+  const largeCodeBlock = useMemo(
     () => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
-    [children, extractTextFromChildren]
+    [children]
   );
-  const [collapsed, setCollapsed] = useState(collapsible);
 
-  const handleCopy = useCallback(() => {
+  const [expanded, setExpand] = useState(false);
+  const [copied, setCopied] = useTimeoutToggle();
+
+  const handleCopy = () => {
     copyToClipboard(extractTextFromChildren(children));
     setCopied();
-  }, [children, extractTextFromChildren, setCopied]);
+  };
 
-  const toggleCollapse = useCallback(() => {
-    setCollapsed((prev) => !prev);
-  }, []);
+  const toggleExpand = () => {
+    setExpand(!expanded);
+  };
 
   return (
-    <>
-      <div className={css.CodeBlockControls}>
-        <IconButton
-          variant="Secondary" // Needs a better copy icon
-          size="300"
-          radii="300"
-          onClick={handleCopy}
-          aria-label="Copy Code Block"
-        >
-          <Icon src={copied ? Icons.Check : Icons.File} size="50" />
-        </IconButton>
-        {collapsible && (
-          <IconButton
-            variant="Secondary"
-            size="300"
-            radii="300"
-            onClick={toggleCollapse}
-            aria-expanded={!collapsed}
-            aria-pressed={!collapsed}
-            aria-controls="code-block-content"
-            aria-label={collapsed ? 'Show Full Code Block' : 'Show Code Block Preview'}
-            style={collapsed ? { visibility: 'visible' } : {}}
+    <Text size="T300" as="pre" className={css.CodeBlock}>
+      <Header variant="Surface" size="400" className={css.CodeBlockHeader}>
+        <Box grow="Yes">
+          <Text size="L400" truncate>
+            {language ?? 'Code'}
+          </Text>
+        </Box>
+        <Box shrink="No" gap="200">
+          <Chip
+            variant={copied ? 'Success' : 'Surface'}
+            fill="None"
+            radii="Pill"
+            onClick={handleCopy}
+            before={copied && <Icon size="50" src={Icons.Check} />}
           >
-            <Icon src={collapsed ? Icons.ChevronBottom : Icons.ChevronTop} size="50" />
-          </IconButton>
-        )}
-      </div>
-      <Scroll direction="Both" variant="Secondary" size="300" visibility="Hover" hideTrack>
-        <div id="code-block-content" className={css.CodeBlockInternal({ collapsed })}>
+            <Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
+          </Chip>
+          {largeCodeBlock && (
+            <IconButton
+              size="300"
+              variant="SurfaceVariant"
+              outlined
+              radii="300"
+              onClick={toggleExpand}
+              aria-label={expanded ? 'Collapse' : 'Expand'}
+            >
+              <Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
+            </IconButton>
+          )}
+        </Box>
+      </Header>
+      <Scroll
+        style={{
+          maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
+          paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
+        }}
+        direction="Both"
+        variant="SurfaceVariant"
+        size="300"
+        visibility="Hover"
+        hideTrack
+      >
+        <div id="code-block-content" className={css.CodeBlockInternal}>
           {domToReact(children, opts)}
         </div>
       </Scroll>
-    </>
+      {largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
+    </Text>
   );
 }
 
@@ -355,11 +383,7 @@ export const getReactCustomHtmlParser = (
         }
 
         if (name === 'pre') {
-          return (
-            <Text {...props} as="pre" className={css.CodeBlock}>
-              {CodeBlock(children, opts)}
-            </Text>
-          );
+          return <CodeBlock opts={opts}>{children}</CodeBlock>;
         }
 
         if (name === 'blockquote') {
@@ -409,9 +433,9 @@ export const getReactCustomHtmlParser = (
             }
           } else {
             return (
-              <code className={css.Code} {...props}>
+              <Text as="code" size="T300" className={css.Code} {...props}>
                 {domToReact(children, opts)}
-              </code>
+              </Text>
             );
           }
         }
index ecbdbeee089f0405df134ecaf594e45996186703..f717669ccd667d8836334c610ea8af89f388d0ec 100644 (file)
@@ -41,16 +41,19 @@ export const BlockQuote = style([
 ]);
 
 const BaseCode = style({
-  fontFamily: 'monospace',
-  color: color.Secondary.OnContainer,
-  background: color.Secondary.Container,
-  border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
+  color: color.SurfaceVariant.OnContainer,
+  background: color.SurfaceVariant.Container,
+  border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
   borderRadius: config.radii.R300,
 });
+const CodeFont = style({
+  fontFamily: 'monospace',
+});
 
 export const Code = style([
   DefaultReset,
   BaseCode,
+  CodeFont,
   {
     padding: `0 ${config.space.S100}`,
   },
@@ -86,34 +89,31 @@ export const CodeBlock = style([
   {
     fontStyle: 'normal',
     position: 'relative',
+    overflow: 'hidden',
   },
 ]);
-export const CodeBlockInternal = recipe({
-  base: {
+export const CodeBlockHeader = style({
+  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
+  borderBottomWidth: config.borderWidth.B300,
+  gap: config.space.S200,
+});
+export const CodeBlockInternal = style([
+  CodeFont,
+  {
     padding: `${config.space.S200} ${config.space.S200} 0`,
-    minWidth: toRem(100),
-  },
-  variants: {
-    collapsed: {
-      true: {
-        maxHeight: `calc(${config.lineHeight.T400} * 9.6)`,
-      },
-    },
+    minWidth: toRem(200),
   },
-});
-export const CodeBlockControls = style({
+]);
+
+export const CodeBlockBottomShadow = style({
   position: 'absolute',
-  top: config.space.S200,
-  right: config.space.S200,
-  visibility: 'hidden',
-  selectors: {
-    [`${CodeBlock}:hover &`]: {
-      visibility: 'visible',
-    },
-    [`${CodeBlock}:focus-within &`]: {
-      visibility: 'visible',
-    },
-  },
+  bottom: 0,
+  left: 0,
+  right: 0,
+  pointerEvents: 'none',
+
+  height: config.space.S400,
+  background: `linear-gradient(to top, #00000022, #00000000)`,
 });
 
 export const List = style([