mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Add code block language header and improve styles (#2403)
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
				
			* add code block language header and improve styles * improve codeblock fallback text * move floating expand button to code block header * reduce code font size
This commit is contained in:
		
							parent
							
								
									d8d4714370
								
							
						
					
					
						commit
						31942b1114
					
				
					 3 changed files with 123 additions and 99 deletions
				
			
		| 
						 | 
				
			
			@ -157,12 +157,12 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
 | 
			
		|||
        <Text as="pre" className={css.CodeBlock} {...attributes}>
 | 
			
		||||
          <Scroll
 | 
			
		||||
            direction="Horizontal"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            size="300"
 | 
			
		||||
            visibility="Hover"
 | 
			
		||||
            hideTrack
 | 
			
		||||
          >
 | 
			
		||||
            <div className={css.CodeBlockInternal()}>{children}</div>
 | 
			
		||||
            <div className={css.CodeBlockInternal}>{children}</div>
 | 
			
		||||
          </Scroll>
 | 
			
		||||
        </Text>
 | 
			
		||||
      );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,7 +4,6 @@ import React, {
 | 
			
		|||
  ReactEventHandler,
 | 
			
		||||
  Suspense,
 | 
			
		||||
  lazy,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +16,7 @@ import {
 | 
			
		|||
} from 'html-react-parser';
 | 
			
		||||
import { MatrixClient } from 'matrix-js-sdk';
 | 
			
		||||
import classNames from 'classnames';
 | 
			
		||||
import { Icon, IconButton, Icons, Scroll, Text } from 'folds';
 | 
			
		||||
import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
 | 
			
		||||
import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs';
 | 
			
		||||
import Linkify from 'linkify-react';
 | 
			
		||||
import { ErrorBoundary } from 'react-error-boundary';
 | 
			
		||||
| 
						 | 
				
			
			@ -205,79 +204,108 @@ export const highlightText = (
 | 
			
		|||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export function CodeBlock(children: ChildNode[], opts: HTMLReactParserOptions) {
 | 
			
		||||
/**
 | 
			
		||||
 * 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 = (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;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function CodeBlock({
 | 
			
		||||
  children,
 | 
			
		||||
  opts,
 | 
			
		||||
}: {
 | 
			
		||||
  children: ChildNode[];
 | 
			
		||||
  opts: HTMLReactParserOptions;
 | 
			
		||||
}) {
 | 
			
		||||
  const code = children[0];
 | 
			
		||||
  const languageClass =
 | 
			
		||||
    code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
 | 
			
		||||
  const language =
 | 
			
		||||
    languageClass && languageClass.startsWith('language-')
 | 
			
		||||
      ? languageClass.replace('language-', '')
 | 
			
		||||
      : languageClass;
 | 
			
		||||
 | 
			
		||||
  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(
 | 
			
		||||
  const largeCodeBlock = useMemo(
 | 
			
		||||
    () => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
 | 
			
		||||
    [children, extractTextFromChildren]
 | 
			
		||||
    [children]
 | 
			
		||||
  );
 | 
			
		||||
  const [collapsed, setCollapsed] = useState(collapsible);
 | 
			
		||||
 | 
			
		||||
  const handleCopy = useCallback(() => {
 | 
			
		||||
  const [expanded, setExpand] = useState(false);
 | 
			
		||||
  const [copied, setCopied] = useTimeoutToggle();
 | 
			
		||||
 | 
			
		||||
  const handleCopy = () => {
 | 
			
		||||
    copyToClipboard(extractTextFromChildren(children));
 | 
			
		||||
    setCopied();
 | 
			
		||||
  }, [children, extractTextFromChildren, setCopied]);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const toggleCollapse = useCallback(() => {
 | 
			
		||||
    setCollapsed((prev) => !prev);
 | 
			
		||||
  }, []);
 | 
			
		||||
  const toggleExpand = () => {
 | 
			
		||||
    setExpand(!expanded);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  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' } : {}}
 | 
			
		||||
    <Text size="T300" as="pre" className={css.CodeBlock}>
 | 
			
		||||
      <Header variant="Surface" size="400" className={css.CodeBlockHeader}>
 | 
			
		||||
        <Box grow="Yes">
 | 
			
		||||
          <Text size="L400" truncate>
 | 
			
		||||
            {language ?? 'Code'}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Box shrink="No" gap="200">
 | 
			
		||||
          <Chip
 | 
			
		||||
            variant={copied ? 'Success' : 'Surface'}
 | 
			
		||||
            fill="None"
 | 
			
		||||
            radii="Pill"
 | 
			
		||||
            onClick={handleCopy}
 | 
			
		||||
            before={copied && <Icon size="50" src={Icons.Check} />}
 | 
			
		||||
          >
 | 
			
		||||
            <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 })}>
 | 
			
		||||
            <Text size="B300">{copied ? 'Copied' : 'Copy'}</Text>
 | 
			
		||||
          </Chip>
 | 
			
		||||
          {largeCodeBlock && (
 | 
			
		||||
            <IconButton
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="SurfaceVariant"
 | 
			
		||||
              outlined
 | 
			
		||||
              radii="300"
 | 
			
		||||
              onClick={toggleExpand}
 | 
			
		||||
              aria-label={expanded ? 'Collapse' : 'Expand'}
 | 
			
		||||
            >
 | 
			
		||||
              <Icon size="50" src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Header>
 | 
			
		||||
      <Scroll
 | 
			
		||||
        style={{
 | 
			
		||||
          maxHeight: largeCodeBlock && !expanded ? toRem(300) : undefined,
 | 
			
		||||
          paddingBottom: largeCodeBlock ? config.space.S400 : undefined,
 | 
			
		||||
        }}
 | 
			
		||||
        direction="Both"
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        size="300"
 | 
			
		||||
        visibility="Hover"
 | 
			
		||||
        hideTrack
 | 
			
		||||
      >
 | 
			
		||||
        <div id="code-block-content" className={css.CodeBlockInternal}>
 | 
			
		||||
          {domToReact(children, opts)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </Scroll>
 | 
			
		||||
    </>
 | 
			
		||||
      {largeCodeBlock && !expanded && <Box className={css.CodeBlockBottomShadow} />}
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -355,11 +383,7 @@ export const getReactCustomHtmlParser = (
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        if (name === 'pre') {
 | 
			
		||||
          return (
 | 
			
		||||
            <Text {...props} as="pre" className={css.CodeBlock}>
 | 
			
		||||
              {CodeBlock(children, opts)}
 | 
			
		||||
            </Text>
 | 
			
		||||
          );
 | 
			
		||||
          return <CodeBlock opts={opts}>{children}</CodeBlock>;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (name === 'blockquote') {
 | 
			
		||||
| 
						 | 
				
			
			@ -409,9 +433,9 @@ export const getReactCustomHtmlParser = (
 | 
			
		|||
            }
 | 
			
		||||
          } else {
 | 
			
		||||
            return (
 | 
			
		||||
              <code className={css.Code} {...props}>
 | 
			
		||||
              <Text as="code" size="T300" className={css.Code} {...props}>
 | 
			
		||||
                {domToReact(children, opts)}
 | 
			
		||||
              </code>
 | 
			
		||||
              </Text>
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,16 +41,19 @@ export const BlockQuote = style([
 | 
			
		|||
]);
 | 
			
		||||
 | 
			
		||||
const BaseCode = style({
 | 
			
		||||
  fontFamily: 'monospace',
 | 
			
		||||
  color: color.Secondary.OnContainer,
 | 
			
		||||
  background: color.Secondary.Container,
 | 
			
		||||
  border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
 | 
			
		||||
  color: color.SurfaceVariant.OnContainer,
 | 
			
		||||
  background: color.SurfaceVariant.Container,
 | 
			
		||||
  border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
 | 
			
		||||
  borderRadius: config.radii.R300,
 | 
			
		||||
});
 | 
			
		||||
const CodeFont = style({
 | 
			
		||||
  fontFamily: 'monospace',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const Code = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  BaseCode,
 | 
			
		||||
  CodeFont,
 | 
			
		||||
  {
 | 
			
		||||
    padding: `0 ${config.space.S100}`,
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -86,34 +89,31 @@ export const CodeBlock = style([
 | 
			
		|||
  {
 | 
			
		||||
    fontStyle: 'normal',
 | 
			
		||||
    position: 'relative',
 | 
			
		||||
    overflow: 'hidden',
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
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 CodeBlockHeader = style({
 | 
			
		||||
  padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
 | 
			
		||||
  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
  gap: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
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 CodeBlockInternal = style([
 | 
			
		||||
  CodeFont,
 | 
			
		||||
  {
 | 
			
		||||
    padding: `${config.space.S200} ${config.space.S200} 0`,
 | 
			
		||||
    minWidth: toRem(200),
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const CodeBlockBottomShadow = style({
 | 
			
		||||
  position: 'absolute',
 | 
			
		||||
  bottom: 0,
 | 
			
		||||
  left: 0,
 | 
			
		||||
  right: 0,
 | 
			
		||||
  pointerEvents: 'none',
 | 
			
		||||
 | 
			
		||||
  height: config.space.S400,
 | 
			
		||||
  background: `linear-gradient(to top, #00000022, #00000000)`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const List = style([
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue