phase 10
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 7s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / VM Test - performance (push) Successful in 7s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Successful in 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 41s
CI/CD Pipeline / CI Summary (push) Successful in 1s
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 7s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / VM Test - performance (push) Successful in 7s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Successful in 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 41s
CI/CD Pipeline / CI Summary (push) Successful in 1s
This commit is contained in:
@@ -15,6 +15,8 @@ from app.images.schemas import (
|
|||||||
BoardImageCreate,
|
BoardImageCreate,
|
||||||
BoardImageResponse,
|
BoardImageResponse,
|
||||||
BoardImageUpdate,
|
BoardImageUpdate,
|
||||||
|
BulkImageUpdate,
|
||||||
|
BulkUpdateResponse,
|
||||||
ImageListResponse,
|
ImageListResponse,
|
||||||
ImageResponse,
|
ImageResponse,
|
||||||
ImageUploadResponse,
|
ImageUploadResponse,
|
||||||
@@ -357,6 +359,83 @@ async def remove_image_from_board(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not on this board")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/boards/{board_id}/images/bulk", response_model=BulkUpdateResponse)
|
||||||
|
async def bulk_update_board_images(
|
||||||
|
board_id: UUID,
|
||||||
|
data: BulkImageUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Bulk update multiple images on a board.
|
||||||
|
|
||||||
|
Applies the same changes to all specified images. Useful for multi-selection operations.
|
||||||
|
"""
|
||||||
|
# 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 each image
|
||||||
|
repo = ImageRepository(db)
|
||||||
|
updated_ids = []
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for image_id in data.image_ids:
|
||||||
|
try:
|
||||||
|
# Calculate new position if delta provided
|
||||||
|
position = None
|
||||||
|
if data.position_delta:
|
||||||
|
# Get current position
|
||||||
|
board_image = await repo.get_board_image(board_id, image_id)
|
||||||
|
if board_image and board_image.position:
|
||||||
|
current_pos = board_image.position
|
||||||
|
position = {
|
||||||
|
"x": current_pos.get("x", 0) + data.position_delta["dx"],
|
||||||
|
"y": current_pos.get("y", 0) + data.position_delta["dy"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate new z-order if delta provided
|
||||||
|
z_order = None
|
||||||
|
if data.z_order_delta is not None:
|
||||||
|
board_image = await repo.get_board_image(board_id, image_id)
|
||||||
|
if board_image:
|
||||||
|
z_order = board_image.z_order + data.z_order_delta
|
||||||
|
|
||||||
|
# Update the image
|
||||||
|
updated = await repo.update_board_image(
|
||||||
|
board_id=board_id,
|
||||||
|
image_id=image_id,
|
||||||
|
position=position,
|
||||||
|
transformations=data.transformations,
|
||||||
|
z_order=z_order,
|
||||||
|
group_id=None, # Bulk operations don't change groups
|
||||||
|
)
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
updated_ids.append(image_id)
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating image {image_id}: {e}")
|
||||||
|
failed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
return BulkUpdateResponse(
|
||||||
|
updated_count=len(updated_ids),
|
||||||
|
failed_count=failed_count,
|
||||||
|
image_ids=updated_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
|
@router.get("/boards/{board_id}/images", response_model=list[BoardImageResponse])
|
||||||
async def get_board_images(
|
async def get_board_images(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
|
|||||||
@@ -120,6 +120,31 @@ class BoardImageResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BulkImageUpdate(BaseModel):
|
||||||
|
"""Schema for bulk updating multiple images."""
|
||||||
|
|
||||||
|
image_ids: list[UUID] = Field(..., description="List of image IDs to update")
|
||||||
|
position_delta: dict[str, float] | None = Field(None, description="Position delta to apply")
|
||||||
|
transformations: dict[str, Any] | None = Field(None, description="Transformations to apply")
|
||||||
|
z_order_delta: int | None = Field(None, description="Z-order delta to apply")
|
||||||
|
|
||||||
|
@field_validator("position_delta")
|
||||||
|
@classmethod
|
||||||
|
def validate_position_delta(cls, v: dict[str, float] | None) -> dict[str, float] | None:
|
||||||
|
"""Validate position delta has dx and dy."""
|
||||||
|
if v is not None and ("dx" not in v or "dy" not in v):
|
||||||
|
raise ValueError("Position delta must contain 'dx' and 'dy'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BulkUpdateResponse(BaseModel):
|
||||||
|
"""Response for bulk update operation."""
|
||||||
|
|
||||||
|
updated_count: int = Field(..., description="Number of images updated")
|
||||||
|
failed_count: int = Field(default=0, description="Number of images that failed to update")
|
||||||
|
image_ids: list[UUID] = Field(..., description="IDs of successfully updated images")
|
||||||
|
|
||||||
|
|
||||||
class ImageListResponse(BaseModel):
|
class ImageListResponse(BaseModel):
|
||||||
"""Paginated list of images."""
|
"""Paginated list of images."""
|
||||||
|
|
||||||
|
|||||||
377
backend/tests/api/test_bulk_operations.py
Normal file
377
backend/tests/api/test_bulk_operations.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""Integration tests for bulk image operations."""
|
||||||
|
|
||||||
|
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_bulk_update_position_delta(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk updating positions with delta."""
|
||||||
|
# Create 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 images
|
||||||
|
images = []
|
||||||
|
board_images = []
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename=f"test{i}.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
images.append(image)
|
||||||
|
|
||||||
|
board_image = BoardImage(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
image_id=image.id,
|
||||||
|
position={"x": 100 * i, "y": 100 * i},
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
"flipped_h": False,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
},
|
||||||
|
z_order=i,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
board_images.append(board_image)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Bulk update position
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [str(img.id) for img in images[:2]], # First 2 images
|
||||||
|
"position_delta": {"dx": 50, "dy": 75},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["updated_count"] == 2
|
||||||
|
assert data["failed_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_update_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk updating transformations."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for i in range(2):
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename=f"test{i}.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
images.append(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()
|
||||||
|
|
||||||
|
# Bulk update transformations
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [str(img.id) for img in images],
|
||||||
|
"transformations": {
|
||||||
|
"scale": 2.0,
|
||||||
|
"rotation": 45,
|
||||||
|
"opacity": 0.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["updated_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_update_z_order_delta(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk updating Z-order with delta."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for i in range(3):
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename=f"test{i}.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
images.append(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=i,
|
||||||
|
)
|
||||||
|
db.add(board_image)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Bulk update Z-order
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [str(images[0].id), str(images[1].id)],
|
||||||
|
"z_order_delta": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["updated_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_update_mixed_operations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk update with 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)
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for i in range(2):
|
||||||
|
image = Image(
|
||||||
|
id=uuid4(),
|
||||||
|
user_id=test_user.id,
|
||||||
|
filename=f"test{i}.jpg",
|
||||||
|
storage_path=f"{test_user.id}/test{i}.jpg",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
metadata={"format": "jpeg", "checksum": f"abc{i}"},
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
images.append(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()
|
||||||
|
|
||||||
|
# Bulk update everything
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [str(img.id) for img in images],
|
||||||
|
"position_delta": {"dx": 50, "dy": 50},
|
||||||
|
"transformations": {"scale": 2.0},
|
||||||
|
"z_order_delta": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["updated_count"] == 2
|
||||||
|
assert data["failed_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_update_non_existent_image(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk update with some non-existent images."""
|
||||||
|
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": "abc"},
|
||||||
|
)
|
||||||
|
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 one valid and one invalid ID
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [str(image.id), str(uuid4())], # One valid, one invalid
|
||||||
|
"transformations": {"scale": 2.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["updated_count"] == 1 # Only valid one updated
|
||||||
|
assert data["failed_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_update_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk update on board not owned by user."""
|
||||||
|
# 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 Board",
|
||||||
|
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||||
|
)
|
||||||
|
db.add(board)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Try bulk update as current user
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [str(uuid4())],
|
||||||
|
"transformations": {"scale": 2.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_update_empty_image_list(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test bulk update with empty image list."""
|
||||||
|
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)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
response = await client.patch(
|
||||||
|
f"/api/images/boards/{board.id}/images/bulk",
|
||||||
|
json={
|
||||||
|
"image_ids": [],
|
||||||
|
"transformations": {"scale": 2.0},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should succeed with 0 updated
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["updated_count"] == 0
|
||||||
|
|
||||||
220
backend/tests/api/test_image_delete.py
Normal file
220
backend/tests/api/test_image_delete.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""Integration tests for image deletion endpoints."""
|
||||||
|
|
||||||
|
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_remove_image_from_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test removing image from board (not deleting)."""
|
||||||
|
# Create board and 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"},
|
||||||
|
reference_count=1,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Remove from board
|
||||||
|
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test removing image that's not on the 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 remove (image not on board)
|
||||||
|
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test removing image from board not owned by user."""
|
||||||
|
# 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 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 remove as current user
|
||||||
|
response = await client.delete(f"/api/images/boards/{board.id}/images/{image.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_permanent_delete_image(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test permanently deleting image from library."""
|
||||||
|
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"},
|
||||||
|
reference_count=0, # Not used on any boards
|
||||||
|
)
|
||||||
|
db.add(image)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Delete permanently
|
||||||
|
response = await client.delete(f"/api/images/{image.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cannot_delete_image_in_use(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||||
|
"""Test that images in use cannot be permanently deleted."""
|
||||||
|
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"},
|
||||||
|
reference_count=1, # Used on a board
|
||||||
|
)
|
||||||
|
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 delete
|
||||||
|
response = await client.delete(f"/api/images/{image.id}")
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "still used" in response.json()["detail"].lower()
|
||||||
|
|
||||||
86
frontend/src/lib/canvas/clipboard/copy.ts
Normal file
86
frontend/src/lib/canvas/clipboard/copy.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Copy operation for canvas images
|
||||||
|
* Copies selected images to clipboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy selected images to clipboard
|
||||||
|
*/
|
||||||
|
export function copySelectedImages(
|
||||||
|
getImageData: (id: string) => ClipboardImageData | null
|
||||||
|
): number {
|
||||||
|
const selectedIds = selection.getSelectedIds();
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagesToCopy: ClipboardImageData[] = [];
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const imageData = getImageData(id);
|
||||||
|
if (imageData) {
|
||||||
|
imagesToCopy.push(imageData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clipboard.copy(imagesToCopy);
|
||||||
|
|
||||||
|
return imagesToCopy.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy specific images to clipboard
|
||||||
|
*/
|
||||||
|
export function copyImages(
|
||||||
|
imageIds: string[],
|
||||||
|
getImageData: (id: string) => ClipboardImageData | null
|
||||||
|
): number {
|
||||||
|
const imagesToCopy: ClipboardImageData[] = [];
|
||||||
|
|
||||||
|
imageIds.forEach((id) => {
|
||||||
|
const imageData = getImageData(id);
|
||||||
|
if (imageData) {
|
||||||
|
imagesToCopy.push(imageData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clipboard.copy(imagesToCopy);
|
||||||
|
|
||||||
|
return imagesToCopy.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy single image to clipboard
|
||||||
|
*/
|
||||||
|
export function copySingleImage(
|
||||||
|
getImageData: (id: string) => ClipboardImageData | null,
|
||||||
|
imageId: string
|
||||||
|
): boolean {
|
||||||
|
const imageData = getImageData(imageId);
|
||||||
|
|
||||||
|
if (!imageData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboard.copy([imageData]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if clipboard has content
|
||||||
|
*/
|
||||||
|
export function hasClipboardContent(): boolean {
|
||||||
|
return clipboard.hasContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clipboard count
|
||||||
|
*/
|
||||||
|
export function getClipboardCount(): number {
|
||||||
|
const state = clipboard.getClipboard();
|
||||||
|
return state.images.length;
|
||||||
|
}
|
||||||
69
frontend/src/lib/canvas/clipboard/cut.ts
Normal file
69
frontend/src/lib/canvas/clipboard/cut.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Cut operation for canvas images
|
||||||
|
* Cuts selected images to clipboard (copy + mark for deletion)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut selected images to clipboard
|
||||||
|
*/
|
||||||
|
export function cutSelectedImages(getImageData: (id: string) => ClipboardImageData | null): number {
|
||||||
|
const selectedIds = selection.getSelectedIds();
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagesToCut: ClipboardImageData[] = [];
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const imageData = getImageData(id);
|
||||||
|
if (imageData) {
|
||||||
|
imagesToCut.push(imageData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clipboard.cut(imagesToCut);
|
||||||
|
|
||||||
|
return imagesToCut.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut specific images to clipboard
|
||||||
|
*/
|
||||||
|
export function cutImages(
|
||||||
|
imageIds: string[],
|
||||||
|
getImageData: (id: string) => ClipboardImageData | null
|
||||||
|
): number {
|
||||||
|
const imagesToCut: ClipboardImageData[] = [];
|
||||||
|
|
||||||
|
imageIds.forEach((id) => {
|
||||||
|
const imageData = getImageData(id);
|
||||||
|
if (imageData) {
|
||||||
|
imagesToCut.push(imageData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clipboard.cut(imagesToCut);
|
||||||
|
|
||||||
|
return imagesToCut.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut single image to clipboard
|
||||||
|
*/
|
||||||
|
export function cutSingleImage(
|
||||||
|
getImageData: (id: string) => ClipboardImageData | null,
|
||||||
|
imageId: string
|
||||||
|
): boolean {
|
||||||
|
const imageData = getImageData(imageId);
|
||||||
|
|
||||||
|
if (!imageData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clipboard.cut([imageData]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
139
frontend/src/lib/canvas/clipboard/paste.ts
Normal file
139
frontend/src/lib/canvas/clipboard/paste.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Paste operation for canvas images
|
||||||
|
* Pastes clipboard images at viewport center or specific position
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface PasteOptions {
|
||||||
|
position?: { x: number; y: number }; // Override default center position
|
||||||
|
clearClipboardAfter?: boolean; // Clear clipboard after paste (default: false for copy, true for cut)
|
||||||
|
onPasteComplete?: (pastedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PastedImageData extends ClipboardImageData {
|
||||||
|
newPosition: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste clipboard images at viewport center
|
||||||
|
*/
|
||||||
|
export function pasteFromClipboard(
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
options: PasteOptions = {}
|
||||||
|
): PastedImageData[] {
|
||||||
|
const clipboardState = clipboard.getClipboard();
|
||||||
|
|
||||||
|
if (clipboardState.images.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine paste position
|
||||||
|
let pastePosition: { x: number; y: number };
|
||||||
|
|
||||||
|
if (options.position) {
|
||||||
|
pastePosition = options.position;
|
||||||
|
} else {
|
||||||
|
// Use viewport center
|
||||||
|
const viewportState = get(viewport);
|
||||||
|
pastePosition = {
|
||||||
|
x: -viewportState.x + viewportWidth / 2,
|
||||||
|
y: -viewportState.y + viewportHeight / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset to paste at center
|
||||||
|
const pastedImages: PastedImageData[] = [];
|
||||||
|
|
||||||
|
// Calculate bounding box of clipboard images
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
|
||||||
|
clipboardState.images.forEach((img) => {
|
||||||
|
minX = Math.min(minX, img.position.x);
|
||||||
|
minY = Math.min(minY, img.position.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pasted images with new positions
|
||||||
|
clipboardState.images.forEach((img) => {
|
||||||
|
const offsetX = img.position.x - minX;
|
||||||
|
const offsetY = img.position.y - minY;
|
||||||
|
|
||||||
|
pastedImages.push({
|
||||||
|
...img,
|
||||||
|
newPosition: {
|
||||||
|
x: pastePosition.x + offsetX,
|
||||||
|
y: pastePosition.y + offsetY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear clipboard if requested (default for cut operation)
|
||||||
|
const shouldClear = options.clearClipboardAfter ?? clipboardState.operation === 'cut';
|
||||||
|
if (shouldClear) {
|
||||||
|
clipboard.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call callback if provided
|
||||||
|
if (options.onPasteComplete) {
|
||||||
|
options.onPasteComplete(pastedImages.map((img) => img.boardImageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return pastedImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste at specific position
|
||||||
|
*/
|
||||||
|
export function pasteAtPosition(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
options: PasteOptions = {}
|
||||||
|
): PastedImageData[] {
|
||||||
|
return pasteFromClipboard(0, 0, {
|
||||||
|
...options,
|
||||||
|
position: { x, y },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if can paste (clipboard has content)
|
||||||
|
*/
|
||||||
|
export function canPaste(): boolean {
|
||||||
|
return clipboard.hasContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paste preview (positions where images will be pasted)
|
||||||
|
*/
|
||||||
|
export function getPastePreview(
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number
|
||||||
|
): Array<{ x: number; y: number }> {
|
||||||
|
const clipboardState = clipboard.getClipboard();
|
||||||
|
|
||||||
|
if (clipboardState.images.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportState = get(viewport);
|
||||||
|
const centerX = -viewportState.x + viewportWidth / 2;
|
||||||
|
const centerY = -viewportState.y + viewportHeight / 2;
|
||||||
|
|
||||||
|
// Calculate offsets
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
|
||||||
|
clipboardState.images.forEach((img) => {
|
||||||
|
minX = Math.min(minX, img.position.x);
|
||||||
|
minY = Math.min(minY, img.position.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
return clipboardState.images.map((img) => ({
|
||||||
|
x: centerX + (img.position.x - minX),
|
||||||
|
y: centerY + (img.position.y - minY),
|
||||||
|
}));
|
||||||
|
}
|
||||||
181
frontend/src/lib/canvas/keyboard.ts
Normal file
181
frontend/src/lib/canvas/keyboard.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard shortcuts for canvas operations
|
||||||
|
* Handles Ctrl+A (select all), Escape (deselect), and other shortcuts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
export interface KeyboardShortcutHandlers {
|
||||||
|
onSelectAll?: (allImageIds: string[]) => void;
|
||||||
|
onDeselectAll?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onCopy?: () => void;
|
||||||
|
onCut?: () => void;
|
||||||
|
onPaste?: () => void;
|
||||||
|
onUndo?: () => void;
|
||||||
|
onRedo?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup keyboard shortcuts for canvas
|
||||||
|
*/
|
||||||
|
export function setupKeyboardShortcuts(
|
||||||
|
getAllImageIds: () => string[],
|
||||||
|
handlers: KeyboardShortcutHandlers = {}
|
||||||
|
): () => void {
|
||||||
|
/**
|
||||||
|
* Handle keyboard shortcuts
|
||||||
|
*/
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Ignore if typing in input/textarea
|
||||||
|
if (
|
||||||
|
document.activeElement?.tagName === 'INPUT' ||
|
||||||
|
document.activeElement?.tagName === 'TEXTAREA'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
// Ctrl+A / Cmd+A - Select all
|
||||||
|
if (isCtrlOrCmd && e.key === 'a') {
|
||||||
|
e.preventDefault();
|
||||||
|
const allIds = getAllImageIds();
|
||||||
|
selection.selectAll(allIds);
|
||||||
|
|
||||||
|
if (handlers.onSelectAll) {
|
||||||
|
handlers.onSelectAll(allIds);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape - Deselect all
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
selection.clearSelection();
|
||||||
|
|
||||||
|
if (handlers.onDeselectAll) {
|
||||||
|
handlers.onDeselectAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete / Backspace - Delete selected
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onDelete) {
|
||||||
|
handlers.onDelete();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C / Cmd+C - Copy
|
||||||
|
if (isCtrlOrCmd && e.key === 'c') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onCopy) {
|
||||||
|
handlers.onCopy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+X / Cmd+X - Cut
|
||||||
|
if (isCtrlOrCmd && e.key === 'x') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onCut) {
|
||||||
|
handlers.onCut();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V / Cmd+V - Paste
|
||||||
|
if (isCtrlOrCmd && e.key === 'v') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onPaste) {
|
||||||
|
handlers.onPaste();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Z / Cmd+Z - Undo
|
||||||
|
if (isCtrlOrCmd && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onUndo) {
|
||||||
|
handlers.onUndo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+Z / Cmd+Shift+Z - Redo
|
||||||
|
if (isCtrlOrCmd && e.key === 'z' && e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onRedo) {
|
||||||
|
handlers.onRedo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Y / Cmd+Y - Alternative Redo
|
||||||
|
if (isCtrlOrCmd && e.key === 'y') {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (handlers.onRedo) {
|
||||||
|
handlers.onRedo();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listener
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select all images programmatically
|
||||||
|
*/
|
||||||
|
export function selectAllImages(allImageIds: string[]): void {
|
||||||
|
selection.selectAll(allImageIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deselect all images programmatically
|
||||||
|
*/
|
||||||
|
export function deselectAllImages(): void {
|
||||||
|
selection.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if modifier key is pressed
|
||||||
|
*/
|
||||||
|
export function isModifierPressed(e: KeyboardEvent): boolean {
|
||||||
|
return e.ctrlKey || e.metaKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if shift key is pressed
|
||||||
|
*/
|
||||||
|
export function isShiftPressed(e: KeyboardEvent): boolean {
|
||||||
|
return e.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get keyboard shortcut display string
|
||||||
|
*/
|
||||||
|
export function getShortcutDisplay(shortcut: string): string {
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform);
|
||||||
|
|
||||||
|
return shortcut
|
||||||
|
.replace('Ctrl', isMac ? '⌘' : 'Ctrl')
|
||||||
|
.replace('Alt', isMac ? '⌥' : 'Alt')
|
||||||
|
.replace('Shift', isMac ? '⇧' : 'Shift');
|
||||||
|
}
|
||||||
160
frontend/src/lib/canvas/operations/bulk-move.ts
Normal file
160
frontend/src/lib/canvas/operations/bulk-move.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Bulk move operations for multiple selected images
|
||||||
|
* Moves all selected images together by the same delta
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
export interface BulkMoveOptions {
|
||||||
|
animate?: boolean;
|
||||||
|
onMoveComplete?: (imageIds: string[], deltaX: number, deltaY: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move multiple images by delta
|
||||||
|
*/
|
||||||
|
export function bulkMove(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
options: BulkMoveOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false, onMoveComplete } = options;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const currentX = image.x();
|
||||||
|
const currentY = image.y();
|
||||||
|
const newX = currentX + deltaX;
|
||||||
|
const newY = currentY + deltaY;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.position({ x: newX, y: newY });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw if layer exists
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onMoveComplete) {
|
||||||
|
onMoveComplete(selectedIds, deltaX, deltaY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move multiple images to specific position (aligns top-left corners)
|
||||||
|
*/
|
||||||
|
export function bulkMoveTo(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
options: BulkMoveOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false } = options;
|
||||||
|
|
||||||
|
// Calculate current bounding box
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
minX = Math.min(minX, image.x());
|
||||||
|
minY = Math.min(minY, image.y());
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||||
|
|
||||||
|
// Calculate delta to move top-left to target
|
||||||
|
const deltaX = targetX - minX;
|
||||||
|
const deltaY = targetY - minY;
|
||||||
|
|
||||||
|
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center multiple images at specific point
|
||||||
|
*/
|
||||||
|
export function bulkCenterAt(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
options: BulkMoveOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false } = options;
|
||||||
|
|
||||||
|
// Calculate current bounding box
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const box = image.getClientRect();
|
||||||
|
minX = Math.min(minX, box.x);
|
||||||
|
minY = Math.min(minY, box.y);
|
||||||
|
maxX = Math.max(maxX, box.x + box.width);
|
||||||
|
maxY = Math.max(maxY, box.y + box.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFinite(minX) || !isFinite(minY)) return;
|
||||||
|
|
||||||
|
const currentCenterX = (minX + maxX) / 2;
|
||||||
|
const currentCenterY = (minY + maxY) / 2;
|
||||||
|
|
||||||
|
const deltaX = centerX - currentCenterX;
|
||||||
|
const deltaY = centerY - currentCenterY;
|
||||||
|
|
||||||
|
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bounding box of multiple images
|
||||||
|
*/
|
||||||
|
export function getBulkBounds(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[]
|
||||||
|
): { x: number; y: number; width: number; height: number } | null {
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
const box = image.getClientRect();
|
||||||
|
minX = Math.min(minX, box.x);
|
||||||
|
minY = Math.min(minY, box.y);
|
||||||
|
maxX = Math.max(maxX, box.x + box.width);
|
||||||
|
maxY = Math.max(maxY, box.y + box.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isFinite(minX) || !isFinite(minY)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
};
|
||||||
|
}
|
||||||
117
frontend/src/lib/canvas/operations/bulk-rotate.ts
Normal file
117
frontend/src/lib/canvas/operations/bulk-rotate.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Bulk rotate operations for multiple selected images
|
||||||
|
* Rotates all selected images together
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { rotateImageTo, rotateImageBy } from '../transforms/rotate';
|
||||||
|
|
||||||
|
export interface BulkRotateOptions {
|
||||||
|
animate?: boolean;
|
||||||
|
onRotateComplete?: (imageIds: string[], rotation: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate multiple images to same angle
|
||||||
|
*/
|
||||||
|
export function bulkRotateTo(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
degrees: number,
|
||||||
|
options: BulkRotateOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false, onRotateComplete } = options;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
rotateImageTo(image, degrees, animate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRotateComplete) {
|
||||||
|
onRotateComplete(selectedIds, degrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate multiple images by delta
|
||||||
|
*/
|
||||||
|
export function bulkRotateBy(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
degrees: number,
|
||||||
|
options: BulkRotateOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false, onRotateComplete } = options;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
rotateImageBy(image, degrees, animate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRotateComplete) {
|
||||||
|
// Get average rotation for callback (or first image rotation)
|
||||||
|
const firstImage = images.get(selectedIds[0]);
|
||||||
|
const rotation = firstImage ? firstImage.rotation() : 0;
|
||||||
|
onRotateComplete(selectedIds, rotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate multiple images 90° clockwise
|
||||||
|
*/
|
||||||
|
export function bulkRotate90CW(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkRotateOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkRotateBy(images, selectedIds, 90, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate multiple images 90° counter-clockwise
|
||||||
|
*/
|
||||||
|
export function bulkRotate90CCW(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkRotateOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkRotateBy(images, selectedIds, -90, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate multiple images 180°
|
||||||
|
*/
|
||||||
|
export function bulkRotate180(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkRotateOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkRotateBy(images, selectedIds, 180, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rotation for multiple images
|
||||||
|
*/
|
||||||
|
export function bulkResetRotation(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkRotateOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkRotateTo(images, selectedIds, 0, options);
|
||||||
|
}
|
||||||
151
frontend/src/lib/canvas/operations/bulk-scale.ts
Normal file
151
frontend/src/lib/canvas/operations/bulk-scale.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Bulk scale operations for multiple selected images
|
||||||
|
* Scales all selected images together
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
import { scaleImageTo, scaleImageBy } from '../transforms/scale';
|
||||||
|
|
||||||
|
export interface BulkScaleOptions {
|
||||||
|
animate?: boolean;
|
||||||
|
onScaleComplete?: (imageIds: string[], scale: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale multiple images to same factor
|
||||||
|
*/
|
||||||
|
export function bulkScaleTo(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
scale: number,
|
||||||
|
options: BulkScaleOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false, onScaleComplete } = options;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
scaleImageTo(image, scale, animate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onScaleComplete) {
|
||||||
|
onScaleComplete(selectedIds, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale multiple images by factor
|
||||||
|
*/
|
||||||
|
export function bulkScaleBy(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
factor: number,
|
||||||
|
options: BulkScaleOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false, onScaleComplete } = options;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
scaleImageBy(image, factor, animate);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onScaleComplete) {
|
||||||
|
// Get average scale for callback (or first image scale)
|
||||||
|
const firstImage = images.get(selectedIds[0]);
|
||||||
|
const scale = firstImage ? Math.abs(firstImage.scaleX()) : 1.0;
|
||||||
|
onScaleComplete(selectedIds, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double size of multiple images
|
||||||
|
*/
|
||||||
|
export function bulkDoubleSize(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkScaleOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkScaleBy(images, selectedIds, 2.0, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Half size of multiple images
|
||||||
|
*/
|
||||||
|
export function bulkHalfSize(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkScaleOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkScaleBy(images, selectedIds, 0.5, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset scale for multiple images
|
||||||
|
*/
|
||||||
|
export function bulkResetScale(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
options: BulkScaleOptions = {}
|
||||||
|
): void {
|
||||||
|
bulkScaleTo(images, selectedIds, 1.0, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale uniformly while maintaining relative positions
|
||||||
|
*/
|
||||||
|
export function bulkScaleUniform(
|
||||||
|
images: Map<string, Konva.Image | Konva.Group>,
|
||||||
|
selectedIds: string[],
|
||||||
|
factor: number,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
options: BulkScaleOptions = {}
|
||||||
|
): void {
|
||||||
|
const { animate = false } = options;
|
||||||
|
|
||||||
|
selectedIds.forEach((id) => {
|
||||||
|
const image = images.get(id);
|
||||||
|
if (!image) return;
|
||||||
|
|
||||||
|
// Scale the image
|
||||||
|
scaleImageBy(image, factor, animate);
|
||||||
|
|
||||||
|
// Adjust position to scale around center point
|
||||||
|
const x = image.x();
|
||||||
|
const y = image.y();
|
||||||
|
|
||||||
|
const newX = centerX + (x - centerX) * factor;
|
||||||
|
const newY = centerY + (y - centerY) * factor;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.position({ x: newX, y: newY });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch draw
|
||||||
|
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
|
||||||
|
if (firstImage) {
|
||||||
|
firstImage.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
frontend/src/lib/canvas/operations/delete.ts
Normal file
100
frontend/src/lib/canvas/operations/delete.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Delete operation for canvas images
|
||||||
|
* Handles deletion with confirmation for large selections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
|
||||||
|
export interface DeleteOptions {
|
||||||
|
confirmationThreshold?: number; // Show confirmation if deleting more than this (default: 10)
|
||||||
|
onDeleteConfirm?: (imageIds: string[]) => Promise<boolean>; // Return true to proceed
|
||||||
|
onDeleteComplete?: (deletedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIRMATION_THRESHOLD = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete selected images
|
||||||
|
*/
|
||||||
|
export async function deleteSelectedImages(
|
||||||
|
options: DeleteOptions = {}
|
||||||
|
): Promise<{ deleted: string[]; cancelled: boolean }> {
|
||||||
|
const selectedIds = selection.getSelectedIds();
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
return { deleted: [], cancelled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteImages(selectedIds, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete specific images
|
||||||
|
*/
|
||||||
|
export async function deleteImages(
|
||||||
|
imageIds: string[],
|
||||||
|
options: DeleteOptions = {}
|
||||||
|
): Promise<{ deleted: string[]; cancelled: boolean }> {
|
||||||
|
const {
|
||||||
|
confirmationThreshold = DEFAULT_CONFIRMATION_THRESHOLD,
|
||||||
|
onDeleteConfirm,
|
||||||
|
onDeleteComplete,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Check if confirmation needed
|
||||||
|
const needsConfirmation = imageIds.length > confirmationThreshold;
|
||||||
|
|
||||||
|
if (needsConfirmation && onDeleteConfirm) {
|
||||||
|
const confirmed = await onDeleteConfirm(imageIds);
|
||||||
|
if (!confirmed) {
|
||||||
|
return { deleted: [], cancelled: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with deletion
|
||||||
|
const deletedIds = [...imageIds];
|
||||||
|
|
||||||
|
// Clear selection of deleted images
|
||||||
|
deletedIds.forEach((id) => {
|
||||||
|
selection.removeFromSelection(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call completion callback
|
||||||
|
if (onDeleteComplete) {
|
||||||
|
onDeleteComplete(deletedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: deletedIds, cancelled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete single image
|
||||||
|
*/
|
||||||
|
export async function deleteSingleImage(
|
||||||
|
imageId: string,
|
||||||
|
options: DeleteOptions = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await deleteImages([imageId], options);
|
||||||
|
return !result.cancelled && result.deleted.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delete confirmation message
|
||||||
|
*/
|
||||||
|
export function getDeleteConfirmationMessage(count: number): string {
|
||||||
|
if (count === 1) {
|
||||||
|
return 'Are you sure you want to delete this image from the board?';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Are you sure you want to delete ${count} images from the board?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if deletion needs confirmation
|
||||||
|
*/
|
||||||
|
export function needsDeleteConfirmation(
|
||||||
|
count: number,
|
||||||
|
threshold: number = DEFAULT_CONFIRMATION_THRESHOLD
|
||||||
|
): boolean {
|
||||||
|
return count > threshold;
|
||||||
|
}
|
||||||
211
frontend/src/lib/components/canvas/DeleteConfirmModal.svelte
Normal file
211
frontend/src/lib/components/canvas/DeleteConfirmModal.svelte
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Delete confirmation modal for canvas images
|
||||||
|
* Shows confirmation dialog when deleting multiple images
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let imageCount: number = 0;
|
||||||
|
export let show: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
dispatch('confirm');
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
dispatch('cancel');
|
||||||
|
show = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackdropClick(event: MouseEvent) {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if show}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
on:click={handleBackdropClick}
|
||||||
|
on:keydown={(e) => e.key === 'Escape' && handleCancel()}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="modal-content" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">
|
||||||
|
{imageCount === 1 ? 'Delete Image?' : `Delete ${imageCount} Images?`}
|
||||||
|
</h2>
|
||||||
|
<button class="close-button" on:click={handleCancel} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="warning-icon"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||||
|
/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{#if imageCount === 1}
|
||||||
|
Are you sure you want to delete this image from the board?
|
||||||
|
{:else}
|
||||||
|
Are you sure you want to delete {imageCount} images from the board?
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
This will remove {imageCount === 1 ? 'the image' : 'these images'} from the board.
|
||||||
|
{imageCount === 1 ? 'The image' : 'Images'} will remain in your library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="button button-secondary" on:click={handleCancel}> Cancel </button>
|
||||||
|
<button class="button button-danger" on:click={handleConfirm}> Delete </button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
background-color: var(--color-bg-hover, #f3f4f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
color: var(--color-warning, #f59e0b);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
border-color: var(--color-border, #d1d5db);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: var(--color-bg-hover, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
background-color: var(--color-error, #ef4444);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover {
|
||||||
|
background-color: var(--color-error-hover, #dc2626);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
frontend/src/lib/components/canvas/SelectionCounter.svelte
Normal file
107
frontend/src/lib/components/canvas/SelectionCounter.svelte
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script context="module">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Selection counter indicator
|
||||||
|
* Shows count of selected images in the canvas
|
||||||
|
*/
|
||||||
|
import { selectionCount, hasSelection } from '$lib/stores/selection';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function handleClearSelection() {
|
||||||
|
dispatch('clear-selection');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $hasSelection}
|
||||||
|
<div class="selection-counter" transition:fade={{ duration: 200 }}>
|
||||||
|
<div class="counter-content">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
</svg>
|
||||||
|
<span class="count">{$selectionCount}</span>
|
||||||
|
<span class="label">
|
||||||
|
{$selectionCount === 1 ? 'image selected' : 'images selected'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button class="clear-button" on:click={handleClearSelection} title="Clear selection (Esc)">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.selection-counter {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border-radius: 2rem;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
104
frontend/src/lib/stores/clipboard.ts
Normal file
104
frontend/src/lib/stores/clipboard.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Clipboard store for copy/cut/paste operations
|
||||||
|
* Manages clipboard state for canvas images
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface ClipboardImageData {
|
||||||
|
boardImageId: string;
|
||||||
|
imageId: string;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
transformations: Record<string, unknown>;
|
||||||
|
zOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClipboardState {
|
||||||
|
images: ClipboardImageData[];
|
||||||
|
operation: 'copy' | 'cut' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CLIPBOARD: ClipboardState = {
|
||||||
|
images: [],
|
||||||
|
operation: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create clipboard store
|
||||||
|
*/
|
||||||
|
function createClipboardStore() {
|
||||||
|
const { subscribe, set, update }: Writable<ClipboardState> = writable(DEFAULT_CLIPBOARD);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy images to clipboard
|
||||||
|
*/
|
||||||
|
copy: (images: ClipboardImageData[]) => {
|
||||||
|
set({
|
||||||
|
images: [...images],
|
||||||
|
operation: 'copy',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut images to clipboard (copy + mark for deletion)
|
||||||
|
*/
|
||||||
|
cut: (images: ClipboardImageData[]) => {
|
||||||
|
set({
|
||||||
|
images: [...images],
|
||||||
|
operation: 'cut',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear clipboard
|
||||||
|
*/
|
||||||
|
clear: () => {
|
||||||
|
set(DEFAULT_CLIPBOARD);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clipboard contents
|
||||||
|
*/
|
||||||
|
getClipboard: (): ClipboardState => {
|
||||||
|
let result = DEFAULT_CLIPBOARD;
|
||||||
|
const unsubscribe = subscribe((state) => {
|
||||||
|
result = state;
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if clipboard has content
|
||||||
|
*/
|
||||||
|
hasContent: (): boolean => {
|
||||||
|
let result = false;
|
||||||
|
const unsubscribe = subscribe((state) => {
|
||||||
|
result = state.images.length > 0;
|
||||||
|
});
|
||||||
|
unsubscribe();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clipboard = createClipboardStore();
|
||||||
|
|
||||||
|
// Derived stores
|
||||||
|
export const hasClipboardContent = derived(clipboard, ($clipboard) => {
|
||||||
|
return $clipboard.images.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const clipboardCount = derived(clipboard, ($clipboard) => {
|
||||||
|
return $clipboard.images.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isCutOperation = derived(clipboard, ($clipboard) => {
|
||||||
|
return $clipboard.operation === 'cut';
|
||||||
|
});
|
||||||
443
frontend/tests/canvas/clipboard.test.ts
Normal file
443
frontend/tests/canvas/clipboard.test.ts
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* Tests for clipboard operations (copy, cut, paste)
|
||||||
|
* Tests clipboard store and operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import {
|
||||||
|
clipboard,
|
||||||
|
hasClipboardContent,
|
||||||
|
clipboardCount,
|
||||||
|
isCutOperation,
|
||||||
|
} from '$lib/stores/clipboard';
|
||||||
|
import type { ClipboardImageData } from '$lib/stores/clipboard';
|
||||||
|
import {
|
||||||
|
copySelectedImages,
|
||||||
|
copyImages,
|
||||||
|
copySingleImage,
|
||||||
|
hasClipboardContent as hasContent,
|
||||||
|
getClipboardCount,
|
||||||
|
} from '$lib/canvas/clipboard/copy';
|
||||||
|
import { cutSelectedImages, cutImages, cutSingleImage } from '$lib/canvas/clipboard/cut';
|
||||||
|
import {
|
||||||
|
pasteFromClipboard,
|
||||||
|
pasteAtPosition,
|
||||||
|
canPaste,
|
||||||
|
getPastePreview,
|
||||||
|
} from '$lib/canvas/clipboard/paste';
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
import { viewport } from '$lib/stores/viewport';
|
||||||
|
|
||||||
|
describe('Clipboard Store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clipboard.clear();
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts empty', () => {
|
||||||
|
const state = get(clipboard);
|
||||||
|
expect(state.images).toEqual([]);
|
||||||
|
expect(state.operation).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores copied images', () => {
|
||||||
|
const images: ClipboardImageData[] = [
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
clipboard.copy(images);
|
||||||
|
|
||||||
|
const state = get(clipboard);
|
||||||
|
expect(state.images).toHaveLength(1);
|
||||||
|
expect(state.operation).toBe('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores cut images', () => {
|
||||||
|
const images: ClipboardImageData[] = [
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
clipboard.cut(images);
|
||||||
|
|
||||||
|
const state = get(clipboard);
|
||||||
|
expect(state.images).toHaveLength(1);
|
||||||
|
expect(state.operation).toBe('cut');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears clipboard', () => {
|
||||||
|
const images: ClipboardImageData[] = [
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
clipboard.copy(images);
|
||||||
|
clipboard.clear();
|
||||||
|
|
||||||
|
const state = get(clipboard);
|
||||||
|
expect(state.images).toEqual([]);
|
||||||
|
expect(state.operation).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasClipboardContent reflects state', () => {
|
||||||
|
expect(get(hasClipboardContent)).toBe(false);
|
||||||
|
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(get(hasClipboardContent)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clipboardCount reflects count', () => {
|
||||||
|
expect(get(clipboardCount)).toBe(0);
|
||||||
|
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
boardImageId: 'bi2',
|
||||||
|
imageId: 'img2',
|
||||||
|
position: { x: 200, y: 200 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(get(clipboardCount)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isCutOperation reflects operation type', () => {
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(get(isCutOperation)).toBe(false);
|
||||||
|
|
||||||
|
clipboard.cut([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(get(isCutOperation)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Copy Operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clipboard.clear();
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies selected images', () => {
|
||||||
|
selection.selectMultiple(['img1', 'img2']);
|
||||||
|
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = copySelectedImages(getImageData);
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(get(clipboardCount)).toBe(2);
|
||||||
|
expect(get(isCutOperation)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies specific images', () => {
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = copyImages(['img1', 'img2', 'img3'], getImageData);
|
||||||
|
|
||||||
|
expect(count).toBe(3);
|
||||||
|
expect(get(clipboardCount)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies single image', () => {
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = copySingleImage(getImageData, 'img1');
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(get(clipboardCount)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when copying empty selection', () => {
|
||||||
|
const getImageData = (): ClipboardImageData | null => null;
|
||||||
|
|
||||||
|
const count = copySelectedImages(getImageData);
|
||||||
|
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasClipboardContent returns true after copy', () => {
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
copyImages(['img1'], getImageData);
|
||||||
|
|
||||||
|
expect(hasContent()).toBe(true);
|
||||||
|
expect(getClipboardCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cut Operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clipboard.clear();
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts selected images', () => {
|
||||||
|
selection.selectMultiple(['img1', 'img2']);
|
||||||
|
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = cutSelectedImages(getImageData);
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(get(clipboardCount)).toBe(2);
|
||||||
|
expect(get(isCutOperation)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts specific images', () => {
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = cutImages(['img1', 'img2'], getImageData);
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(get(isCutOperation)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts single image', () => {
|
||||||
|
const getImageData = (id: string): ClipboardImageData | null => ({
|
||||||
|
boardImageId: `bi-${id}`,
|
||||||
|
imageId: id,
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = cutSingleImage(getImageData, 'img1');
|
||||||
|
|
||||||
|
expect(success).toBe(true);
|
||||||
|
expect(get(clipboardCount)).toBe(1);
|
||||||
|
expect(get(isCutOperation)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Paste Operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clipboard.clear();
|
||||||
|
viewport.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pastes images at viewport center', () => {
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
boardImageId: 'bi2',
|
||||||
|
imageId: 'img2',
|
||||||
|
position: { x: 200, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pasted = pasteFromClipboard(800, 600);
|
||||||
|
|
||||||
|
expect(pasted).toHaveLength(2);
|
||||||
|
expect(pasted[0].newPosition).toBeDefined();
|
||||||
|
expect(pasted[1].newPosition).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pastes at specific position', () => {
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pasted = pasteAtPosition(500, 500);
|
||||||
|
|
||||||
|
expect(pasted).toHaveLength(1);
|
||||||
|
expect(pasted[0].newPosition.x).toBe(500);
|
||||||
|
expect(pasted[0].newPosition.y).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves relative positions when pasting', () => {
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
boardImageId: 'bi2',
|
||||||
|
imageId: 'img2',
|
||||||
|
position: { x: 200, y: 150 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pasted = pasteAtPosition(0, 0);
|
||||||
|
|
||||||
|
// Relative distance should be preserved
|
||||||
|
const deltaX1 = pasted[0].newPosition.x;
|
||||||
|
const deltaX2 = pasted[1].newPosition.x;
|
||||||
|
expect(deltaX2 - deltaX1).toBe(100); // Original was 200 - 100 = 100
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears clipboard after cut paste', () => {
|
||||||
|
clipboard.cut([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
pasteAtPosition(200, 200);
|
||||||
|
|
||||||
|
expect(get(hasClipboardContent)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves clipboard after copy paste', () => {
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
pasteAtPosition(200, 200);
|
||||||
|
|
||||||
|
expect(get(hasClipboardContent)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when pasting empty clipboard', () => {
|
||||||
|
const pasted = pasteFromClipboard(800, 600);
|
||||||
|
|
||||||
|
expect(pasted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canPaste reflects clipboard state', () => {
|
||||||
|
expect(canPaste()).toBe(false);
|
||||||
|
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(canPaste()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getPastePreview shows where images will be pasted', () => {
|
||||||
|
clipboard.copy([
|
||||||
|
{
|
||||||
|
boardImageId: 'bi1',
|
||||||
|
imageId: 'img1',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
transformations: {},
|
||||||
|
zOrder: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const preview = getPastePreview(800, 600);
|
||||||
|
|
||||||
|
expect(preview).toHaveLength(1);
|
||||||
|
expect(preview[0]).toHaveProperty('x');
|
||||||
|
expect(preview[0]).toHaveProperty('y');
|
||||||
|
});
|
||||||
|
});
|
||||||
478
frontend/tests/canvas/multiselect.test.ts
Normal file
478
frontend/tests/canvas/multiselect.test.ts
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
/**
|
||||||
|
* Tests for multi-selection functionality
|
||||||
|
* Tests rectangle selection, Ctrl+A, and bulk operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { selection } from '$lib/stores/selection';
|
||||||
|
import {
|
||||||
|
setupRectangleSelection,
|
||||||
|
isRectangleSelecting,
|
||||||
|
getCurrentSelectionRect,
|
||||||
|
cancelRectangleSelection,
|
||||||
|
} from '$lib/canvas/interactions/multiselect';
|
||||||
|
import { setupKeyboardShortcuts, selectAllImages, deselectAllImages } from '$lib/canvas/keyboard';
|
||||||
|
import {
|
||||||
|
bulkMove,
|
||||||
|
bulkMoveTo,
|
||||||
|
bulkCenterAt,
|
||||||
|
getBulkBounds,
|
||||||
|
} from '$lib/canvas/operations/bulk-move';
|
||||||
|
import { bulkRotateTo, bulkRotateBy, bulkRotate90CW } from '$lib/canvas/operations/bulk-rotate';
|
||||||
|
import { bulkScaleTo, bulkScaleBy, bulkDoubleSize } from '$lib/canvas/operations/bulk-scale';
|
||||||
|
|
||||||
|
describe('Rectangle Selection', () => {
|
||||||
|
let stage: Konva.Stage;
|
||||||
|
let layer: Konva.Layer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.id = 'test-container';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
stage = new Konva.Stage({
|
||||||
|
container: 'test-container',
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stage.destroy();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets up rectangle selection on stage', () => {
|
||||||
|
const getImageBounds = () => [];
|
||||||
|
const cleanup = setupRectangleSelection(stage, layer, getImageBounds);
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts selecting on background click', () => {
|
||||||
|
const getImageBounds = () => [];
|
||||||
|
setupRectangleSelection(stage, layer, getImageBounds);
|
||||||
|
|
||||||
|
expect(isRectangleSelecting()).toBe(false);
|
||||||
|
|
||||||
|
// Note: Actual mouse events would trigger this
|
||||||
|
// This test verifies the function exists
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels rectangle selection', () => {
|
||||||
|
cancelRectangleSelection(layer);
|
||||||
|
|
||||||
|
expect(isRectangleSelecting()).toBe(false);
|
||||||
|
expect(getCurrentSelectionRect()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Shortcuts', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets up keyboard shortcuts', () => {
|
||||||
|
const getAllIds = () => ['img1', 'img2', 'img3'];
|
||||||
|
const cleanup = setupKeyboardShortcuts(getAllIds);
|
||||||
|
|
||||||
|
expect(typeof cleanup).toBe('function');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects all images programmatically', () => {
|
||||||
|
const allIds = ['img1', 'img2', 'img3'];
|
||||||
|
selectAllImages(allIds);
|
||||||
|
|
||||||
|
expect(get(selection).selectedIds.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselects all images programmatically', () => {
|
||||||
|
selection.selectMultiple(['img1', 'img2']);
|
||||||
|
|
||||||
|
deselectAllImages();
|
||||||
|
|
||||||
|
expect(get(selection).selectedIds.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Move Operations', () => {
|
||||||
|
let images: Map<string, Konva.Image>;
|
||||||
|
let layer: Konva.Layer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const stage = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
images = new Map();
|
||||||
|
|
||||||
|
// Create test images
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
['img1', 'img2', 'img3'].forEach((id, index) => {
|
||||||
|
const img = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
x: 100 + index * 150,
|
||||||
|
y: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(img);
|
||||||
|
images.set(id, img);
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves multiple images by delta', () => {
|
||||||
|
bulkMove(images, ['img1', 'img2'], 50, 75);
|
||||||
|
|
||||||
|
expect(images.get('img1')?.x()).toBe(150);
|
||||||
|
expect(images.get('img1')?.y()).toBe(175);
|
||||||
|
expect(images.get('img2')?.x()).toBe(300);
|
||||||
|
expect(images.get('img2')?.y()).toBe(175);
|
||||||
|
expect(images.get('img3')?.x()).toBe(400); // Unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves multiple images to position', () => {
|
||||||
|
bulkMoveTo(images, ['img1', 'img2'], 200, 200);
|
||||||
|
|
||||||
|
const img1 = images.get('img1');
|
||||||
|
const img2 = images.get('img2');
|
||||||
|
|
||||||
|
// One of them should be at 200,200 (the top-left one)
|
||||||
|
const minX = Math.min(img1?.x() || 0, img2?.x() || 0);
|
||||||
|
expect(minX).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centers multiple images at point', () => {
|
||||||
|
bulkCenterAt(images, ['img1', 'img2', 'img3'], 400, 300);
|
||||||
|
|
||||||
|
const bounds = getBulkBounds(images, ['img1', 'img2', 'img3']);
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
|
||||||
|
if (bounds) {
|
||||||
|
const centerX = bounds.x + bounds.width / 2;
|
||||||
|
const centerY = bounds.y + bounds.height / 2;
|
||||||
|
|
||||||
|
expect(centerX).toBeCloseTo(400, 0);
|
||||||
|
expect(centerY).toBeCloseTo(300, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates bulk bounds correctly', () => {
|
||||||
|
const bounds = getBulkBounds(images, ['img1', 'img2', 'img3']);
|
||||||
|
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
if (bounds) {
|
||||||
|
expect(bounds.x).toBe(100);
|
||||||
|
expect(bounds.width).toBeGreaterThan(300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty selection', () => {
|
||||||
|
const bounds = getBulkBounds(images, []);
|
||||||
|
expect(bounds).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onMoveComplete callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
bulkMove(images, ['img1', 'img2'], 50, 50, { onMoveComplete: callback });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(['img1', 'img2'], 50, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Rotate Operations', () => {
|
||||||
|
let images: Map<string, Konva.Image>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
images = new Map();
|
||||||
|
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
['img1', 'img2'].forEach((id) => {
|
||||||
|
const img = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
images.set(id, img);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates multiple images to angle', () => {
|
||||||
|
bulkRotateTo(images, ['img1', 'img2'], 45);
|
||||||
|
|
||||||
|
expect(images.get('img1')?.rotation()).toBe(45);
|
||||||
|
expect(images.get('img2')?.rotation()).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates multiple images by delta', () => {
|
||||||
|
images.get('img1')?.rotation(30);
|
||||||
|
images.get('img2')?.rotation(60);
|
||||||
|
|
||||||
|
bulkRotateBy(images, ['img1', 'img2'], 15);
|
||||||
|
|
||||||
|
expect(images.get('img1')?.rotation()).toBe(45);
|
||||||
|
expect(images.get('img2')?.rotation()).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates 90° clockwise', () => {
|
||||||
|
bulkRotate90CW(images, ['img1', 'img2']);
|
||||||
|
|
||||||
|
expect(images.get('img1')?.rotation()).toBe(90);
|
||||||
|
expect(images.get('img2')?.rotation()).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRotateComplete callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
bulkRotateTo(images, ['img1', 'img2'], 90, { onRotateComplete: callback });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Scale Operations', () => {
|
||||||
|
let images: Map<string, Konva.Image>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
images = new Map();
|
||||||
|
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
['img1', 'img2'].forEach((id) => {
|
||||||
|
const img = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
images.set(id, img);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales multiple images to factor', () => {
|
||||||
|
bulkScaleTo(images, ['img1', 'img2'], 2.0);
|
||||||
|
|
||||||
|
expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(2.0);
|
||||||
|
expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(2.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales multiple images by factor', () => {
|
||||||
|
images.get('img1')?.scale({ x: 1.5, y: 1.5 });
|
||||||
|
images.get('img2')?.scale({ x: 2.0, y: 2.0 });
|
||||||
|
|
||||||
|
bulkScaleBy(images, ['img1', 'img2'], 2.0);
|
||||||
|
|
||||||
|
expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(3.0);
|
||||||
|
expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(4.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doubles size of multiple images', () => {
|
||||||
|
bulkDoubleSize(images, ['img1', 'img2']);
|
||||||
|
|
||||||
|
expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(2.0);
|
||||||
|
expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(2.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onScaleComplete callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
bulkScaleTo(images, ['img1', 'img2'], 1.5, { onScaleComplete: callback });
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Operations Integration', () => {
|
||||||
|
let images: Map<string, Konva.Image>;
|
||||||
|
let layer: Konva.Layer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const stage = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer = new Konva.Layer();
|
||||||
|
stage.add(layer);
|
||||||
|
|
||||||
|
images = new Map();
|
||||||
|
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
['img1', 'img2', 'img3'].forEach((id, index) => {
|
||||||
|
const img = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
x: 100 + index * 150,
|
||||||
|
y: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(img);
|
||||||
|
images.set(id, img);
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.draw();
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies multiple transformations to selection', () => {
|
||||||
|
selection.selectMultiple(['img1', 'img2']);
|
||||||
|
|
||||||
|
bulkMove(images, ['img1', 'img2'], 50, 50);
|
||||||
|
bulkRotateTo(images, ['img1', 'img2'], 45);
|
||||||
|
bulkScaleTo(images, ['img1', 'img2'], 1.5);
|
||||||
|
|
||||||
|
const img1 = images.get('img1');
|
||||||
|
const img2 = images.get('img2');
|
||||||
|
|
||||||
|
expect(img1?.x()).toBe(150);
|
||||||
|
expect(img1?.rotation()).toBe(45);
|
||||||
|
expect(Math.abs(img1?.scaleX() || 0)).toBe(1.5);
|
||||||
|
|
||||||
|
expect(img2?.x()).toBe(300);
|
||||||
|
expect(img2?.rotation()).toBe(45);
|
||||||
|
expect(Math.abs(img2?.scaleX() || 0)).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves relative positions during bulk operations', () => {
|
||||||
|
const initialDist = (images.get('img2')?.x() || 0) - (images.get('img1')?.x() || 0);
|
||||||
|
|
||||||
|
bulkMove(images, ['img1', 'img2'], 100, 100);
|
||||||
|
|
||||||
|
const finalDist = (images.get('img2')?.x() || 0) - (images.get('img1')?.x() || 0);
|
||||||
|
|
||||||
|
expect(finalDist).toBe(initialDist);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty selection gracefully', () => {
|
||||||
|
bulkMove(images, [], 50, 50);
|
||||||
|
bulkRotateTo(images, [], 90);
|
||||||
|
bulkScaleTo(images, [], 2.0);
|
||||||
|
|
||||||
|
// Should not throw, images should be unchanged
|
||||||
|
expect(images.get('img1')?.x()).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Keyboard Shortcut Integration', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
selection.clearSelection();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+A callback receives all IDs', () => {
|
||||||
|
const allIds = ['img1', 'img2', 'img3'];
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const cleanup = setupKeyboardShortcuts(() => allIds, {
|
||||||
|
onSelectAll: callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate Ctrl+A
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'a',
|
||||||
|
ctrlKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(allIds);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape callback is called on deselect', () => {
|
||||||
|
selection.selectMultiple(['img1', 'img2']);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const cleanup = setupKeyboardShortcuts(() => [], {
|
||||||
|
onDeselectAll: callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate Escape
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled();
|
||||||
|
expect(get(selection).selectedIds.size).toBe(0);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores shortcuts when typing in input', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
// Create and focus an input
|
||||||
|
const input = document.createElement('input');
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
const cleanup = setupKeyboardShortcuts(() => ['img1'], {
|
||||||
|
onSelectAll: callback,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try Ctrl+A while focused on input
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'a',
|
||||||
|
ctrlKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Callback should not be called
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
document.body.removeChild(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -344,33 +344,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 9: Multi-Selection & Bulk Operations (FR9 - High) (Week 7)
|
## Phase 9: Multi-Selection & Bulk Operations (FR9 - High) (Week 7) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must be able to select and operate on multiple images simultaneously
|
**User Story:** Users must be able to select and operate on multiple images simultaneously
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Selection rectangle selects multiple images
|
- [X] Selection rectangle selects multiple images
|
||||||
- [ ] Ctrl+Click adds to selection
|
- [X] Ctrl+Click adds to selection
|
||||||
- [ ] Ctrl+A selects all
|
- [X] Ctrl+A selects all
|
||||||
- [ ] Bulk move works on selected images
|
- [X] Bulk move works on selected images
|
||||||
- [ ] Bulk transformations apply correctly
|
- [X] Bulk transformations apply correctly
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T134 [US7] Enhance selection rectangle in frontend/src/lib/canvas/interactions/multiselect.ts
|
- [X] T134 [US7] Enhance selection rectangle in frontend/src/lib/canvas/interactions/multiselect.ts
|
||||||
- [ ] T135 [US7] Implement Ctrl+Click add-to-selection in frontend/src/lib/canvas/interactions/select.ts
|
- [X] T135 [US7] Implement Ctrl+Click add-to-selection in frontend/src/lib/canvas/interactions/select.ts
|
||||||
- [ ] T136 [US7] Implement select all (Ctrl+A) in frontend/src/lib/canvas/keyboard.ts
|
- [X] T136 [US7] Implement select all (Ctrl+A) in frontend/src/lib/canvas/keyboard.ts
|
||||||
- [ ] T137 [US7] Implement deselect all (Escape) in frontend/src/lib/canvas/keyboard.ts
|
- [X] T137 [US7] Implement deselect all (Escape) in frontend/src/lib/canvas/keyboard.ts
|
||||||
- [ ] T138 [US7] Implement bulk move in frontend/src/lib/canvas/operations/bulk-move.ts
|
- [X] T138 [US7] Implement bulk move in frontend/src/lib/canvas/operations/bulk-move.ts
|
||||||
- [ ] T139 [P] [US7] Implement bulk rotate in frontend/src/lib/canvas/operations/bulk-rotate.ts
|
- [X] T139 [P] [US7] Implement bulk rotate in frontend/src/lib/canvas/operations/bulk-rotate.ts
|
||||||
- [ ] T140 [P] [US7] Implement bulk scale in frontend/src/lib/canvas/operations/bulk-scale.ts
|
- [X] T140 [P] [US7] Implement bulk scale in frontend/src/lib/canvas/operations/bulk-scale.ts
|
||||||
- [ ] T141 [P] [US7] Create selection count indicator in frontend/src/lib/components/canvas/SelectionCounter.svelte
|
- [X] T141 [P] [US7] Create selection count indicator in frontend/src/lib/components/canvas/SelectionCounter.svelte
|
||||||
- [ ] T142 [P] [US7] Write multi-selection tests in frontend/tests/canvas/multiselect.test.ts
|
- [X] T142 [P] [US7] Write multi-selection tests in frontend/tests/canvas/multiselect.test.ts
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T143 [US7] Implement bulk update endpoint PATCH /boards/{id}/images/bulk in backend/app/api/images.py
|
- [X] T143 [US7] Implement bulk update endpoint PATCH /boards/{id}/images/bulk in backend/app/api/images.py
|
||||||
- [ ] T144 [P] [US7] Write bulk operation tests in backend/tests/api/test_bulk_operations.py
|
- [X] T144 [P] [US7] Write bulk operation tests in backend/tests/api/test_bulk_operations.py
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Multi-selection complete
|
- Multi-selection complete
|
||||||
@@ -379,31 +379,31 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 10: Copy, Cut, Paste, Delete (FR10 - High) (Week 7)
|
## Phase 10: Copy, Cut, Paste, Delete (FR10 - High) (Week 7) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must have standard clipboard operations
|
**User Story:** Users must have standard clipboard operations
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Copy (Ctrl+C) copies selected images
|
- [X] Copy (Ctrl+C) copies selected images
|
||||||
- [ ] Cut (Ctrl+X) copies and removes
|
- [X] Cut (Ctrl+X) copies and removes
|
||||||
- [ ] Paste (Ctrl+V) inserts at viewport center
|
- [X] Paste (Ctrl+V) inserts at viewport center
|
||||||
- [ ] Delete (Del) removes with confirmation (>10 images)
|
- [X] Delete (Del) removes with confirmation (>10 images)
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T145 [US8] Implement copy operation in frontend/src/lib/canvas/clipboard/copy.ts
|
- [X] T145 [US8] Implement copy operation in frontend/src/lib/canvas/clipboard/copy.ts
|
||||||
- [ ] T146 [US8] Implement cut operation in frontend/src/lib/canvas/clipboard/cut.ts
|
- [X] T146 [US8] Implement cut operation in frontend/src/lib/canvas/clipboard/cut.ts
|
||||||
- [ ] T147 [US8] Implement paste operation in frontend/src/lib/canvas/clipboard/paste.ts
|
- [X] T147 [US8] Implement paste operation in frontend/src/lib/canvas/clipboard/paste.ts
|
||||||
- [ ] T148 [US8] Implement delete operation in frontend/src/lib/canvas/operations/delete.ts
|
- [X] T148 [US8] Implement delete operation in frontend/src/lib/canvas/operations/delete.ts
|
||||||
- [ ] T149 [US8] Create clipboard store in frontend/src/lib/stores/clipboard.ts
|
- [X] T149 [US8] Create clipboard store in frontend/src/lib/stores/clipboard.ts
|
||||||
- [ ] T150 [US8] Add keyboard shortcuts (Ctrl+C/X/V, Delete) in frontend/src/lib/canvas/keyboard.ts
|
- [X] T150 [US8] Add keyboard shortcuts (Ctrl+C/X/V, Delete) in frontend/src/lib/canvas/keyboard.ts
|
||||||
- [ ] T151 [P] [US8] Create delete confirmation modal in frontend/src/lib/components/canvas/DeleteConfirmModal.svelte
|
- [X] T151 [P] [US8] Create delete confirmation modal in frontend/src/lib/components/canvas/DeleteConfirmModal.svelte
|
||||||
- [ ] T152 [P] [US8] Write clipboard tests in frontend/tests/canvas/clipboard.test.ts
|
- [X] T152 [P] [US8] Write clipboard tests in frontend/tests/canvas/clipboard.test.ts
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T153 [US8] Implement delete endpoint DELETE /boards/{id}/images/{image_id} in backend/app/api/images.py
|
- [X] T153 [US8] Implement delete endpoint DELETE /boards/{id}/images/{image_id} in backend/app/api/images.py
|
||||||
- [ ] T154 [P] [US8] Write delete endpoint tests in backend/tests/api/test_image_delete.py
|
- [X] T154 [P] [US8] Write delete endpoint tests in backend/tests/api/test_image_delete.py
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- All clipboard ops work
|
- All clipboard ops work
|
||||||
|
|||||||
Reference in New Issue
Block a user