diff --git a/client/src/components/entities/PicksPanel.tsx b/client/src/components/entities/PicksPanel.tsx new file mode 100644 index 0000000..8598916 --- /dev/null +++ b/client/src/components/entities/PicksPanel.tsx @@ -0,0 +1,296 @@ +/** + * Enhanced picks panel with editing capabilities. + * Extends PicksTable with create, edit, and delete functionality. + */ + +import { useState } from "react"; +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Typography, + CircularProgress, + Button, + Tooltip, +} from "@mui/material"; +import { + Visibility, + VisibilityOff, + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Lock as LockIcon, +} from "@mui/icons-material"; +import { usePicks, usePickPoints, useCreatePicks, useDeletePicks } from "@/api/hooks"; +import { useCopick } from "@/contexts/CopickContext"; +import { usePicking, type PickingPoint } from "@/contexts/PickingContext"; +import { rgbaToHex } from "@/utils/colorUtils"; +import { NewPickDialog } from "@/components/picking/NewPickDialog"; +import type { PicksSummaryResponse } from "@/api/types"; + +interface PicksPanelProps { + runName: string; +} + +export function PicksPanel({ runName }: PicksPanelProps) { + const { data: picks, isLoading, error } = usePicks(runName); + const { state, togglePickVisibility, addPick } = useCopick(); + const { state: pickingState, isEditing } = usePicking(); + const createPicks = useCreatePicks(); + const deletePicks = useDeletePicks(); + + const [dialogOpen, setDialogOpen] = useState(false); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + Failed to load picks + + ); + } + + const handleCreatePicks = async (objectName: string, userId: string, sessionId: string) => { + await createPicks.mutateAsync({ + runName, + data: { object_name: objectName, user_id: userId, session_id: sessionId }, + }); + }; + + const handleDeletePicks = async (pick: PicksSummaryResponse) => { + if (window.confirm(`Delete picks for ${pick.object_name} by ${pick.user_id}?`)) { + await deletePicks.mutateAsync({ + runName, + objectName: pick.object_name, + userId: pick.user_id, + sessionId: pick.session_id, + }); + } + }; + + const handleToggle = (pick: PicksSummaryResponse) => { + const existing = state.selectedPicks.find( + (p) => p.objectName === pick.object_name && p.userId === pick.user_id && p.sessionId === pick.session_id + ); + + if (existing) { + togglePickVisibility(pick.object_name, pick.user_id, pick.session_id); + } else { + addPick({ + objectName: pick.object_name, + userId: pick.user_id, + sessionId: pick.session_id, + }); + } + }; + + const isVisible = (pick: PicksSummaryResponse) => { + const existing = state.selectedPicks.find( + (p) => p.objectName === pick.object_name && p.userId === pick.user_id && p.sessionId === pick.session_id + ); + return existing?.visible ?? false; + }; + + const isToolPick = (pick: PicksSummaryResponse) => pick.session_id === "0"; + + const isCurrentlyEditing = (pick: PicksSummaryResponse) => + pickingState.editingPicks?.objectName === pick.object_name && + pickingState.editingPicks?.userId === pick.user_id && + pickingState.editingPicks?.sessionId === pick.session_id; + + return ( + + {/* Toolbar */} + + + + + {/* Table */} + {!picks || picks.length === 0 ? ( + + No picks found + + ) : ( + + + + + + Object + User + Session + + Count + + + Actions + + + + + {picks.map((pick) => ( + handleToggle(pick)} + onDelete={() => handleDeletePicks(pick)} + /> + ))} + +
+
+ )} + + {/* New Pick Dialog */} + setDialogOpen(false)} onSubmit={handleCreatePicks} runName={runName} /> +
+ ); +} + +// Separate row component for editing functionality +interface PickRowProps { + pick: PicksSummaryResponse; + runName: string; + isVisible: boolean; + isToolPick: boolean; + isCurrentlyEditing: boolean; + isAnyEditing: boolean; + onToggle: () => void; + onDelete: () => void; +} + +function PickRow({ + pick, + runName, + isVisible, + isToolPick, + isCurrentlyEditing, + isAnyEditing, + onToggle, + onDelete, +}: PickRowProps) { + const { startEditing } = usePicking(); + const { data: pickDetail } = usePickPoints(runName, pick.object_name, pick.user_id, pick.session_id); + + const handleEdit = () => { + if (!pickDetail) return; + + // Convert points to PickingPoint format + const points: PickingPoint[] = pickDetail.points.map((p, index) => ({ + id: `${p.x.toFixed(2)}-${p.y.toFixed(2)}-${p.z.toFixed(2)}-${index}`, + x: p.x, + y: p.y, + z: p.z, + instance_id: p.instance_id, + score: p.score, + })); + + startEditing( + { + runName, + objectName: pick.object_name, + userId: pick.user_id, + sessionId: pick.session_id, + color: pick.color, + }, + points + ); + }; + + return ( + + + + {isVisible ? : } + + + + + + + {pick.object_name} + + + + + + {pick.user_id} + + + + + + {pick.session_id} + + {isToolPick && ( + + + + )} + + + + {pick.point_count} + + + + {!isToolPick && ( + <> + + + + + + + + + + + + + + + + )} + + + + ); +} diff --git a/client/src/components/overlays/InteractivePicksOverlay.tsx b/client/src/components/overlays/InteractivePicksOverlay.tsx new file mode 100644 index 0000000..2914a4f --- /dev/null +++ b/client/src/components/overlays/InteractivePicksOverlay.tsx @@ -0,0 +1,222 @@ +/** + * Interactive picks overlay that supports both viewing and editing modes. + * Renders both read-only picks and editable picks being modified. + * + * When editing, the picks being edited are rendered from local state + * instead of fetched data, allowing real-time visual feedback. + */ + +import { useEffect, useRef } from "react"; +import { useIdetik } from "@idetik/react"; +import { useCopick } from "@/contexts/CopickContext"; +import { usePicking, type PickingPoint } from "@/contexts/PickingContext"; +import { usePickPoints } from "@/api/hooks"; +import { PicksLayer } from "./layers/PicksLayer"; + +const DEFAULT_POINT_SIZE_PIXELS = 30; +const DEFAULT_Z_FADE_FACTOR = 64.0; +const SELECTED_POINT_SIZE_PIXELS = 40; // Larger for selected points + +interface InteractivePicksOverlayProps { + currentZIndex: number; + voxelSpacing: number; +} + +export function InteractivePicksOverlay({ currentZIndex, voxelSpacing }: InteractivePicksOverlayProps) { + const { state: copickState } = useCopick(); + const { state: pickingState, isEditing } = usePicking(); + + // Get all visible picks + const visiblePicks = copickState.selectedPicks.filter((p) => p.visible); + + return ( + <> + {/* Read-only picks (excluding the one being edited) */} + {visiblePicks + .filter( + (pick) => + !isEditing || + pick.objectName !== pickingState.editingPicks?.objectName || + pick.userId !== pickingState.editingPicks?.userId || + pick.sessionId !== pickingState.editingPicks?.sessionId + ) + .map((pick) => ( + + ))} + + {/* Editable picks layer */} + {isEditing && pickingState.editingPicks && ( + + )} + + ); +} + +// Read-only layer (same as existing PickPointsLayer) +interface ReadOnlyPickPointsLayerProps { + objectName: string; + userId: string; + sessionId: string; + currentZIndex: number; + voxelSpacing: number; +} + +function ReadOnlyPickPointsLayer({ + objectName, + userId, + sessionId, + currentZIndex, + voxelSpacing, +}: ReadOnlyPickPointsLayerProps) { + const { state } = useCopick(); + const { runtime } = useIdetik(); + const layerRef = useRef(null); + + const { data: picks } = usePickPoints(state.selectedRunName, objectName, userId, sessionId); + + useEffect(() => { + if (!runtime || !picks || picks.points.length === 0) { + return; + } + + const layerManager = runtime.viewports[0]?.layerManager; + if (!layerManager) return; + + if (layerRef.current) { + layerManager.remove(layerRef.current); + layerRef.current = null; + } + + const layer = new PicksLayer({ + points: picks.points.map((pt) => ({ x: pt.x, y: pt.y, z: pt.z })), + color: picks.color, + pointSizePixels: DEFAULT_POINT_SIZE_PIXELS, + zFadeRadius: DEFAULT_Z_FADE_FACTOR, + }); + + const currentZInAngstroms = currentZIndex * voxelSpacing; + layer.setCurrentZ(currentZInAngstroms); + + layerManager.add(layer); + layerRef.current = layer; + + return () => { + if (layerRef.current && layerManager.layers.includes(layerRef.current)) { + layerManager.remove(layerRef.current); + layerRef.current = null; + } + }; + }, [runtime, picks]); + + useEffect(() => { + if (layerRef.current) { + const currentZInAngstroms = currentZIndex * voxelSpacing; + layerRef.current.setCurrentZ(currentZInAngstroms); + } + }, [currentZIndex, voxelSpacing]); + + return null; +} + +// Editable layer with selection highlighting +interface EditablePicksLayerProps { + points: PickingPoint[]; + selectedIds: Set; + color: [number, number, number, number]; + currentZIndex: number; + voxelSpacing: number; +} + +function EditablePicksLayer({ points, selectedIds, color, currentZIndex, voxelSpacing }: EditablePicksLayerProps) { + const { runtime } = useIdetik(); + const normalLayerRef = useRef(null); + const selectedLayerRef = useRef(null); + + useEffect(() => { + if (!runtime) return; + + const layerManager = runtime.viewports[0]?.layerManager; + if (!layerManager) return; + + // Clean up old layers + if (normalLayerRef.current && layerManager.layers.includes(normalLayerRef.current)) { + layerManager.remove(normalLayerRef.current); + } + if (selectedLayerRef.current && layerManager.layers.includes(selectedLayerRef.current)) { + layerManager.remove(selectedLayerRef.current); + } + normalLayerRef.current = null; + selectedLayerRef.current = null; + + const normalPoints = points.filter((p) => !selectedIds.has(p.id)); + const selectedPoints = points.filter((p) => selectedIds.has(p.id)); + const currentZInAngstroms = currentZIndex * voxelSpacing; + + // Normal points layer + if (normalPoints.length > 0) { + const normalLayer = new PicksLayer({ + points: normalPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })), + color, + pointSizePixels: DEFAULT_POINT_SIZE_PIXELS, + zFadeRadius: DEFAULT_Z_FADE_FACTOR, + }); + normalLayer.setCurrentZ(currentZInAngstroms); + layerManager.add(normalLayer); + normalLayerRef.current = normalLayer; + } + + // Selected points layer (brighter, larger) + if (selectedPoints.length > 0) { + // Use a brighter version of the color for selection + const highlightColor: [number, number, number, number] = [ + Math.min(255, color[0] + 80), + Math.min(255, color[1] + 80), + Math.min(255, color[2] + 80), + 255, + ]; + + const selectedLayer = new PicksLayer({ + points: selectedPoints.map((p) => ({ x: p.x, y: p.y, z: p.z })), + color: highlightColor, + pointSizePixels: SELECTED_POINT_SIZE_PIXELS, + zFadeRadius: DEFAULT_Z_FADE_FACTOR, + }); + selectedLayer.setCurrentZ(currentZInAngstroms); + layerManager.add(selectedLayer); + selectedLayerRef.current = selectedLayer; + } + + return () => { + if (normalLayerRef.current && layerManager.layers.includes(normalLayerRef.current)) { + layerManager.remove(normalLayerRef.current); + } + if (selectedLayerRef.current && layerManager.layers.includes(selectedLayerRef.current)) { + layerManager.remove(selectedLayerRef.current); + } + normalLayerRef.current = null; + selectedLayerRef.current = null; + }; + }, [runtime, points, selectedIds, color, currentZIndex, voxelSpacing]); + + // Update z-position when slice changes (without recreating layers) + useEffect(() => { + const currentZInAngstroms = currentZIndex * voxelSpacing; + normalLayerRef.current?.setCurrentZ(currentZInAngstroms); + selectedLayerRef.current?.setCurrentZ(currentZInAngstroms); + }, [currentZIndex, voxelSpacing]); + + return null; +} diff --git a/client/src/components/picking/NewPickDialog.tsx b/client/src/components/picking/NewPickDialog.tsx new file mode 100644 index 0000000..28b6c0a --- /dev/null +++ b/client/src/components/picking/NewPickDialog.tsx @@ -0,0 +1,148 @@ +/** + * Dialog for creating a new picks collection. + * Inspired by chimerax-copick's NewPickDialog.py + */ + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Box, + Typography, +} from "@mui/material"; +import { useConfig, useObjects, usePicks } from "@/api/hooks"; +import { validateCopickName, generateSessionId } from "@/utils/validation"; +import { rgbaToHex } from "@/utils/colorUtils"; + +interface NewPickDialogProps { + open: boolean; + onClose: () => void; + onSubmit: (objectName: string, userId: string, sessionId: string) => void; + runName: string; +} + +export function NewPickDialog({ open, onClose, onSubmit, runName }: NewPickDialogProps) { + const { data: config } = useConfig(); + const { data: objects } = useObjects(); + const { data: existingPicks } = usePicks(runName); + + // Form state + const [objectName, setObjectName] = useState(""); + const [userId, setUserId] = useState(""); + const [sessionId, setSessionId] = useState(""); + + // Initialize defaults when dialog opens + useEffect(() => { + if (open) { + // Default user ID from config + setUserId(config?.user_id || "user"); + + // Auto-generate session ID + const existingSessionIds = existingPicks?.map((p) => p.session_id) || []; + setSessionId(generateSessionId(existingSessionIds)); + + // Select first object by default + if (objects && objects.length > 0) { + setObjectName(objects[0].name); + } + } + }, [open, config, existingPicks, objects]); + + // Validation + const userValidation = validateCopickName(userId); + const sessionValidation = validateCopickName(sessionId); + + const isValid = objectName && userValidation.isValid && sessionValidation.isValid; + + const handleSubmit = () => { + if (isValid) { + onSubmit(objectName, userId, sessionId); + handleClose(); + } + }; + + const handleClose = () => { + setObjectName(""); + setUserId(""); + setSessionId(""); + onClose(); + }; + + return ( + + Create New Pick + + + + Create a new set of picks with the specified parameters. + + + {/* Object selector */} + + Object + + + + {/* User ID */} + setUserId(e.target.value)} + error={!userValidation.isValid && userId.length > 0} + helperText={!userValidation.isValid && userId.length > 0 ? userValidation.errorMessage : ""} + fullWidth + /> + + {/* Session ID */} + setSessionId(e.target.value)} + error={!sessionValidation.isValid && sessionId.length > 0} + helperText={ + !sessionValidation.isValid && sessionId.length > 0 + ? sessionValidation.errorMessage + : "Auto-generated session identifier" + } + fullWidth + /> + + + + + + + + ); +} diff --git a/client/src/components/picking/PickingEventHandler.tsx b/client/src/components/picking/PickingEventHandler.tsx new file mode 100644 index 0000000..38a4b4b --- /dev/null +++ b/client/src/components/picking/PickingEventHandler.tsx @@ -0,0 +1,236 @@ +/** + * Handles mouse events for picking interactions. + * Attaches event listeners to the viewer canvas. + */ + +import { useEffect, useCallback, useRef } from "react"; +import { OrthographicCamera } from "@idetik/core"; +import { useIdetik } from "@idetik/react"; +import { usePicking, type PickingPoint } from "@/contexts/PickingContext"; + +interface PickingEventHandlerProps { + currentZIndex: number; + onZIndexChange: (newIndex: number) => void; + maxZIndex: number | undefined; + voxelSpacing: number; +} + +export function PickingEventHandler({ + currentZIndex, + onZIndexChange, + maxZIndex, + voxelSpacing, +}: PickingEventHandlerProps) { + const { runtime } = useIdetik(); + const { state: pickingState, addPoint, deletePoint, selectPoint, clearSelection, isEditing } = usePicking(); + + // Store canvas reference + const canvasRef = useRef(null); + + // Get canvas when runtime is available + useEffect(() => { + if (!runtime) return; + // Access canvas directly from runtime + canvasRef.current = runtime.canvas; + }, [runtime]); + + // Convert screen coordinates to world coordinates + const screenToWorld = useCallback( + (screenX: number, screenY: number): { x: number; y: number } | null => { + if (!runtime || !canvasRef.current) return null; + + const viewport = runtime.viewports[0]; + if (!viewport) return null; + + // Get canvas bounds + const canvas = runtime.canvas; + const rect = canvas.getBoundingClientRect(); + const canvasX = screenX - rect.left; + const canvasY = screenY - rect.top; + + // Get camera for coordinate conversion + const camera = viewport.camera; + if (!camera || camera.type !== "OrthographicCamera") return null; + + const orthoCamera = camera as OrthographicCamera; + + // Canvas dimensions + const canvasWidth = canvas.clientWidth; + const canvasHeight = canvas.clientHeight; + + // Convert from canvas space to normalized device coordinates (-1 to 1) + // Note: Don't flip Y - image coordinates have Y increasing downward + const ndcX = (canvasX / canvasWidth) * 2 - 1; + const ndcY = (canvasY / canvasHeight) * 2 - 1; + + // For idetik's orthographic camera: + // cameraWidthWorld = transform.scale[0] * viewportSize[0] + const cameraWidthWorld = orthoCamera.transform.scale[0] * orthoCamera.viewportSize[0]; + const cameraHeightWorld = orthoCamera.transform.scale[1] * orthoCamera.viewportSize[1]; + + // Camera center is at transform.translation + const centerX = orthoCamera.transform.translation[0]; + const centerY = orthoCamera.transform.translation[1]; + + // Convert NDC to world coordinates + const worldX = centerX + ndcX * (cameraWidthWorld / 2); + const worldY = centerY + ndcY * (cameraHeightWorld / 2); + + return { x: worldX, y: worldY }; + }, + [runtime] + ); + + // Find nearest point to click location + const findNearestPoint = useCallback( + (worldX: number, worldY: number, threshold: number = 50): PickingPoint | null => { + if (!isEditing) return null; + + const currentZ = currentZIndex * voxelSpacing; + let nearest: PickingPoint | null = null; + let minDist = threshold; + + for (const point of pickingState.localPoints) { + // Only consider points near current z-slice (within 3 slices) + const zDist = Math.abs(point.z - currentZ); + if (zDist > voxelSpacing * 3) continue; + + const dist = Math.sqrt(Math.pow(point.x - worldX, 2) + Math.pow(point.y - worldY, 2)); + + if (dist < minDist) { + minDist = dist; + nearest = point; + } + } + + return nearest; + }, + [isEditing, pickingState.localPoints, currentZIndex, voxelSpacing] + ); + + // Handle mouse click + const handleClick = useCallback( + (event: MouseEvent) => { + if (!isEditing) return; + + // Don't intercept if user is interacting with UI elements + if ((event.target as HTMLElement).tagName !== "CANVAS") return; + + const worldPos = screenToWorld(event.clientX, event.clientY); + if (!worldPos) return; + + const currentZ = currentZIndex * voxelSpacing; + + switch (pickingState.activeTool) { + case "add": { + // Add new point at click location on current z-slice + const newPoint: PickingPoint = { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + x: worldPos.x, + y: worldPos.y, + z: currentZ, + score: 1.0, + }; + addPoint(newPoint); + break; + } + + case "select": { + const nearest = findNearestPoint(worldPos.x, worldPos.y); + if (nearest) { + selectPoint(nearest.id, event.shiftKey); + } else if (!event.shiftKey) { + clearSelection(); + } + break; + } + + case "delete": { + const nearest = findNearestPoint(worldPos.x, worldPos.y); + if (nearest) { + deletePoint(nearest.id); + } + break; + } + } + }, + [ + isEditing, + pickingState.activeTool, + screenToWorld, + findNearestPoint, + addPoint, + selectPoint, + deletePoint, + clearSelection, + currentZIndex, + voxelSpacing, + ] + ); + + // Handle wheel for z-navigation (Shift + scroll) + const handleWheel = useCallback( + (event: WheelEvent) => { + if (!event.shiftKey || maxZIndex === undefined) return; + + // Only intercept on canvas + if ((event.target as HTMLElement).tagName !== "CANVAS") return; + + event.preventDefault(); + + const delta = event.deltaY > 0 ? 1 : -1; + const newIndex = Math.max(0, Math.min(maxZIndex, currentZIndex + delta)); + if (newIndex !== currentZIndex) { + onZIndexChange(newIndex); + } + }, + [currentZIndex, maxZIndex, onZIndexChange] + ); + + // Attach event listeners + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + canvas.addEventListener("click", handleClick); + canvas.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + canvas.removeEventListener("click", handleClick); + canvas.removeEventListener("wheel", handleWheel); + }; + }, [handleClick, handleWheel]); + + // Update cursor based on tool + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + if (!isEditing) { + canvas.style.cursor = ""; + return; + } + + switch (pickingState.activeTool) { + case "add": + canvas.style.cursor = "crosshair"; + break; + case "select": + canvas.style.cursor = "pointer"; + break; + case "delete": + canvas.style.cursor = "not-allowed"; + break; + default: + canvas.style.cursor = ""; + } + + return () => { + if (canvas) { + canvas.style.cursor = ""; + } + }; + }, [pickingState.activeTool, isEditing]); + + return null; +} diff --git a/client/src/components/picking/PickingToolbar.tsx b/client/src/components/picking/PickingToolbar.tsx new file mode 100644 index 0000000..d01b047 --- /dev/null +++ b/client/src/components/picking/PickingToolbar.tsx @@ -0,0 +1,144 @@ +/** + * Toolbar for selecting picking tools and displaying current edit state. + */ + +import { Box, ToggleButtonGroup, ToggleButton, Button, Chip, Tooltip } from "@mui/material"; +import PanToolIcon from "@mui/icons-material/PanTool"; +import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; +import HighlightAltIcon from "@mui/icons-material/HighlightAlt"; +import DeleteIcon from "@mui/icons-material/Delete"; +import SaveIcon from "@mui/icons-material/Save"; +import CloseIcon from "@mui/icons-material/Close"; +import { usePicking, type PickingTool } from "@/contexts/PickingContext"; +import { useUpdatePicks } from "@/api/hooks"; +import { rgbaToCss } from "@/utils/colorUtils"; + +export function PickingToolbar() { + const { state, setTool, stopEditing, markSaved, deleteSelectedPoints } = usePicking(); + const updatePicks = useUpdatePicks(); + + const handleToolChange = (_: React.MouseEvent, tool: PickingTool | null) => { + if (tool) { + setTool(tool); + } + }; + + const handleSave = async () => { + if (!state.editingPicks) return; + + await updatePicks.mutateAsync({ + runName: state.editingPicks.runName, + objectName: state.editingPicks.objectName, + userId: state.editingPicks.userId, + sessionId: state.editingPicks.sessionId, + points: state.localPoints.map((p) => ({ + x: p.x, + y: p.y, + z: p.z, + instance_id: p.instance_id, + score: p.score, + })), + }); + + markSaved(); + }; + + const handleCancel = () => { + stopEditing(); + }; + + const handleDeleteSelected = () => { + if (state.selectedPointIds.size > 0) { + deleteSelectedPoints(); + } + }; + + if (!state.editingPicks) { + return null; // Don't show toolbar when not editing + } + + return ( + + {/* Current editing info */} + + + {/* Tool selector */} + + + + + + + + + + + + + + + + + + + + + + + + {/* Point count */} + + + {/* Selection info and delete button */} + {state.selectedPointIds.size > 0 && ( + <> + } + /> + + )} + + {/* Spacer */} + + + {/* Unsaved changes indicator */} + {state.hasUnsavedChanges && } + + {/* Save/Cancel buttons */} + + + + ); +} diff --git a/client/src/contexts/PickingContext.tsx b/client/src/contexts/PickingContext.tsx new file mode 100644 index 0000000..b114d7b --- /dev/null +++ b/client/src/contexts/PickingContext.tsx @@ -0,0 +1,176 @@ +/** + * Context for managing picking tool state and operations. + */ + +import { createContext, useContext, useReducer, useCallback, type ReactNode } from "react"; + +export type PickingTool = "view" | "add" | "select" | "delete"; + +export interface EditingPicks { + runName: string; + objectName: string; + userId: string; + sessionId: string; + color: [number, number, number, number]; +} + +export interface PickingPoint { + id: string; // Unique ID for selection + x: number; + y: number; + z: number; + instance_id?: number | null; + score?: number | null; +} + +interface PickingState { + activeTool: PickingTool; + editingPicks: EditingPicks | null; + localPoints: PickingPoint[]; // Local copy of points being edited + selectedPointIds: Set; + hasUnsavedChanges: boolean; +} + +type PickingAction = + | { type: "SET_TOOL"; tool: PickingTool } + | { type: "START_EDITING"; picks: EditingPicks; points: PickingPoint[] } + | { type: "STOP_EDITING" } + | { type: "ADD_POINT"; point: PickingPoint } + | { type: "DELETE_POINT"; pointId: string } + | { type: "DELETE_SELECTED_POINTS" } + | { type: "SELECT_POINT"; pointId: string; addToSelection: boolean } + | { type: "CLEAR_SELECTION" } + | { type: "SET_POINTS"; points: PickingPoint[] } + | { type: "MARK_SAVED" }; + +const initialState: PickingState = { + activeTool: "view", + editingPicks: null, + localPoints: [], + selectedPointIds: new Set(), + hasUnsavedChanges: false, +}; + +function pickingReducer(state: PickingState, action: PickingAction): PickingState { + switch (action.type) { + case "SET_TOOL": + return { ...state, activeTool: action.tool }; + + case "START_EDITING": + return { + ...state, + editingPicks: action.picks, + localPoints: action.points, + selectedPointIds: new Set(), + hasUnsavedChanges: false, + activeTool: "add", // Switch to add mode when starting to edit + }; + + case "STOP_EDITING": + return { + ...state, + editingPicks: null, + localPoints: [], + selectedPointIds: new Set(), + hasUnsavedChanges: false, + activeTool: "view", + }; + + case "ADD_POINT": + return { + ...state, + localPoints: [...state.localPoints, action.point], + hasUnsavedChanges: true, + }; + + case "DELETE_POINT": { + return { + ...state, + localPoints: state.localPoints.filter((p) => p.id !== action.pointId), + selectedPointIds: new Set([...state.selectedPointIds].filter((id) => id !== action.pointId)), + hasUnsavedChanges: true, + }; + } + + case "DELETE_SELECTED_POINTS": { + const selected = state.selectedPointIds; + return { + ...state, + localPoints: state.localPoints.filter((p) => !selected.has(p.id)), + selectedPointIds: new Set(), + hasUnsavedChanges: true, + }; + } + + case "SELECT_POINT": { + const newSelection = new Set(action.addToSelection ? state.selectedPointIds : []); + if (newSelection.has(action.pointId)) { + newSelection.delete(action.pointId); + } else { + newSelection.add(action.pointId); + } + return { ...state, selectedPointIds: newSelection }; + } + + case "CLEAR_SELECTION": + return { ...state, selectedPointIds: new Set() }; + + case "SET_POINTS": + return { ...state, localPoints: action.points, hasUnsavedChanges: true }; + + case "MARK_SAVED": + return { ...state, hasUnsavedChanges: false }; + + default: + return state; + } +} + +interface PickingContextType { + state: PickingState; + dispatch: React.Dispatch; + setTool: (tool: PickingTool) => void; + startEditing: (picks: EditingPicks, points: PickingPoint[]) => void; + stopEditing: () => void; + addPoint: (point: PickingPoint) => void; + deletePoint: (pointId: string) => void; + deleteSelectedPoints: () => void; + selectPoint: (pointId: string, addToSelection?: boolean) => void; + clearSelection: () => void; + markSaved: () => void; + isEditing: boolean; +} + +const PickingContext = createContext(null); + +export function PickingProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(pickingReducer, initialState); + + const value: PickingContextType = { + state, + dispatch, + setTool: useCallback((tool) => dispatch({ type: "SET_TOOL", tool }), []), + startEditing: useCallback((picks, points) => dispatch({ type: "START_EDITING", picks, points }), []), + stopEditing: useCallback(() => dispatch({ type: "STOP_EDITING" }), []), + addPoint: useCallback((point) => dispatch({ type: "ADD_POINT", point }), []), + deletePoint: useCallback((pointId) => dispatch({ type: "DELETE_POINT", pointId }), []), + deleteSelectedPoints: useCallback(() => dispatch({ type: "DELETE_SELECTED_POINTS" }), []), + selectPoint: useCallback( + (pointId, addToSelection = false) => dispatch({ type: "SELECT_POINT", pointId, addToSelection }), + [] + ), + clearSelection: useCallback(() => dispatch({ type: "CLEAR_SELECTION" }), []), + markSaved: useCallback(() => dispatch({ type: "MARK_SAVED" }), []), + isEditing: state.editingPicks !== null, + }; + + return {children}; +} + +export function usePicking(): PickingContextType { + const context = useContext(PickingContext); + if (!context) { + throw new Error("usePicking must be used within a PickingProvider"); + } + return context; +} diff --git a/client/src/utils/validation.ts b/client/src/utils/validation.ts new file mode 100644 index 0000000..7242c50 --- /dev/null +++ b/client/src/utils/validation.ts @@ -0,0 +1,74 @@ +/** + * Validation utilities for copick entity names. + * Mirrors copick-shared-ui/util/validation.py + */ + +// Invalid characters pattern from copick.util.escape +// Invalid: <>:"/\|?* (Windows), control chars, spaces, and underscores +const INVALID_CHARS_PATTERN = /[<>:"/\\|?*\x00-\x1F\x7F\s_]/g; + +export interface ValidationResult { + isValid: boolean; + sanitized: string; + errorMessage: string; +} + +/** + * Validate a string for use as copick object name, user_id or session_id. + * + * @param inputStr - The input string to validate + * @returns Validation result with isValid flag, sanitized version, and error message + */ +export function validateCopickName(inputStr: string): ValidationResult { + if (!inputStr) { + return { isValid: false, sanitized: "", errorMessage: "Name cannot be empty" }; + } + + // Check if string contains invalid characters + const hasInvalid = INVALID_CHARS_PATTERN.test(inputStr); + + // Reset regex lastIndex (global regex) + INVALID_CHARS_PATTERN.lastIndex = 0; + + // Create sanitized version + let sanitized = inputStr.replace(INVALID_CHARS_PATTERN, "-"); + // Reset again after replace + INVALID_CHARS_PATTERN.lastIndex = 0; + // Trim leading/trailing dashes + sanitized = sanitized.replace(/^-+|-+$/g, ""); + + if (sanitized === "") { + return { isValid: false, sanitized: "", errorMessage: "Name cannot consist only of invalid characters" }; + } + + if (hasInvalid) { + // Find invalid characters for error message + INVALID_CHARS_PATTERN.lastIndex = 0; + const invalidFound = new Set(inputStr.match(INVALID_CHARS_PATTERN) || []); + const invalidList = Array.from(invalidFound) + .map((char) => (char === " " ? "'space'" : char === "_" ? "'underscore'" : `'${char}'`)) + .sort() + .join(", "); + return { isValid: false, sanitized, errorMessage: `Invalid characters: ${invalidList}` }; + } + + return { isValid: true, sanitized: inputStr, errorMessage: "" }; +} + +/** + * Generate an auto-incremented session ID in the format 'manual-X'. + * + * @param existingSessionIds - List of existing session IDs to check against + * @returns New unique session ID + */ +export function generateSessionId(existingSessionIds: string[]): string { + const existing = new Set(existingSessionIds.map((s) => s.toLowerCase())); + let counter = 1; + while (true) { + const candidate = `manual-${counter}`; + if (!existing.has(candidate)) { + return candidate; + } + counter++; + } +} diff --git a/server/src/copick_web/app/validation.py b/server/src/copick_web/app/validation.py new file mode 100644 index 0000000..3b2dd55 --- /dev/null +++ b/server/src/copick_web/app/validation.py @@ -0,0 +1,46 @@ +""" +Validation utilities for copick entity names using copick.util.escape rules. +""" + +import re +from typing import Tuple + + +def validate_copick_name(input_str: str) -> Tuple[bool, str, str]: + """ + Validate a string for use as copick object name, user_id or session_id. + + Args: + input_str: The input string to validate + + Returns: + Tuple of (is_valid, sanitized_name, error_message) + - is_valid: True if the original string is valid + - sanitized_name: The sanitized version of the input + - error_message: Error message if invalid, empty string if valid + """ + if not input_str: + return False, "", "Name cannot be empty" + + # Define invalid characters pattern from copick.util.escape + # Invalid: <>:"/\|?* (Windows), control chars, spaces, and underscores + invalid_chars = r'[<>:"/\\|?*\x00-\x1F\x7F\s_]' + + # Check if string contains invalid characters + has_invalid = bool(re.search(invalid_chars, input_str)) + + # Create sanitized version + sanitized = re.sub(invalid_chars, "-", input_str) + sanitized = sanitized.strip("-") + + if sanitized == "": + return False, "", "Name cannot consist only of invalid characters" + + if has_invalid: + # Get list of invalid characters found + invalid_found = set(re.findall(invalid_chars, input_str)) + invalid_list = ", ".join(f"'{char}'" if char != " " else "'space'" for char in sorted(invalid_found)) + error_msg = f"Invalid characters: {invalid_list}" + return False, sanitized, error_msg + + return True, input_str, ""