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 app.boards.repository import BoardRepository
|
||||
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate
|
||||
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
|
||||
from app.core.deps import get_current_user, get_db
|
||||
from app.database.models.user import User
|
||||
|
||||
@@ -152,6 +152,48 @@ def update_board(
|
||||
return BoardDetail.model_validate(board)
|
||||
|
||||
|
||||
@router.patch("/{board_id}/viewport", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def update_viewport(
|
||||
board_id: UUID,
|
||||
viewport_data: ViewportStateUpdate,
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
"""
|
||||
Update board viewport state only (optimized for frequent updates).
|
||||
|
||||
This endpoint is designed for high-frequency viewport state updates
|
||||
(debounced pan/zoom/rotate changes) with minimal overhead.
|
||||
|
||||
Args:
|
||||
board_id: Board UUID
|
||||
viewport_data: Viewport state data
|
||||
current_user: Current authenticated user
|
||||
db: Database session
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 if board not found or not owned by user
|
||||
"""
|
||||
repo = BoardRepository(db)
|
||||
|
||||
# Convert viewport data to dict
|
||||
viewport_dict = viewport_data.model_dump()
|
||||
|
||||
board = repo.update_board(
|
||||
board_id=board_id,
|
||||
user_id=current_user.id,
|
||||
title=None,
|
||||
description=None,
|
||||
viewport_state=viewport_dict,
|
||||
)
|
||||
|
||||
if not board:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Board {board_id} not found",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{board_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_board(
|
||||
board_id: UUID,
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.images.repository import ImageRepository
|
||||
from app.images.schemas import (
|
||||
BoardImageCreate,
|
||||
BoardImageResponse,
|
||||
BoardImageUpdate,
|
||||
ImageListResponse,
|
||||
ImageResponse,
|
||||
ImageUploadResponse,
|
||||
@@ -277,6 +278,52 @@ async def add_image_to_board(
|
||||
return board_image
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/images/{image_id}", response_model=BoardImageResponse)
|
||||
async def update_board_image(
|
||||
board_id: UUID,
|
||||
image_id: UUID,
|
||||
data: BoardImageUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update board image position, transformations, z-order, or group.
|
||||
|
||||
This endpoint is optimized for frequent position updates (debounced from frontend).
|
||||
Only provided fields are updated.
|
||||
"""
|
||||
# Verify board ownership
|
||||
from sqlalchemy import select
|
||||
|
||||
board_result = await db.execute(select(Board).where(Board.id == board_id))
|
||||
board = board_result.scalar_one_or_none()
|
||||
|
||||
if not board:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
|
||||
if board.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Update board image
|
||||
repo = ImageRepository(db)
|
||||
board_image = await repo.update_board_image(
|
||||
board_id=board_id,
|
||||
image_id=image_id,
|
||||
position=data.position,
|
||||
transformations=data.transformations,
|
||||
z_order=data.z_order,
|
||||
group_id=data.group_id,
|
||||
)
|
||||
|
||||
if not board_image:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
|
||||
|
||||
# Load image relationship for response
|
||||
await db.refresh(board_image, ["image"])
|
||||
|
||||
return board_image
|
||||
|
||||
|
||||
@router.delete("/boards/{board_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_image_from_board(
|
||||
board_id: UUID,
|
||||
|
||||
@@ -22,6 +22,15 @@ class BoardCreate(BaseModel):
|
||||
description: str | None = Field(default=None, description="Optional board description")
|
||||
|
||||
|
||||
class ViewportStateUpdate(BaseModel):
|
||||
"""Schema for updating viewport state only."""
|
||||
|
||||
x: float = Field(..., description="Horizontal pan position")
|
||||
y: float = Field(..., description="Vertical pan position")
|
||||
zoom: float = Field(..., ge=0.1, le=5.0, description="Zoom level (0.1 to 5.0)")
|
||||
rotation: float = Field(..., ge=0, le=360, description="Canvas rotation in degrees (0 to 360)")
|
||||
|
||||
|
||||
class BoardUpdate(BaseModel):
|
||||
"""Schema for updating board metadata."""
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BoardImageUpdate(BaseModel):
|
||||
"""Schema for updating board image position/transformations."""
|
||||
|
||||
position: dict[str, float] | None = Field(None, description="Canvas position")
|
||||
transformations: dict[str, Any] | None = Field(None, description="Image transformations")
|
||||
z_order: int | None = Field(None, description="Layer order")
|
||||
group_id: UUID | None = Field(None, description="Group membership")
|
||||
|
||||
@field_validator("position")
|
||||
@classmethod
|
||||
def validate_position(cls, v: dict[str, float] | None) -> dict[str, float] | None:
|
||||
"""Validate position has x and y if provided."""
|
||||
if v is not None and ("x" not in v or "y" not in v):
|
||||
raise ValueError("Position must contain 'x' and 'y' coordinates")
|
||||
return v
|
||||
|
||||
|
||||
class BoardImageResponse(BaseModel):
|
||||
"""Response for board image with all metadata."""
|
||||
|
||||
|
||||
@@ -3,21 +3,11 @@
|
||||
import magic
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
|
||||
# Maximum file size: 50MB
|
||||
MAX_FILE_SIZE = 52_428_800
|
||||
|
||||
# Allowed MIME types
|
||||
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"}
|
||||
from app.core.constants import (
|
||||
ALLOWED_EXTENSIONS,
|
||||
ALLOWED_MIME_TYPES,
|
||||
MAX_IMAGE_SIZE,
|
||||
)
|
||||
|
||||
|
||||
async def validate_image_file(file: UploadFile) -> bytes:
|
||||
@@ -50,10 +40,10 @@ async def validate_image_file(file: UploadFile) -> bytes:
|
||||
if file_size == 0:
|
||||
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(
|
||||
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
|
||||
|
||||
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">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { validateEmail, validateRequired } from '$lib/utils/validation';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
@@ -14,14 +15,14 @@
|
||||
function validateForm(): boolean {
|
||||
errors = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
errors.email = emailValidation.message;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
const passwordValidation = validateRequired(password, 'Password');
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -40,40 +41,42 @@
|
||||
|
||||
<form on:submit={handleSubmit} class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
placeholder="you@example.com"
|
||||
class="form-input"
|
||||
class:error={errors.email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<span class="error-text">{errors.email}</span>
|
||||
<span class="form-error-text">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.password}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
{#if errors.password}
|
||||
<span class="error-text">{errors.password}</span>
|
||||
<span class="form-error-text">{errors.password}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} class="submit-button">
|
||||
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||
{#if isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner-small"></span>
|
||||
Logging in...
|
||||
{:else}
|
||||
Login
|
||||
@@ -88,87 +91,16 @@
|
||||
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 {
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { validateEmail, validatePassword, validatePasswordsMatch } from '$lib/utils/validation';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
@@ -12,44 +13,22 @@
|
||||
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 {
|
||||
errors = {};
|
||||
|
||||
if (!email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
const emailValidation = validateEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
errors.email = emailValidation.message;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
} else {
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
errors.password = passwordValidation.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
errors.confirmPassword = 'Please confirm your password';
|
||||
} else if (password !== confirmPassword) {
|
||||
errors.confirmPassword = 'Passwords do not match';
|
||||
const confirmValidation = validatePasswordsMatch(password, confirmPassword);
|
||||
if (!confirmValidation.valid) {
|
||||
errors.confirmPassword = confirmValidation.message;
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
@@ -68,58 +47,63 @@
|
||||
|
||||
<form on:submit={handleSubmit} class="register-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
disabled={isLoading}
|
||||
placeholder="you@example.com"
|
||||
class="form-input"
|
||||
class:error={errors.email}
|
||||
autocomplete="email"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<span class="error-text">{errors.email}</span>
|
||||
<span class="form-error-text">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.password}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
{#if errors.password}
|
||||
<span class="error-text">{errors.password}</span>
|
||||
<span class="form-error-text">{errors.password}</span>
|
||||
{: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}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Confirm Password</label>
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
class="form-input"
|
||||
class:error={errors.confirmPassword}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
{#if errors.confirmPassword}
|
||||
<span class="error-text">{errors.confirmPassword}</span>
|
||||
<span class="form-error-text">{errors.confirmPassword}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading} class="submit-button">
|
||||
<button type="submit" disabled={isLoading} class="btn btn-primary submit-button">
|
||||
{#if isLoading}
|
||||
<span class="spinner"></span>
|
||||
<span class="spinner-small"></span>
|
||||
Creating account...
|
||||
{:else}
|
||||
Create Account
|
||||
@@ -134,92 +118,16 @@
|
||||
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 {
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { BoardSummary } from '$lib/types/boards';
|
||||
import { formatDate } from '$lib/utils/format';
|
||||
|
||||
export let board: BoardSummary;
|
||||
|
||||
@@ -15,11 +16,6 @@
|
||||
event.stopPropagation();
|
||||
dispatch('delete');
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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 />
|
||||
|
||||
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] T095 [P] [US3] Create upload progress component in frontend/src/lib/components/upload/ProgressBar.svelte
|
||||
- [X] T096 [P] [US3] Create upload error display in frontend/src/lib/components/upload/ErrorDisplay.svelte
|
||||
- [ ] T097 [P] [US3] Write upload component tests in frontend/tests/components/upload.test.ts
|
||||
- [X] T097 [P] [US3] Write upload component tests in frontend/tests/components/upload.test.ts
|
||||
|
||||
**Infrastructure:**
|
||||
|
||||
@@ -232,33 +232,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Canvas Navigation & Viewport (FR12 - Critical) (Week 5)
|
||||
## Phase 6: Canvas Navigation & Viewport (FR12 - Critical) (Week 5) ✅ COMPLETE
|
||||
|
||||
**User Story:** Users must be able to navigate the infinite canvas efficiently
|
||||
|
||||
**Independent Test Criteria:**
|
||||
- [ ] Users can pan canvas (drag or spacebar+drag)
|
||||
- [ ] Users can zoom in/out (mouse wheel, pinch)
|
||||
- [ ] Users can rotate canvas view
|
||||
- [ ] Users can reset camera and fit to screen
|
||||
- [ ] Viewport state persists
|
||||
- [X] Users can pan canvas (drag or spacebar+drag)
|
||||
- [X] Users can zoom in/out (mouse wheel, pinch)
|
||||
- [X] Users can rotate canvas view
|
||||
- [X] Users can reset camera and fit to screen
|
||||
- [X] Viewport state persists
|
||||
|
||||
**Frontend Tasks:**
|
||||
|
||||
- [ ] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte
|
||||
- [ ] T101 [US4] Implement pan functionality in frontend/src/lib/canvas/controls/pan.ts
|
||||
- [ ] T102 [P] [US4] Implement zoom functionality in frontend/src/lib/canvas/controls/zoom.ts
|
||||
- [ ] T103 [P] [US4] Implement canvas rotation in frontend/src/lib/canvas/controls/rotate.ts
|
||||
- [ ] T104 [US4] Create viewport store in frontend/src/lib/stores/viewport.ts
|
||||
- [ ] T105 [US4] Implement reset camera function in frontend/src/lib/canvas/controls/reset.ts
|
||||
- [ ] T106 [US4] Implement fit-to-screen function in frontend/src/lib/canvas/controls/fit.ts
|
||||
- [ ] T107 [US4] Add touch gesture support in frontend/src/lib/canvas/gestures.ts (pinch, two-finger pan)
|
||||
- [ ] T108 [US4] Persist viewport state to backend when changed
|
||||
- [ ] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts
|
||||
- [X] T100 [US4] Initialize Konva.js Stage in frontend/src/lib/canvas/Stage.svelte
|
||||
- [X] T101 [US4] Implement pan functionality in frontend/src/lib/canvas/controls/pan.ts
|
||||
- [X] T102 [P] [US4] Implement zoom functionality in frontend/src/lib/canvas/controls/zoom.ts
|
||||
- [X] T103 [P] [US4] Implement canvas rotation in frontend/src/lib/canvas/controls/rotate.ts
|
||||
- [X] T104 [US4] Create viewport store in frontend/src/lib/stores/viewport.ts
|
||||
- [X] T105 [US4] Implement reset camera function in frontend/src/lib/canvas/controls/reset.ts
|
||||
- [X] T106 [US4] Implement fit-to-screen function in frontend/src/lib/canvas/controls/fit.ts
|
||||
- [X] T107 [US4] Add touch gesture support in frontend/src/lib/canvas/gestures.ts (pinch, two-finger pan)
|
||||
- [X] T108 [US4] Persist viewport state to backend when changed
|
||||
- [X] T109 [P] [US4] Write canvas control tests in frontend/tests/canvas/controls.test.ts
|
||||
|
||||
**Backend Tasks:**
|
||||
|
||||
- [ ] T110 [US4] Add viewport persistence endpoint PATCH /boards/{id}/viewport in backend/app/api/boards.py
|
||||
- [X] T110 [US4] Add viewport persistence endpoint PATCH /boards/{id}/viewport in backend/app/api/boards.py
|
||||
|
||||
**Deliverables:**
|
||||
- Infinite canvas working
|
||||
@@ -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
|
||||
|
||||
**Independent Test Criteria:**
|
||||
- [ ] Users can drag images to any position
|
||||
- [ ] Images can overlap (Z-order controlled)
|
||||
- [ ] Users can select single/multiple images
|
||||
- [ ] Selection shows visual indicators
|
||||
- [ ] Positions persist in database
|
||||
- [X] Users can drag images to any position
|
||||
- [X] Images can overlap (Z-order controlled)
|
||||
- [X] Users can select single/multiple images
|
||||
- [X] Selection shows visual indicators
|
||||
- [X] Positions persist in database
|
||||
|
||||
**Frontend Tasks:**
|
||||
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] 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
|
||||
- [ ] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
|
||||
- [X] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte
|
||||
- [X] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts
|
||||
- [X] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts
|
||||
- [X] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts
|
||||
- [X] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts
|
||||
- [X] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte
|
||||
- [X] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts
|
||||
- [X] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts
|
||||
- [X] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
|
||||
|
||||
**Backend Tasks:**
|
||||
|
||||
- [ ] 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] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py
|
||||
- [X] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py
|
||||
|
||||
**Deliverables:**
|
||||
- Images draggable
|
||||
|
||||
Reference in New Issue
Block a user