Add code block copy and collapse functionality (#2361)

* add buttons to codeblocks

* add functionality

* Document functions

* Improve accessibility

* Remove pointless DefaultReset

* implement some requested changes

* fix content shift when expanding or collapsing

---------

Co-authored-by: Ajay Bura <32841439+ajbura@users.noreply.github.com>
This commit is contained in:
Gimle Larpes 2025-07-23 18:10:56 +03:00 committed by GitHub
parent acc7d4ff56
commit 3cdb5c2fe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 155 additions and 15 deletions

View file

@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
visibility="Hover" visibility="Hover"
hideTrack hideTrack
> >
<div className={css.CodeBlockInternal}>{children}</div> <div className={css.CodeBlockInternal()}>{children}</div>
</Scroll> </Scroll>
</Text> </Text>
); );

View file

@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Temporarily sets a boolean state.
*
* @param duration - Duration in milliseconds before resetting (default: 1500)
* @param initial - Initial value (default: false)
*/
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
const [active, setActive] = useState(initial);
const timeoutRef = useRef<number | null>(null);
const clear = () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
const trigger = useCallback(() => {
setActive(!initial);
clear();
timeoutRef.current = window.setTimeout(() => {
setActive(initial);
timeoutRef.current = null;
}, duration);
}, [duration, initial]);
useEffect(
() => () => {
clear();
},
[]
);
return [active, trigger];
}

View file

@ -1,5 +1,13 @@
/* eslint-disable jsx-a11y/alt-text */ /* eslint-disable jsx-a11y/alt-text */
import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy } from 'react'; import React, {
ComponentPropsWithoutRef,
ReactEventHandler,
Suspense,
lazy,
useCallback,
useMemo,
useState,
} from 'react';
import { import {
Element, Element,
Text as DOMText, Text as DOMText,
@ -9,10 +17,11 @@ 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 { Scroll, Text } from 'folds'; import { Icon, IconButton, Icons, Scroll, Text } 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';
import { ChildNode } from 'domhandler';
import * as css from '../styles/CustomHtml.css'; import * as css from '../styles/CustomHtml.css';
import { import {
getMxIdLocalPart, getMxIdLocalPart,
@ -31,7 +40,8 @@ import {
testMatrixTo, testMatrixTo,
} from './matrix-to'; } from './matrix-to';
import { onEnterOrSpace } from '../utils/keyboard'; import { onEnterOrSpace } from '../utils/keyboard';
import { tryDecodeURIComponent } from '../utils/dom'; import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom';
import { useTimeoutToggle } from '../hooks/useTimeoutToggle';
const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
@ -195,6 +205,82 @@ 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 = 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,
[children, extractTextFromChildren]
);
const [collapsed, setCollapsed] = useState(collapsible);
const handleCopy = useCallback(() => {
copyToClipboard(extractTextFromChildren(children));
setCopied();
}, [children, extractTextFromChildren, setCopied]);
const toggleCollapse = useCallback(() => {
setCollapsed((prev) => !prev);
}, []);
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' } : {}}
>
<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 })}>
{domToReact(children, opts)}
</div>
</Scroll>
</>
);
}
export const getReactCustomHtmlParser = ( export const getReactCustomHtmlParser = (
mx: MatrixClient, mx: MatrixClient,
roomId: string | undefined, roomId: string | undefined,
@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = (
if (name === 'pre') { if (name === 'pre') {
return ( return (
<Text {...props} as="pre" className={css.CodeBlock}> <Text {...props} as="pre" className={css.CodeBlock}>
<Scroll {CodeBlock(children, opts)}
direction="Horizontal"
variant="Secondary"
size="300"
visibility="Hover"
hideTrack
>
<div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
</Scroll>
</Text> </Text>
); );
} }

View file

@ -85,10 +85,35 @@ export const CodeBlock = style([
MarginSpaced, MarginSpaced,
{ {
fontStyle: 'normal', fontStyle: 'normal',
position: 'relative',
}, },
]); ]);
export const CodeBlockInternal = style({ export const CodeBlockInternal = recipe({
base: {
padding: `${config.space.S200} ${config.space.S200} 0`, padding: `${config.space.S200} ${config.space.S200} 0`,
minWidth: toRem(100),
},
variants: {
collapsed: {
true: {
maxHeight: `calc(${config.lineHeight.T400} * 9.6)`,
},
},
},
});
export const CodeBlockControls = style({
position: 'absolute',
top: config.space.S200,
right: config.space.S200,
visibility: 'hidden',
selectors: {
[`${CodeBlock}:hover &`]: {
visibility: 'visible',
},
[`${CodeBlock}:focus-within &`]: {
visibility: 'visible',
},
},
}); });
export const List = style([ export const List = style([