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