From 3cdb5c2fe6483e08ea49ff4f90209fd22dc329fc Mon Sep 17 00:00:00 2001 From: Gimle Larpes <97182804+GimleLarpes@users.noreply.github.com> Date: Wed, 23 Jul 2025 18:10:56 +0300 Subject: [PATCH] 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> --- src/app/components/editor/Elements.tsx | 2 +- src/app/hooks/useTimeoutToggle.ts | 37 +++++++ src/app/plugins/react-custom-html-parser.tsx | 102 ++++++++++++++++--- src/app/styles/CustomHtml.css.ts | 29 +++++- 4 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 src/app/hooks/useTimeoutToggle.ts diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index a7438ecd..6a6659b9 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr visibility="Hover" hideTrack > -
{children}
+
{children}
); diff --git a/src/app/hooks/useTimeoutToggle.ts b/src/app/hooks/useTimeoutToggle.ts new file mode 100644 index 00000000..7eda99c1 --- /dev/null +++ b/src/app/hooks/useTimeoutToggle.ts @@ -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(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]; +} diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index cd683e36..04ebacd4 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -1,5 +1,13 @@ /* 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 { Element, Text as DOMText, @@ -9,10 +17,11 @@ import { } from 'html-react-parser'; import { MatrixClient } from 'matrix-js-sdk'; 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 Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; +import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; import { getMxIdLocalPart, @@ -31,7 +40,8 @@ import { testMatrixTo, } from './matrix-to'; 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')); @@ -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 ( + <> +
+ + + + {collapsible && ( + + + + )} +
+ +
+ {domToReact(children, opts)} +
+
+ + ); +} + export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, @@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = ( if (name === 'pre') { return ( - -
{domToReact(children, opts)}
-
+ {CodeBlock(children, opts)}
); } diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index d86a3236..ecbdbeee 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -85,10 +85,35 @@ export const CodeBlock = style([ MarginSpaced, { fontStyle: 'normal', + position: 'relative', }, ]); -export const CodeBlockInternal = style({ - padding: `${config.space.S200} ${config.space.S200} 0`, +export const CodeBlockInternal = recipe({ + base: { + 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([