From daed6d9c0e0096797cfb6a4c5c4b440164423d23 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 26 Dec 2025 20:56:06 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8B=20feat:=20Add=20Floating=20Copy=20?= =?UTF-8?q?Button=20to=20Code=20Blocks=20(#11113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add MermaidErrorBoundary for handling rendering errors in Mermaid diagrams * feat: Implement FloatingCodeBar for enhanced code block interaction and copy functionality * feat: Add zoom-level bar copy functionality to Mermaid component * feat: Enhance button styles in FloatingCodeBar and RunCode components for improved user interaction * refactor: copy button rendering in CodeBar and FloatingCodeBar for improved accessibility and clarity * chore: linting * chore: import order --- .../Messages/Content/MarkdownComponents.tsx | 7 +- .../components/Messages/Content/CodeBlock.tsx | 136 ++++++++++++++++-- .../components/Messages/Content/Mermaid.tsx | 52 +++++++ .../Messages/Content/MermaidErrorBoundary.tsx | 59 ++++++++ .../components/Messages/Content/RunCode.tsx | 4 +- 5 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 client/src/components/Messages/Content/MermaidErrorBoundary.tsx diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index bc468678ed..7db3fa668a 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -2,6 +2,7 @@ import React, { memo, useMemo, useRef, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { useToastContext } from '@librechat/client'; import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider'; +import MermaidErrorBoundary from '~/components/Messages/Content/MermaidErrorBoundary'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import Mermaid from '~/components/Messages/Content/Mermaid'; import useHasAccess from '~/hooks/Roles/useHasAccess'; @@ -39,7 +40,11 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps return <>{children}; } else if (isMermaid) { const content = typeof children === 'string' ? children : String(children); - return {content}; + return ( + + {content} + + ); } else if (isSingleLine) { return ( diff --git a/client/src/components/Messages/Content/CodeBlock.tsx b/client/src/components/Messages/Content/CodeBlock.tsx index 9ac31beb0d..3bde90ddbb 100644 --- a/client/src/components/Messages/Content/CodeBlock.tsx +++ b/client/src/components/Messages/Content/CodeBlock.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useMemo, useEffect } from 'react'; +import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react'; import copy from 'copy-to-clipboard'; import { InfoIcon } from 'lucide-react'; import { Tools } from 'librechat-data-provider'; @@ -19,6 +19,10 @@ type CodeBlockProps = Pick< classProp?: string; }; +interface FloatingCodeBarProps extends CodeBarProps { + isVisible: boolean; +} + const CodeBar: React.FC = React.memo( ({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => { const localize = useLocalize(); @@ -51,16 +55,14 @@ const CodeBar: React.FC = React.memo( } }} > - {isCopied ? ( - <> - - {error === true ? '' : localize('com_ui_copied')} - - ) : ( - <> - - {error === true ? '' : localize('com_ui_copy_code')} - + {isCopied ? : } + {error !== true && ( + + {localize('com_ui_copy_code')} + + {isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')} + + )} @@ -70,6 +72,75 @@ const CodeBar: React.FC = React.memo( }, ); +const FloatingCodeBar: React.FC = React.memo( + ({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => { + const localize = useLocalize(); + const [isCopied, setIsCopied] = useState(false); + const copyButtonRef = useRef(null); + + const handleCopy = useCallback(() => { + const codeString = codeRef.current?.textContent; + if (codeString != null) { + const wasFocused = document.activeElement === copyButtonRef.current; + setIsCopied(true); + copy(codeString.trim(), { format: 'text/plain' }); + if (wasFocused) { + requestAnimationFrame(() => { + copyButtonRef.current?.focus(); + }); + } + + setTimeout(() => { + const focusedElement = document.activeElement as HTMLElement | null; + setIsCopied(false); + requestAnimationFrame(() => { + focusedElement?.focus(); + }); + }, 3000); + } + }, [codeRef]); + + return ( +
+ {plugin === true ? ( + + ) : ( + <> + {allowExecution === true && ( + + )} + + + )} +
+ ); + }, +); + const CodeBlock: React.FC = ({ lang, blockIndex, @@ -80,6 +151,8 @@ const CodeBlock: React.FC = ({ error, }) => { const codeRef = useRef(null); + const containerRef = useRef(null); + const [isBarVisible, setIsBarVisible] = useState(false); const toolCallsMap = useToolCallsMapContext(); const { messageId, partIndex } = useMessageContext(); const key = allowExecution @@ -97,6 +170,29 @@ const CodeBlock: React.FC = ({ } }, [fetchedToolCalls]); + // Handle focus within the container (for keyboard navigation) + const handleFocus = useCallback(() => { + setIsBarVisible(true); + }, []); + + const handleBlur = useCallback((e: React.FocusEvent) => { + // Check if focus is moving to another element within the container + if (!containerRef.current?.contains(e.relatedTarget as Node)) { + setIsBarVisible(false); + } + }, []); + + const handleMouseEnter = useCallback(() => { + setIsBarVisible(true); + }, []); + + const handleMouseLeave = useCallback(() => { + // Only hide if no element inside has focus + if (!containerRef.current?.contains(document.activeElement)) { + setIsBarVisible(false); + } + }, []); + const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]); const next = () => { @@ -118,7 +214,14 @@ const CodeBlock: React.FC = ({ const language = isNonCode ? 'json' : lang; return ( -
+
= ({ {codeChildren}
+ {allowExecution === true && toolCalls && toolCalls.length > 0 && ( <>
diff --git a/client/src/components/Messages/Content/Mermaid.tsx b/client/src/components/Messages/Content/Mermaid.tsx index 02a3086c3c..33e88d4353 100644 --- a/client/src/components/Messages/Content/Mermaid.tsx +++ b/client/src/components/Messages/Content/Mermaid.tsx @@ -49,6 +49,8 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { const copyButtonRef = useRef(null); const dialogShowCodeButtonRef = useRef(null); const dialogCopyButtonRef = useRef(null); + const zoomCopyButtonRef = useRef(null); + const dialogZoomCopyButtonRef = useRef(null); // Zoom and pan state const [zoom, setZoom] = useState(1); @@ -154,6 +156,30 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { }); }, [children]); + // Zoom controls copy with focus restoration + const [isZoomCopied, setIsZoomCopied] = useState(false); + const handleZoomCopy = useCallback(() => { + copy(children.trim(), { format: 'text/plain' }); + setIsZoomCopied(true); + requestAnimationFrame(() => { + zoomCopyButtonRef.current?.focus(); + }); + setTimeout(() => { + setIsZoomCopied(false); + requestAnimationFrame(() => { + zoomCopyButtonRef.current?.focus(); + }); + }, 3000); + }, [children]); + + // Dialog zoom controls copy + const handleDialogZoomCopy = useCallback(() => { + copy(children.trim(), { format: 'text/plain' }); + requestAnimationFrame(() => { + dialogZoomCopyButtonRef.current?.focus(); + }); + }, [children]); + const handleRetry = () => { setRetryCount((prev) => prev + 1); }; @@ -392,6 +418,19 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { > +
+
); @@ -438,6 +477,19 @@ const Mermaid: React.FC = memo(({ children, id, theme }) => { > +
+
); diff --git a/client/src/components/Messages/Content/MermaidErrorBoundary.tsx b/client/src/components/Messages/Content/MermaidErrorBoundary.tsx new file mode 100644 index 0000000000..a2edca062c --- /dev/null +++ b/client/src/components/Messages/Content/MermaidErrorBoundary.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +interface MermaidErrorBoundaryProps { + children: React.ReactNode; + /** The mermaid code to display as fallback */ + code: string; +} + +interface MermaidErrorBoundaryState { + hasError: boolean; +} + +/** + * Error boundary specifically for Mermaid diagrams. + * Falls back to displaying the raw mermaid code if rendering fails. + */ +class MermaidErrorBoundary extends React.Component< + MermaidErrorBoundaryProps, + MermaidErrorBoundaryState +> { + constructor(props: MermaidErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): MermaidErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Mermaid rendering error:', error, errorInfo); + } + + componentDidUpdate(prevProps: MermaidErrorBoundaryProps) { + // Reset error state if code changes (e.g., user edits the message) + if (prevProps.code !== this.props.code && this.state.hasError) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) { + return ( +
+
+ {'mermaid'} +
+
+            {this.props.code}
+          
+
+ ); + } + + return this.props.children; + } +} + +export default MermaidErrorBoundary; diff --git a/client/src/components/Messages/Content/RunCode.tsx b/client/src/components/Messages/Content/RunCode.tsx index 197865e2b0..020b00703c 100644 --- a/client/src/components/Messages/Content/RunCode.tsx +++ b/client/src/components/Messages/Content/RunCode.tsx @@ -86,7 +86,9 @@ const RunCode: React.FC = React.memo(({ lang, codeRef, blockIndex <>