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"
 | 
					            visibility="Hover"
 | 
				
			||||||
            hideTrack
 | 
					            hideTrack
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
            <div className={css.CodeBlockInternal}>{children}</div>
 | 
					            <div className={css.CodeBlockInternal()}>{children}</div>
 | 
				
			||||||
          </Scroll>
 | 
					          </Scroll>
 | 
				
			||||||
        </Text>
 | 
					        </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 */
 | 
					/* 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>
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -85,10 +85,35 @@ export const CodeBlock = style([
 | 
				
			||||||
  MarginSpaced,
 | 
					  MarginSpaced,
 | 
				
			||||||
  {
 | 
					  {
 | 
				
			||||||
    fontStyle: 'normal',
 | 
					    fontStyle: 'normal',
 | 
				
			||||||
 | 
					    position: 'relative',
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
]);
 | 
					]);
 | 
				
			||||||
export const CodeBlockInternal = style({
 | 
					export const CodeBlockInternal = recipe({
 | 
				
			||||||
  padding: `${config.space.S200} ${config.space.S200} 0`,
 | 
					  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([
 | 
					export const List = style([
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue