ReactEventHandler,
Suspense,
lazy,
- useCallback,
useMemo,
useState,
} from 'react';
} 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';
);
});
-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>
);
}
}
if (name === 'pre') {
- return (
- <Text {...props} as="pre" className={css.CodeBlock}>
- {CodeBlock(children, opts)}
- </Text>
- );
+ return <CodeBlock opts={opts}>{children}</CodeBlock>;
}
if (name === 'blockquote') {
}
} else {
return (
- <code className={css.Code} {...props}>
+ <Text as="code" size="T300" className={css.Code} {...props}>
{domToReact(children, opts)}
- </code>
+ </Text>
);
}
}
]);
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}`,
},
{
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([