fix: remove code block language label overlap, add diagram zoom/pan/fullscreen

- Remove 'bash' language label that overlapped with code text
- Add zoom (scroll), pan (drag), and fullscreen toggle to Mermaid diagrams
- Fullscreen mode with dark overlay, controls toolbar, and Esc to close
- Zoom percentage indicator and reset button
This commit is contained in:
Vectry
2026-02-09 18:45:47 +00:00
parent 31be269aab
commit dd03d86642
2 changed files with 175 additions and 24 deletions

View File

@@ -20,7 +20,6 @@ export function CodeBlock({ children, className, inline }: CodeBlockProps) {
); );
} }
const language = className?.replace("language-", "") || "";
const codeString = String(children).replace(/\n$/, ""); const codeString = String(children).replace(/\n$/, "");
const handleCopy = async () => { const handleCopy = async () => {
@@ -31,11 +30,6 @@ export function CodeBlock({ children, className, inline }: CodeBlockProps) {
return ( return (
<div className="relative group my-4"> <div className="relative group my-4">
{language && (
<div className="absolute top-0 left-0 px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-zinc-500 bg-black/30 rounded-tl-lg rounded-br-lg border-b border-r border-white/5">
{language}
</div>
)}
<button <button
onClick={handleCopy} onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-white/5 border border-white/10 text-zinc-500 hover:text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 z-10" className="absolute top-2 right-2 p-1.5 rounded-md bg-white/5 border border-white/10 text-zinc-500 hover:text-white hover:bg-white/10 transition-all opacity-0 group-hover:opacity-100 z-10"
@@ -47,7 +41,7 @@ export function CodeBlock({ children, className, inline }: CodeBlockProps) {
<Copy className="w-3.5 h-3.5" /> <Copy className="w-3.5 h-3.5" />
)} )}
</button> </button>
<pre className="overflow-x-auto rounded-lg bg-black/50 border border-white/10 p-4 pt-8 text-sm leading-relaxed font-mono scrollbar-thin"> <pre className="overflow-x-auto rounded-lg bg-black/50 border border-white/10 p-4 text-sm leading-relaxed font-mono scrollbar-thin">
<code className={`text-zinc-300 ${className || ""}`}>{codeString}</code> <code className={`text-zinc-300 ${className || ""}`}>{codeString}</code>
</pre> </pre>
</div> </div>

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { Maximize2, Minimize2, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
interface MermaidDiagramProps { interface MermaidDiagramProps {
chart: string; chart: string;
@@ -11,6 +12,12 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [svgHtml, setSvgHtml] = useState<string>("");
const [isFullscreen, setIsFullscreen] = useState(false);
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const panStart = useRef({ x: 0, y: 0 });
useEffect(() => { useEffect(() => {
mermaid.initialize({ mermaid.initialize({
@@ -38,25 +45,85 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isReady || !containerRef.current || !chart) return; if (!isReady || !chart) return;
const renderChart = async () => { const renderChart = async () => {
try { try {
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`; const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
const { svg } = await mermaid.render(id, chart); const { svg } = await mermaid.render(id, chart);
setSvgHtml(svg);
if (containerRef.current) { setError(null);
containerRef.current.innerHTML = svg;
setError(null);
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to render diagram"); setError(
err instanceof Error ? err.message : "Failed to render diagram"
);
} }
}; };
renderChart(); renderChart();
}, [chart, isReady]); }, [chart, isReady]);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoom((prev) => Math.min(Math.max(0.3, prev + delta), 5));
}, []);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
setIsPanning(true);
panStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
},
[pan]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isPanning) return;
setPan({
x: e.clientX - panStart.current.x,
y: e.clientY - panStart.current.y,
});
},
[isPanning]
);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
const resetView = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
useEffect(() => {
if (!isFullscreen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsFullscreen(false);
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isFullscreen]);
useEffect(() => {
if (isFullscreen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isFullscreen]);
if (error) { if (error) {
return ( return (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20"> <div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20">
@@ -66,17 +133,107 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) {
); );
} }
return ( const controls = (
<div <div className="flex items-center gap-1">
ref={containerRef} <button
className="mermaid-diagram overflow-x-auto" onClick={() => setZoom((z) => Math.min(z + 0.25, 5))}
style={{ minHeight: "100px" }} className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
title="Zoom in"
>
<ZoomIn className="w-4 h-4" />
</button>
<span className="text-xs text-zinc-500 w-12 text-center tabular-nums">
{Math.round(zoom * 100)}%
</span>
<button
onClick={() => setZoom((z) => Math.max(z - 0.25, 0.3))}
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
title="Zoom out"
>
<ZoomOut className="w-4 h-4" />
</button>
<div className="w-px h-4 bg-white/10 mx-1" />
<button
onClick={resetView}
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
title="Reset view"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={toggleFullscreen}
className="p-1.5 rounded-md text-zinc-500 hover:text-white hover:bg-white/10 transition-colors"
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
>
{isFullscreen ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
</div>
);
const diagramView = (fullHeight?: boolean) => (
<div
className={`overflow-hidden ${isPanning ? "cursor-grabbing" : "cursor-grab"}`}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={fullHeight ? { height: "100%" } : { minHeight: "100px" }}
> >
{!isReady && ( <div
<div className="flex items-center justify-center py-8"> className="mermaid-diagram flex items-center justify-center"
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" /> style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "center center",
transition: isPanning ? "none" : "transform 0.15s ease-out",
minHeight: fullHeight ? "100%" : "100px",
}}
dangerouslySetInnerHTML={svgHtml ? { __html: svgHtml } : undefined}
>
{!svgHtml && !isReady && (
<div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
</div>
</div>
);
if (isFullscreen) {
return (
<>
<div style={{ minHeight: "100px" }} />
<div className="fixed inset-0 z-50 bg-[#0a0a0f]/95 backdrop-blur-sm flex flex-col">
<div className="flex items-center justify-between px-6 py-3 border-b border-white/10">
<span className="text-sm text-zinc-400">Architecture Diagram</span>
{controls}
</div>
<div className="flex-1 overflow-hidden">
{diagramView(true)}
</div>
<div className="px-6 py-2 border-t border-white/10 text-center">
<span className="text-xs text-zinc-600">
Scroll to zoom · Drag to pan · Esc to close
</span>
</div>
</div> </div>
)} </>
);
}
return (
<div className="relative">
<div className="flex items-center justify-end mb-2">{controls}</div>
{diagramView()}
<div className="mt-2 text-center">
<span className="text-xs text-zinc-600">
Scroll to zoom · Drag to pan
</span>
</div>
</div> </div>
); );
} }