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:
@@ -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>
|
||||||
|
|||||||
@@ -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 className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setZoom((z) => Math.min(z + 0.25, 5))}
|
||||||
|
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
|
<div
|
||||||
ref={containerRef}
|
className={`overflow-hidden ${isPanning ? "cursor-grabbing" : "cursor-grab"}`}
|
||||||
className="mermaid-diagram overflow-x-auto"
|
onWheel={handleWheel}
|
||||||
style={{ minHeight: "100px" }}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user