|
|
@@ -70,6 +70,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
|
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
|
|
const [isPanning, setIsPanning] = useState(false);
|
|
|
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
|
|
+ const [lastTouchDistance, setLastTouchDistance] = useState<number | null>(null);
|
|
|
+ const [lastTouchCenter, setLastTouchCenter] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
|
|
// Stream state
|
|
|
const [streamError, setStreamError] = useState(false);
|
|
|
@@ -260,15 +262,26 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ // Calculate max pan based on container size and zoom level
|
|
|
+ const getMaxPan = useCallback(() => {
|
|
|
+ if (!containerRef.current || !imgRef.current) {
|
|
|
+ return { x: 200, y: 150 };
|
|
|
+ }
|
|
|
+ const container = containerRef.current.getBoundingClientRect();
|
|
|
+ // Allow panning up to half the zoomed overflow in each direction
|
|
|
+ const maxX = (container.width * (zoomLevel - 1)) / 2;
|
|
|
+ const maxY = (container.height * (zoomLevel - 1)) / 2;
|
|
|
+ return { x: Math.max(50, maxX), y: Math.max(50, maxY) };
|
|
|
+ }, [zoomLevel]);
|
|
|
+
|
|
|
const handleImageMouseMove = (e: React.MouseEvent) => {
|
|
|
if (isPanning && zoomLevel > 1) {
|
|
|
const newX = e.clientX - panStart.x;
|
|
|
const newY = e.clientY - panStart.y;
|
|
|
- // Limit panning based on zoom level
|
|
|
- const maxPan = (zoomLevel - 1) * 150;
|
|
|
+ const maxPan = getMaxPan();
|
|
|
setPanOffset({
|
|
|
- x: Math.max(-maxPan, Math.min(maxPan, newX)),
|
|
|
- y: Math.max(-maxPan, Math.min(maxPan, newY)),
|
|
|
+ x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
|
|
|
+ y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
|
|
|
});
|
|
|
}
|
|
|
};
|
|
|
@@ -277,6 +290,91 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
|
|
setIsPanning(false);
|
|
|
};
|
|
|
|
|
|
+ // Touch event handlers for mobile
|
|
|
+ const getTouchDistance = (touches: React.TouchList) => {
|
|
|
+ if (touches.length < 2) return 0;
|
|
|
+ const dx = touches[0].clientX - touches[1].clientX;
|
|
|
+ const dy = touches[0].clientY - touches[1].clientY;
|
|
|
+ return Math.sqrt(dx * dx + dy * dy);
|
|
|
+ };
|
|
|
+
|
|
|
+ const getTouchCenter = (touches: React.TouchList) => {
|
|
|
+ if (touches.length < 2) {
|
|
|
+ return { x: touches[0].clientX, y: touches[0].clientY };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ x: (touches[0].clientX + touches[1].clientX) / 2,
|
|
|
+ y: (touches[0].clientY + touches[1].clientY) / 2,
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTouchStart = (e: React.TouchEvent) => {
|
|
|
+ if (e.touches.length === 2) {
|
|
|
+ // Pinch gesture start
|
|
|
+ e.preventDefault();
|
|
|
+ setLastTouchDistance(getTouchDistance(e.touches));
|
|
|
+ setLastTouchCenter(getTouchCenter(e.touches));
|
|
|
+ } else if (e.touches.length === 1 && zoomLevel > 1) {
|
|
|
+ // Single touch pan start
|
|
|
+ e.preventDefault();
|
|
|
+ setIsPanning(true);
|
|
|
+ setPanStart({
|
|
|
+ x: e.touches[0].clientX - panOffset.x,
|
|
|
+ y: e.touches[0].clientY - panOffset.y,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTouchMove = (e: React.TouchEvent) => {
|
|
|
+ if (e.touches.length === 2 && lastTouchDistance !== null) {
|
|
|
+ // Pinch gesture
|
|
|
+ e.preventDefault();
|
|
|
+ const newDistance = getTouchDistance(e.touches);
|
|
|
+ const scale = newDistance / lastTouchDistance;
|
|
|
+
|
|
|
+ setZoomLevel(prev => {
|
|
|
+ const newZoom = Math.max(1, Math.min(4, prev * scale));
|
|
|
+ if (newZoom === 1) {
|
|
|
+ setPanOffset({ x: 0, y: 0 });
|
|
|
+ }
|
|
|
+ return newZoom;
|
|
|
+ });
|
|
|
+
|
|
|
+ setLastTouchDistance(newDistance);
|
|
|
+
|
|
|
+ // Also handle pan during pinch
|
|
|
+ const newCenter = getTouchCenter(e.touches);
|
|
|
+ if (lastTouchCenter) {
|
|
|
+ const maxPan = getMaxPan();
|
|
|
+ setPanOffset(prev => ({
|
|
|
+ x: Math.max(-maxPan.x, Math.min(maxPan.x, prev.x + (newCenter.x - lastTouchCenter.x))),
|
|
|
+ y: Math.max(-maxPan.y, Math.min(maxPan.y, prev.y + (newCenter.y - lastTouchCenter.y))),
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ setLastTouchCenter(newCenter);
|
|
|
+ } else if (e.touches.length === 1 && isPanning && zoomLevel > 1) {
|
|
|
+ // Single touch pan
|
|
|
+ e.preventDefault();
|
|
|
+ const newX = e.touches[0].clientX - panStart.x;
|
|
|
+ const newY = e.touches[0].clientY - panStart.y;
|
|
|
+ const maxPan = getMaxPan();
|
|
|
+ setPanOffset({
|
|
|
+ x: Math.max(-maxPan.x, Math.min(maxPan.x, newX)),
|
|
|
+ y: Math.max(-maxPan.y, Math.min(maxPan.y, newY)),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTouchEnd = (e: React.TouchEvent) => {
|
|
|
+ if (e.touches.length < 2) {
|
|
|
+ setLastTouchDistance(null);
|
|
|
+ setLastTouchCenter(null);
|
|
|
+ }
|
|
|
+ if (e.touches.length === 0) {
|
|
|
+ setIsPanning(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
const resetZoom = () => {
|
|
|
setZoomLevel(1);
|
|
|
setPanOffset({ x: 0, y: 0 });
|
|
|
@@ -435,6 +533,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
|
|
|
onMouseMove={handleImageMouseMove}
|
|
|
onMouseUp={handleImageMouseUp}
|
|
|
onMouseLeave={handleImageMouseUp}
|
|
|
+ onTouchStart={handleTouchStart}
|
|
|
+ onTouchMove={handleTouchMove}
|
|
|
+ onTouchEnd={handleTouchEnd}
|
|
|
+ style={{ touchAction: 'none' }}
|
|
|
>
|
|
|
{streamLoading && !isReconnecting && (
|
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
|