mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	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:
		
							parent
							
								
									acc7d4ff56
								
							
						
					
					
						commit
						3cdb5c2fe6
					
				
					 4 changed files with 155 additions and 15 deletions
				
			
		| 
						 | 
				
			
			@ -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>
 | 
			
		||||
      );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										37
									
								
								src/app/hooks/useTimeoutToggle.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/hooks/useTimeoutToggle.ts
									
										
									
									
									
										Normal 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];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -85,10 +85,35 @@ export const CodeBlock = style([
 | 
			
		|||
  MarginSpaced,
 | 
			
		||||
  {
 | 
			
		||||
    fontStyle: 'normal',
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
export const CodeBlockInternal = style({
 | 
			
		||||
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([
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue