add code block language header and improve styles

This commit is contained in:
Ajay Bura 2025-07-27 11:33:58 +05:30
parent 67b05eeb09
commit 9d37693a21
3 changed files with 119 additions and 96 deletions

View file

@ -157,12 +157,12 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
<Text as="pre" className={css.CodeBlock} {...attributes}> <Text as="pre" className={css.CodeBlock} {...attributes}>
<Scroll <Scroll
direction="Horizontal" direction="Horizontal"
variant="Secondary" variant="SurfaceVariant"
size="300" size="300"
visibility="Hover" visibility="Hover"
hideTrack hideTrack
> >
<div className={css.CodeBlockInternal()}>{children}</div> <div className={css.CodeBlockInternal}>{children}</div>
</Scroll> </Scroll>
</Text> </Text>
); );

View file

@ -4,7 +4,6 @@ import React, {
ReactEventHandler, ReactEventHandler,
Suspense, Suspense,
lazy, lazy,
useCallback,
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
@ -17,7 +16,7 @@ import {
} from 'html-react-parser'; } from 'html-react-parser';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import classNames from 'classnames'; import classNames from 'classnames';
import { Icon, IconButton, Icons, Scroll, Text } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary'; 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; const LINE_LIMIT = 14;
const largeCodeBlock = useMemo(
/**
* 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(
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT, () => 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)); copyToClipboard(extractTextFromChildren(children));
setCopied(); setCopied();
}, [children, extractTextFromChildren, setCopied]); };
const toggleCollapse = useCallback(() => { const toggleExpand = () => {
setCollapsed((prev) => !prev); setExpand(!expanded);
}, []); };
return ( return (
<> <Text as="pre" className={css.CodeBlock}>
<div className={css.CodeBlockControls}> <Header variant="Surface" size="400" className={css.CodeBlockHeader}>
<IconButton <Box grow="Yes">
variant="Secondary" // Needs a better copy icon <Text size="L400" truncate>
size="300" {language ?? 'Text'}
radii="300" </Text>
onClick={handleCopy} </Box>
aria-label="Copy Code Block" <Box shrink="No">
> <Chip
<Icon src={copied ? Icons.Check : Icons.File} size="50" /> variant="Surface"
</IconButton>
{collapsible && (
<IconButton
variant="Secondary"
size="300"
radii="300" radii="300"
onClick={toggleCollapse} onClick={handleCopy}
aria-expanded={!collapsed} before={copied && <Icon size="50" src={Icons.Check} />}
aria-pressed={!collapsed}
aria-controls="code-block-content"
aria-label={collapsed ? 'Show Full Code Block' : 'Show Code Block Preview'}
style={collapsed ? { visibility: 'visible' } : {}}
> >
<Icon src={collapsed ? Icons.ChevronBottom : Icons.ChevronTop} size="50" /> <Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
</IconButton> </Chip>
)} </Box>
</div> </Header>
<Scroll direction="Both" variant="Secondary" size="300" visibility="Hover" hideTrack> <Scroll
<div id="code-block-content" className={css.CodeBlockInternal({ collapsed })}> style={{
maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
paddingBottom: largeCodeBlock ? toRem(48) : undefined,
}}
direction="Both"
variant="SurfaceVariant"
size="300"
visibility="Hover"
hideTrack
>
<div id="code-block-content" className={css.CodeBlockInternal}>
{domToReact(children, opts)} {domToReact(children, opts)}
</div> </div>
</Scroll> </Scroll>
</> {largeCodeBlock && (
<Box className={css.CodeBlockBottomBar} justifyContent="End">
<IconButton
style={{ pointerEvents: 'all' }}
variant="Secondary"
radii="300"
outlined
onClick={toggleExpand}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
</IconButton>
</Box>
)}
</Text>
); );
} }
@ -355,11 +383,7 @@ export const getReactCustomHtmlParser = (
} }
if (name === 'pre') { if (name === 'pre') {
return ( return <CodeBlock opts={opts}>{children}</CodeBlock>;
<Text {...props} as="pre" className={css.CodeBlock}>
{CodeBlock(children, opts)}
</Text>
);
} }
if (name === 'blockquote') { if (name === 'blockquote') {

View file

@ -41,16 +41,19 @@ export const BlockQuote = style([
]); ]);
const BaseCode = style({ const BaseCode = style({
fontFamily: 'monospace', color: color.SurfaceVariant.OnContainer,
color: color.Secondary.OnContainer, background: color.SurfaceVariant.Container,
background: color.Secondary.Container, border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
}); });
const CodeFont = style({
fontFamily: 'monospace',
});
export const Code = style([ export const Code = style([
DefaultReset, DefaultReset,
BaseCode, BaseCode,
CodeFont,
{ {
padding: `0 ${config.space.S100}`, padding: `0 ${config.space.S100}`,
}, },
@ -86,34 +89,30 @@ export const CodeBlock = style([
{ {
fontStyle: 'normal', fontStyle: 'normal',
position: 'relative', position: 'relative',
overflow: 'hidden',
}, },
]); ]);
export const CodeBlockInternal = recipe({ export const CodeBlockHeader = style({
base: { padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
padding: `${config.space.S200} ${config.space.S200} 0`, borderBottomWidth: config.borderWidth.B300,
minWidth: toRem(100),
},
variants: {
collapsed: {
true: {
maxHeight: `calc(${config.lineHeight.T400} * 9.6)`,
},
},
},
}); });
export const CodeBlockControls = style({ export const CodeBlockInternal = style([
position: 'absolute', CodeFont,
top: config.space.S200, {
right: config.space.S200, padding: `${config.space.S200} ${config.space.S200} 0`,
visibility: 'hidden', minWidth: toRem(200),
selectors: {
[`${CodeBlock}:hover &`]: {
visibility: 'visible',
},
[`${CodeBlock}:focus-within &`]: {
visibility: 'visible',
},
}, },
]);
export const CodeBlockBottomBar = style({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
pointerEvents: 'none',
padding: config.space.S200,
background: `linear-gradient(to top, #00000022, #00000000)`,
}); });
export const List = style([ export const List = style([