001-reference-board-viewer #1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
203
frontend/src/lib/canvas/Image.svelte
Normal file
203
frontend/src/lib/canvas/Image.svelte
Normal 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 -->
|
||||
179
frontend/src/lib/canvas/SelectionBox.svelte
Normal file
179
frontend/src/lib/canvas/SelectionBox.svelte
Normal 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 -->
|
||||
184
frontend/src/lib/canvas/interactions/drag.ts
Normal file
184
frontend/src/lib/canvas/interactions/drag.ts
Normal 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;
|
||||
}
|
||||
234
frontend/src/lib/canvas/interactions/multiselect.ts
Normal file
234
frontend/src/lib/canvas/interactions/multiselect.ts
Normal 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();
|
||||
}
|
||||
157
frontend/src/lib/canvas/interactions/select.ts
Normal file
157
frontend/src/lib/canvas/interactions/select.ts
Normal 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);
|
||||
}
|
||||
188
frontend/src/lib/canvas/sync.ts
Normal file
188
frontend/src/lib/canvas/sync.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
200
frontend/src/lib/stores/selection.ts
Normal file
200
frontend/src/lib/stores/selection.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
627
frontend/tests/canvas/controls.test.ts
Normal file
627
frontend/tests/canvas/controls.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
997
frontend/tests/components/upload.test.ts
Normal file
997
frontend/tests/components/upload.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user