Add code block language header and improve styles (#2403)
Some checks failed
Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled

* add code block language header and improve styles

* improve codeblock fallback text

* move floating expand button to code block header

* reduce code font size
This commit is contained in:
Ajay Bura 2025-07-27 17:51:09 +05:30 committed by GitHub
parent d8d4714370
commit 31942b1114
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 99 deletions

View 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) {
/**
* 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);
}
});
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 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 = 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;
}, []);
const [copied, setCopied] = useTimeoutToggle();
const collapsible = useMemo(
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>
);
}
}