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([