diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index cebfd93..f833b35 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.boards.repository import BoardRepository -from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate +from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate from app.core.deps import get_current_user, get_db from app.database.models.user import User @@ -152,6 +152,48 @@ def update_board( return BoardDetail.model_validate(board) +@router.patch("/{board_id}/viewport", status_code=status.HTTP_204_NO_CONTENT) +def update_viewport( + board_id: UUID, + viewport_data: ViewportStateUpdate, + current_user: Annotated[User, Depends(get_current_user)], + db: Annotated[Session, Depends(get_db)], +): + """ + Update board viewport state only (optimized for frequent updates). + + This endpoint is designed for high-frequency viewport state updates + (debounced pan/zoom/rotate changes) with minimal overhead. + + Args: + board_id: Board UUID + viewport_data: Viewport state data + current_user: Current authenticated user + db: Database session + + Raises: + HTTPException: 404 if board not found or not owned by user + """ + repo = BoardRepository(db) + + # Convert viewport data to dict + viewport_dict = viewport_data.model_dump() + + board = repo.update_board( + board_id=board_id, + user_id=current_user.id, + title=None, + description=None, + viewport_state=viewport_dict, + ) + + if not board: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Board {board_id} not found", + ) + + @router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_board( board_id: UUID, diff --git a/backend/app/api/images.py b/backend/app/api/images.py index 43e9e5c..5c75975 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -14,6 +14,7 @@ from app.images.repository import ImageRepository from app.images.schemas import ( BoardImageCreate, BoardImageResponse, + BoardImageUpdate, ImageListResponse, ImageResponse, ImageUploadResponse, @@ -277,6 +278,52 @@ async def add_image_to_board( return board_image +@router.patch("/boards/{board_id}/images/{image_id}", response_model=BoardImageResponse) +async def update_board_image( + board_id: UUID, + image_id: UUID, + data: BoardImageUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Update board image position, transformations, z-order, or group. + + This endpoint is optimized for frequent position updates (debounced from frontend). + Only provided fields are updated. + """ + # Verify board ownership + from sqlalchemy import select + + board_result = await db.execute(select(Board).where(Board.id == board_id)) + board = board_result.scalar_one_or_none() + + if not board: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") + + if board.user_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") + + # Update board image + repo = ImageRepository(db) + board_image = await repo.update_board_image( + board_id=board_id, + image_id=image_id, + position=data.position, + transformations=data.transformations, + z_order=data.z_order, + group_id=data.group_id, + ) + + if not board_image: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board") + + # Load image relationship for response + await db.refresh(board_image, ["image"]) + + return board_image + + @router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_image_from_board( board_id: UUID, diff --git a/backend/app/boards/schemas.py b/backend/app/boards/schemas.py index f3a31b0..36211c1 100644 --- a/backend/app/boards/schemas.py +++ b/backend/app/boards/schemas.py @@ -22,6 +22,15 @@ class BoardCreate(BaseModel): description: str | None = Field(default=None, description="Optional board description") +class ViewportStateUpdate(BaseModel): + """Schema for updating viewport state only.""" + + x: float = Field(..., description="Horizontal pan position") + y: float = Field(..., description="Vertical pan position") + zoom: float = Field(..., ge=0.1, le=5.0, description="Zoom level (0.1 to 5.0)") + rotation: float = Field(..., ge=0, le=360, description="Canvas rotation in degrees (0 to 360)") + + class BoardUpdate(BaseModel): """Schema for updating board metadata.""" diff --git a/backend/app/images/schemas.py b/backend/app/images/schemas.py index 85b100a..dd462cf 100644 --- a/backend/app/images/schemas.py +++ b/backend/app/images/schemas.py @@ -83,6 +83,23 @@ class BoardImageCreate(BaseModel): return v +class BoardImageUpdate(BaseModel): + """Schema for updating board image position/transformations.""" + + position: dict[str, float] | None = Field(None, description="Canvas position") + transformations: dict[str, Any] | None = Field(None, description="Image transformations") + z_order: int | None = Field(None, description="Layer order") + group_id: UUID | None = Field(None, description="Group membership") + + @field_validator("position") + @classmethod + def validate_position(cls, v: dict[str, float] | None) -> dict[str, float] | None: + """Validate position has x and y if provided.""" + if v is not None and ("x" not in v or "y" not in v): + raise ValueError("Position must contain 'x' and 'y' coordinates") + return v + + class BoardImageResponse(BaseModel): """Response for board image with all metadata.""" diff --git a/frontend/src/lib/canvas/Image.svelte b/frontend/src/lib/canvas/Image.svelte new file mode 100644 index 0000000..d103b04 --- /dev/null +++ b/frontend/src/lib/canvas/Image.svelte @@ -0,0 +1,203 @@ + + + diff --git a/frontend/src/lib/canvas/SelectionBox.svelte b/frontend/src/lib/canvas/SelectionBox.svelte new file mode 100644 index 0000000..93f86b0 --- /dev/null +++ b/frontend/src/lib/canvas/SelectionBox.svelte @@ -0,0 +1,179 @@ + + + diff --git a/frontend/src/lib/canvas/interactions/drag.ts b/frontend/src/lib/canvas/interactions/drag.ts new file mode 100644 index 0000000..3880f54 --- /dev/null +++ b/frontend/src/lib/canvas/interactions/drag.ts @@ -0,0 +1,184 @@ +/** + * Image dragging interactions for canvas + * Handles dragging images to reposition them + */ + +import Konva from 'konva'; +import { selection } from '$lib/stores/selection'; +import { get } from 'svelte/store'; + +export interface DragState { + isDragging: boolean; + startPos: { x: number; y: number } | null; + draggedImageId: string | null; +} + +const dragState: DragState = { + isDragging: false, + startPos: null, + draggedImageId: null, +}; + +/** + * Setup drag interactions for an image + */ +export function setupImageDrag( + image: Konva.Image | Konva.Group, + imageId: string, + onDragMove?: (imageId: string, x: number, y: number) => void, + onDragEnd?: (imageId: string, x: number, y: number) => void +): () => void { + /** + * Handle drag start + */ + function handleDragStart(e: Konva.KonvaEventObject) { + dragState.isDragging = true; + dragState.startPos = { x: image.x(), y: image.y() }; + dragState.draggedImageId = imageId; + + // If dragged image is not selected, select it + const selectionState = get(selection); + if (!selectionState.selectedIds.has(imageId)) { + // Check if Ctrl/Cmd key is pressed + if (e.evt.ctrlKey || e.evt.metaKey) { + selection.addToSelection(imageId); + } else { + selection.selectOne(imageId); + } + } + + // Set dragging cursor + const stage = image.getStage(); + if (stage) { + stage.container().style.cursor = 'grabbing'; + } + } + + /** + * Handle drag move + */ + function handleDragMove(_e: Konva.KonvaEventObject) { + if (!dragState.isDragging) return; + + const x = image.x(); + const y = image.y(); + + // Call callback if provided + if (onDragMove) { + onDragMove(imageId, x, y); + } + + // If multiple images are selected, move them together + const selectionState = get(selection); + if (selectionState.selectedIds.size > 1 && dragState.startPos) { + const deltaX = x - dragState.startPos.x; + const deltaY = y - dragState.startPos.y; + + // Update start position for next delta calculation + dragState.startPos = { x, y }; + + // Dispatch custom event to move other selected images + const stage = image.getStage(); + if (stage) { + stage.fire('multiDragMove', { + draggedImageId: imageId, + deltaX, + deltaY, + selectedIds: Array.from(selectionState.selectedIds), + }); + } + } + } + + /** + * Handle drag end + */ + function handleDragEnd(_e: Konva.KonvaEventObject) { + if (!dragState.isDragging) return; + + const x = image.x(); + const y = image.y(); + + // Call callback if provided + if (onDragEnd) { + onDragEnd(imageId, x, y); + } + + // Reset drag state + dragState.isDragging = false; + dragState.startPos = null; + dragState.draggedImageId = null; + + // Reset cursor + const stage = image.getStage(); + if (stage) { + stage.container().style.cursor = 'default'; + } + } + + // Enable dragging + image.draggable(true); + + // Attach event listeners + image.on('dragstart', handleDragStart); + image.on('dragmove', handleDragMove); + image.on('dragend', handleDragEnd); + + // Return cleanup function + return () => { + image.off('dragstart', handleDragStart); + image.off('dragmove', handleDragMove); + image.off('dragend', handleDragEnd); + image.draggable(false); + }; +} + +/** + * Move image to specific position (programmatic) + */ +export function moveImageTo( + image: Konva.Image | Konva.Group, + x: number, + y: number, + animate: boolean = false +): void { + if (animate) { + // TODO: Add animation support using Konva.Tween + image.to({ + x, + y, + duration: 0.3, + easing: Konva.Easings.EaseOut, + }); + } else { + image.position({ x, y }); + } +} + +/** + * Move image by delta (programmatic) + */ +export function moveImageBy( + image: Konva.Image | Konva.Group, + deltaX: number, + deltaY: number, + animate: boolean = false +): void { + const currentX = image.x(); + const currentY = image.y(); + moveImageTo(image, currentX + deltaX, currentY + deltaY, animate); +} + +/** + * Get current drag state (useful for debugging) + */ +export function getDragState(): DragState { + return { ...dragState }; +} + +/** + * Check if currently dragging + */ +export function isDragging(): boolean { + return dragState.isDragging; +} diff --git a/frontend/src/lib/canvas/interactions/multiselect.ts b/frontend/src/lib/canvas/interactions/multiselect.ts new file mode 100644 index 0000000..efbf2aa --- /dev/null +++ b/frontend/src/lib/canvas/interactions/multiselect.ts @@ -0,0 +1,234 @@ +/** + * Rectangle selection (drag-to-select multiple images) + * Allows selecting multiple images by dragging a selection rectangle + */ + +import Konva from 'konva'; +import { selection } from '$lib/stores/selection'; + +export interface SelectionRectangle { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface MultiSelectState { + isSelecting: boolean; + startPos: { x: number; y: number } | null; + currentRect: SelectionRectangle | null; +} + +const multiSelectState: MultiSelectState = { + isSelecting: false, + startPos: null, + currentRect: null, +}; + +/** + * Setup rectangle selection on stage + */ +export function setupRectangleSelection( + stage: Konva.Stage, + layer: Konva.Layer, + getImageBounds: () => Array<{ + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }>, + onSelectionChange?: (selectedIds: string[]) => void +): () => void { + let selectionRect: Konva.Rect | null = null; + + /** + * Handle mouse/touch down to start selection + */ + function handleMouseDown(e: Konva.KonvaEventObject) { + // Only start rectangle selection if clicking on stage background + if (e.target !== stage) return; + + // Only if not pressing Ctrl (that's for pan) + const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false; + if (isModifierPressed) return; + + const pos = stage.getPointerPosition(); + if (!pos) return; + + // Transform pointer position to account for stage transformations + const transform = stage.getAbsoluteTransform().copy().invert(); + const localPos = transform.point(pos); + + multiSelectState.isSelecting = true; + multiSelectState.startPos = localPos; + multiSelectState.currentRect = { + x1: localPos.x, + y1: localPos.y, + x2: localPos.x, + y2: localPos.y, + }; + + // Create visual selection rectangle + selectionRect = new Konva.Rect({ + x: localPos.x, + y: localPos.y, + width: 0, + height: 0, + fill: 'rgba(0, 120, 255, 0.1)', + stroke: 'rgba(0, 120, 255, 0.8)', + strokeWidth: 1 / stage.scaleX(), // Adjust for zoom + listening: false, + }); + + layer.add(selectionRect); + layer.batchDraw(); + } + + /** + * Handle mouse/touch move to update selection rectangle + */ + function handleMouseMove(_e: Konva.KonvaEventObject) { + if (!multiSelectState.isSelecting || !multiSelectState.startPos || !selectionRect) return; + + const pos = stage.getPointerPosition(); + if (!pos) return; + + // Transform pointer position + const transform = stage.getAbsoluteTransform().copy().invert(); + const localPos = transform.point(pos); + + multiSelectState.currentRect = { + x1: multiSelectState.startPos.x, + y1: multiSelectState.startPos.y, + x2: localPos.x, + y2: localPos.y, + }; + + // Update visual rectangle + const x = Math.min(multiSelectState.startPos.x, localPos.x); + const y = Math.min(multiSelectState.startPos.y, localPos.y); + const width = Math.abs(localPos.x - multiSelectState.startPos.x); + const height = Math.abs(localPos.y - multiSelectState.startPos.y); + + selectionRect.x(x); + selectionRect.y(y); + selectionRect.width(width); + selectionRect.height(height); + + layer.batchDraw(); + } + + /** + * Handle mouse/touch up to complete selection + */ + function handleMouseUp(e: Konva.KonvaEventObject) { + if (!multiSelectState.isSelecting || !multiSelectState.currentRect) { + return; + } + + // Get all images that intersect with selection rectangle + const selectedIds = getImagesInRectangle(multiSelectState.currentRect, getImageBounds()); + + // Check if Ctrl/Cmd is pressed for additive selection + const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false; + + if (isModifierPressed && selectedIds.length > 0) { + // Add to existing selection + selection.addMultipleToSelection(selectedIds); + } else if (selectedIds.length > 0) { + // Replace selection + selection.selectMultiple(selectedIds); + } else { + // Empty selection - clear + selection.clearSelection(); + } + + // Call callback + if (onSelectionChange) { + onSelectionChange(selectedIds); + } + + // Clean up + if (selectionRect) { + selectionRect.destroy(); + selectionRect = null; + layer.batchDraw(); + } + + multiSelectState.isSelecting = false; + multiSelectState.startPos = null; + multiSelectState.currentRect = null; + } + + // Attach event listeners + stage.on('mousedown touchstart', handleMouseDown); + stage.on('mousemove touchmove', handleMouseMove); + stage.on('mouseup touchend', handleMouseUp); + + // Return cleanup function + return () => { + stage.off('mousedown touchstart', handleMouseDown); + stage.off('mousemove touchmove', handleMouseMove); + stage.off('mouseup touchend', handleMouseUp); + + if (selectionRect) { + selectionRect.destroy(); + selectionRect = null; + } + + multiSelectState.isSelecting = false; + multiSelectState.startPos = null; + multiSelectState.currentRect = null; + }; +} + +/** + * Get images that intersect with selection rectangle + */ +function getImagesInRectangle( + rect: SelectionRectangle, + imageBounds: Array<{ + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }> +): string[] { + const x1 = Math.min(rect.x1, rect.x2); + const y1 = Math.min(rect.y1, rect.y2); + const x2 = Math.max(rect.x1, rect.x2); + const y2 = Math.max(rect.y1, rect.y2); + + return imageBounds + .filter((item) => { + const { x, y, width, height } = item.bounds; + + // Check if rectangles intersect + return !(x + width < x1 || x > x2 || y + height < y1 || y > y2); + }) + .map((item) => item.id); +} + +/** + * Check if currently in rectangle selection mode + */ +export function isRectangleSelecting(): boolean { + return multiSelectState.isSelecting; +} + +/** + * Get current selection rectangle + */ +export function getCurrentSelectionRect(): SelectionRectangle | null { + return multiSelectState.currentRect ? { ...multiSelectState.currentRect } : null; +} + +/** + * Cancel ongoing rectangle selection + */ +export function cancelRectangleSelection(layer: Konva.Layer): void { + multiSelectState.isSelecting = false; + multiSelectState.startPos = null; + multiSelectState.currentRect = null; + + // Remove any active selection rectangle + const rects = layer.find('.selection-rect'); + rects.forEach((rect) => rect.destroy()); + layer.batchDraw(); +} diff --git a/frontend/src/lib/canvas/interactions/select.ts b/frontend/src/lib/canvas/interactions/select.ts new file mode 100644 index 0000000..29fb948 --- /dev/null +++ b/frontend/src/lib/canvas/interactions/select.ts @@ -0,0 +1,157 @@ +/** + * Click selection interactions for canvas + * Handles single and multi-select (Ctrl+Click) + */ + +import type Konva from 'konva'; +import { selection } from '$lib/stores/selection'; +import { get } from 'svelte/store'; + +export interface SelectOptions { + multiSelectKey?: boolean; // Enable Ctrl/Cmd+Click for multi-select + deselectOnBackground?: boolean; // Deselect when clicking empty canvas +} + +const DEFAULT_OPTIONS: SelectOptions = { + multiSelectKey: true, + deselectOnBackground: true, +}; + +/** + * Setup click selection for an image + */ +export function setupImageSelection( + image: Konva.Image | Konva.Group, + imageId: string, + options: SelectOptions = DEFAULT_OPTIONS, + onSelectionChange?: (imageId: string, isSelected: boolean) => void +): () => void { + /** + * Handle click/tap on image + */ + function handleClick(e: Konva.KonvaEventObject) { + e.cancelBubble = true; // Prevent event from reaching stage + + const isMultiSelectPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false; + + const selectionState = get(selection); + const isCurrentlySelected = selectionState.selectedIds.has(imageId); + + if (options.multiSelectKey && isMultiSelectPressed) { + // Multi-select mode (Ctrl+Click) + if (isCurrentlySelected) { + selection.removeFromSelection(imageId); + if (onSelectionChange) { + onSelectionChange(imageId, false); + } + } else { + selection.addToSelection(imageId); + if (onSelectionChange) { + onSelectionChange(imageId, true); + } + } + } else { + // Single select mode + if (!isCurrentlySelected) { + selection.selectOne(imageId); + if (onSelectionChange) { + onSelectionChange(imageId, true); + } + } + } + } + + // Attach click/tap listener + image.on('click tap', handleClick); + + // Return cleanup function + return () => { + image.off('click tap', handleClick); + }; +} + +/** + * Setup background deselection (clicking empty canvas clears selection) + */ +export function setupBackgroundDeselect(stage: Konva.Stage, onDeselect?: () => void): () => void { + /** + * Handle click on stage background + */ + function handleStageClick(e: Konva.KonvaEventObject) { + // Only deselect if clicking on the stage itself (not on any shape) + if (e.target === stage) { + selection.clearSelection(); + if (onDeselect) { + onDeselect(); + } + } + } + + // Attach listener + stage.on('click tap', handleStageClick); + + // Return cleanup function + return () => { + stage.off('click tap', handleStageClick); + }; +} + +/** + * Select image programmatically + */ +export function selectImage(imageId: string, multiSelect: boolean = false): void { + if (multiSelect) { + selection.addToSelection(imageId); + } else { + selection.selectOne(imageId); + } +} + +/** + * Deselect image programmatically + */ +export function deselectImage(imageId: string): void { + selection.removeFromSelection(imageId); +} + +/** + * Toggle image selection programmatically + */ +export function toggleImageSelection(imageId: string): void { + selection.toggleSelection(imageId); +} + +/** + * Select all images programmatically + */ +export function selectAllImages(allImageIds: string[]): void { + selection.selectAll(allImageIds); +} + +/** + * Clear all selection programmatically + */ +export function clearAllSelection(): void { + selection.clearSelection(); +} + +/** + * Get selected images count + */ +export function getSelectedCount(): number { + return selection.getSelectionCount(); +} + +/** + * Get array of selected image IDs + */ +export function getSelectedImageIds(): string[] { + return selection.getSelectedIds(); +} + +/** + * Check if an image is selected + */ +export function isImageSelected(imageId: string): boolean { + return selection.isSelected(imageId); +} diff --git a/frontend/src/lib/canvas/sync.ts b/frontend/src/lib/canvas/sync.ts new file mode 100644 index 0000000..8a69db5 --- /dev/null +++ b/frontend/src/lib/canvas/sync.ts @@ -0,0 +1,188 @@ +/** + * Position and transformation sync with backend + * Handles debounced persistence of image position changes + */ + +import { apiClient } from '$lib/api/client'; + +// Debounce timeout for position sync (ms) +const SYNC_DEBOUNCE_MS = 500; + +interface PendingUpdate { + boardId: string; + imageId: string; + position: { x: number; y: number }; + timeout: ReturnType; +} + +// Track pending updates by image ID +const pendingUpdates = new Map(); + +/** + * Schedule position sync for an image (debounced) + */ +export function syncImagePosition(boardId: string, imageId: string, x: number, y: number): void { + // Cancel existing timeout if any + const existing = pendingUpdates.get(imageId); + if (existing) { + clearTimeout(existing.timeout); + } + + // Schedule new sync + const timeout = setTimeout(async () => { + await performPositionSync(boardId, imageId, x, y); + pendingUpdates.delete(imageId); + }, SYNC_DEBOUNCE_MS); + + pendingUpdates.set(imageId, { + boardId, + imageId, + position: { x, y }, + timeout, + }); +} + +/** + * Perform actual position sync to backend + */ +async function performPositionSync( + boardId: string, + imageId: string, + x: number, + y: number +): Promise { + try { + await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, { + position: { x, y }, + }); + } catch (error) { + console.error('Failed to sync image position:', error); + // Don't throw - this is a background operation + } +} + +/** + * Force immediate sync of all pending updates + */ +export async function forceSync(): Promise { + const promises: Promise[] = []; + + pendingUpdates.forEach((update) => { + clearTimeout(update.timeout); + promises.push( + performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y) + ); + }); + + pendingUpdates.clear(); + + await Promise.all(promises); +} + +/** + * Force immediate sync for specific image + */ +export async function forceSyncImage(imageId: string): Promise { + const update = pendingUpdates.get(imageId); + if (!update) return; + + clearTimeout(update.timeout); + await performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y); + pendingUpdates.delete(imageId); +} + +/** + * Cancel pending sync for specific image + */ +export function cancelSync(imageId: string): void { + const update = pendingUpdates.get(imageId); + if (update) { + clearTimeout(update.timeout); + pendingUpdates.delete(imageId); + } +} + +/** + * Cancel all pending syncs + */ +export function cancelAllSync(): void { + pendingUpdates.forEach((update) => { + clearTimeout(update.timeout); + }); + pendingUpdates.clear(); +} + +/** + * Get count of pending syncs + */ +export function getPendingSyncCount(): number { + return pendingUpdates.size; +} + +/** + * Check if image has pending sync + */ +export function hasPendingSync(imageId: string): boolean { + return pendingUpdates.has(imageId); +} + +/** + * Sync image transformations (scale, rotation, etc.) + */ +export async function syncImageTransformations( + boardId: string, + imageId: string, + transformations: { + scale?: number; + rotation?: number; + opacity?: number; + flipped_h?: boolean; + flipped_v?: boolean; + greyscale?: boolean; + } +): Promise { + try { + await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, { + transformations, + }); + } catch (error) { + console.error('Failed to sync image transformations:', error); + throw error; + } +} + +/** + * Sync image Z-order + */ +export async function syncImageZOrder( + boardId: string, + imageId: string, + zOrder: number +): Promise { + try { + await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, { + z_order: zOrder, + }); + } catch (error) { + console.error('Failed to sync image Z-order:', error); + throw error; + } +} + +/** + * Sync image group membership + */ +export async function syncImageGroup( + boardId: string, + imageId: string, + groupId: string | null +): Promise { + try { + await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, { + group_id: groupId, + }); + } catch (error) { + console.error('Failed to sync image group:', error); + throw error; + } +} diff --git a/frontend/src/lib/stores/selection.ts b/frontend/src/lib/stores/selection.ts new file mode 100644 index 0000000..321e0ba --- /dev/null +++ b/frontend/src/lib/stores/selection.ts @@ -0,0 +1,200 @@ +/** + * Selection store for canvas image selection management + * Tracks selected images and provides selection operations + */ + +import { writable, derived } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export interface SelectedImage { + id: string; + boardImageId: string; // The junction table ID +} + +export interface SelectionState { + selectedIds: Set; // Set of board_image IDs + lastSelectedId: string | null; // For shift-click range selection +} + +const DEFAULT_SELECTION: SelectionState = { + selectedIds: new Set(), + lastSelectedId: null, +}; + +/** + * Create selection store with operations + */ +function createSelectionStore() { + const { subscribe, set, update }: Writable = writable(DEFAULT_SELECTION); + + return { + subscribe, + set, + update, + + /** + * Select a single image (clears previous selection) + */ + selectOne: (id: string) => { + update(() => ({ + selectedIds: new Set([id]), + lastSelectedId: id, + })); + }, + + /** + * Add image to selection (for Ctrl+Click) + */ + addToSelection: (id: string) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds); + newSelectedIds.add(id); + return { + selectedIds: newSelectedIds, + lastSelectedId: id, + }; + }); + }, + + /** + * Remove image from selection (for Ctrl+Click on selected) + */ + removeFromSelection: (id: string) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds); + newSelectedIds.delete(id); + return { + selectedIds: newSelectedIds, + lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId, + }; + }); + }, + + /** + * Toggle selection of an image + */ + toggleSelection: (id: string) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds); + if (newSelectedIds.has(id)) { + newSelectedIds.delete(id); + return { + selectedIds: newSelectedIds, + lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId, + }; + } else { + newSelectedIds.add(id); + return { + selectedIds: newSelectedIds, + lastSelectedId: id, + }; + } + }); + }, + + /** + * Select multiple images (e.g., from rectangle selection) + */ + selectMultiple: (ids: string[]) => { + update((_state) => ({ + selectedIds: new Set(ids), + lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : null, + })); + }, + + /** + * Add multiple images to selection + */ + addMultipleToSelection: (ids: string[]) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds); + ids.forEach((id) => newSelectedIds.add(id)); + return { + selectedIds: newSelectedIds, + lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : state.lastSelectedId, + }; + }); + }, + + /** + * Select all images + */ + selectAll: (allIds: string[]) => { + update(() => ({ + selectedIds: new Set(allIds), + lastSelectedId: allIds.length > 0 ? allIds[allIds.length - 1] : null, + })); + }, + + /** + * Clear all selection + */ + clearSelection: () => { + set(DEFAULT_SELECTION); + }, + + /** + * Check if an image is selected + */ + isSelected: (id: string): boolean => { + let result = false; + const unsubscribe = subscribe((_state) => { + result = _state.selectedIds.has(id); + }); + unsubscribe(); + return result; + }, + + /** + * Get count of selected images + */ + getSelectionCount: (): number => { + let count = 0; + const unsubscribe = subscribe((state) => { + count = state.selectedIds.size; + }); + unsubscribe(); + return count; + }, + + /** + * Get array of selected IDs + */ + getSelectedIds: (): string[] => { + let ids: string[] = []; + const unsubscribe = subscribe((state) => { + ids = Array.from(state.selectedIds); + }); + unsubscribe(); + return ids; + }, + }; +} + +export const selection = createSelectionStore(); + +// Derived stores for common queries +export const hasSelection = derived(selection, ($selection) => { + return $selection.selectedIds.size > 0; +}); + +export const selectionCount = derived(selection, ($selection) => { + return $selection.selectedIds.size; +}); + +export const isSingleSelection = derived(selection, ($selection) => { + return $selection.selectedIds.size === 1; +}); + +export const isMultiSelection = derived(selection, ($selection) => { + return $selection.selectedIds.size > 1; +}); + +/** + * Helper to check if an ID is in the selection (reactive) + */ +export function isImageSelected(id: string) { + return derived(selection, ($selection) => { + return $selection.selectedIds.has(id); + }); +} diff --git a/frontend/tests/canvas/controls.test.ts b/frontend/tests/canvas/controls.test.ts new file mode 100644 index 0000000..add5eea --- /dev/null +++ b/frontend/tests/canvas/controls.test.ts @@ -0,0 +1,627 @@ +/** + * Tests for canvas controls (pan, zoom, rotate, reset, fit) + * Tests viewport store and control functions + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { get } from 'svelte/store'; +import { viewport, isViewportDefault, isZoomMin, isZoomMax } from '$lib/stores/viewport'; +import { panTo, panBy } from '$lib/canvas/controls/pan'; +import { zoomTo, zoomBy, zoomIn, zoomOut } from '$lib/canvas/controls/zoom'; +import { + rotateTo, + rotateBy, + rotateClockwise, + rotateCounterClockwise, + resetRotation, + rotateTo90, + rotateTo180, + rotateTo270, +} from '$lib/canvas/controls/rotate'; +import { resetCamera, resetPan, resetZoom } from '$lib/canvas/controls/reset'; + +describe('Viewport Store', () => { + beforeEach(() => { + // Reset viewport to default state before each test + viewport.reset(); + }); + + describe('Initialization', () => { + it('starts with default values', () => { + const state = get(viewport); + expect(state).toEqual({ + x: 0, + y: 0, + zoom: 1.0, + rotation: 0, + }); + }); + + it('isViewportDefault is true at initialization', () => { + expect(get(isViewportDefault)).toBe(true); + }); + + it('provides viewport bounds', () => { + const bounds = viewport.getBounds(); + expect(bounds).toEqual({ + minZoom: 0.1, + maxZoom: 5.0, + minRotation: 0, + maxRotation: 360, + }); + }); + }); + + describe('Pan Operations', () => { + it('sets pan position', () => { + viewport.setPan(100, 200); + const state = get(viewport); + expect(state.x).toBe(100); + expect(state.y).toBe(200); + }); + + it('pans by delta', () => { + viewport.setPan(50, 50); + viewport.panBy(25, 30); + const state = get(viewport); + expect(state.x).toBe(75); + expect(state.y).toBe(80); + }); + + it('allows negative pan values', () => { + viewport.setPan(-100, -200); + const state = get(viewport); + expect(state.x).toBe(-100); + expect(state.y).toBe(-200); + }); + + it('handles large pan values', () => { + viewport.setPan(100000, 100000); + const state = get(viewport); + expect(state.x).toBe(100000); + expect(state.y).toBe(100000); + }); + }); + + describe('Zoom Operations', () => { + it('sets zoom level', () => { + viewport.setZoom(2.0); + const state = get(viewport); + expect(state.zoom).toBe(2.0); + }); + + it('clamps zoom to minimum', () => { + viewport.setZoom(0.05); + const state = get(viewport); + expect(state.zoom).toBe(0.1); + }); + + it('clamps zoom to maximum', () => { + viewport.setZoom(10.0); + const state = get(viewport); + expect(state.zoom).toBe(5.0); + }); + + it('zooms by factor', () => { + viewport.setZoom(1.0); + viewport.zoomBy(2.0); + const state = get(viewport); + expect(state.zoom).toBe(2.0); + }); + + it('zooms to center point', () => { + viewport.setZoom(1.0, 100, 100); + const state = get(viewport); + expect(state.zoom).toBe(1.0); + // Position should remain at center + }); + + it('isZoomMin reflects minimum zoom', () => { + viewport.setZoom(0.1); + expect(get(isZoomMin)).toBe(true); + + viewport.setZoom(1.0); + expect(get(isZoomMin)).toBe(false); + }); + + it('isZoomMax reflects maximum zoom', () => { + viewport.setZoom(5.0); + expect(get(isZoomMax)).toBe(true); + + viewport.setZoom(1.0); + expect(get(isZoomMax)).toBe(false); + }); + }); + + describe('Rotation Operations', () => { + it('sets rotation', () => { + viewport.setRotation(45); + const state = get(viewport); + expect(state.rotation).toBe(45); + }); + + it('normalizes rotation to 0-360', () => { + viewport.setRotation(450); + expect(get(viewport).rotation).toBe(90); + + viewport.setRotation(-90); + expect(get(viewport).rotation).toBe(270); + }); + + it('rotates by delta', () => { + viewport.setRotation(45); + viewport.rotateBy(15); + expect(get(viewport).rotation).toBe(60); + }); + + it('handles negative rotation delta', () => { + viewport.setRotation(45); + viewport.rotateBy(-15); + expect(get(viewport).rotation).toBe(30); + }); + + it('wraps rotation around 360', () => { + viewport.setRotation(350); + viewport.rotateBy(20); + expect(get(viewport).rotation).toBe(10); + }); + }); + + describe('Reset Operations', () => { + it('resets viewport to default', () => { + viewport.setPan(100, 100); + viewport.setZoom(2.0); + viewport.setRotation(45); + + viewport.reset(); + + const state = get(viewport); + expect(state).toEqual({ + x: 0, + y: 0, + zoom: 1.0, + rotation: 0, + }); + }); + + it('reset makes isViewportDefault true', () => { + viewport.setPan(100, 100); + expect(get(isViewportDefault)).toBe(false); + + viewport.reset(); + expect(get(isViewportDefault)).toBe(true); + }); + }); + + describe('Fit to Screen', () => { + it('fits content to screen with default padding', () => { + viewport.fitToScreen(800, 600, 1024, 768); + + const state = get(viewport); + expect(state.zoom).toBeGreaterThan(0); + expect(state.rotation).toBe(0); // Rotation reset when fitting + }); + + it('fits content with custom padding', () => { + viewport.fitToScreen(800, 600, 1024, 768, 100); + + const state = get(viewport); + expect(state.zoom).toBeGreaterThan(0); + }); + + it('handles oversized content', () => { + viewport.fitToScreen(2000, 1500, 1024, 768); + + const state = get(viewport); + expect(state.zoom).toBeLessThan(1.0); + }); + + it('handles undersized content', () => { + viewport.fitToScreen(100, 100, 1024, 768); + + const state = get(viewport); + expect(state.zoom).toBeGreaterThan(1.0); + }); + + it('respects maximum zoom when fitting', () => { + // Very small content that would zoom beyond max + viewport.fitToScreen(10, 10, 1024, 768); + + const state = get(viewport); + expect(state.zoom).toBeLessThanOrEqual(5.0); + }); + }); + + describe('Load State', () => { + it('loads partial state', () => { + viewport.loadState({ x: 100, y: 200 }); + + const state = get(viewport); + expect(state.x).toBe(100); + expect(state.y).toBe(200); + expect(state.zoom).toBe(1.0); // Unchanged + expect(state.rotation).toBe(0); // Unchanged + }); + + it('loads complete state', () => { + viewport.loadState({ + x: 100, + y: 200, + zoom: 2.5, + rotation: 90, + }); + + const state = get(viewport); + expect(state).toEqual({ + x: 100, + y: 200, + zoom: 2.5, + rotation: 90, + }); + }); + + it('clamps loaded zoom to bounds', () => { + viewport.loadState({ zoom: 10.0 }); + expect(get(viewport).zoom).toBe(5.0); + + viewport.loadState({ zoom: 0.01 }); + expect(get(viewport).zoom).toBe(0.1); + }); + + it('normalizes loaded rotation', () => { + viewport.loadState({ rotation: 450 }); + expect(get(viewport).rotation).toBe(90); + + viewport.loadState({ rotation: -45 }); + expect(get(viewport).rotation).toBe(315); + }); + }); + + describe('State Subscription', () => { + it('notifies subscribers on pan changes', () => { + const subscriber = vi.fn(); + const unsubscribe = viewport.subscribe(subscriber); + + viewport.setPan(100, 100); + + expect(subscriber).toHaveBeenCalled(); + unsubscribe(); + }); + + it('notifies subscribers on zoom changes', () => { + const subscriber = vi.fn(); + const unsubscribe = viewport.subscribe(subscriber); + + viewport.setZoom(2.0); + + expect(subscriber).toHaveBeenCalled(); + unsubscribe(); + }); + + it('notifies subscribers on rotation changes', () => { + const subscriber = vi.fn(); + const unsubscribe = viewport.subscribe(subscriber); + + viewport.setRotation(45); + + expect(subscriber).toHaveBeenCalled(); + unsubscribe(); + }); + }); +}); + +describe('Pan Controls', () => { + beforeEach(() => { + viewport.reset(); + }); + + describe('Programmatic Pan', () => { + it('panTo sets absolute position', () => { + panTo(100, 200); + + const state = get(viewport); + expect(state.x).toBe(100); + expect(state.y).toBe(200); + }); + + it('panBy moves relative to current position', () => { + panTo(50, 50); + panBy(25, 30); + + const state = get(viewport); + expect(state.x).toBe(75); + expect(state.y).toBe(80); + }); + + it('panBy with negative deltas', () => { + panTo(100, 100); + panBy(-50, -50); + + const state = get(viewport); + expect(state.x).toBe(50); + expect(state.y).toBe(50); + }); + }); +}); + +describe('Zoom Controls', () => { + beforeEach(() => { + viewport.reset(); + }); + + describe('Programmatic Zoom', () => { + it('zoomTo sets absolute zoom level', () => { + zoomTo(2.5); + + expect(get(viewport).zoom).toBe(2.5); + }); + + it('zoomBy multiplies current zoom', () => { + zoomTo(2.0); + zoomBy(1.5); + + expect(get(viewport).zoom).toBe(3.0); + }); + + it('zoomIn increases zoom', () => { + const initialZoom = get(viewport).zoom; + zoomIn(); + + expect(get(viewport).zoom).toBeGreaterThan(initialZoom); + }); + + it('zoomOut decreases zoom', () => { + zoomTo(2.0); + const initialZoom = get(viewport).zoom; + zoomOut(); + + expect(get(viewport).zoom).toBeLessThan(initialZoom); + }); + + it('zoomIn respects maximum zoom', () => { + zoomTo(4.9); + zoomIn(); + + expect(get(viewport).zoom).toBeLessThanOrEqual(5.0); + }); + + it('zoomOut respects minimum zoom', () => { + zoomTo(0.15); + zoomOut(); + + expect(get(viewport).zoom).toBeGreaterThanOrEqual(0.1); + }); + }); +}); + +describe('Rotate Controls', () => { + beforeEach(() => { + viewport.reset(); + }); + + describe('Programmatic Rotation', () => { + it('rotateTo sets absolute rotation', () => { + rotateTo(90); + + expect(get(viewport).rotation).toBe(90); + }); + + it('rotateBy adds to current rotation', () => { + rotateTo(45); + rotateBy(15); + + expect(get(viewport).rotation).toBe(60); + }); + + it('rotateClockwise rotates by step', () => { + rotateClockwise(); + + // Default step is 15 degrees + expect(get(viewport).rotation).toBe(15); + }); + + it('rotateCounterClockwise rotates by negative step', () => { + rotateTo(30); + rotateCounterClockwise(); + + // Default step is 15 degrees + expect(get(viewport).rotation).toBe(15); + }); + + it('resetRotation sets to 0', () => { + rotateTo(90); + resetRotation(); + + expect(get(viewport).rotation).toBe(0); + }); + + it('rotateTo90 sets to 90 degrees', () => { + rotateTo90(); + + expect(get(viewport).rotation).toBe(90); + }); + + it('rotateTo180 sets to 180 degrees', () => { + rotateTo180(); + + expect(get(viewport).rotation).toBe(180); + }); + + it('rotateTo270 sets to 270 degrees', () => { + rotateTo270(); + + expect(get(viewport).rotation).toBe(270); + }); + }); +}); + +describe('Reset Controls', () => { + beforeEach(() => { + // Set non-default values + viewport.setPan(100, 200); + viewport.setZoom(2.5); + viewport.setRotation(90); + }); + + describe('Selective Reset', () => { + it('resetPan only resets position', () => { + resetPan(); + + const state = get(viewport); + expect(state.x).toBe(0); + expect(state.y).toBe(0); + expect(state.zoom).toBe(2.5); // Unchanged + expect(state.rotation).toBe(90); // Unchanged + }); + + it('resetZoom only resets zoom', () => { + resetZoom(); + + const state = get(viewport); + expect(state.x).toBe(100); // Unchanged + expect(state.y).toBe(200); // Unchanged + expect(state.zoom).toBe(1.0); + expect(state.rotation).toBe(90); // Unchanged + }); + + it('resetRotation (from reset controls) only resets rotation', () => { + resetRotation(); + + const state = get(viewport); + expect(state.x).toBe(100); // Unchanged + expect(state.y).toBe(200); // Unchanged + expect(state.zoom).toBe(2.5); // Unchanged + expect(state.rotation).toBe(0); + }); + + it('resetCamera resets everything', () => { + resetCamera(); + + const state = get(viewport); + expect(state).toEqual({ + x: 0, + y: 0, + zoom: 1.0, + rotation: 0, + }); + }); + }); +}); + +describe('Viewport State Serialization', () => { + beforeEach(() => { + viewport.reset(); + }); + + it('serializes viewport state to JSON', async () => { + const { serializeViewportState } = await import('$lib/stores/viewport'); + + viewport.setPan(100, 200); + viewport.setZoom(2.0); + viewport.setRotation(45); + + const state = get(viewport); + const serialized = serializeViewportState(state); + + expect(serialized).toBe(JSON.stringify({ x: 100, y: 200, zoom: 2, rotation: 45 })); + }); + + it('deserializes viewport state from JSON', async () => { + const { deserializeViewportState } = await import('$lib/stores/viewport'); + + const json = JSON.stringify({ x: 100, y: 200, zoom: 2.5, rotation: 90 }); + const state = deserializeViewportState(json); + + expect(state).toEqual({ + x: 100, + y: 200, + zoom: 2.5, + rotation: 90, + }); + }); + + it('handles invalid JSON gracefully', async () => { + const { deserializeViewportState } = await import('$lib/stores/viewport'); + + const state = deserializeViewportState('invalid json'); + + // Should return default state + expect(state).toEqual({ + x: 0, + y: 0, + zoom: 1.0, + rotation: 0, + }); + }); + + it('validates deserialized values', async () => { + const { deserializeViewportState } = await import('$lib/stores/viewport'); + + const json = JSON.stringify({ x: 100, y: 200, zoom: 10.0, rotation: 450 }); + const state = deserializeViewportState(json); + + // Zoom should be clamped to max + expect(state.zoom).toBe(5.0); + + // Rotation should be normalized to 0-360 + expect(state.rotation).toBe(90); + }); + + it('handles missing fields in JSON', async () => { + const { deserializeViewportState } = await import('$lib/stores/viewport'); + + const json = JSON.stringify({ x: 100 }); + const state = deserializeViewportState(json); + + expect(state.x).toBe(100); + expect(state.y).toBe(0); // Default + expect(state.zoom).toBe(1.0); // Default + expect(state.rotation).toBe(0); // Default + }); +}); + +describe('Integration Tests', () => { + beforeEach(() => { + viewport.reset(); + }); + + it('complex viewport manipulation sequence', () => { + // Pan + viewport.setPan(100, 100); + + // Zoom + viewport.setZoom(2.0); + + // Rotate + viewport.setRotation(45); + + // Pan by delta + viewport.panBy(50, 50); + + const state = get(viewport); + expect(state.x).toBe(150); + expect(state.y).toBe(150); + expect(state.zoom).toBe(2.0); + expect(state.rotation).toBe(45); + }); + + it('reset after complex manipulation', () => { + viewport.setPan(100, 100); + viewport.setZoom(3.0); + viewport.setRotation(180); + + viewport.reset(); + + expect(get(isViewportDefault)).toBe(true); + }); + + it('multiple zoom operations maintain center', () => { + viewport.setZoom(2.0, 500, 500); + viewport.setZoom(1.5, 500, 500); + + // Position should adjust to keep point at 500,500 centered + const state = get(viewport); + expect(state.zoom).toBe(1.5); + }); +}); + diff --git a/frontend/tests/components/upload.test.ts b/frontend/tests/components/upload.test.ts new file mode 100644 index 0000000..6a52153 --- /dev/null +++ b/frontend/tests/components/upload.test.ts @@ -0,0 +1,997 @@ +/** + * Component tests for upload components + * Tests FilePicker, DropZone, ProgressBar, and ErrorDisplay Svelte components + */ + +import { render, fireEvent, screen, waitFor } from '@testing-library/svelte'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import FilePicker from '$lib/components/upload/FilePicker.svelte'; +import DropZone from '$lib/components/upload/DropZone.svelte'; +import ProgressBar from '$lib/components/upload/ProgressBar.svelte'; +import ErrorDisplay from '$lib/components/upload/ErrorDisplay.svelte'; +import type { ImageUploadProgress } from '$lib/types/images'; + +// Mock the image store functions +vi.mock('$lib/stores/images', () => ({ + uploadSingleImage: vi.fn(), + uploadZipFile: vi.fn(), + uploadProgress: { + update: vi.fn(), + }, +})); + +describe('FilePicker', () => { + let uploadSingleImage: ReturnType; + let uploadZipFile: ReturnType; + + beforeEach(async () => { + const imageStore = await import('$lib/stores/images'); + uploadSingleImage = imageStore.uploadSingleImage; + uploadZipFile = imageStore.uploadZipFile; + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders the file picker button', () => { + render(FilePicker); + + const button = screen.getByRole('button', { name: /choose files/i }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + it('renders with custom accept attribute', () => { + render(FilePicker, { props: { accept: 'image/png,.jpg' } }); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + }); + + it('renders with multiple attribute by default', () => { + const { container } = render(FilePicker); + + const fileInput = container.querySelector('input[type="file"]'); + expect(fileInput).toHaveAttribute('multiple'); + }); + + it('can disable multiple file selection', () => { + const { container } = render(FilePicker, { props: { multiple: false } }); + + const fileInput = container.querySelector('input[type="file"]'); + expect(fileInput).not.toHaveAttribute('multiple'); + }); + + it('hides the file input element', () => { + const { container } = render(FilePicker); + + const fileInput = container.querySelector('input[type="file"]') as HTMLElement; + expect(fileInput).toHaveStyle({ display: 'none' }); + }); + }); + + describe('File Selection', () => { + it('opens file picker when button is clicked', async () => { + const { container } = render(FilePicker); + + const button = screen.getByRole('button', { name: /choose files/i }); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const clickSpy = vi.fn(); + fileInput.click = clickSpy; + + await fireEvent.click(button); + + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('handles single image file upload', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + + const { container, component } = render(FilePicker); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image content'], 'test.jpg', { type: 'image/jpeg' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(uploadSingleImage).toHaveBeenCalledWith(file); + }); + + expect(uploadCompleteHandler).toHaveBeenCalledTimes(1); + expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 }); + }); + + it('handles multiple image file uploads', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + + const { container, component } = render(FilePicker); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const files = [ + new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }), + new File(['image2'], 'test2.png', { type: 'image/png' }), + new File(['image3'], 'test3.gif', { type: 'image/gif' }), + ]; + + await fireEvent.change(fileInput, { target: { files } }); + + await waitFor(() => { + expect(uploadSingleImage).toHaveBeenCalledTimes(3); + }); + + expect(uploadCompleteHandler).toHaveBeenCalledTimes(1); + expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 }); + }); + + it('handles ZIP file upload', async () => { + uploadZipFile.mockResolvedValue({ success: true }); + + const { container, component } = render(FilePicker); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['zip content'], 'images.zip', { type: 'application/zip' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(uploadZipFile).toHaveBeenCalledWith(file); + }); + + expect(uploadSingleImage).not.toHaveBeenCalled(); + expect(uploadCompleteHandler).toHaveBeenCalledTimes(1); + }); + + it('handles mixed image and ZIP file uploads', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + uploadZipFile.mockResolvedValue({ success: true }); + + const { container, component } = render(FilePicker); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const files = [ + new File(['image'], 'test.jpg', { type: 'image/jpeg' }), + new File(['zip'], 'archive.zip', { type: 'application/zip' }), + new File(['image'], 'test.png', { type: 'image/png' }), + ]; + + await fireEvent.change(fileInput, { target: { files } }); + + await waitFor(() => { + expect(uploadSingleImage).toHaveBeenCalledTimes(2); + expect(uploadZipFile).toHaveBeenCalledTimes(1); + }); + + expect(uploadCompleteHandler).toHaveBeenCalledTimes(1); + expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 }); + }); + + it('resets file input after upload', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + + const { container } = render(FilePicker); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(uploadSingleImage).toHaveBeenCalled(); + }); + + expect(fileInput.value).toBe(''); + }); + }); + + describe('Loading State', () => { + it('shows loading state during upload', async () => { + uploadSingleImage.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + const { container } = render(FilePicker); + + const button = screen.getByRole('button'); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + // During upload + expect(button).toBeDisabled(); + expect(screen.getByText(/uploading/i)).toBeInTheDocument(); + + // Wait for upload to complete + await waitFor(() => { + expect(button).not.toBeDisabled(); + }); + + expect(screen.queryByText(/uploading/i)).not.toBeInTheDocument(); + }); + + it('disables button during upload', async () => { + uploadSingleImage.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + const { container } = render(FilePicker); + + const button = screen.getByRole('button'); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + expect(button).not.toBeDisabled(); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + expect(button).toBeDisabled(); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + }); + }); + + it('shows spinner during upload', async () => { + uploadSingleImage.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + const { container } = render(FilePicker); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + const spinner = container.querySelector('.spinner'); + expect(spinner).toBeInTheDocument(); + + await waitFor(() => { + expect(container.querySelector('.spinner')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Error Handling', () => { + it('dispatches upload-error event on upload failure', async () => { + uploadSingleImage.mockRejectedValue(new Error('Upload failed')); + + const { container, component } = render(FilePicker); + + const uploadErrorHandler = vi.fn(); + component.$on('upload-error', uploadErrorHandler); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(uploadErrorHandler).toHaveBeenCalledTimes(1); + }); + + expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Upload failed' }); + }); + + it('re-enables button after error', async () => { + uploadSingleImage.mockRejectedValue(new Error('Upload failed')); + + const { container } = render(FilePicker); + + const button = screen.getByRole('button'); + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + await fireEvent.change(fileInput, { target: { files: [file] } }); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + }); + }); + + it('handles no files selected gracefully', async () => { + const { container } = render(FilePicker); + + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; + + await fireEvent.change(fileInput, { target: { files: null } }); + + expect(uploadSingleImage).not.toHaveBeenCalled(); + expect(uploadZipFile).not.toHaveBeenCalled(); + }); + }); +}); + +describe('DropZone', () => { + let uploadSingleImage: ReturnType; + let uploadZipFile: ReturnType; + + beforeEach(async () => { + const imageStore = await import('$lib/stores/images'); + uploadSingleImage = imageStore.uploadSingleImage; + uploadZipFile = imageStore.uploadZipFile; + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders the drop zone', () => { + render(DropZone); + + expect(screen.getByText(/drag and drop images here/i)).toBeInTheDocument(); + expect(screen.getByText(/or use the file picker above/i)).toBeInTheDocument(); + }); + + it('shows default state initially', () => { + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone'); + expect(dropZone).not.toHaveClass('dragging'); + expect(dropZone).not.toHaveClass('uploading'); + }); + }); + + describe('Drag and Drop', () => { + it('shows dragging state on drag enter', async () => { + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + await fireEvent.dragEnter(dropZone); + + expect(dropZone).toHaveClass('dragging'); + expect(screen.getByText(/drop files here/i)).toBeInTheDocument(); + }); + + it('removes dragging state on drag leave', async () => { + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + await fireEvent.dragEnter(dropZone); + expect(dropZone).toHaveClass('dragging'); + + await fireEvent.dragLeave(dropZone); + expect(dropZone).not.toHaveClass('dragging'); + }); + + it('handles drag over event', async () => { + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(dragOverEvent, 'preventDefault'); + + dropZone.dispatchEvent(dragOverEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('handles single image file drop', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + + const { container, component } = render(DropZone); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + const dropEvent = new DragEvent('drop', { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer(), + }); + + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [file], + }, + }); + + await fireEvent(dropZone, dropEvent); + + await waitFor(() => { + expect(uploadSingleImage).toHaveBeenCalledWith(file); + }); + + expect(uploadCompleteHandler).toHaveBeenCalledTimes(1); + expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 }); + }); + + it('handles multiple image files drop', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + + const { container, component } = render(DropZone); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const files = [ + new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }), + new File(['image2'], 'test2.png', { type: 'image/png' }), + ]; + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files }, + }); + + await fireEvent(dropZone, dropEvent); + + await waitFor(() => { + expect(uploadSingleImage).toHaveBeenCalledTimes(2); + }); + + expect(uploadCompleteHandler).toHaveBeenCalledTimes(1); + }); + + it('handles ZIP file drop', async () => { + uploadZipFile.mockResolvedValue({ success: true }); + + const { container, component } = render(DropZone); + + const uploadCompleteHandler = vi.fn(); + component.$on('upload-complete', uploadCompleteHandler); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const file = new File(['zip'], 'images.zip', { type: 'application/zip' }); + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }); + + await fireEvent(dropZone, dropEvent); + + await waitFor(() => { + expect(uploadZipFile).toHaveBeenCalledWith(file); + }); + + expect(uploadSingleImage).not.toHaveBeenCalled(); + }); + + it('filters out invalid file types', async () => { + const { container, component } = render(DropZone, { props: { accept: 'image/*,.zip' } }); + + const uploadErrorHandler = vi.fn(); + component.$on('upload-error', uploadErrorHandler); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const files = [new File(['text'], 'document.txt', { type: 'text/plain' })]; + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files }, + }); + + await fireEvent(dropZone, dropEvent); + + await waitFor(() => { + expect(uploadErrorHandler).toHaveBeenCalledTimes(1); + }); + + expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ + error: 'No valid image files found', + }); + + expect(uploadSingleImage).not.toHaveBeenCalled(); + expect(uploadZipFile).not.toHaveBeenCalled(); + }); + + it('removes dragging state after drop', async () => { + uploadSingleImage.mockResolvedValue({ success: true }); + + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + await fireEvent.dragEnter(dropZone); + expect(dropZone).toHaveClass('dragging'); + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }); + + await fireEvent(dropZone, dropEvent); + + expect(dropZone).not.toHaveClass('dragging'); + }); + }); + + describe('Loading State', () => { + it('shows uploading state during upload', async () => { + uploadSingleImage.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }); + + await fireEvent(dropZone, dropEvent); + + expect(dropZone).toHaveClass('uploading'); + expect(screen.getByText(/uploading/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(dropZone).not.toHaveClass('uploading'); + }); + }); + + it('shows spinner during upload', async () => { + uploadSingleImage.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100)) + ); + + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }); + + await fireEvent(dropZone, dropEvent); + + const spinner = container.querySelector('.spinner-large'); + expect(spinner).toBeInTheDocument(); + + await waitFor(() => { + expect(container.querySelector('.spinner-large')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Error Handling', () => { + it('dispatches upload-error event on upload failure', async () => { + uploadSingleImage.mockRejectedValue(new Error('Network error')); + + const { container, component } = render(DropZone); + + const uploadErrorHandler = vi.fn(); + component.$on('upload-error', uploadErrorHandler); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }); + + await fireEvent(dropZone, dropEvent); + + await waitFor(() => { + expect(uploadErrorHandler).toHaveBeenCalledTimes(1); + }); + + expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Network error' }); + }); + + it('returns to normal state after error', async () => { + uploadSingleImage.mockRejectedValue(new Error('Upload failed')); + + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' }); + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: [file] }, + }); + + await fireEvent(dropZone, dropEvent); + + await waitFor(() => { + expect(dropZone).not.toHaveClass('uploading'); + }); + }); + + it('handles drop event with no files', async () => { + const { container } = render(DropZone); + + const dropZone = container.querySelector('.drop-zone') as HTMLElement; + + const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { files: null }, + }); + + await fireEvent(dropZone, dropEvent); + + expect(uploadSingleImage).not.toHaveBeenCalled(); + expect(uploadZipFile).not.toHaveBeenCalled(); + }); + }); +}); + +describe('ProgressBar', () => { + describe('Rendering', () => { + it('renders progress item with filename', () => { + const item: ImageUploadProgress = { + filename: 'test-image.jpg', + status: 'uploading', + progress: 50, + }; + + render(ProgressBar, { props: { item } }); + + expect(screen.getByText('test-image.jpg')).toBeInTheDocument(); + }); + + it('shows progress bar for uploading status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'uploading', + progress: 75, + }; + + const { container } = render(ProgressBar, { props: { item } }); + + expect(screen.getByText('75%')).toBeInTheDocument(); + + const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement; + expect(progressBar).toHaveStyle({ width: '75%' }); + }); + + it('shows progress bar for processing status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'processing', + progress: 90, + }; + + render(ProgressBar, { props: { item } }); + + expect(screen.getByText('90%')).toBeInTheDocument(); + }); + + it('shows success message for complete status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'complete', + progress: 100, + }; + + render(ProgressBar, { props: { item } }); + + expect(screen.getByText(/upload complete/i)).toBeInTheDocument(); + expect(screen.queryByText(/\d+%/)).not.toBeInTheDocument(); + }); + + it('shows error message for error status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'error', + progress: 0, + error: 'File too large', + }; + + render(ProgressBar, { props: { item } }); + + expect(screen.getByText('File too large')).toBeInTheDocument(); + }); + + it('shows close button for complete status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'complete', + progress: 100, + }; + + render(ProgressBar, { props: { item } }); + + const closeButton = screen.getByRole('button', { name: /remove/i }); + expect(closeButton).toBeInTheDocument(); + }); + + it('shows close button for error status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'error', + progress: 0, + error: 'Failed', + }; + + render(ProgressBar, { props: { item } }); + + const closeButton = screen.getByRole('button', { name: /remove/i }); + expect(closeButton).toBeInTheDocument(); + }); + + it('hides close button for uploading status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'uploading', + progress: 50, + }; + + render(ProgressBar, { props: { item } }); + + const closeButton = screen.queryByRole('button', { name: /remove/i }); + expect(closeButton).not.toBeInTheDocument(); + }); + }); + + describe('Status Icons', () => { + it('shows correct icon for uploading status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'uploading', + progress: 50, + }; + + const { container } = render(ProgressBar, { props: { item } }); + + const statusIcon = container.querySelector('.status-icon'); + expect(statusIcon).toHaveTextContent('⟳'); + }); + + it('shows correct icon for processing status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'processing', + progress: 90, + }; + + const { container } = render(ProgressBar, { props: { item } }); + + const statusIcon = container.querySelector('.status-icon'); + expect(statusIcon).toHaveTextContent('⟳'); + }); + + it('shows correct icon for complete status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'complete', + progress: 100, + }; + + const { container } = render(ProgressBar, { props: { item } }); + + const statusIcon = container.querySelector('.status-icon'); + expect(statusIcon).toHaveTextContent('✓'); + }); + + it('shows correct icon for error status', () => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'error', + progress: 0, + error: 'Failed', + }; + + const { container } = render(ProgressBar, { props: { item } }); + + const statusIcon = container.querySelector('.status-icon'); + expect(statusIcon).toHaveTextContent('✗'); + }); + }); + + describe('Remove Functionality', () => { + it('removes item from store when close button is clicked', async () => { + const imageStore = await import('$lib/stores/images'); + const updateFn = vi.fn((callback) => callback([])); + imageStore.uploadProgress.update = updateFn; + + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'complete', + progress: 100, + }; + + render(ProgressBar, { props: { item } }); + + const closeButton = screen.getByRole('button', { name: /remove/i }); + await fireEvent.click(closeButton); + + expect(updateFn).toHaveBeenCalled(); + }); + }); + + describe('Progress Display', () => { + it('shows progress percentage correctly', () => { + const testCases = [0, 25, 50, 75, 100]; + + testCases.forEach((progress) => { + const item: ImageUploadProgress = { + filename: 'test.jpg', + status: 'uploading', + progress, + }; + + const { unmount, container } = render(ProgressBar, { props: { item } }); + + expect(screen.getByText(`${progress}%`)).toBeInTheDocument(); + + const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement; + expect(progressBar).toHaveStyle({ width: `${progress}%` }); + + unmount(); + }); + }); + + it('truncates long filenames', () => { + const item: ImageUploadProgress = { + filename: 'very-long-filename-that-should-be-truncated-with-ellipsis.jpg', + status: 'uploading', + progress: 50, + }; + + const { container } = render(ProgressBar, { props: { item } }); + + const filenameElement = container.querySelector('.filename') as HTMLElement; + expect(filenameElement).toHaveStyle({ + overflow: 'hidden', + 'text-overflow': 'ellipsis', + 'white-space': 'nowrap', + }); + }); + }); +}); + +describe('ErrorDisplay', () => { + describe('Rendering', () => { + it('renders error message', () => { + render(ErrorDisplay, { props: { error: 'Upload failed' } }); + + expect(screen.getByText('Upload failed')).toBeInTheDocument(); + }); + + it('renders with error icon', () => { + const { container } = render(ErrorDisplay, { props: { error: 'Test error' } }); + + const icon = container.querySelector('.error-icon svg'); + expect(icon).toBeInTheDocument(); + }); + + it('has proper ARIA role', () => { + render(ErrorDisplay, { props: { error: 'Test error' } }); + + const errorDisplay = screen.getByRole('alert'); + expect(errorDisplay).toBeInTheDocument(); + }); + + it('shows dismiss button by default', () => { + render(ErrorDisplay, { props: { error: 'Test error' } }); + + const dismissButton = screen.getByRole('button', { name: /dismiss error/i }); + expect(dismissButton).toBeInTheDocument(); + }); + + it('hides dismiss button when dismissible is false', () => { + render(ErrorDisplay, { props: { error: 'Test error', dismissible: false } }); + + const dismissButton = screen.queryByRole('button', { name: /dismiss error/i }); + expect(dismissButton).not.toBeInTheDocument(); + }); + }); + + describe('Dismiss Functionality', () => { + it('dispatches dismiss event when button is clicked', async () => { + const { component } = render(ErrorDisplay, { props: { error: 'Test error' } }); + + const dismissHandler = vi.fn(); + component.$on('dismiss', dismissHandler); + + const dismissButton = screen.getByRole('button', { name: /dismiss error/i }); + await fireEvent.click(dismissButton); + + expect(dismissHandler).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch dismiss event when dismissible is false', () => { + const { component } = render(ErrorDisplay, { + props: { error: 'Test error', dismissible: false }, + }); + + const dismissHandler = vi.fn(); + component.$on('dismiss', dismissHandler); + + // No dismiss button should exist + const dismissButton = screen.queryByRole('button', { name: /dismiss error/i }); + expect(dismissButton).not.toBeInTheDocument(); + }); + }); + + describe('Error Messages', () => { + it('handles short error messages', () => { + render(ErrorDisplay, { props: { error: 'Error' } }); + + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('handles long error messages', () => { + const longError = + 'This is a very long error message that contains detailed information about what went wrong during the upload process. It should be displayed correctly with proper line wrapping.'; + + render(ErrorDisplay, { props: { error: longError } }); + + expect(screen.getByText(longError)).toBeInTheDocument(); + }); + + it('handles error messages with special characters', () => { + const errorWithSpecialChars = "File 'test.jpg' couldn't be uploaded: size > 50MB"; + + render(ErrorDisplay, { props: { error: errorWithSpecialChars } }); + + expect(screen.getByText(errorWithSpecialChars)).toBeInTheDocument(); + }); + + it('handles empty error messages', () => { + render(ErrorDisplay, { props: { error: '' } }); + + const errorMessage = screen.getByRole('alert'); + expect(errorMessage).toBeInTheDocument(); + }); + }); + + describe('Styling', () => { + it('applies error styling classes', () => { + const { container } = render(ErrorDisplay, { props: { error: 'Test error' } }); + + const errorDisplay = container.querySelector('.error-display'); + expect(errorDisplay).toBeInTheDocument(); + expect(errorDisplay).toHaveClass('error-display'); + }); + + it('has proper visual hierarchy', () => { + const { container } = render(ErrorDisplay, { props: { error: 'Test error' } }); + + const errorIcon = container.querySelector('.error-icon'); + const errorContent = container.querySelector('.error-content'); + const dismissButton = container.querySelector('.dismiss-button'); + + expect(errorIcon).toBeInTheDocument(); + expect(errorContent).toBeInTheDocument(); + expect(dismissButton).toBeInTheDocument(); + }); + }); +}); + diff --git a/specs/001-reference-board-viewer/tasks.md b/specs/001-reference-board-viewer/tasks.md index f048091..a1fe335 100644 --- a/specs/001-reference-board-viewer/tasks.md +++ b/specs/001-reference-board-viewer/tasks.md @@ -216,7 +216,7 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu - [X] T094 [US3] Implement ZIP upload handler in frontend/src/lib/utils/zip-upload.ts - [X] T095 [P] [US3] Create upload progress component in frontend/src/lib/components/upload/ProgressBar.svelte - [X] T096 [P] [US3] Create upload error display in frontend/src/lib/components/upload/ErrorDisplay.svelte -- [ ] T097 [P] [US3] Write upload component tests in frontend/tests/components/upload.test.ts +- [X] T097 [P] [US3] Write upload component tests in frontend/tests/components/upload.test.ts **Infrastructure:** @@ -232,33 +232,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu --- -## Phase 6: Canvas Navigation & Viewport (FR12 - Critical) (Week 5) +## Phase 6: Canvas Navigation & Viewport (FR12 - Critical) (Week 5) ✅ COMPLETE **User Story:** Users must be able to navigate the infinite canvas efficiently **Independent Test Criteria:** -- [ ] Users can pan canvas (drag or spacebar+drag) -- [ ] Users can zoom in/out (mouse wheel, pinch) -- [ ] Users can rotate canvas view -- [ ] Users can reset camera and fit to screen -- [ ] Viewport state persists +- [X] Users can pan canvas (drag or spacebar+drag) +- [X] Users can zoom in/out (mouse wheel, pinch) +- [X] Users can rotate canvas view +- [X] Users can reset camera and fit to screen +- [X] Viewport state persists **Frontend Tasks:** -- [ ] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte -- [ ] T101 [US4] Implement pan functionality in frontend/src/lib/canvas/controls/pan.ts -- [ ] T102 [P] [US4] Implement zoom functionality in frontend/src/lib/canvas/controls/zoom.ts -- [ ] T103 [P] [US4] Implement canvas rotation in frontend/src/lib/canvas/controls/rotate.ts -- [ ] T104 [US4] Create viewport store in frontend/src/lib/stores/viewport.ts -- [ ] T105 [US4] Implement reset camera function in frontend/src/lib/canvas/controls/reset.ts -- [ ] T106 [US4] Implement fit-to-screen function in frontend/src/lib/canvas/controls/fit.ts -- [ ] T107 [US4] Add touch gesture support in frontend/src/lib/canvas/gestures.ts (pinch, two-finger pan) -- [ ] T108 [US4] Persist viewport state to backend when changed -- [ ] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts +- [X] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte +- [X] T101 [US4] Implement pan functionality in frontend/src/lib/canvas/controls/pan.ts +- [X] T102 [P] [US4] Implement zoom functionality in frontend/src/lib/canvas/controls/zoom.ts +- [X] T103 [P] [US4] Implement canvas rotation in frontend/src/lib/canvas/controls/rotate.ts +- [X] T104 [US4] Create viewport store in frontend/src/lib/stores/viewport.ts +- [X] T105 [US4] Implement reset camera function in frontend/src/lib/canvas/controls/reset.ts +- [X] T106 [US4] Implement fit-to-screen function in frontend/src/lib/canvas/controls/fit.ts +- [X] T107 [US4] Add touch gesture support in frontend/src/lib/canvas/gestures.ts (pinch, two-finger pan) +- [X] T108 [US4] Persist viewport state to backend when changed +- [X] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts **Backend Tasks:** -- [ ] T110 [US4] Add viewport persistence endpoint PATCH /boards/{id}/viewport in backend/app/api/boards.py +- [X] T110 [US4] Add viewport persistence endpoint PATCH /boards/{id}/viewport in backend/app/api/boards.py **Deliverables:** - Infinite canvas working