001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
14 changed files with 3103 additions and 19 deletions
Showing only changes of commit 3700ba02ea - Show all commits

View File

@@ -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,

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -0,0 +1,203 @@
<script lang="ts">
/**
* Konva Image wrapper component for canvas images
* Wraps a Konva.Image with selection, dragging, and transformation support
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import { isImageSelected } from '$lib/stores/selection';
import { setupImageDrag } from './interactions/drag';
import { setupImageSelection } from './interactions/select';
// Props
export let id: string; // Board image ID
export let imageUrl: string;
export let x: number = 0;
export let y: number = 0;
export let width: number = 100;
export let height: number = 100;
export let rotation: number = 0;
export let scaleX: number = 1;
export let scaleY: number = 1;
export let opacity: number = 1;
export let layer: Konva.Layer | null = null;
export let zOrder: number = 0;
// Callbacks
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined;
let imageNode: Konva.Image | null = null;
let imageGroup: Konva.Group | null = null;
let imageObj: HTMLImageElement | null = null;
let cleanupDrag: (() => void) | null = null;
let cleanupSelection: (() => void) | null = null;
let unsubscribeSelection: (() => void) | null = null;
// Subscribe to selection state for this image
$: isSelected = isImageSelected(id);
onMount(() => {
if (!layer) return;
// Load image
imageObj = new Image();
imageObj.crossOrigin = 'Anonymous';
imageObj.onload = () => {
if (!layer || !imageObj) return;
// Create Konva image
imageNode = new Konva.Image({
image: imageObj!,
x: 0,
y: 0,
width,
height,
listening: true,
});
// Create group for image and selection box
imageGroup = new Konva.Group({
x,
y,
rotation,
scaleX,
scaleY,
opacity,
draggable: true,
id: `image-group-${id}`,
});
imageGroup.add(imageNode);
// Set Z-index
imageGroup.zIndex(zOrder);
layer.add(imageGroup);
// Setup interactions
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
if (onDragEnd) {
onDragEnd(imageId, newX, newY);
}
});
cleanupSelection = setupImageSelection(imageGroup, id, undefined, (imageId, _selected) => {
updateSelectionVisual();
if (onSelectionChange) {
onSelectionChange(imageId, _selected);
}
});
// Subscribe to selection changes for visual updates
unsubscribeSelection = isSelected.subscribe((_selected) => {
updateSelectionVisual();
});
layer.batchDraw();
};
imageObj.src = imageUrl;
});
onDestroy(() => {
// Clean up event listeners
if (cleanupDrag) cleanupDrag();
if (cleanupSelection) cleanupSelection();
if (unsubscribeSelection) unsubscribeSelection();
// Destroy Konva nodes
if (imageNode) imageNode.destroy();
if (imageGroup) imageGroup.destroy();
// Redraw layer
if (layer) layer.batchDraw();
});
/**
* Update selection visual (highlight border)
*/
function updateSelectionVisual() {
if (!imageGroup || !$isSelected) return;
// Remove existing selection box
const existingBox = imageGroup.findOne('.selection-box');
if (existingBox) existingBox.destroy();
if ($isSelected && imageNode) {
// Add selection box
const selectionBox = new Konva.Rect({
x: 0,
y: 0,
width: imageNode.width(),
height: imageNode.height(),
stroke: '#3b82f6',
strokeWidth: 2,
listening: false,
name: 'selection-box',
});
imageGroup.add(selectionBox);
}
if (layer) layer.batchDraw();
}
/**
* Update image position
*/
$: if (imageGroup && (imageGroup.x() !== x || imageGroup.y() !== y)) {
imageGroup.position({ x, y });
if (layer) layer.batchDraw();
}
/**
* Update image transformations
*/
$: if (imageGroup) {
if (imageGroup.rotation() !== rotation) {
imageGroup.rotation(rotation);
if (layer) layer.batchDraw();
}
if (imageGroup.scaleX() !== scaleX || imageGroup.scaleY() !== scaleY) {
imageGroup.scale({ x: scaleX, y: scaleY });
if (layer) layer.batchDraw();
}
if (imageGroup.opacity() !== opacity) {
imageGroup.opacity(opacity);
if (layer) layer.batchDraw();
}
if (imageGroup.zIndex() !== zOrder) {
imageGroup.zIndex(zOrder);
if (layer) layer.batchDraw();
}
}
/**
* Update image dimensions
*/
$: if (imageNode && (imageNode.width() !== width || imageNode.height() !== height)) {
imageNode.size({ width, height });
updateSelectionVisual();
if (layer) layer.batchDraw();
}
/**
* Expose image group for external manipulation
*/
export function getImageGroup(): Konva.Group | null {
return imageGroup;
}
/**
* Expose image node for external manipulation
*/
export function getImageNode(): Konva.Image | null {
return imageNode;
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -0,0 +1,179 @@
<script lang="ts">
/**
* Selection box visual indicator for canvas
* Displays a border and resize handles around selected images
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import { selection, selectionCount } from '$lib/stores/selection';
export let layer: Konva.Layer | null = null;
export let getImageBounds: (
id: string
) => { x: number; y: number; width: number; height: number } | null;
let selectionGroup: Konva.Group | null = null;
let unsubscribe: (() => void) | null = null;
onMount(() => {
if (!layer) return;
// Create group for selection visuals
selectionGroup = new Konva.Group({
listening: false,
name: 'selection-group',
});
layer.add(selectionGroup);
// Subscribe to selection changes
unsubscribe = selection.subscribe(() => {
updateSelectionVisuals();
});
layer.batchDraw();
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
if (selectionGroup) {
selectionGroup.destroy();
selectionGroup = null;
}
if (layer) layer.batchDraw();
});
/**
* Update selection visual indicators
*/
function updateSelectionVisuals() {
if (!selectionGroup || !layer) return;
// Clear existing visuals
selectionGroup.destroyChildren();
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
layer.batchDraw();
return;
}
// Calculate bounding box of all selected images
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.forEach((id) => {
const bounds = getImageBounds(id);
if (bounds) {
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + bounds.width);
maxY = Math.max(maxY, bounds.y + bounds.height);
}
});
if (!isFinite(minX) || !isFinite(minY)) {
layer.batchDraw();
return;
}
const width = maxX - minX;
const height = maxY - minY;
// Draw selection border
const border = new Konva.Rect({
x: minX,
y: minY,
width,
height,
stroke: '#3b82f6',
strokeWidth: 2,
dash: [8, 4],
listening: false,
});
selectionGroup.add(border);
// Draw resize handles if single selection
if ($selectionCount === 1) {
const handleSize = 8;
const handlePositions = [
{ x: minX, y: minY, cursor: 'nw-resize' }, // Top-left
{ x: minX + width / 2, y: minY, cursor: 'n-resize' }, // Top-center
{ x: maxX, y: minY, cursor: 'ne-resize' }, // Top-right
{ x: maxX, y: minY + height / 2, cursor: 'e-resize' }, // Right-center
{ x: maxX, y: maxY, cursor: 'se-resize' }, // Bottom-right
{ x: minX + width / 2, y: maxY, cursor: 's-resize' }, // Bottom-center
{ x: minX, y: maxY, cursor: 'sw-resize' }, // Bottom-left
{ x: minX, y: minY + height / 2, cursor: 'w-resize' }, // Left-center
];
handlePositions.forEach((pos) => {
const handle = new Konva.Rect({
x: pos.x - handleSize / 2,
y: pos.y - handleSize / 2,
width: handleSize,
height: handleSize,
fill: '#3b82f6',
stroke: '#ffffff',
strokeWidth: 1,
listening: false,
});
selectionGroup!.add(handle);
});
}
// Draw selection count badge if multiple selection
if ($selectionCount > 1) {
const badgeX = maxX - 30;
const badgeY = minY - 30;
const badge = new Konva.Group({
x: badgeX,
y: badgeY,
listening: false,
});
const badgeBackground = new Konva.Rect({
x: 0,
y: 0,
width: 30,
height: 24,
fill: '#3b82f6',
cornerRadius: 4,
listening: false,
});
const badgeText = new Konva.Text({
x: 0,
y: 0,
width: 30,
height: 24,
text: $selectionCount.toString(),
fontSize: 14,
fill: '#ffffff',
align: 'center',
verticalAlign: 'middle',
listening: false,
});
badge.add(badgeBackground);
badge.add(badgeText);
selectionGroup!.add(badge);
}
layer.batchDraw();
}
/**
* Force update of selection visuals (for external calls)
*/
export function update() {
updateSelectionVisuals();
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -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<DragEvent>) {
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<DragEvent>) {
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<DragEvent>) {
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;
}

View File

@@ -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<MouseEvent | TouchEvent>) {
// 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<MouseEvent | TouchEvent>) {
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<MouseEvent | TouchEvent>) {
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();
}

View File

@@ -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<MouseEvent | TouchEvent>) {
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<MouseEvent | TouchEvent>) {
// 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);
}

View File

@@ -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<typeof setTimeout>;
}
// Track pending updates by image ID
const pendingUpdates = new Map<string, PendingUpdate>();
/**
* 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<void> {
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<void> {
const promises: Promise<void>[] = [];
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<void> {
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<void> {
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<void> {
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<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
group_id: groupId,
});
} catch (error) {
console.error('Failed to sync image group:', error);
throw error;
}
}

View File

@@ -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<string>; // 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<SelectionState> = 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);
});
}

View File

@@ -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);
});
});

View File

@@ -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<typeof vi.fn>;
let uploadZipFile: ReturnType<typeof vi.fn>;
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<typeof vi.fn>;
let uploadZipFile: ReturnType<typeof vi.fn>;
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();
});
});
});

View File

@@ -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