Add code block copy and collapse functionality (#2361)
authorGimle Larpes <97182804+GimleLarpes@users.noreply.github.com>
Wed, 23 Jul 2025 15:10:56 +0000 (18:10 +0300)
committerGitHub <noreply@github.com>
Wed, 23 Jul 2025 15:10:56 +0000 (20:40 +0530)
* 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
src/app/hooks/useTimeoutToggle.ts [new file with mode: 0644]
src/app/plugins/react-custom-html-parser.tsx
src/app/styles/CustomHtml.css.ts

index a7438ecdfd74ae6817555cb2f98939dfdf316e16..6a6659b9a7deef76b8a686edc1ff684e8154859b 100644 (file)
@@ -162,7 +162,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
             visibility="Hover"
             hideTrack
           >
-            <div className={css.CodeBlockInternal}>{children}</div>
+            <div className={css.CodeBlockInternal()}>{children}</div>
           </Scroll>
         </Text>
       );
diff --git a/src/app/hooks/useTimeoutToggle.ts b/src/app/hooks/useTimeoutToggle.ts
new file mode 100644 (file)
index 0000000..7eda99c
--- /dev/null
@@ -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];
+}
index cd683e36581173a6d52f61c4572fd2d59d1f362f..04ebacd495edef38df1f4325671ca74cde3e9edd 100644 (file)
@@ -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 (
+    <>
+      <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 = (
   mx: MatrixClient,
   roomId: string | undefined,
@@ -271,15 +357,7 @@ export const getReactCustomHtmlParser = (
         if (name === 'pre') {
           return (
             <Text {...props} as="pre" className={css.CodeBlock}>
-              <Scroll
-                direction="Horizontal"
-                variant="Secondary"
-                size="300"
-                visibility="Hover"
-                hideTrack
-              >
-                <div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
-              </Scroll>
+              {CodeBlock(children, opts)}
             </Text>
           );
         }
index d86a32366084405f0ff373ff806d635564e5e585..ecbdbeee089f0405df134ecaf594e45996186703 100644 (file)
@@ -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([