import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AlertCircle, Box, CheckSquare, Loader2, Maximize2, Square, X } from 'lucide-react'; import { api, withStreamToken } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { pickObjectIdAt, plateClickToMaskPoint } from '../utils/skipObjects'; import { ConfirmModal } from './ConfirmModal'; export const SkipObjectsIcon = ({ className }: { className?: string }) => ( ); interface SkipObjectsModalProps { printerId: number; isOpen: boolean; onClose: () => void; } interface PrintableObject { id: number; name: string; x: number | null; y: number | null; skipped: boolean; } export function SkipObjectsModal({ printerId, isOpen, onClose }: SkipObjectsModalProps) { const { t } = useTranslation(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const [selectedIds, setSelectedIds] = useState>(new Set()); const [confirming, setConfirming] = useState(false); const [enlarged, setEnlarged] = useState(false); const [pickReady, setPickReady] = useState(false); const pickDataRef = useRef(null); const overlayRef = useRef(null); const enlargedOverlayRef = useRef(null); const { data: status } = useQuery({ queryKey: ['printerStatus', printerId], queryFn: () => api.getPrinterStatus(printerId), refetchInterval: 30000, enabled: isOpen, }); const { data: objectsData, refetch: refetchObjects } = useQuery({ queryKey: ['printableObjects', printerId], queryFn: () => api.getPrintableObjects(printerId), enabled: isOpen, refetchInterval: isOpen ? 5000 : false, }); const hasObjects = (objectsData?.objects.length ?? 0) > 0; const topViewUrl = hasObjects && status?.cover_url ? withStreamToken(`${status.cover_url}?view=top`) : null; const pickViewUrl = hasObjects && status?.cover_url ? withStreamToken(`${status.cover_url}?view=pick`) : null; const activeObjects = useMemo( () => (objectsData?.objects ?? []).filter((object) => !object.skipped), [objectsData], ); const selectedObjects = useMemo( () => activeObjects.filter((object) => selectedIds.has(object.id)), [activeObjects, selectedIds], ); const allSelected = activeObjects.length > 0 && selectedObjects.length === activeObjects.length; const skippingAllRemaining = allSelected && selectedIds.size > 0; const canSubmit = selectedIds.size > 0 && (status?.layer_num ?? 0) > 1 && hasPermission('printers:control'); const skipObjectsMutation = useMutation({ mutationFn: (objectIds: number[]) => api.skipObjects(printerId, objectIds), onSuccess: async (data) => { showToast(data.message || t('printers.skipObjects.objectsSkipped')); // Refresh before closing: this modal is the only on-demand refetch of the // shared printableObjects query, so the printer card behind it would keep // showing the pre-skip count otherwise. await refetchObjects(); setConfirming(false); setSelectedIds(new Set()); onClose(); }, onError: (error: Error) => { setConfirming(false); showToast(error.message || t('printers.toast.failedToSkipObjects'), 'error'); }, }); useEffect(() => { if (isOpen) return; setSelectedIds(new Set()); setConfirming(false); setEnlarged(false); }, [isOpen]); useEffect(() => { pickDataRef.current = null; setPickReady(false); if (!isOpen || !pickViewUrl) return; let cancelled = false; const image = new Image(); image.onload = () => { if (cancelled) return; const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth || 512; canvas.height = image.naturalHeight || 512; const context = canvas.getContext('2d', { willReadFrequently: true }); if (!context) return; context.drawImage(image, 0, 0); pickDataRef.current = context.getImageData(0, 0, canvas.width, canvas.height); setPickReady(true); }; image.onerror = () => { if (!cancelled) setPickReady(false); }; image.src = pickViewUrl; return () => { cancelled = true; }; }, [isOpen, pickViewUrl]); useEffect(() => { const pickData = pickDataRef.current; if (!pickData || !objectsData) return; const skippedIds = new Set(objectsData.objects.filter((object) => object.skipped).map((object) => object.id)); for (const canvas of [overlayRef.current, enlargedOverlayRef.current]) { if (!canvas) continue; canvas.width = pickData.width; canvas.height = pickData.height; const context = canvas.getContext('2d'); if (!context) continue; const overlay = context.createImageData(pickData.width, pickData.height); for (let offset = 0; offset < pickData.data.length; offset += 4) { const objectId = pickData.data[offset] + (pickData.data[offset + 1] << 8) + (pickData.data[offset + 2] << 16); const selected = selectedIds.has(objectId); const skipped = skippedIds.has(objectId); if (!selected && !skipped) continue; const pixel = offset / 4; const x = pixel % pickData.width; const y = Math.floor(pixel / pickData.width); const stripe = ((x + y) % 14) < 7; overlay.data[offset] = selected ? (stripe ? 37 : 74) : 148; overlay.data[offset + 1] = selected ? (stripe ? 199 : 222) : 163; overlay.data[offset + 2] = selected ? (stripe ? 91 : 128) : 184; overlay.data[offset + 3] = selected ? (stripe ? 205 : 145) : 175; } context.putImageData(overlay, 0, 0); } }, [enlarged, objectsData, pickReady, selectedIds]); const toggleObject = (object: PrintableObject) => { if (object.skipped) return; setSelectedIds((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; }); }; const toggleFromPlate = (event: React.MouseEvent) => { const pickData = pickDataRef.current; if (!pickData) return; const point = plateClickToMaskPoint( event.currentTarget.getBoundingClientRect(), pickData.width, pickData.height, event.clientX, event.clientY, ); if (!point) return; const objectId = pickObjectIdAt(pickData, point.x, point.y); const object = objectsData?.objects.find((candidate) => candidate.id === objectId); if (object) toggleObject(object); }; const renderPlate = (large = false) => (
{topViewUrl ? ( {t('printers.printPreview')} ) : (
)} {!large && topViewUrl && ( )} {!pickReady && topViewUrl && (
{t('common.unavailable')}
)}
); if (!isOpen) return null; return ( <>
{ if (event.key !== 'Escape') return; if (enlarged) setEnlarged(false); else onClose(); }} tabIndex={-1} ref={(element) => element?.focus()} >
event.stopPropagation()} aria-label={t('printers.skipObjects.title')} >

{t('printers.skipObjects.title')}

{t('printers.skipObjects.selectObjectsToSkip')}

{!objectsData ? (
) : objectsData.objects.length === 0 ? (

{t('printers.noObjectsFound')}

{t('printers.objectsLoadedOnPrintStart')}

) : ( <> {(status?.layer_num ?? 0) <= 1 && (
{t('printers.skipObjects.waitForLayer', { layer: status?.layer_num ?? 0 })}
)}
{renderPlate()}
{selectedIds.size}/{activeObjects.length} {objectsData.skipped_count} {t('printers.skipObjects.skipped')}
{objectsData.objects.map((object, index) => { const selected = selectedIds.has(object.id); return ( ); })}
{selectedIds.size === 0 ? t('printers.skipObjects.noObjectsSelected') : `${selectedIds.size}/${activeObjects.length}`}
)}
{confirming && ( skipObjectsMutation.mutate([...selectedIds])} onCancel={() => setConfirming(false)} /> )} {enlarged && (
setEnlarged(false)}>
event.stopPropagation()}> {renderPlate(true)}
)} ); }