Compare commits
3 Commits
ca81729c50
...
cd8ce33f5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8ce33f5e | ||
|
|
3700ba02ea | ||
|
|
f85ae4d417 |
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.boards.repository import BoardRepository
|
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.core.deps import get_current_user, get_db
|
||||||
from app.database.models.user import User
|
from app.database.models.user import User
|
||||||
|
|
||||||
@@ -152,6 +152,48 @@ def update_board(
|
|||||||
return BoardDetail.model_validate(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)
|
@router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_board(
|
def delete_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.images.repository import ImageRepository
|
|||||||
from app.images.schemas import (
|
from app.images.schemas import (
|
||||||
BoardImageCreate,
|
BoardImageCreate,
|
||||||
BoardImageResponse,
|
BoardImageResponse,
|
||||||
|
BoardImageUpdate,
|
||||||
ImageListResponse,
|
ImageListResponse,
|
||||||
ImageResponse,
|
ImageResponse,
|
||||||
ImageUploadResponse,
|
ImageUploadResponse,
|
||||||
@@ -277,6 +278,52 @@ async def add_image_to_board(
|
|||||||
return board_image
|
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)
|
@router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def remove_image_from_board(
|
async def remove_image_from_board(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ class BoardCreate(BaseModel):
|
|||||||
description: str | None = Field(default=None, description="Optional board description")
|
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):
|
class BoardUpdate(BaseModel):
|
||||||
"""Schema for updating board metadata."""
|
"""Schema for updating board metadata."""
|
||||||
|
|
||||||
|
|||||||
38
backend/app/core/constants.py
Normal file
38
backend/app/core/constants.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Application-wide constants."""
|
||||||
|
|
||||||
|
# File upload limits
|
||||||
|
MAX_IMAGE_SIZE = 52_428_800 # 50MB in bytes
|
||||||
|
MAX_ZIP_SIZE = 209_715_200 # 200MB in bytes
|
||||||
|
|
||||||
|
# Image processing
|
||||||
|
MAX_IMAGE_DIMENSION = 10_000 # Max width or height in pixels
|
||||||
|
THUMBNAIL_SIZES = {
|
||||||
|
"low": 800, # For slow connections (<1 Mbps)
|
||||||
|
"medium": 1600, # For medium connections (1-5 Mbps)
|
||||||
|
"high": 3200, # For fast connections (>5 Mbps)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pagination defaults
|
||||||
|
DEFAULT_PAGE_SIZE = 50
|
||||||
|
MAX_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
# Board limits
|
||||||
|
MAX_BOARD_TITLE_LENGTH = 255
|
||||||
|
MAX_BOARD_DESCRIPTION_LENGTH = 1000
|
||||||
|
MAX_IMAGES_PER_BOARD = 1000
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
TOKEN_EXPIRE_HOURS = 168 # 7 days
|
||||||
|
PASSWORD_MIN_LENGTH = 8
|
||||||
|
|
||||||
|
# Supported image formats
|
||||||
|
ALLOWED_MIME_TYPES = {
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/svg+xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
|
||||||
69
backend/app/core/ownership.py
Normal file
69
backend/app/core/ownership.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Ownership verification utilities."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database.models.board import Board
|
||||||
|
|
||||||
|
|
||||||
|
def verify_board_ownership_sync(db: Session, board_id: UUID, user_id: UUID) -> Board:
|
||||||
|
"""
|
||||||
|
Verify board ownership (synchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
board_id: Board UUID
|
||||||
|
user_id: User UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Board instance if owned by user
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if board not found or not owned by user
|
||||||
|
"""
|
||||||
|
stmt = select(Board).where(
|
||||||
|
Board.id == board_id,
|
||||||
|
Board.user_id == user_id,
|
||||||
|
Board.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
|
||||||
|
board = db.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
|
||||||
|
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_board_ownership_async(db: AsyncSession, board_id: UUID, user_id: UUID) -> Board:
|
||||||
|
"""
|
||||||
|
Verify board ownership (asynchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Async database session
|
||||||
|
board_id: Board UUID
|
||||||
|
user_id: User UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Board instance if owned by user
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if board not found or not owned by user
|
||||||
|
"""
|
||||||
|
stmt = select(Board).where(
|
||||||
|
Board.id == board_id,
|
||||||
|
Board.user_id == user_id,
|
||||||
|
Board.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
board = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Board {board_id} not found")
|
||||||
|
|
||||||
|
return board
|
||||||
119
backend/app/core/repository.py
Normal file
119
backend/app/core/repository.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Base repository with common database operations."""
|
||||||
|
|
||||||
|
from typing import TypeVar
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Type variable for model classes
|
||||||
|
ModelType = TypeVar("ModelType")
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository[ModelType]:
|
||||||
|
"""Base repository with common CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, model: type[ModelType], db: Session | AsyncSession):
|
||||||
|
"""
|
||||||
|
Initialize repository.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: SQLAlchemy model class
|
||||||
|
db: Database session (sync or async)
|
||||||
|
"""
|
||||||
|
self.model = model
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def get_by_id_sync(self, id: UUID) -> ModelType | None:
|
||||||
|
"""
|
||||||
|
Get entity by ID (synchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: Entity UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Entity if found, None otherwise
|
||||||
|
"""
|
||||||
|
return self.db.query(self.model).filter(self.model.id == id).first()
|
||||||
|
|
||||||
|
async def get_by_id_async(self, id: UUID) -> ModelType | None:
|
||||||
|
"""
|
||||||
|
Get entity by ID (asynchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: Entity UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Entity if found, None otherwise
|
||||||
|
"""
|
||||||
|
stmt = select(self.model).where(self.model.id == id)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
def count_sync(self, **filters) -> int:
|
||||||
|
"""
|
||||||
|
Count entities with optional filters (synchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**filters: Column filters (column_name=value)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Count of matching entities
|
||||||
|
"""
|
||||||
|
query = self.db.query(func.count(self.model.id))
|
||||||
|
for key, value in filters.items():
|
||||||
|
query = query.filter(getattr(self.model, key) == value)
|
||||||
|
return query.scalar()
|
||||||
|
|
||||||
|
async def count_async(self, **filters) -> int:
|
||||||
|
"""
|
||||||
|
Count entities with optional filters (asynchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**filters: Column filters (column_name=value)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Count of matching entities
|
||||||
|
"""
|
||||||
|
stmt = select(func.count(self.model.id))
|
||||||
|
for key, value in filters.items():
|
||||||
|
stmt = stmt.where(getattr(self.model, key) == value)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
def delete_sync(self, id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Delete entity by ID (synchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: Entity UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
entity = self.get_by_id_sync(id)
|
||||||
|
if not entity:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.db.delete(entity)
|
||||||
|
self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_async(self, id: UUID) -> bool:
|
||||||
|
"""
|
||||||
|
Delete entity by ID (asynchronous).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: Entity UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
entity = await self.get_by_id_async(id)
|
||||||
|
if not entity:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.db.delete(entity)
|
||||||
|
await self.db.commit()
|
||||||
|
return True
|
||||||
75
backend/app/core/responses.py
Normal file
75
backend/app/core/responses.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Standard response utilities."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorResponse:
|
||||||
|
"""Standard error response formats."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def not_found(resource: str = "Resource") -> dict[str, Any]:
|
||||||
|
"""404 Not Found response."""
|
||||||
|
return {
|
||||||
|
"status_code": status.HTTP_404_NOT_FOUND,
|
||||||
|
"detail": f"{resource} not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def forbidden(message: str = "Access denied") -> dict[str, Any]:
|
||||||
|
"""403 Forbidden response."""
|
||||||
|
return {
|
||||||
|
"status_code": status.HTTP_403_FORBIDDEN,
|
||||||
|
"detail": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def unauthorized(message: str = "Authentication required") -> dict[str, Any]:
|
||||||
|
"""401 Unauthorized response."""
|
||||||
|
return {
|
||||||
|
"status_code": status.HTTP_401_UNAUTHORIZED,
|
||||||
|
"detail": message,
|
||||||
|
"headers": {"WWW-Authenticate": "Bearer"},
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def bad_request(message: str) -> dict[str, Any]:
|
||||||
|
"""400 Bad Request response."""
|
||||||
|
return {
|
||||||
|
"status_code": status.HTTP_400_BAD_REQUEST,
|
||||||
|
"detail": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def conflict(message: str) -> dict[str, Any]:
|
||||||
|
"""409 Conflict response."""
|
||||||
|
return {
|
||||||
|
"status_code": status.HTTP_409_CONFLICT,
|
||||||
|
"detail": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse:
|
||||||
|
"""Standard success response formats."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def created(data: dict[str, Any], message: str = "Created successfully") -> dict[str, Any]:
|
||||||
|
"""201 Created response."""
|
||||||
|
return {
|
||||||
|
"message": message,
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ok(data: dict[str, Any] | None = None, message: str = "Success") -> dict[str, Any]:
|
||||||
|
"""200 OK response."""
|
||||||
|
response = {"message": message}
|
||||||
|
if data:
|
||||||
|
response["data"] = data
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def no_content() -> None:
|
||||||
|
"""204 No Content response."""
|
||||||
|
return None
|
||||||
@@ -83,6 +83,23 @@ class BoardImageCreate(BaseModel):
|
|||||||
return v
|
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):
|
class BoardImageResponse(BaseModel):
|
||||||
"""Response for board image with all metadata."""
|
"""Response for board image with all metadata."""
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,11 @@
|
|||||||
import magic
|
import magic
|
||||||
from fastapi import HTTPException, UploadFile, status
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
|
||||||
# Maximum file size: 50MB
|
from app.core.constants import (
|
||||||
MAX_FILE_SIZE = 52_428_800
|
ALLOWED_EXTENSIONS,
|
||||||
|
ALLOWED_MIME_TYPES,
|
||||||
# Allowed MIME types
|
MAX_IMAGE_SIZE,
|
||||||
ALLOWED_MIME_TYPES = {
|
)
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"image/svg+xml",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Allowed file extensions
|
|
||||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_image_file(file: UploadFile) -> bytes:
|
async def validate_image_file(file: UploadFile) -> bytes:
|
||||||
@@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes:
|
|||||||
if file_size == 0:
|
if file_size == 0:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file uploaded")
|
||||||
|
|
||||||
if file_size > MAX_FILE_SIZE:
|
if file_size > MAX_IMAGE_SIZE:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
detail=f"File too large. Maximum size is {MAX_FILE_SIZE / 1_048_576:.1f}MB",
|
detail=f"File too large. Maximum size is {MAX_IMAGE_SIZE / 1_048_576:.1f}MB",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate file extension
|
# Validate file extension
|
||||||
|
|||||||
454
backend/tests/api/test_image_position.py
Normal file
454
backend/tests/api/test_image_position.py
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
"""Integration tests for image position update endpoint."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from app.database.models.user import User
|
||||||
|
from app.database.models.board import Board
|
||||||
|
from app.database.models.image import Image
|
||||||
|
from app.database.models.board_image import BoardImage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_position(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test updating image position on board."""
|
||||||
|
# Create a board
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
# Create an image
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
# Add image to board
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=0,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update position
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={"position": {"x": 200, "y": 250}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["position"]["x"] == 200
|
||||||
|
assert data["position"]["y"] == 250
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test updating image transformations."""
|
||||||
|
# Create board, image, and board_image
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=0,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update transformations
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={
|
||||||
|
"transformations": {
|
||||||
|
"scale": 1.5,
|
||||||
|
"rotation": 45,
|
||||||
|
"opacity": 0.8,
|
||||||
|
"flipped_h": True,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["transformations"]["scale"] == 1.5
|
||||||
|
assert data["transformations"]["rotation"] == 45
|
||||||
|
assert data["transformations"]["opacity"] == 0.8
|
||||||
|
assert data["transformations"]["flipped_h"] is True
|
||||||
|
assert data["transformations"]["greyscale"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test updating image Z-order."""
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=0,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update Z-order
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={"z_order": 5},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["z_order"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_multiple_fields(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test updating position, transformations, and z-order together."""
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=0,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update everything
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={
|
||||||
|
"position": {"x": 300, "y": 400},
|
||||||
|
"transformations": {"scale": 2.0, "rotation": 90},
|
||||||
|
"z_order": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["position"]["x"] == 300
|
||||||
|
assert data["position"]["y"] == 400
|
||||||
|
assert data["transformations"]["scale"] == 2.0
|
||||||
|
assert data["transformations"]["rotation"] == 90
|
||||||
|
assert data["z_order"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test updating image that's not on the specified board."""
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Try to update image that's not on board
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={"position": {"x": 200, "y": 200}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert "not on this board" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_invalid_position(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test updating with invalid position data."""
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=0,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Try to update with missing y coordinate
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={"position": {"x": 200}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test that other users cannot update images on boards they don't own."""
|
||||||
|
# Create another user
|
||||||
|
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||||
|
db.add(other_user)
|
||||||
|
|
||||||
|
# Create board owned by other user
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=other_user.id,
|
||||||
|
title="Other User's Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=other_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{other_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=0,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Try to update as current user (should fail)
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={"position": {"x": 200, "y": 200}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_preserves_other_fields(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test that updating one field preserves others."""
|
||||||
|
board = Board(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
title="Test Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename="test.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100, "y": 100},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.5,
|
||||||
|
"rotation": 45,
|
||||||
|
"opacity": 0.9,
|
||||||
|
"flipped_h": True,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=3,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update only position
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||||
|
json={"position": {"x": 200, "y": 200}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Position should be updated
|
||||||
|
assert data["position"]["x"] == 200
|
||||||
|
assert data["position"]["y"] == 200
|
||||||
|
|
||||||
|
# Other fields should be preserved
|
||||||
|
assert data["transformations"]["scale"] == 1.5
|
||||||
|
assert data["transformations"]["rotation"] == 45
|
||||||
|
assert data["transformations"]["opacity"] == 0.9
|
||||||
|
assert data["z_order"] == 3
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { validateEmail, validateRequired } from '$lib/utils/validation';
|
||||||
|
|
||||||
export let isLoading = false;
|
export let isLoading = false;
|
||||||
|
|
||||||
@@ -14,14 +15,14 @@
|
|||||||
function validateForm(): boolean {
|
function validateForm(): boolean {
|
||||||
errors = {};
|
errors = {};
|
||||||
|
|
||||||
if (!email) {
|
const emailValidation = validateEmail(email);
|
||||||
errors.email = 'Email is required';
|
if (!emailValidation.valid) {
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
errors.email = emailValidation.message;
|
||||||
errors.email = 'Please enter a valid email address';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
const passwordValidation = validateRequired(password, 'Password');
|
||||||
errors.password = 'Password is required';
|
if (!passwordValidation.valid) {
|
||||||
|
errors.password = passwordValidation.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
@@ -40,40 +41,42 @@
|
|||||||
|
|
||||||
<form on:submit={handleSubmit} class="login-form">
|
<form on:submit={handleSubmit} class="login-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email" class="form-label">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.email}
|
class:error={errors.email}
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
{#if errors.email}
|
{#if errors.email}
|
||||||
<span class="error-text">{errors.email}</span>
|
<span class="form-error-text">{errors.email}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.password}
|
class:error={errors.password}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
{#if errors.password}
|
{#if errors.password}
|
||||||
<span class="error-text">{errors.password}</span>
|
<span class="form-error-text">{errors.password}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading} class="submit-button">
|
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="spinner"></span>
|
<span class="spinner-small"></span>
|
||||||
Logging in...
|
Logging in...
|
||||||
{:else}
|
{:else}
|
||||||
Login
|
Login
|
||||||
@@ -88,87 +91,16 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.error {
|
|
||||||
border-color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #ef4444;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button {
|
.submit-button {
|
||||||
padding: 0.875rem 1.5rem;
|
padding: 0.875rem 1.5rem;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
}
|
||||||
color: white;
|
|
||||||
border: none;
|
.form-group :global(label) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group :global(input) {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
transform 0.2s,
|
|
||||||
box-shadow 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { validateEmail, validatePassword, validatePasswordsMatch } from '$lib/utils/validation';
|
||||||
|
|
||||||
export let isLoading = false;
|
export let isLoading = false;
|
||||||
|
|
||||||
@@ -12,44 +13,22 @@
|
|||||||
submit: { email: string; password: string };
|
submit: { email: string; password: string };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function validatePassword(pwd: string): { valid: boolean; message: string } {
|
|
||||||
if (pwd.length < 8) {
|
|
||||||
return { valid: false, message: 'Password must be at least 8 characters' };
|
|
||||||
}
|
|
||||||
if (!/[A-Z]/.test(pwd)) {
|
|
||||||
return { valid: false, message: 'Password must contain an uppercase letter' };
|
|
||||||
}
|
|
||||||
if (!/[a-z]/.test(pwd)) {
|
|
||||||
return { valid: false, message: 'Password must contain a lowercase letter' };
|
|
||||||
}
|
|
||||||
if (!/\d/.test(pwd)) {
|
|
||||||
return { valid: false, message: 'Password must contain a number' };
|
|
||||||
}
|
|
||||||
return { valid: true, message: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateForm(): boolean {
|
function validateForm(): boolean {
|
||||||
errors = {};
|
errors = {};
|
||||||
|
|
||||||
if (!email) {
|
const emailValidation = validateEmail(email);
|
||||||
errors.email = 'Email is required';
|
if (!emailValidation.valid) {
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
errors.email = emailValidation.message;
|
||||||
errors.email = 'Please enter a valid email address';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
errors.password = 'Password is required';
|
|
||||||
} else {
|
|
||||||
const passwordValidation = validatePassword(password);
|
const passwordValidation = validatePassword(password);
|
||||||
if (!passwordValidation.valid) {
|
if (!passwordValidation.valid) {
|
||||||
errors.password = passwordValidation.message;
|
errors.password = passwordValidation.message;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmPassword) {
|
const confirmValidation = validatePasswordsMatch(password, confirmPassword);
|
||||||
errors.confirmPassword = 'Please confirm your password';
|
if (!confirmValidation.valid) {
|
||||||
} else if (password !== confirmPassword) {
|
errors.confirmPassword = confirmValidation.message;
|
||||||
errors.confirmPassword = 'Passwords do not match';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(errors).length === 0;
|
return Object.keys(errors).length === 0;
|
||||||
@@ -68,58 +47,63 @@
|
|||||||
|
|
||||||
<form on:submit={handleSubmit} class="register-form">
|
<form on:submit={handleSubmit} class="register-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="email">Email</label>
|
<label for="email" class="form-label">Email</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.email}
|
class:error={errors.email}
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
/>
|
/>
|
||||||
{#if errors.email}
|
{#if errors.email}
|
||||||
<span class="error-text">{errors.email}</span>
|
<span class="form-error-text">{errors.email}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password" class="form-label">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.password}
|
class:error={errors.password}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
{#if errors.password}
|
{#if errors.password}
|
||||||
<span class="error-text">{errors.password}</span>
|
<span class="form-error-text">{errors.password}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="help-text"> Must be 8+ characters with uppercase, lowercase, and number </span>
|
<span class="form-help-text">
|
||||||
|
Must be 8+ characters with uppercase, lowercase, and number
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="confirmPassword">Confirm Password</label>
|
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
class="form-input"
|
||||||
class:error={errors.confirmPassword}
|
class:error={errors.confirmPassword}
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
{#if errors.confirmPassword}
|
{#if errors.confirmPassword}
|
||||||
<span class="error-text">{errors.confirmPassword}</span>
|
<span class="form-error-text">{errors.confirmPassword}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={isLoading} class="submit-button">
|
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<span class="spinner"></span>
|
<span class="spinner-small"></span>
|
||||||
Creating account...
|
Creating account...
|
||||||
{:else}
|
{:else}
|
||||||
Create Account
|
Create Account
|
||||||
@@ -134,92 +118,16 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
input.error {
|
|
||||||
border-color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
color: #ef4444;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-text {
|
|
||||||
color: #6b7280;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button {
|
.submit-button {
|
||||||
padding: 0.875rem 1.5rem;
|
padding: 0.875rem 1.5rem;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
}
|
||||||
color: white;
|
|
||||||
border: none;
|
.form-group :global(label) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group :global(input) {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
transform 0.2s,
|
|
||||||
box-shadow 0.2s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button:hover:not(:disabled) {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { BoardSummary } from '$lib/types/boards';
|
import type { BoardSummary } from '$lib/types/boards';
|
||||||
|
import { formatDate } from '$lib/utils/format';
|
||||||
|
|
||||||
export let board: BoardSummary;
|
export let board: BoardSummary;
|
||||||
|
|
||||||
@@ -15,11 +16,6 @@
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
dispatch('delete');
|
dispatch('delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||||
|
|||||||
34
frontend/src/lib/components/common/Message.svelte
Normal file
34
frontend/src/lib/components/common/Message.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let type: 'error' | 'success' | 'warning' | 'info' = 'info';
|
||||||
|
export let message: string;
|
||||||
|
export let dismissible: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ dismiss: void }>();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
error: '⚠',
|
||||||
|
success: '✓',
|
||||||
|
warning: '⚠',
|
||||||
|
info: 'ℹ',
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleDismiss() {
|
||||||
|
dispatch('dismiss');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="message-banner message-{type}" role="alert">
|
||||||
|
<span class="message-icon">{icons[type]}</span>
|
||||||
|
<span class="message-text">{message}</span>
|
||||||
|
{#if dismissible}
|
||||||
|
<button class="close-btn" on:click={handleDismiss} aria-label="Dismiss">×</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.message-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
76
frontend/src/lib/styles/buttons.css
Normal file
76
frontend/src/lib/styles/buttons.css
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Shared button styles
|
||||||
|
* Consistent button styling across the application
|
||||||
|
*/
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
75
frontend/src/lib/styles/form.css
Normal file
75
frontend/src/lib/styles/form.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Shared form styles
|
||||||
|
* Used across all forms to maintain consistency and reduce duplication
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Form containers */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels */
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label .required {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs and textareas */
|
||||||
|
.form-input,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input.error,
|
||||||
|
.form-textarea.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled,
|
||||||
|
.form-textarea:disabled {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help and error text */
|
||||||
|
.form-help-text {
|
||||||
|
display: block;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error-text {
|
||||||
|
display: block;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
278
frontend/src/lib/styles/global.css
Normal file
278
frontend/src/lib/styles/global.css
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Global styles - Import this once in app layout
|
||||||
|
* Contains all shared form, button, loading, and message styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
FORM STYLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label .required {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input.error,
|
||||||
|
.form-textarea.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled,
|
||||||
|
.form-textarea:disabled {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help-text {
|
||||||
|
display: block;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error-text {
|
||||||
|
display: block;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BUTTON STYLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-large {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LOADING STYLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-medium {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container .spinner {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MESSAGE / ALERT STYLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.message-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-banner .close-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-banner .close-btn:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-error {
|
||||||
|
background: #fee2e2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-error .message-icon,
|
||||||
|
.message-error .close-btn {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-success {
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-success .message-icon,
|
||||||
|
.message-success .close-btn {
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-warning .message-icon,
|
||||||
|
.message-warning .close-btn {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-info {
|
||||||
|
background: #dbeafe;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-info .message-icon,
|
||||||
|
.message-info .close-btn {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
49
frontend/src/lib/styles/loading.css
Normal file
49
frontend/src/lib/styles/loading.css
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Shared loading/spinner styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #e5e7eb;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-medium {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid #e5e7eb;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container .spinner {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
frontend/src/lib/styles/messages.css
Normal file
80
frontend/src/lib/styles/messages.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Shared message/alert/banner styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
.message-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-banner .close-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-banner .close-btn:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.message-error {
|
||||||
|
background: #fee2e2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-error .message-icon,
|
||||||
|
.message-error .close-btn {
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success message */
|
||||||
|
.message-success {
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-success .message-icon,
|
||||||
|
.message-success .close-btn {
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning message */
|
||||||
|
.message-warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-warning .message-icon,
|
||||||
|
.message-warning .close-btn {
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info message */
|
||||||
|
.message-info {
|
||||||
|
background: #dbeafe;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-info .message-icon,
|
||||||
|
.message-info .close-btn {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
78
frontend/src/lib/utils/format.ts
Normal file
78
frontend/src/lib/utils/format.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Formatting utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to readable string
|
||||||
|
*/
|
||||||
|
export function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date with time
|
||||||
|
*/
|
||||||
|
export function formatDateTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time (e.g., "2 hours ago")
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffSecs = Math.floor(diffMs / 1000);
|
||||||
|
const diffMins = Math.floor(diffSecs / 60);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffSecs < 60) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? 'minute' : 'minutes'} ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? 'hour' : 'hours'} ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`;
|
||||||
|
|
||||||
|
return formatDate(dateString);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size to human-readable string
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pluralize word based on count
|
||||||
|
*/
|
||||||
|
export function pluralize(count: number, singular: string, plural?: string): string {
|
||||||
|
if (count === 1) return singular;
|
||||||
|
return plural || `${singular}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text with ellipsis
|
||||||
|
*/
|
||||||
|
export function truncate(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
125
frontend/src/lib/utils/validation.ts
Normal file
125
frontend/src/lib/utils/validation.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Shared validation utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*/
|
||||||
|
export function validateEmail(email: string): ValidationResult {
|
||||||
|
if (!email) {
|
||||||
|
return { valid: false, message: 'Email is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return { valid: false, message: 'Please enter a valid email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate password strength
|
||||||
|
* Requirements: 8+ chars, uppercase, lowercase, number
|
||||||
|
*/
|
||||||
|
export function validatePassword(password: string): ValidationResult {
|
||||||
|
if (!password) {
|
||||||
|
return { valid: false, message: 'Password is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
return { valid: false, message: 'Password must be at least 8 characters' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[A-Z]/.test(password)) {
|
||||||
|
return { valid: false, message: 'Password must contain an uppercase letter' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[a-z]/.test(password)) {
|
||||||
|
return { valid: false, message: 'Password must contain a lowercase letter' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/\d/.test(password)) {
|
||||||
|
return { valid: false, message: 'Password must contain a number' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate passwords match
|
||||||
|
*/
|
||||||
|
export function validatePasswordsMatch(
|
||||||
|
password: string,
|
||||||
|
confirmPassword: string
|
||||||
|
): ValidationResult {
|
||||||
|
if (!confirmPassword) {
|
||||||
|
return { valid: false, message: 'Please confirm your password' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return { valid: false, message: 'Passwords do not match' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate board title
|
||||||
|
*/
|
||||||
|
export function validateBoardTitle(title: string): ValidationResult {
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
return { valid: false, message: 'Title is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title.length > 255) {
|
||||||
|
return { valid: false, message: 'Title must be 255 characters or less' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate board description
|
||||||
|
*/
|
||||||
|
export function validateBoardDescription(description: string): ValidationResult {
|
||||||
|
if (description && description.length > 1000) {
|
||||||
|
return { valid: false, message: 'Description must be 1000 characters or less' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate required field
|
||||||
|
*/
|
||||||
|
export function validateRequired(value: string, fieldName: string = 'Field'): ValidationResult {
|
||||||
|
if (!value || !value.trim()) {
|
||||||
|
return { valid: false, message: `${fieldName} is required` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate max length
|
||||||
|
*/
|
||||||
|
export function validateMaxLength(
|
||||||
|
value: string,
|
||||||
|
maxLength: number,
|
||||||
|
fieldName: string = 'Field'
|
||||||
|
): ValidationResult {
|
||||||
|
if (value && value.length > maxLength) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: `${fieldName} must be ${maxLength} characters or less`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, message: '' };
|
||||||
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
|
<script>
|
||||||
|
import '$lib/styles/global.css';
|
||||||
|
</script>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
303
frontend/tests/canvas/drag.test.ts
Normal file
303
frontend/tests/canvas/drag.test.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Tests for canvas image dragging functionality
|
||||||
|
* Tests drag interactions, position updates, and multi-drag
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
import { setupImageDrag, moveImageTo, moveImageBy, isDragging } from '$lib/canvas/interactions/drag';
|
||||||
|
|
||||||
|
describe('Image Dragging', () => {
|
||||||
|
let stage: Konva.Stage;
|
||||||
|
let layer: Konva.Layer;
|
||||||
|
let image: Konva.Image;
|
||||||
|
let imageId: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create container
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'test-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Create stage and layer
|
||||||
|
stage = new Konva.Stage({
|
||||||
|
container: 'test-container',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
// Create test image
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(image);
|
||||||
|
layer.draw();
|
||||||
|
|
||||||
|
imageId = 'test-image-1';
|
||||||
|
|
||||||
|
// Reset selection
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stage.destroy();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Setup and Initialization', () => {
|
||||||
|
it('sets up drag handlers on image', () => {
|
||||||
|
const cleanup = setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
expect(image.draggable()).toBe(true);
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup function removes drag handlers', () => {
|
||||||
|
const cleanup = setupImageDrag(image, imageId);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
expect(image.draggable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows custom drag callbacks', () => {
|
||||||
|
const onDragMove = vi.fn();
|
||||||
|
const onDragEnd = vi.fn();
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId, onDragMove, onDragEnd);
|
||||||
|
|
||||||
|
// Callbacks should be set up
|
||||||
|
expect(onDragMove).not.toHaveBeenCalled();
|
||||||
|
expect(onDragEnd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Drag Start', () => {
|
||||||
|
it('selects image on drag start if not selected', () => {
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
// Simulate drag start
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds to selection with Ctrl key', () => {
|
||||||
|
const otherId = 'other-image';
|
||||||
|
selection.selectOne(otherId);
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: true, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.has(otherId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates drag state', () => {
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// isDragging should return true during drag
|
||||||
|
// Note: In actual implementation, this would be checked during dragmove
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Drag Move', () => {
|
||||||
|
it('calls onDragMove callback with current position', () => {
|
||||||
|
const onDragMove = vi.fn();
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId, onDragMove);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
image.position({ x: 150, y: 150 });
|
||||||
|
|
||||||
|
image.fire('dragmove', {
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onDragMove).toHaveBeenCalledWith(imageId, 150, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-drag when multiple images selected', () => {
|
||||||
|
selection.selectMultiple([imageId, 'other-image']);
|
||||||
|
|
||||||
|
const multiDragHandler = vi.fn();
|
||||||
|
stage.on('multiDragMove', multiDragHandler);
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
image.position({ x: 150, y: 150 });
|
||||||
|
|
||||||
|
image.fire('dragmove', {
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should fire multiDragMove event for other selected images
|
||||||
|
expect(multiDragHandler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Drag End', () => {
|
||||||
|
it('calls onDragEnd callback with final position', () => {
|
||||||
|
const onDragEnd = vi.fn();
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId, undefined, onDragEnd);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
image.position({ x: 200, y: 200 });
|
||||||
|
|
||||||
|
image.fire('dragend', {
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onDragEnd).toHaveBeenCalledWith(imageId, 200, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets drag state after drag ends', () => {
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
image.fire('dragend', {
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isDragging()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Programmatic Movement', () => {
|
||||||
|
it('moveImageTo sets absolute position', () => {
|
||||||
|
moveImageTo(image, 300, 300);
|
||||||
|
|
||||||
|
expect(image.x()).toBe(300);
|
||||||
|
expect(image.y()).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveImageBy moves relative to current position', () => {
|
||||||
|
image.position({ x: 100, y: 100 });
|
||||||
|
|
||||||
|
moveImageBy(image, 50, 50);
|
||||||
|
|
||||||
|
expect(image.x()).toBe(150);
|
||||||
|
expect(image.y()).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative delta in moveImageBy', () => {
|
||||||
|
image.position({ x: 100, y: 100 });
|
||||||
|
|
||||||
|
moveImageBy(image, -25, -25);
|
||||||
|
|
||||||
|
expect(image.x()).toBe(75);
|
||||||
|
expect(image.y()).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moveImageTo works with large values', () => {
|
||||||
|
moveImageTo(image, 10000, 10000);
|
||||||
|
|
||||||
|
expect(image.x()).toBe(10000);
|
||||||
|
expect(image.y()).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('handles dragging to negative coordinates', () => {
|
||||||
|
moveImageTo(image, -100, -100);
|
||||||
|
|
||||||
|
expect(image.x()).toBe(-100);
|
||||||
|
expect(image.y()).toBe(-100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero movement', () => {
|
||||||
|
const initialX = image.x();
|
||||||
|
const initialY = image.y();
|
||||||
|
|
||||||
|
moveImageBy(image, 0, 0);
|
||||||
|
|
||||||
|
expect(image.x()).toBe(initialX);
|
||||||
|
expect(image.y()).toBe(initialY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid position changes', () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
moveImageBy(image, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(image.x()).toBeGreaterThan(100);
|
||||||
|
expect(image.y()).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration with Selection', () => {
|
||||||
|
it('maintains selection during drag', () => {
|
||||||
|
selection.selectOne(imageId);
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
image.fire('dragend', {
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears other selections when dragging unselected image', () => {
|
||||||
|
selection.selectMultiple(['other-image-1', 'other-image-2']);
|
||||||
|
|
||||||
|
setupImageDrag(image, imageId);
|
||||||
|
|
||||||
|
image.fire('dragstart', {
|
||||||
|
evt: { button: 0, ctrlKey: false, metaKey: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.has('other-image-1')).toBe(false);
|
||||||
|
expect(selectionState.selectedIds.has('other-image-2')).toBe(false);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
423
frontend/tests/canvas/select.test.ts
Normal file
423
frontend/tests/canvas/select.test.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
/**
|
||||||
|
* Tests for canvas image selection functionality
|
||||||
|
* Tests click selection, multi-select, and background deselection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
import {
|
||||||
|
setupImageSelection,
|
||||||
|
setupBackgroundDeselect,
|
||||||
|
selectImage,
|
||||||
|
deselectImage,
|
||||||
|
toggleImageSelection,
|
||||||
|
selectAllImages,
|
||||||
|
clearAllSelection,
|
||||||
|
getSelectedCount,
|
||||||
|
getSelectedImageIds,
|
||||||
|
isImageSelected,
|
||||||
|
} from '$lib/canvas/interactions/select';
|
||||||
|
|
||||||
|
describe('Image Selection', () => {
|
||||||
|
let stage: Konva.Stage;
|
||||||
|
let layer: Konva.Layer;
|
||||||
|
let image: Konva.Image;
|
||||||
|
let imageId: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create container
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'test-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
// Create stage and layer
|
||||||
|
stage = new Konva.Stage({
|
||||||
|
container: 'test-container',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
// Create test image
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(image);
|
||||||
|
layer.draw();
|
||||||
|
|
||||||
|
imageId = 'test-image-1';
|
||||||
|
|
||||||
|
// Reset selection
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stage.destroy();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Setup', () => {
|
||||||
|
it('sets up click handler on image', () => {
|
||||||
|
const cleanup = setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup function removes click handlers', () => {
|
||||||
|
const cleanup = setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
// Select the image
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
expect(get(selection).selectedIds.has(imageId)).toBe(true);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
selection.clearSelection();
|
||||||
|
|
||||||
|
// Click should no longer work
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
expect(get(selection).selectedIds.has(imageId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Single Click Selection', () => {
|
||||||
|
it('selects image on click', () => {
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces selection when clicking different image', () => {
|
||||||
|
selection.selectOne('other-image');
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.has('other-image')).toBe(false);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSelectionChange callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId, undefined, callback);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(imageId, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not deselect on second click without Ctrl', () => {
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multi-Select (Ctrl+Click)', () => {
|
||||||
|
it('adds to selection with Ctrl+Click', () => {
|
||||||
|
selection.selectOne('other-image');
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.has('other-image')).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes from selection with Ctrl+Click on selected image', () => {
|
||||||
|
selection.selectMultiple([imageId, 'other-image']);
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(false);
|
||||||
|
expect(selectionState.selectedIds.has('other-image')).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with Cmd key (metaKey) on Mac', () => {
|
||||||
|
selection.selectOne('other-image');
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: false, metaKey: true } });
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.has(imageId)).toBe(true);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls callback with correct state when adding to selection', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId, undefined, callback);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(imageId, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls callback with correct state when removing from selection', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
selection.selectOne(imageId);
|
||||||
|
|
||||||
|
setupImageSelection(image, imageId, undefined, callback);
|
||||||
|
|
||||||
|
image.fire('click', { evt: { ctrlKey: true, metaKey: false } });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(imageId, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Background Deselection', () => {
|
||||||
|
it('clears selection when clicking stage background', () => {
|
||||||
|
selection.selectMultiple([imageId, 'other-image']);
|
||||||
|
|
||||||
|
setupBackgroundDeselect(stage);
|
||||||
|
|
||||||
|
// Simulate click on stage (not on shape)
|
||||||
|
stage.fire('click', {
|
||||||
|
target: stage,
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clear selection when clicking on shape', () => {
|
||||||
|
selection.selectMultiple([imageId, 'other-image']);
|
||||||
|
|
||||||
|
setupBackgroundDeselect(stage);
|
||||||
|
|
||||||
|
// Simulate click on shape
|
||||||
|
stage.fire('click', {
|
||||||
|
target: image,
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDeselect callback when background clicked', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
selection.selectOne(imageId);
|
||||||
|
|
||||||
|
setupBackgroundDeselect(stage, callback);
|
||||||
|
|
||||||
|
stage.fire('click', {
|
||||||
|
target: stage,
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleanup removes background deselect handler', () => {
|
||||||
|
selection.selectOne(imageId);
|
||||||
|
|
||||||
|
const cleanup = setupBackgroundDeselect(stage);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
stage.fire('click', {
|
||||||
|
target: stage,
|
||||||
|
evt: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionState = get(selection);
|
||||||
|
expect(selectionState.selectedIds.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Programmatic Selection', () => {
|
||||||
|
it('selectImage selects single image', () => {
|
||||||
|
selectImage(imageId);
|
||||||
|
|
||||||
|
expect(isImageSelected(imageId)).toBe(true);
|
||||||
|
expect(getSelectedCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectImage with multiSelect adds to selection', () => {
|
||||||
|
selectImage('image-1');
|
||||||
|
selectImage('image-2', true);
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(2);
|
||||||
|
expect(getSelectedImageIds()).toEqual(['image-1', 'image-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselectImage removes from selection', () => {
|
||||||
|
selection.selectMultiple([imageId, 'other-image']);
|
||||||
|
|
||||||
|
deselectImage(imageId);
|
||||||
|
|
||||||
|
expect(isImageSelected(imageId)).toBe(false);
|
||||||
|
expect(isImageSelected('other-image')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggleImageSelection toggles state', () => {
|
||||||
|
toggleImageSelection(imageId);
|
||||||
|
expect(isImageSelected(imageId)).toBe(true);
|
||||||
|
|
||||||
|
toggleImageSelection(imageId);
|
||||||
|
expect(isImageSelected(imageId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectAllImages selects all provided IDs', () => {
|
||||||
|
const allIds = ['img-1', 'img-2', 'img-3', 'img-4'];
|
||||||
|
|
||||||
|
selectAllImages(allIds);
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(4);
|
||||||
|
expect(getSelectedImageIds()).toEqual(allIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearAllSelection clears everything', () => {
|
||||||
|
selection.selectMultiple(['img-1', 'img-2', 'img-3']);
|
||||||
|
|
||||||
|
clearAllSelection();
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(0);
|
||||||
|
expect(getSelectedImageIds()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Query Functions', () => {
|
||||||
|
it('getSelectedCount returns correct count', () => {
|
||||||
|
expect(getSelectedCount()).toBe(0);
|
||||||
|
|
||||||
|
selection.selectOne(imageId);
|
||||||
|
expect(getSelectedCount()).toBe(1);
|
||||||
|
|
||||||
|
selection.addToSelection('other-image');
|
||||||
|
expect(getSelectedCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSelectedImageIds returns array of IDs', () => {
|
||||||
|
selection.selectMultiple(['img-1', 'img-2', 'img-3']);
|
||||||
|
|
||||||
|
const ids = getSelectedImageIds();
|
||||||
|
|
||||||
|
expect(Array.isArray(ids)).toBe(true);
|
||||||
|
expect(ids.length).toBe(3);
|
||||||
|
expect(ids).toContain('img-1');
|
||||||
|
expect(ids).toContain('img-2');
|
||||||
|
expect(ids).toContain('img-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isImageSelected returns correct boolean', () => {
|
||||||
|
expect(isImageSelected(imageId)).toBe(false);
|
||||||
|
|
||||||
|
selection.selectOne(imageId);
|
||||||
|
expect(isImageSelected(imageId)).toBe(true);
|
||||||
|
|
||||||
|
selection.clearSelection();
|
||||||
|
expect(isImageSelected(imageId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('handles selecting non-existent image', () => {
|
||||||
|
selectImage('non-existent-id');
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(1);
|
||||||
|
expect(isImageSelected('non-existent-id')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles deselecting non-selected image', () => {
|
||||||
|
deselectImage('not-selected-id');
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles toggling same image multiple times', () => {
|
||||||
|
toggleImageSelection(imageId);
|
||||||
|
toggleImageSelection(imageId);
|
||||||
|
toggleImageSelection(imageId);
|
||||||
|
|
||||||
|
expect(isImageSelected(imageId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty array in selectAllImages', () => {
|
||||||
|
selectAllImages([]);
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles large selection sets', () => {
|
||||||
|
const largeSet = Array.from({ length: 1000 }, (_, i) => `img-${i}`);
|
||||||
|
|
||||||
|
selectAllImages(largeSet);
|
||||||
|
|
||||||
|
expect(getSelectedCount()).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Touch Events', () => {
|
||||||
|
it('handles tap event same as click', () => {
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
|
||||||
|
image.fire('tap', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
|
|
||||||
|
expect(isImageSelected(imageId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents event bubbling to stage', () => {
|
||||||
|
setupImageSelection(image, imageId);
|
||||||
|
setupBackgroundDeselect(stage);
|
||||||
|
|
||||||
|
const clickEvent = new Event('click', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(clickEvent, 'cancelBubble', {
|
||||||
|
writable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
image.fire('click', {
|
||||||
|
evt: {
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
...clickEvent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image should be selected
|
||||||
|
expect(isImageSelected(imageId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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] 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] 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
|
- [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:**
|
**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
|
**User Story:** Users must be able to navigate the infinite canvas efficiently
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Users can pan canvas (drag or spacebar+drag)
|
- [X] Users can pan canvas (drag or spacebar+drag)
|
||||||
- [ ] Users can zoom in/out (mouse wheel, pinch)
|
- [X] Users can zoom in/out (mouse wheel, pinch)
|
||||||
- [ ] Users can rotate canvas view
|
- [X] Users can rotate canvas view
|
||||||
- [ ] Users can reset camera and fit to screen
|
- [X] Users can reset camera and fit to screen
|
||||||
- [ ] Viewport state persists
|
- [X] Viewport state persists
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte
|
- [X] 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
|
- [X] 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
|
- [X] 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
|
- [X] 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
|
- [X] 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
|
- [X] 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
|
- [X] 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)
|
- [X] 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
|
- [X] T108 [US4] Persist viewport state to backend when changed
|
||||||
- [ ] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts
|
- [X] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts
|
||||||
|
|
||||||
**Backend Tasks:**
|
**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:**
|
**Deliverables:**
|
||||||
- Infinite canvas working
|
- Infinite canvas working
|
||||||
@@ -268,33 +268,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 7: Image Positioning & Selection (FR5 - Critical) (Week 5-6)
|
## Phase 7: Image Positioning & Selection (FR5 - Critical) (Week 5-6) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must be able to freely position and organize images on canvas
|
**User Story:** Users must be able to freely position and organize images on canvas
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Users can drag images to any position
|
- [X] Users can drag images to any position
|
||||||
- [ ] Images can overlap (Z-order controlled)
|
- [X] Images can overlap (Z-order controlled)
|
||||||
- [ ] Users can select single/multiple images
|
- [X] Users can select single/multiple images
|
||||||
- [ ] Selection shows visual indicators
|
- [X] Selection shows visual indicators
|
||||||
- [ ] Positions persist in database
|
- [X] Positions persist in database
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte
|
- [X] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte
|
||||||
- [ ] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts
|
- [X] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts
|
||||||
- [ ] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts
|
- [X] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts
|
||||||
- [ ] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts
|
- [X] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts
|
||||||
- [ ] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts
|
- [X] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts
|
||||||
- [ ] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte
|
- [X] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte
|
||||||
- [ ] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts
|
- [X] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts
|
||||||
- [ ] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts
|
- [X] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts
|
||||||
- [ ] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
|
- [X] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py
|
- [X] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py
|
||||||
- [ ] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py
|
- [X] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Images draggable
|
- Images draggable
|
||||||
|
|||||||
Reference in New Issue
Block a user