Files
stinkin_badges/src/components/BadgeCanvas.tsx

160 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}