160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
import { useMemo, useRef, useState, useCallback } from 'react';
|
||
import { useBadgeStore } from '@/stores/badgeStore';
|
||
import { generateBadgeSVG } from '@/utils/svgGenerators';
|
||
|
||
export function BadgeCanvas() {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const [zoom, setZoom] = useState(1);
|
||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||
const [isPanning, setIsPanning] = useState(false);
|
||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||
const [autoCenterOnZoom, setAutoCenterOnZoom] = useState(true);
|
||
|
||
const geometry = useBadgeStore((state) => state.geometry);
|
||
const star = useBadgeStore((state) => state.star);
|
||
const shield = useBadgeStore((state) => state.shield);
|
||
const arcTexts = useBadgeStore((state) => state.text.arcTexts);
|
||
const ribbonTexts = useBadgeStore((state) => state.text.ribbonTexts);
|
||
|
||
const svgString = useMemo(() => {
|
||
return generateBadgeSVG(geometry, star, shield, arcTexts, ribbonTexts);
|
||
}, [geometry, star, shield, arcTexts, ribbonTexts]);
|
||
|
||
const diameterPx = geometry.diameter * 96;
|
||
|
||
// Calculate pan position to keep badge centered when zooming
|
||
const calculateCenteredPan = useCallback((zoomLevel: number) => {
|
||
// Badge center is at (diameterPx/2, diameterPx/2)
|
||
// ViewBox center should map to badge center
|
||
// pan.x + viewBoxWidth/2 = diameterPx/2
|
||
// pan.x = diameterPx/2 - viewBoxWidth/2
|
||
// pan.x = diameterPx/2 - (diameterPx/zoom)/2
|
||
// pan.x = diameterPx * (1 - 1/zoom) / 2
|
||
const panX = diameterPx * (1 - 1 / zoomLevel) / 2;
|
||
const panY = diameterPx * (1 - 1 / zoomLevel) / 2;
|
||
return { x: panX, y: panY };
|
||
}, [diameterPx]);
|
||
|
||
const viewBox = useMemo(() => {
|
||
const baseWidth = diameterPx;
|
||
const baseHeight = diameterPx;
|
||
// Use auto-centered pan when auto-centering is enabled, otherwise use manual pan
|
||
const effectivePan = autoCenterOnZoom ? calculateCenteredPan(zoom) : pan;
|
||
return `${effectivePan.x} ${effectivePan.y} ${baseWidth / zoom} ${baseHeight / zoom}`;
|
||
}, [diameterPx, zoom, pan, autoCenterOnZoom, calculateCenteredPan]);
|
||
|
||
const handleWheel = (e: React.WheelEvent) => {
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||
const newZoom = Math.max(0.1, Math.min(5, zoom * delta));
|
||
setZoom(newZoom);
|
||
// Auto-center on zoom unless user is actively panning
|
||
if (!isPanning) {
|
||
setAutoCenterOnZoom(true);
|
||
}
|
||
};
|
||
|
||
const handleMouseDown = (e: React.MouseEvent) => {
|
||
if (e.button === 1 || (e.button === 0 && e.ctrlKey)) {
|
||
setIsPanning(true);
|
||
// When starting to pan, disable auto-center and initialize pan position
|
||
if (autoCenterOnZoom) {
|
||
// If we were auto-centering, calculate and set the current pan position
|
||
const currentPan = calculateCenteredPan(zoom);
|
||
setPan(currentPan);
|
||
setAutoCenterOnZoom(false);
|
||
setPanStart({ x: e.clientX - currentPan.x, y: e.clientY - currentPan.y });
|
||
} else {
|
||
// If already in manual pan mode, use existing pan
|
||
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||
}
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
const handleMouseMove = (e: React.MouseEvent) => {
|
||
if (isPanning) {
|
||
setPan({
|
||
x: e.clientX - panStart.x,
|
||
y: e.clientY - panStart.y,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleMouseUp = () => {
|
||
setIsPanning(false);
|
||
};
|
||
|
||
const handleZoomIn = () => {
|
||
const newZoom = Math.min(5, zoom * 1.2);
|
||
setZoom(newZoom);
|
||
setAutoCenterOnZoom(true);
|
||
};
|
||
|
||
const handleZoomOut = () => {
|
||
const newZoom = Math.max(0.1, zoom / 1.2);
|
||
setZoom(newZoom);
|
||
setAutoCenterOnZoom(true);
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setZoom(1);
|
||
setPan({ x: 0, y: 0 });
|
||
setAutoCenterOnZoom(true);
|
||
};
|
||
|
||
// Parse and update SVG with viewBox
|
||
const svgWithViewBox = useMemo(() => {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(svgString, 'image/svg+xml');
|
||
const svgElement = doc.documentElement;
|
||
svgElement.setAttribute('viewBox', viewBox);
|
||
svgElement.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||
svgElement.setAttribute('width', '100%');
|
||
svgElement.setAttribute('height', '100%');
|
||
return svgElement.outerHTML;
|
||
}, [svgString, viewBox]);
|
||
|
||
return (
|
||
<div className="relative w-full h-full bg-gray-100 border border-gray-300 rounded-lg overflow-hidden">
|
||
<div
|
||
ref={containerRef}
|
||
className="w-full h-full flex items-center justify-center"
|
||
onWheel={handleWheel}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseMove={handleMouseMove}
|
||
onMouseUp={handleMouseUp}
|
||
onMouseLeave={handleMouseUp}
|
||
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
|
||
dangerouslySetInnerHTML={{ __html: svgWithViewBox }}
|
||
/>
|
||
|
||
{/* Zoom controls */}
|
||
<div className="absolute top-4 right-4 flex flex-col gap-2">
|
||
<button
|
||
onClick={handleZoomIn}
|
||
className="px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={handleZoomOut}
|
||
className="px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={handleReset}
|
||
className="px-3 py-1 bg-white border border-gray-300 rounded hover:bg-gray-50 text-sm"
|
||
>
|
||
Reset
|
||
</button>
|
||
<div className="px-3 py-1 bg-white border border-gray-300 rounded text-sm text-center">
|
||
{Math.round(zoom * 100)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|