mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-27 05:38:51 +01:00
📋 feat: Add Floating Copy Button to Code Blocks (#11113)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 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
This commit is contained in:
parent
3503b7caeb
commit
daed6d9c0e
5 changed files with 244 additions and 14 deletions
|
|
@ -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 <Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>;
|
||||
return (
|
||||
<MermaidErrorBoundary code={content}>
|
||||
<Mermaid id={`mermaid-${blockIndex}`}>{content}</Mermaid>
|
||||
</MermaidErrorBoundary>
|
||||
);
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
|
|
|||
|
|
@ -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<CodeBarProps> = React.memo(
|
||||
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true }) => {
|
||||
const localize = useLocalize();
|
||||
|
|
@ -51,16 +55,14 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
|
|||
}
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<CheckMark className="h-[18px] w-[18px]" />
|
||||
{error === true ? '' : localize('com_ui_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clipboard />
|
||||
{error === true ? '' : localize('com_ui_copy_code')}
|
||||
</>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{error !== true && (
|
||||
<span className="relative">
|
||||
<span className="invisible">{localize('com_ui_copy_code')}</span>
|
||||
<span className="absolute inset-0 flex items-center">
|
||||
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -70,6 +72,75 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(
|
|||
},
|
||||
);
|
||||
|
||||
const FloatingCodeBar: React.FC<FloatingCodeBarProps> = React.memo(
|
||||
({ lang, error, codeRef, blockIndex, plugin = null, allowExecution = true, isVisible }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const copyButtonRef = useRef<HTMLButtonElement>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-2 right-2 flex items-center gap-2 font-sans text-xs text-gray-200 transition-opacity duration-150',
|
||||
isVisible ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
{plugin === true ? (
|
||||
<InfoIcon className="flex h-4 w-4 gap-2 text-white/50" />
|
||||
) : (
|
||||
<>
|
||||
{allowExecution === true && (
|
||||
<RunCode lang={lang} codeRef={codeRef} blockIndex={blockIndex} />
|
||||
)}
|
||||
<button
|
||||
ref={copyButtonRef}
|
||||
type="button"
|
||||
tabIndex={isVisible ? 0 : -1}
|
||||
className={cn(
|
||||
'flex gap-2 rounded px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
|
||||
error === true ? 'h-4 w-4 items-start text-white/50' : '',
|
||||
)}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
|
||||
{error !== true && (
|
||||
<span className="relative">
|
||||
<span className="invisible">{localize('com_ui_copy_code')}</span>
|
||||
<span className="absolute inset-0 flex items-center">
|
||||
{isCopied ? localize('com_ui_copied') : localize('com_ui_copy_code')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const CodeBlock: React.FC<CodeBlockProps> = ({
|
||||
lang,
|
||||
blockIndex,
|
||||
|
|
@ -80,6 +151,8 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
error,
|
||||
}) => {
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isBarVisible, setIsBarVisible] = useState(false);
|
||||
const toolCallsMap = useToolCallsMapContext();
|
||||
const { messageId, partIndex } = useMessageContext();
|
||||
const key = allowExecution
|
||||
|
|
@ -97,6 +170,29 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
}
|
||||
}, [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<CodeBlockProps> = ({
|
|||
const language = isNonCode ? 'json' : lang;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md bg-gray-900 text-xs text-white/80">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full rounded-md bg-gray-900 text-xs text-white/80"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<CodeBar
|
||||
lang={lang}
|
||||
error={error}
|
||||
|
|
@ -137,6 +240,15 @@ const CodeBlock: React.FC<CodeBlockProps> = ({
|
|||
{codeChildren}
|
||||
</code>
|
||||
</div>
|
||||
<FloatingCodeBar
|
||||
lang={lang}
|
||||
error={error}
|
||||
codeRef={codeRef}
|
||||
blockIndex={blockIndex}
|
||||
plugin={plugin === true}
|
||||
allowExecution={allowExecution}
|
||||
isVisible={isBarVisible}
|
||||
/>
|
||||
{allowExecution === true && toolCalls && toolCalls.length > 0 && (
|
||||
<>
|
||||
<div className="bg-gray-700 p-4 text-xs">
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
|
|||
const copyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogShowCodeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogCopyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const zoomCopyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogZoomCopyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Zoom and pan state
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
|
@ -154,6 +156,30 @@ const Mermaid: React.FC<MermaidProps> = 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<MermaidProps> = memo(({ children, id, theme }) => {
|
|||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
ref={zoomCopyButtonRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleZoomCopy();
|
||||
}}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
|
||||
title={localize('com_ui_copy_code')}
|
||||
>
|
||||
{isZoomCopied ? <CheckMark className="h-4 w-4" /> : <Clipboard className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -438,6 +477,19 @@ const Mermaid: React.FC<MermaidProps> = memo(({ children, id, theme }) => {
|
|||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-border-medium" />
|
||||
<button
|
||||
ref={dialogZoomCopyButtonRef}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDialogZoomCopy();
|
||||
}}
|
||||
className="rounded p-1.5 text-text-secondary hover:bg-surface-hover"
|
||||
title={localize('com_ui_copy_code')}
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="w-full overflow-hidden rounded-md border border-border-light">
|
||||
<div className="rounded-t-md bg-gray-700 px-4 py-2 font-sans text-xs text-gray-200">
|
||||
{'mermaid'}
|
||||
</div>
|
||||
<pre className="overflow-auto whitespace-pre-wrap rounded-b-md bg-gray-900 p-4 font-mono text-xs text-gray-300">
|
||||
{this.props.code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default MermaidErrorBoundary;
|
||||
|
|
@ -86,7 +86,9 @@ const RunCode: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, blockIndex
|
|||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('ml-auto flex gap-2 rounded-sm focus:outline focus:outline-white')}
|
||||
className={cn(
|
||||
'ml-auto flex gap-2 rounded-sm px-2 py-1 hover:bg-gray-700 focus:bg-gray-700 focus:outline focus:outline-white',
|
||||
)}
|
||||
onClick={debouncedExecute}
|
||||
disabled={execute.isLoading}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue