Skip to content

Commit daed6d9

Browse files
authored
📋 feat: Add Floating Copy Button to Code Blocks (#11113)
* 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
1 parent 3503b7c commit daed6d9

File tree

5 files changed

+244
-14
lines changed

5 files changed

+244
-14
lines changed

client/src/components/Chat/Messages/Content/MarkdownComponents.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { memo, useMemo, useRef, useEffect } from 'react';
22
import { useRecoilValue } from 'recoil';
33
import { useToastContext } from '@librechat/client';
44
import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider';
5+
import MermaidErrorBoundary from '~/components/Messages/Content/MermaidErrorBoundary';
56
import CodeBlock from '~/components/Messages/Content/CodeBlock';
67
import Mermaid from '~/components/Messages/Content/Mermaid';
78
import useHasAccess from '~/hooks/Roles/useHasAccess';
@@ -39,7 +40,11 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
3940
return <>{children}</>;
4041
} else if (isMermaid) {
4142
const content = typeof children === 'string' ? children : String(children);
42-
return <Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>;
43+
return (
44+
<MermaidErrorBoundary code={content}>
45+
<Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>
46+
</MermaidErrorBoundary>
47+
);
4348
} else if (isSingleLine) {
4449
return (
4550
<code onDoubleClick={handleDoubleClick} className={className}>

client/src/components/Messages/Content/CodeBlock.tsx

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useRef, useState, useMemo, useEffect } from 'react';
1+
import React, { useRef, useState, useMemo, useEffect, useCallback } from 'react';
22
import copy from 'copy-to-clipboard';
33
import { InfoIcon } from 'lucide-react';
44
import { Tools } from 'librechat-data-provider';
@@ -19,6 +19,10 @@ type CodeBlockProps = Pick<
1919
classProp?: string;
2020
};
2121

22+
interface FloatingCodeBarProps extends CodeBarProps {
23+
isVisible: boolean;
24+
}
25+
2226
const CodeBar: React.FC<CodeBarProps> = React.memo(
2327
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
2428
const localize = useLocalize();
@@ -51,16 +55,14 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
5155
}
5256
}}
5357
>
54-
{isCopied ? (
55-
<>
56-
<CheckMark className="h-[18px] w-[18px]" />
57-
{error === true ? '' : localize('com_ui_copied')}
58-
</>
59-
) : (
60-
<>
61-
<Clipboard />
62-
{error === true ? '' : localize('com_ui_copy_code')}
63-
</>
58+
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
59+
{error !== true && (
60+
<span className="relative">
61+
<span className="invisible">{localize('com_ui_copy_code')}</span>
62+
<span className="absolute inset-0 flex items-center">
63+
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
64+
</span>
65+
</span>
6466
)}
6567
</button>
6668
</div>
@@ -70,6 +72,75 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
7072
},
7173
);
7274

75+
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
76+
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
77+
const localize = useLocalize();
78+
const [isCopied, setIsCopied] = useState(false);
79+
const copyButtonRef = useRef<HTMLButtonElement>(null);
80+
81+
const handleCopy = useCallback(() => {
82+
const codeString = codeRef.current?.textContent;
83+
if (codeString != null) {
84+
const wasFocused = document.activeElement === copyButtonRef.current;
85+
setIsCopied(true);
86+
copy(codeString.trim(), { format: 'text/plain' });
87+
if (wasFocused) {
88+
requestAnimationFrame(() => {
89+
copyButtonRef.current?.focus();
90+
});
91+
}
92+
93+
setTimeout(() => {
94+
const focusedElement = document.activeElement as HTMLElement | null;
95+
setIsCopied(false);
96+
requestAnimationFrame(() => {
97+
focusedElement?.focus();
98+
});
99+
}, 3000);
100+
}
101+
}, [codeRef]);
102+
103+
return (
104+
<div
105+
className={cn(
106+
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
107+
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
108+
)}
109+
>
110+
{plugin === true ? (
111+
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
112+
) : (
113+
<>
114+
{allowExecution === true && (
115+
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
116+
)}
117+
<button
118+
ref={copyButtonRef}
119+
type="button"
120+
tabIndex={isVisible ? 0 : -1}
121+
className={cn(
122+
'flex gap-2 rounded px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
123+
error === true ? 'h-4 w-4 items-start text-white/50' : '',
124+
)}
125+
onClick={handleCopy}
126+
>
127+
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
128+
{error !== true && (
129+
<span className="relative">
130+
<span className="invisible">{localize('com_ui_copy_code')}</span>
131+
<span className="absolute inset-0 flex items-center">
132+
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
133+
</span>
134+
</span>
135+
)}
136+
</button>
137+
</>
138+
)}
139+
</div>
140+
);
141+
},
142+
);
143+
73144
const CodeBlock: React.FC<CodeBlockProps> = ({
74145
lang,
75146
blockIndex,
@@ -80,6 +151,8 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
80151
error,
81152
}) => {
82153
const codeRef = useRef<HTMLElement>(null);
154+
const containerRef = useRef<HTMLDivElement>(null);
155+
const [isBarVisible, setIsBarVisible] = useState(false);
83156
const toolCallsMap = useToolCallsMapContext();
84157
const { messageId, partIndex } = useMessageContext();
85158
const key = allowExecution
@@ -97,6 +170,29 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
97170
}
98171
}, [fetchedToolCalls]);
99172

173+
// Handle focus within the container (for keyboard navigation)
174+
const handleFocus = useCallback(() => {
175+
setIsBarVisible(true);
176+
}, []);
177+
178+
const handleBlur = useCallback((e: React.FocusEvent) => {
179+
// Check if focus is moving to another element within the container
180+
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
181+
setIsBarVisible(false);
182+
}
183+
}, []);
184+
185+
const handleMouseEnter = useCallback(() => {
186+
setIsBarVisible(true);
187+
}, []);
188+
189+
const handleMouseLeave = useCallback(() => {
190+
// Only hide if no element inside has focus
191+
if (!containerRef.current?.contains(document.activeElement)) {
192+
setIsBarVisible(false);
193+
}
194+
}, []);
195+
100196
const currentToolCall = useMemo(() => toolCalls?.[currentIndex], [toolCalls, currentIndex]);
101197

102198
const next = () => {
@@ -118,7 +214,14 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
118214
const language = isNonCode ? 'json' : lang;
119215

120216
return (
121-
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
217+
<div
218+
ref={containerRef}
219+
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
220+
onMouseEnter={handleMouseEnter}
221+
onMouseLeave={handleMouseLeave}
222+
onFocus={handleFocus}
223+
onBlur={handleBlur}
224+
>
122225
<CodeBar
123226
lang={lang}
124227
error={error}
@@ -137,6 +240,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
137240
{codeChildren}
138241
</code>
139242
</div>
243+
<FloatingCodeBar
244+
lang={lang}
245+
error={error}
246+
codeRef={codeRef}
247+
blockIndex={blockIndex}
248+
plugin={plugin === true}
249+
allowExecution={allowExecution}
250+
isVisible={isBarVisible}
251+
/>
140252
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
141253
<>
142254
<div className="bg-gray-700 p-4 text-xs">

client/src/components/Messages/Content/Mermaid.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
4949
const copyButtonRef = useRef<HTMLButtonElement>(null);
5050
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
5151
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
52+
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
53+
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
5254

5355
// Zoom and pan state
5456
const [zoom, setZoom] = useState(1);
@@ -154,6 +156,30 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
154156
});
155157
}, [children]);
156158

159+
// Zoom controls copy with focus restoration
160+
const [isZoomCopied, setIsZoomCopied] = useState(false);
161+
const handleZoomCopy = useCallback(() => {
162+
copy(children.trim(), { format: 'text/plain' });
163+
setIsZoomCopied(true);
164+
requestAnimationFrame(() => {
165+
zoomCopyButtonRef.current?.focus();
166+
});
167+
setTimeout(() => {
168+
setIsZoomCopied(false);
169+
requestAnimationFrame(() => {
170+
zoomCopyButtonRef.current?.focus();
171+
});
172+
}, 3000);
173+
}, [children]);
174+
175+
// Dialog zoom controls copy
176+
const handleDialogZoomCopy = useCallback(() => {
177+
copy(children.trim(), { format: 'text/plain' });
178+
requestAnimationFrame(() => {
179+
dialogZoomCopyButtonRef.current?.focus();
180+
});
181+
}, [children]);
182+
157183
const handleRetry = () => {
158184
setRetryCount((prev) => prev + 1);
159185
};
@@ -392,6 +418,19 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
392418
>
393419
<RotateCcw className="h-4 w-4" />
394420
</button>
421+
<div className="mx-1 h-4 w-px bg-border-medium" />
422+
<button
423+
ref={zoomCopyButtonRef}
424+
type="button"
425+
onClick={(e) => {
426+
e.stopPropagation();
427+
handleZoomCopy();
428+
}}
429+
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
430+
title={localize('com_ui_copy_code')}
431+
>
432+
{isZoomCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
433+
</button>
395434
</div>
396435
);
397436

@@ -438,6 +477,19 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
438477
>
439478
<RotateCcw className="h-4 w-4" />
440479
</button>
480+
<div className="mx-1 h-4 w-px bg-border-medium" />
481+
<button
482+
ref={dialogZoomCopyButtonRef}
483+
type="button"
484+
onClick={(e) => {
485+
e.stopPropagation();
486+
handleDialogZoomCopy();
487+
}}
488+
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
489+
title={localize('com_ui_copy_code')}
490+
>
491+
<Clipboard className="h-4 w-4" />
492+
</button>
441493
</div>
442494
);
443495

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
3+
interface MermaidErrorBoundaryProps {
4+
children: React.ReactNode;
5+
/** The mermaid code to display as fallback */
6+
code: string;
7+
}
8+
9+
interface MermaidErrorBoundaryState {
10+
hasError: boolean;
11+
}
12+
13+
/**
14+
* Error boundary specifically for Mermaid diagrams.
15+
* Falls back to displaying the raw mermaid code if rendering fails.
16+
*/
17+
class MermaidErrorBoundary extends React.Component<
18+
MermaidErrorBoundaryProps,
19+
MermaidErrorBoundaryState
20+
> {
21+
constructor(props: MermaidErrorBoundaryProps) {
22+
super(props);
23+
this.state = { hasError: false };
24+
}
25+
26+
static getDerivedStateFromError(): MermaidErrorBoundaryState {
27+
return { hasError: true };
28+
}
29+
30+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
31+
console.error('Mermaid rendering error:', error, errorInfo);
32+
}
33+
34+
componentDidUpdate(prevProps: MermaidErrorBoundaryProps) {
35+
// Reset error state if code changes (e.g., user edits the message)
36+
if (prevProps.code !== this.props.code && this.state.hasError) {
37+
this.setState({ hasError: false });
38+
}
39+
}
40+
41+
render() {
42+
if (this.state.hasError) {
43+
return (
44+
<div className="w-full overflow-hidden rounded-md border border-border-light">
45+
<div className="rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
46+
{'mermaid'}
47+
</div>
48+
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-gray-900 p-4 font-mono text-xs text-gray-300">
49+
{this.props.code}
50+
</pre>
51+
</div>
52+
);
53+
}
54+
55+
return this.props.children;
56+
}
57+
}
58+
59+
export default MermaidErrorBoundary;

client/src/components/Messages/Content/RunCode.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
8686
<>
8787
<button
8888
type="button"
89-
className={cn('ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white')}
89+
className={cn(
90+
'ml-auto flex gap-2 rounded-sm px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
91+
)}
9092
onClick={debouncedExecute}
9193
disabled={execute.isLoading}
9294
>

0 commit comments

Comments
 (0)