Compare commits
2 Commits
cd8ce33f5e
...
3eb3d977f9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb3d977f9 | ||
|
|
ce0b692aee |
@@ -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()
|
||||||
|
|
||||||
236
backend/tests/images/test_transformations.py
Normal file
236
backend/tests/images/test_transformations.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Tests for image transformation validation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.images.schemas import BoardImageUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_transformations():
|
||||||
|
"""Test that valid transformations are accepted."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"scale": 1.5,
|
||||||
|
"rotation": 45,
|
||||||
|
"opacity": 0.8,
|
||||||
|
"flipped_h": True,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.transformations is not None
|
||||||
|
assert data.transformations["scale"] == 1.5
|
||||||
|
assert data.transformations["rotation"] == 45
|
||||||
|
assert data.transformations["opacity"] == 0.8
|
||||||
|
assert data.transformations["flipped_h"] is True
|
||||||
|
assert data.transformations["greyscale"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_minimal_transformations():
|
||||||
|
"""Test that minimal transformation data is accepted."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"scale": 1.0,
|
||||||
|
"rotation": 0,
|
||||||
|
"opacity": 1.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.transformations is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_scale_bounds():
|
||||||
|
"""Test scale bounds validation."""
|
||||||
|
# Valid scales
|
||||||
|
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
|
||||||
|
|
||||||
|
for scale in valid_scales:
|
||||||
|
data = BoardImageUpdate(transformations={"scale": scale})
|
||||||
|
assert data.transformations["scale"] == scale
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_rotation_bounds():
|
||||||
|
"""Test rotation bounds (any value allowed, normalized client-side)."""
|
||||||
|
# Various rotation values
|
||||||
|
rotations = [0, 45, 90, 180, 270, 360, 450, -90]
|
||||||
|
|
||||||
|
for rotation in rotations:
|
||||||
|
data = BoardImageUpdate(transformations={"rotation": rotation})
|
||||||
|
assert data.transformations["rotation"] == rotation
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_opacity_bounds():
|
||||||
|
"""Test opacity bounds."""
|
||||||
|
# Valid opacity values
|
||||||
|
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
|
||||||
|
|
||||||
|
for opacity in valid_opacities:
|
||||||
|
data = BoardImageUpdate(transformations={"opacity": opacity})
|
||||||
|
assert data.transformations["opacity"] == opacity
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_boolean_flags():
|
||||||
|
"""Test boolean transformation flags."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"flipped_h": True,
|
||||||
|
"flipped_v": True,
|
||||||
|
"greyscale": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.transformations["flipped_h"] is True
|
||||||
|
assert data.transformations["flipped_v"] is True
|
||||||
|
assert data.transformations["greyscale"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_crop_data():
|
||||||
|
"""Test crop transformation data."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"crop": {
|
||||||
|
"x": 10,
|
||||||
|
"y": 10,
|
||||||
|
"width": 100,
|
||||||
|
"height": 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.transformations["crop"] is not None
|
||||||
|
assert data.transformations["crop"]["x"] == 10
|
||||||
|
assert data.transformations["crop"]["width"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_null_crop():
|
||||||
|
"""Test that crop can be null (no crop)."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"crop": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.transformations["crop"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_transformation_update():
|
||||||
|
"""Test updating only some transformation fields."""
|
||||||
|
# Only update scale
|
||||||
|
data = BoardImageUpdate(transformations={"scale": 2.0})
|
||||||
|
assert data.transformations["scale"] == 2.0
|
||||||
|
|
||||||
|
# Only update rotation
|
||||||
|
data = BoardImageUpdate(transformations={"rotation": 90})
|
||||||
|
assert data.transformations["rotation"] == 90
|
||||||
|
|
||||||
|
# Only update opacity
|
||||||
|
data = BoardImageUpdate(transformations={"opacity": 0.5})
|
||||||
|
assert data.transformations["opacity"] == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_transformation_update():
|
||||||
|
"""Test updating all transformation fields."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"scale": 1.5,
|
||||||
|
"rotation": 45,
|
||||||
|
"opacity": 0.8,
|
||||||
|
"flipped_h": True,
|
||||||
|
"flipped_v": False,
|
||||||
|
"greyscale": True,
|
||||||
|
"crop": {
|
||||||
|
"x": 20,
|
||||||
|
"y": 20,
|
||||||
|
"width": 150,
|
||||||
|
"height": 150,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.transformations is not None
|
||||||
|
assert len(data.transformations) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_position_validation_with_transformations():
|
||||||
|
"""Test that position and transformations can be updated together."""
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
position={"x": 100, "y": 200},
|
||||||
|
transformations={"scale": 1.5, "rotation": 45},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert data.position == {"x": 100, "y": 200}
|
||||||
|
assert data.transformations["scale"] == 1.5
|
||||||
|
assert data.transformations["rotation"] == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_position_missing_x():
|
||||||
|
"""Test that position without x coordinate is rejected."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
BoardImageUpdate(position={"y": 100})
|
||||||
|
|
||||||
|
assert "must contain 'x' and 'y'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_position_missing_y():
|
||||||
|
"""Test that position without y coordinate is rejected."""
|
||||||
|
with pytest.raises(ValidationError) as exc_info:
|
||||||
|
BoardImageUpdate(position={"x": 100})
|
||||||
|
|
||||||
|
assert "must contain 'x' and 'y'" in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_z_order_update():
|
||||||
|
"""Test Z-order update."""
|
||||||
|
data = BoardImageUpdate(z_order=5)
|
||||||
|
assert data.z_order == 5
|
||||||
|
|
||||||
|
# Negative Z-order allowed (layering)
|
||||||
|
data = BoardImageUpdate(z_order=-1)
|
||||||
|
assert data.z_order == -1
|
||||||
|
|
||||||
|
# Large Z-order allowed
|
||||||
|
data = BoardImageUpdate(z_order=999999)
|
||||||
|
assert data.z_order == 999999
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_id_update():
|
||||||
|
"""Test group ID update."""
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
group_id = uuid4()
|
||||||
|
data = BoardImageUpdate(group_id=group_id)
|
||||||
|
assert data.group_id == group_id
|
||||||
|
|
||||||
|
# Null group ID (remove from group)
|
||||||
|
data = BoardImageUpdate(group_id=None)
|
||||||
|
assert data.group_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_update():
|
||||||
|
"""Test that empty update (no fields) is valid."""
|
||||||
|
data = BoardImageUpdate()
|
||||||
|
|
||||||
|
assert data.position is None
|
||||||
|
assert data.transformations is None
|
||||||
|
assert data.z_order is None
|
||||||
|
assert data.group_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transformation_data_types():
|
||||||
|
"""Test that transformation data types are validated."""
|
||||||
|
# Valid types
|
||||||
|
data = BoardImageUpdate(
|
||||||
|
transformations={
|
||||||
|
"scale": 1.5, # float
|
||||||
|
"rotation": 45, # int (converted to float)
|
||||||
|
"opacity": 0.8, # float
|
||||||
|
"flipped_h": True, # bool
|
||||||
|
"flipped_v": False, # bool
|
||||||
|
"greyscale": True, # bool
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(data.transformations["scale"], (int, float))
|
||||||
|
assert isinstance(data.transformations["flipped_h"], bool)
|
||||||
|
|
||||||
@@ -4,28 +4,28 @@ module.exports = {
|
|||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:svelte/recommended',
|
'plugin:svelte/recommended',
|
||||||
'prettier'
|
'prettier',
|
||||||
],
|
],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
plugins: ['@typescript-eslint'],
|
plugins: ['@typescript-eslint'],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
extraFileExtensions: ['.svelte']
|
extraFileExtensions: ['.svelte'],
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
es2017: true,
|
es2017: true,
|
||||||
node: true
|
node: true,
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['*.svelte'],
|
files: ['*.svelte'],
|
||||||
parser: 'svelte-eslint-parser',
|
parser: 'svelte-eslint-parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: '@typescript-eslint/parser'
|
parser: '@typescript-eslint/parser',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
// TypeScript rules
|
// TypeScript rules
|
||||||
@@ -33,18 +33,18 @@ module.exports = {
|
|||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
varsIgnorePattern: '^_'
|
varsIgnorePattern: '^_',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-explicit-any': 'warn',
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
|
||||||
// General rules
|
// General rules
|
||||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||||
'prefer-const': 'error',
|
'prefer-const': 'error',
|
||||||
'no-var': 'error',
|
'no-var': 'error',
|
||||||
|
|
||||||
// Svelte specific
|
// Svelte specific
|
||||||
'svelte/no-at-html-tags': 'error',
|
'svelte/no-at-html-tags': 'error',
|
||||||
'svelte/no-target-blank': 'error'
|
'svelte/no-target-blank': 'error',
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,4 +15,3 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,3 @@ export default [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
180
frontend/src/lib/canvas/transforms/crop.ts
Normal file
180
frontend/src/lib/canvas/transforms/crop.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Image crop transformations
|
||||||
|
* Non-destructive rectangular cropping
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Konva from 'konva';
|
||||||
|
|
||||||
|
export interface CropRegion {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply crop to image
|
||||||
|
*/
|
||||||
|
export function cropImage(image: Konva.Image | Konva.Group, cropRegion: CropRegion): void {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
const img = imageNode as Konva.Image;
|
||||||
|
|
||||||
|
// Validate crop region
|
||||||
|
const imageWidth = img.width();
|
||||||
|
const imageHeight = img.height();
|
||||||
|
|
||||||
|
const validCrop = {
|
||||||
|
x: Math.max(0, Math.min(cropRegion.x, imageWidth)),
|
||||||
|
y: Math.max(0, Math.min(cropRegion.y, imageHeight)),
|
||||||
|
width: Math.max(1, Math.min(cropRegion.width, imageWidth - cropRegion.x)),
|
||||||
|
height: Math.max(1, Math.min(cropRegion.height, imageHeight - cropRegion.y)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply crop using Konva's crop property
|
||||||
|
img.crop(validCrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove crop (reset to full image)
|
||||||
|
*/
|
||||||
|
export function removeCrop(image: Konva.Image | Konva.Group): void {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
(imageNode as Konva.Image).crop(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current crop region
|
||||||
|
*/
|
||||||
|
export function getCropRegion(image: Konva.Image | Konva.Group): CropRegion | null {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return null;
|
||||||
|
|
||||||
|
const crop = (imageNode as Konva.Image).crop();
|
||||||
|
if (!crop) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: crop.x || 0,
|
||||||
|
y: crop.y || 0,
|
||||||
|
width: crop.width || 0,
|
||||||
|
height: crop.height || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is cropped
|
||||||
|
*/
|
||||||
|
export function isCropped(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
const crop = getCropRegion(image);
|
||||||
|
return crop !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop to square (centered)
|
||||||
|
*/
|
||||||
|
export function cropToSquare(image: Konva.Image | Konva.Group): void {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
const img = imageNode as Konva.Image;
|
||||||
|
const width = img.width();
|
||||||
|
const height = img.height();
|
||||||
|
const size = Math.min(width, height);
|
||||||
|
|
||||||
|
const cropRegion: CropRegion = {
|
||||||
|
x: (width - size) / 2,
|
||||||
|
y: (height - size) / 2,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
};
|
||||||
|
|
||||||
|
cropImage(image, cropRegion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create interactive crop tool (returns cleanup function)
|
||||||
|
*/
|
||||||
|
export function enableCropTool(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
layer: Konva.Layer,
|
||||||
|
onCropComplete: (cropRegion: CropRegion) => void
|
||||||
|
): () => void {
|
||||||
|
let cropRect: Konva.Rect | null = null;
|
||||||
|
let isDragging = false;
|
||||||
|
let startPos: { x: number; y: number } | null = null;
|
||||||
|
|
||||||
|
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
const pos = e.target.getStage()?.getPointerPosition();
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
isDragging = true;
|
||||||
|
startPos = pos;
|
||||||
|
|
||||||
|
cropRect = new Konva.Rect({
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
stroke: '#3b82f6',
|
||||||
|
strokeWidth: 2,
|
||||||
|
dash: [4, 2],
|
||||||
|
listening: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(cropRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
|
if (!isDragging || !startPos || !cropRect) return;
|
||||||
|
|
||||||
|
const pos = e.target.getStage()?.getPointerPosition();
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
const width = pos.x - startPos.x;
|
||||||
|
const height = pos.y - startPos.y;
|
||||||
|
|
||||||
|
cropRect.width(width);
|
||||||
|
cropRect.height(height);
|
||||||
|
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp() {
|
||||||
|
if (!isDragging || !startPos || !cropRect) return;
|
||||||
|
|
||||||
|
const cropRegion: CropRegion = {
|
||||||
|
x: Math.min(startPos.x, cropRect.x() + cropRect.width()),
|
||||||
|
y: Math.min(startPos.y, cropRect.y() + cropRect.height()),
|
||||||
|
width: Math.abs(cropRect.width()),
|
||||||
|
height: Math.abs(cropRect.height()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cropRegion.width > 10 && cropRegion.height > 10) {
|
||||||
|
onCropComplete(cropRegion);
|
||||||
|
}
|
||||||
|
|
||||||
|
cropRect.destroy();
|
||||||
|
cropRect = null;
|
||||||
|
isDragging = false;
|
||||||
|
startPos = null;
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
image.on('mousedown', handleMouseDown);
|
||||||
|
image.on('mousemove', handleMouseMove);
|
||||||
|
image.on('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
image.off('mousedown', handleMouseDown);
|
||||||
|
image.off('mousemove', handleMouseMove);
|
||||||
|
image.off('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
if (cropRect) {
|
||||||
|
cropRect.destroy();
|
||||||
|
layer.batchDraw();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
100
frontend/src/lib/canvas/transforms/flip.ts
Normal file
100
frontend/src/lib/canvas/transforms/flip.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Image flip transformations
|
||||||
|
* Non-destructive horizontal and vertical flipping
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip image horizontally
|
||||||
|
*/
|
||||||
|
export function flipImageHorizontal(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const currentScaleX = image.scaleX();
|
||||||
|
const newScaleX = -currentScaleX;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
scaleX: newScaleX,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.scaleX(newScaleX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip image vertically
|
||||||
|
*/
|
||||||
|
export function flipImageVertical(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const currentScaleY = image.scaleY();
|
||||||
|
const newScaleY = -currentScaleY;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
scaleY: newScaleY,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.scaleY(newScaleY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is flipped horizontally
|
||||||
|
*/
|
||||||
|
export function isFlippedHorizontal(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
return image.scaleX() < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is flipped vertically
|
||||||
|
*/
|
||||||
|
export function isFlippedVertical(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
return image.scaleY() < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset horizontal flip
|
||||||
|
*/
|
||||||
|
export function resetFlipHorizontal(image: Konva.Image | Konva.Group): void {
|
||||||
|
const scale = Math.abs(image.scaleX());
|
||||||
|
image.scaleX(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset vertical flip
|
||||||
|
*/
|
||||||
|
export function resetFlipVertical(image: Konva.Image | Konva.Group): void {
|
||||||
|
const scale = Math.abs(image.scaleY());
|
||||||
|
image.scaleY(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset both flips
|
||||||
|
*/
|
||||||
|
export function resetAllFlips(image: Konva.Image | Konva.Group): void {
|
||||||
|
const scaleX = Math.abs(image.scaleX());
|
||||||
|
const scaleY = Math.abs(image.scaleY());
|
||||||
|
image.scale({ x: scaleX, y: scaleY });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set flip state explicitly
|
||||||
|
*/
|
||||||
|
export function setFlipState(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
horizontal: boolean,
|
||||||
|
vertical: boolean
|
||||||
|
): void {
|
||||||
|
const currentScaleX = Math.abs(image.scaleX());
|
||||||
|
const currentScaleY = Math.abs(image.scaleY());
|
||||||
|
|
||||||
|
image.scaleX(horizontal ? -currentScaleX : currentScaleX);
|
||||||
|
image.scaleY(vertical ? -currentScaleY : currentScaleY);
|
||||||
|
}
|
||||||
70
frontend/src/lib/canvas/transforms/greyscale.ts
Normal file
70
frontend/src/lib/canvas/transforms/greyscale.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Image greyscale filter transformation
|
||||||
|
* Non-destructive greyscale conversion
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Konva from 'konva';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply greyscale filter to image
|
||||||
|
*/
|
||||||
|
export function applyGreyscale(image: Konva.Image | Konva.Group): void {
|
||||||
|
// Find the actual image node
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
// Apply greyscale filter using Konva.Filters
|
||||||
|
(imageNode as Konva.Image).filters([Konva.Filters.Grayscale]);
|
||||||
|
(imageNode as Konva.Image).cache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove greyscale filter from image
|
||||||
|
*/
|
||||||
|
export function removeGreyscale(image: Konva.Image | Konva.Group): void {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
(imageNode as Konva.Image).filters([]);
|
||||||
|
(imageNode as Konva.Image).clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle greyscale filter
|
||||||
|
*/
|
||||||
|
export function toggleGreyscale(image: Konva.Image | Konva.Group): void {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
const filters = (imageNode as Konva.Image).filters() || [];
|
||||||
|
|
||||||
|
if (filters.length > 0 && filters.some((f) => f.name === 'Grayscale')) {
|
||||||
|
removeGreyscale(image);
|
||||||
|
} else {
|
||||||
|
applyGreyscale(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if greyscale is applied
|
||||||
|
*/
|
||||||
|
export function isGreyscaleApplied(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return false;
|
||||||
|
|
||||||
|
const filters = (imageNode as Konva.Image).filters() || [];
|
||||||
|
return filters.some((f) => f.name === 'Grayscale');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set greyscale state explicitly
|
||||||
|
*/
|
||||||
|
export function setGreyscale(image: Konva.Image | Konva.Group, enabled: boolean): void {
|
||||||
|
const isCurrentlyGreyscale = isGreyscaleApplied(image);
|
||||||
|
|
||||||
|
if (enabled && !isCurrentlyGreyscale) {
|
||||||
|
applyGreyscale(image);
|
||||||
|
} else if (!enabled && isCurrentlyGreyscale) {
|
||||||
|
removeGreyscale(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
frontend/src/lib/canvas/transforms/opacity.ts
Normal file
96
frontend/src/lib/canvas/transforms/opacity.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Image opacity transformations
|
||||||
|
* Non-destructive opacity adjustment (0-100%)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
const MIN_OPACITY = 0.0;
|
||||||
|
const MAX_OPACITY = 1.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set image opacity (0.0 to 1.0)
|
||||||
|
*/
|
||||||
|
export function setImageOpacity(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
opacity: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
// Clamp to 0.0-1.0
|
||||||
|
const clampedOpacity = Math.max(MIN_OPACITY, Math.min(MAX_OPACITY, opacity));
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
opacity: clampedOpacity,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.opacity(clampedOpacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set opacity by percentage (0-100)
|
||||||
|
*/
|
||||||
|
export function setImageOpacityPercent(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
percent: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const opacity = Math.max(0, Math.min(100, percent)) / 100;
|
||||||
|
setImageOpacity(image, opacity, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increase opacity by delta
|
||||||
|
*/
|
||||||
|
export function increaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void {
|
||||||
|
const currentOpacity = image.opacity();
|
||||||
|
setImageOpacity(image, currentOpacity + delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease opacity by delta
|
||||||
|
*/
|
||||||
|
export function decreaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void {
|
||||||
|
const currentOpacity = image.opacity();
|
||||||
|
setImageOpacity(image, currentOpacity - delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset opacity to 100% (1.0)
|
||||||
|
*/
|
||||||
|
export function resetImageOpacity(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
setImageOpacity(image, 1.0, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current opacity
|
||||||
|
*/
|
||||||
|
export function getImageOpacity(image: Konva.Image | Konva.Group): number {
|
||||||
|
return image.opacity();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get opacity as percentage (0-100)
|
||||||
|
*/
|
||||||
|
export function getImageOpacityPercent(image: Konva.Image | Konva.Group): number {
|
||||||
|
return Math.round(image.opacity() * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is fully opaque
|
||||||
|
*/
|
||||||
|
export function isFullyOpaque(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
return image.opacity() >= MAX_OPACITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is fully transparent
|
||||||
|
*/
|
||||||
|
export function isFullyTransparent(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
return image.opacity() <= MIN_OPACITY;
|
||||||
|
}
|
||||||
106
frontend/src/lib/canvas/transforms/reset.ts
Normal file
106
frontend/src/lib/canvas/transforms/reset.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Reset transformations to original state
|
||||||
|
* Resets all non-destructive transformations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Konva from 'konva';
|
||||||
|
import { resetImageRotation } from './rotate';
|
||||||
|
import { resetImageScale } from './scale';
|
||||||
|
import { resetAllFlips } from './flip';
|
||||||
|
import { resetImageOpacity } from './opacity';
|
||||||
|
import { removeCrop } from './crop';
|
||||||
|
import { removeGreyscale } from './greyscale';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all transformations to original state
|
||||||
|
*/
|
||||||
|
export function resetAllTransformations(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
// Reset rotation
|
||||||
|
resetImageRotation(image, animate);
|
||||||
|
|
||||||
|
// Reset scale
|
||||||
|
resetImageScale(image, animate);
|
||||||
|
|
||||||
|
// Reset flips
|
||||||
|
resetAllFlips(image);
|
||||||
|
|
||||||
|
// Reset opacity
|
||||||
|
resetImageOpacity(image, animate);
|
||||||
|
|
||||||
|
// Remove crop
|
||||||
|
removeCrop(image);
|
||||||
|
|
||||||
|
// Remove greyscale
|
||||||
|
removeGreyscale(image);
|
||||||
|
|
||||||
|
// Redraw
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset only geometric transformations (position, scale, rotation)
|
||||||
|
*/
|
||||||
|
export function resetGeometricTransformations(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
resetImageRotation(image, animate);
|
||||||
|
resetImageScale(image, animate);
|
||||||
|
resetAllFlips(image);
|
||||||
|
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset only visual transformations (opacity, greyscale, crop)
|
||||||
|
*/
|
||||||
|
export function resetVisualTransformations(image: Konva.Image | Konva.Group): void {
|
||||||
|
resetImageOpacity(image);
|
||||||
|
removeCrop(image);
|
||||||
|
removeGreyscale(image);
|
||||||
|
|
||||||
|
image.getLayer()?.batchDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image has any transformations applied
|
||||||
|
*/
|
||||||
|
export function hasTransformations(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
const hasRotation = image.rotation() !== 0;
|
||||||
|
const hasScale = Math.abs(image.scaleX()) !== 1.0 || Math.abs(image.scaleY()) !== 1.0;
|
||||||
|
const hasOpacity = image.opacity() !== 1.0;
|
||||||
|
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
const hasCrop = imageNode ? (imageNode as Konva.Image).crop() !== undefined : false;
|
||||||
|
const hasGreyscale = imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false;
|
||||||
|
|
||||||
|
return hasRotation || hasScale || hasOpacity || hasCrop || hasGreyscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transformation summary
|
||||||
|
*/
|
||||||
|
export function getTransformationSummary(image: Konva.Image | Konva.Group): {
|
||||||
|
rotation: number;
|
||||||
|
scale: number;
|
||||||
|
opacity: number;
|
||||||
|
flippedH: boolean;
|
||||||
|
flippedV: boolean;
|
||||||
|
cropped: boolean;
|
||||||
|
greyscale: boolean;
|
||||||
|
} {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
|
||||||
|
return {
|
||||||
|
rotation: image.rotation(),
|
||||||
|
scale: Math.abs(image.scaleX()),
|
||||||
|
opacity: image.opacity(),
|
||||||
|
flippedH: image.scaleX() < 0,
|
||||||
|
flippedV: image.scaleY() < 0,
|
||||||
|
cropped: imageNode ? (imageNode as Konva.Image).crop() !== undefined : false,
|
||||||
|
greyscale: imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
frontend/src/lib/canvas/transforms/rotate.ts
Normal file
79
frontend/src/lib/canvas/transforms/rotate.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Image rotation transformations
|
||||||
|
* Non-destructive rotation of canvas images
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Konva from 'konva';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate image to specific angle (0-360 degrees)
|
||||||
|
*/
|
||||||
|
export function rotateImageTo(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
degrees: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
// Normalize to 0-360
|
||||||
|
const normalizedDegrees = ((degrees % 360) + 360) % 360;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
rotation: normalizedDegrees,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.rotation(normalizedDegrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate image by delta degrees
|
||||||
|
*/
|
||||||
|
export function rotateImageBy(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
degrees: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const currentRotation = image.rotation();
|
||||||
|
const newRotation = (((currentRotation + degrees) % 360) + 360) % 360;
|
||||||
|
|
||||||
|
rotateImageTo(image, newRotation, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate image by 90 degrees clockwise
|
||||||
|
*/
|
||||||
|
export function rotateImage90CW(image: Konva.Image | Konva.Group): void {
|
||||||
|
rotateImageBy(image, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate image by 90 degrees counter-clockwise
|
||||||
|
*/
|
||||||
|
export function rotateImage90CCW(image: Konva.Image | Konva.Group): void {
|
||||||
|
rotateImageBy(image, -90);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip image to 180 degrees
|
||||||
|
*/
|
||||||
|
export function rotateImage180(image: Konva.Image | Konva.Group): void {
|
||||||
|
rotateImageTo(image, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rotation to 0 degrees
|
||||||
|
*/
|
||||||
|
export function resetImageRotation(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
rotateImageTo(image, 0, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current rotation angle
|
||||||
|
*/
|
||||||
|
export function getImageRotation(image: Konva.Image | Konva.Group): number {
|
||||||
|
return image.rotation();
|
||||||
|
}
|
||||||
109
frontend/src/lib/canvas/transforms/scale.ts
Normal file
109
frontend/src/lib/canvas/transforms/scale.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Image scaling transformations
|
||||||
|
* Non-destructive scaling with resize handles
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Konva from 'konva';
|
||||||
|
|
||||||
|
const MIN_SCALE = 0.01;
|
||||||
|
const MAX_SCALE = 10.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale image to specific factor
|
||||||
|
*/
|
||||||
|
export function scaleImageTo(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
scale: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
// Clamp to min/max
|
||||||
|
const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale));
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
image.to({
|
||||||
|
scaleX: clampedScale,
|
||||||
|
scaleY: clampedScale,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
image.scale({ x: clampedScale, y: clampedScale });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale image by factor (multiply current scale)
|
||||||
|
*/
|
||||||
|
export function scaleImageBy(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
factor: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const currentScale = image.scaleX();
|
||||||
|
const newScale = currentScale * factor;
|
||||||
|
|
||||||
|
scaleImageTo(image, newScale, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale image to fit specific dimensions
|
||||||
|
*/
|
||||||
|
export function scaleImageToFit(
|
||||||
|
image: Konva.Image | Konva.Group,
|
||||||
|
maxWidth: number,
|
||||||
|
maxHeight: number,
|
||||||
|
animate: boolean = false
|
||||||
|
): void {
|
||||||
|
const imageNode = image instanceof Konva.Image ? image : image.findOne('Image');
|
||||||
|
if (!imageNode) return;
|
||||||
|
|
||||||
|
const width = (imageNode as Konva.Image).width();
|
||||||
|
const height = (imageNode as Konva.Image).height();
|
||||||
|
|
||||||
|
const scaleX = maxWidth / width;
|
||||||
|
const scaleY = maxHeight / height;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
scaleImageTo(image, scale, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset scale to 1.0 (original size)
|
||||||
|
*/
|
||||||
|
export function resetImageScale(image: Konva.Image | Konva.Group, animate: boolean = false): void {
|
||||||
|
scaleImageTo(image, 1.0, animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double image size
|
||||||
|
*/
|
||||||
|
export function doubleImageSize(image: Konva.Image | Konva.Group): void {
|
||||||
|
scaleImageBy(image, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Half image size
|
||||||
|
*/
|
||||||
|
export function halfImageSize(image: Konva.Image | Konva.Group): void {
|
||||||
|
scaleImageBy(image, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current scale
|
||||||
|
*/
|
||||||
|
export function getImageScale(image: Konva.Image | Konva.Group): number {
|
||||||
|
return image.scaleX();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is at minimum scale
|
||||||
|
*/
|
||||||
|
export function isAtMinScale(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
return image.scaleX() <= MIN_SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if image is at maximum scale
|
||||||
|
*/
|
||||||
|
export function isAtMaxScale(image: Konva.Image | Konva.Group): boolean {
|
||||||
|
return image.scaleX() >= MAX_SCALE;
|
||||||
|
}
|
||||||
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>
|
||||||
401
frontend/src/lib/components/canvas/TransformPanel.svelte
Normal file
401
frontend/src/lib/components/canvas/TransformPanel.svelte
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Transformation panel for canvas image manipulation
|
||||||
|
* Provides UI controls for rotate, scale, flip, crop, opacity, greyscale
|
||||||
|
*/
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let rotation: number = 0;
|
||||||
|
export let scale: number = 1.0;
|
||||||
|
export let opacity: number = 1.0;
|
||||||
|
export let flippedH: boolean = false;
|
||||||
|
export let flippedV: boolean = false;
|
||||||
|
export let greyscale: boolean = false;
|
||||||
|
export let hasCrop: boolean = false;
|
||||||
|
export let disabled: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function handleRotationChange(event: Event) {
|
||||||
|
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||||
|
dispatch('rotation-change', { rotation: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScaleChange(event: Event) {
|
||||||
|
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||||
|
dispatch('scale-change', { scale: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpacityChange(event: Event) {
|
||||||
|
const value = parseFloat((event.target as HTMLInputElement).value);
|
||||||
|
dispatch('opacity-change', { opacity: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFlipH() {
|
||||||
|
dispatch('flip-horizontal', { flipped: !flippedH });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFlipV() {
|
||||||
|
dispatch('flip-vertical', { flipped: !flippedV });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGreyscaleToggle() {
|
||||||
|
dispatch('greyscale-toggle', { enabled: !greyscale });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCropStart() {
|
||||||
|
dispatch('crop-start');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveCrop() {
|
||||||
|
dispatch('crop-remove');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRotate90CW() {
|
||||||
|
dispatch('rotate-90-cw');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRotate90CCW() {
|
||||||
|
dispatch('rotate-90-ccw');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResetAll() {
|
||||||
|
dispatch('reset-all');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="transform-panel" class:disabled>
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>Transform</h3>
|
||||||
|
<button
|
||||||
|
class="reset-button"
|
||||||
|
on:click={handleResetAll}
|
||||||
|
{disabled}
|
||||||
|
title="Reset all transformations"
|
||||||
|
>
|
||||||
|
Reset All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-content">
|
||||||
|
<!-- Rotation Controls -->
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="rotation">
|
||||||
|
Rotation
|
||||||
|
<span class="value">{Math.round(rotation)}°</span>
|
||||||
|
</label>
|
||||||
|
<div class="control-row">
|
||||||
|
<button class="icon-button" on:click={handleRotate90CCW} {disabled} title="Rotate 90° CCW">
|
||||||
|
↶
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id="rotation"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
value={rotation}
|
||||||
|
on:input={handleRotationChange}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
<button class="icon-button" on:click={handleRotate90CW} {disabled} title="Rotate 90° CW">
|
||||||
|
↷
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scale Controls -->
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="scale">
|
||||||
|
Scale
|
||||||
|
<span class="value">{(scale * 100).toFixed(0)}%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="scale"
|
||||||
|
type="range"
|
||||||
|
min="0.01"
|
||||||
|
max="10"
|
||||||
|
step="0.01"
|
||||||
|
value={scale}
|
||||||
|
on:input={handleScaleChange}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opacity Controls -->
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="opacity">
|
||||||
|
Opacity
|
||||||
|
<span class="value">{Math.round(opacity * 100)}%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="opacity"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={opacity}
|
||||||
|
on:input={handleOpacityChange}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flip Controls -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="label-text">Flip</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
class="toggle-button"
|
||||||
|
class:active={flippedH}
|
||||||
|
on:click={handleFlipH}
|
||||||
|
{disabled}
|
||||||
|
title="Flip Horizontal"
|
||||||
|
>
|
||||||
|
⇄ Horizontal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="toggle-button"
|
||||||
|
class:active={flippedV}
|
||||||
|
on:click={handleFlipV}
|
||||||
|
{disabled}
|
||||||
|
title="Flip Vertical"
|
||||||
|
>
|
||||||
|
⇅ Vertical
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="label-text">Filters</div>
|
||||||
|
<div class="button-row">
|
||||||
|
<button
|
||||||
|
class="toggle-button"
|
||||||
|
class:active={greyscale}
|
||||||
|
on:click={handleGreyscaleToggle}
|
||||||
|
{disabled}
|
||||||
|
title="Toggle Greyscale"
|
||||||
|
>
|
||||||
|
Greyscale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Crop Controls -->
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="label-text">Crop</div>
|
||||||
|
<div class="button-row">
|
||||||
|
{#if hasCrop}
|
||||||
|
<button class="action-button" on:click={handleRemoveCrop} {disabled}>
|
||||||
|
Remove Crop
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="action-button" on:click={handleCropStart} {disabled}>
|
||||||
|
Start Crop Tool
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.transform-panel {
|
||||||
|
background-color: var(--color-bg, #ffffff);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-panel.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #111827);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-hover, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary, #6b7280);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #374151);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--color-bg-secondary, #e5e7eb);
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-hover, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background-color: var(--color-bg-secondary, #f3f4f6);
|
||||||
|
border: 1px solid var(--color-border, #d1d5db);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-bg-hover, #e5e7eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button.active {
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
border-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background-color: var(--color-primary, #3b82f6);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-primary-hover, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</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';
|
||||||
|
});
|
||||||
@@ -19,4 +19,3 @@ const config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -624,4 +624,3 @@ describe('Integration Tests', () => {
|
|||||||
expect(state.zoom).toBe(1.5);
|
expect(state.zoom).toBe(1.5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { selection } from '$lib/stores/selection';
|
import { selection } from '$lib/stores/selection';
|
||||||
import { setupImageDrag, moveImageTo, moveImageBy, isDragging } from '$lib/canvas/interactions/drag';
|
import {
|
||||||
|
setupImageDrag,
|
||||||
|
moveImageTo,
|
||||||
|
moveImageBy,
|
||||||
|
isDragging,
|
||||||
|
} from '$lib/canvas/interactions/drag';
|
||||||
|
|
||||||
describe('Image Dragging', () => {
|
describe('Image Dragging', () => {
|
||||||
let stage: Konva.Stage;
|
let stage: Konva.Stage;
|
||||||
@@ -33,8 +38,9 @@ describe('Image Dragging', () => {
|
|||||||
|
|
||||||
// Create test image
|
// Create test image
|
||||||
const imageElement = new Image();
|
const imageElement = new Image();
|
||||||
imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
image = new Konva.Image({
|
image = new Konva.Image({
|
||||||
image: imageElement,
|
image: imageElement,
|
||||||
x: 100,
|
x: 100,
|
||||||
@@ -300,4 +306,3 @@ describe('Image Dragging', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,8 +44,9 @@ describe('Image Selection', () => {
|
|||||||
|
|
||||||
// Create test image
|
// Create test image
|
||||||
const imageElement = new Image();
|
const imageElement = new Image();
|
||||||
imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
image = new Konva.Image({
|
image = new Konva.Image({
|
||||||
image: imageElement,
|
image: imageElement,
|
||||||
x: 100,
|
x: 100,
|
||||||
@@ -79,7 +80,7 @@ describe('Image Selection', () => {
|
|||||||
|
|
||||||
it('cleanup function removes click handlers', () => {
|
it('cleanup function removes click handlers', () => {
|
||||||
const cleanup = setupImageSelection(image, imageId);
|
const cleanup = setupImageSelection(image, imageId);
|
||||||
|
|
||||||
// Select the image
|
// Select the image
|
||||||
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
|
||||||
expect(get(selection).selectedIds.has(imageId)).toBe(true);
|
expect(get(selection).selectedIds.has(imageId)).toBe(true);
|
||||||
@@ -420,4 +421,3 @@ describe('Image Selection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
626
frontend/tests/canvas/transforms.test.ts
Normal file
626
frontend/tests/canvas/transforms.test.ts
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
/**
|
||||||
|
* Tests for canvas image transformations
|
||||||
|
* Tests rotate, scale, flip, crop, opacity, greyscale, and reset
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import Konva from 'konva';
|
||||||
|
import {
|
||||||
|
rotateImageTo,
|
||||||
|
rotateImageBy,
|
||||||
|
rotateImage90CW,
|
||||||
|
rotateImage90CCW,
|
||||||
|
rotateImage180,
|
||||||
|
resetImageRotation,
|
||||||
|
getImageRotation,
|
||||||
|
} from '$lib/canvas/transforms/rotate';
|
||||||
|
import {
|
||||||
|
scaleImageTo,
|
||||||
|
scaleImageBy,
|
||||||
|
resetImageScale,
|
||||||
|
doubleImageSize,
|
||||||
|
halfImageSize,
|
||||||
|
getImageScale,
|
||||||
|
isAtMinScale,
|
||||||
|
isAtMaxScale,
|
||||||
|
} from '$lib/canvas/transforms/scale';
|
||||||
|
import {
|
||||||
|
flipImageHorizontal,
|
||||||
|
flipImageVertical,
|
||||||
|
isFlippedHorizontal,
|
||||||
|
isFlippedVertical,
|
||||||
|
resetFlipHorizontal,
|
||||||
|
resetFlipVertical,
|
||||||
|
resetAllFlips,
|
||||||
|
setFlipState,
|
||||||
|
} from '$lib/canvas/transforms/flip';
|
||||||
|
import {
|
||||||
|
setImageOpacity,
|
||||||
|
setImageOpacityPercent,
|
||||||
|
increaseOpacity,
|
||||||
|
decreaseOpacity,
|
||||||
|
resetImageOpacity,
|
||||||
|
getImageOpacity,
|
||||||
|
getImageOpacityPercent,
|
||||||
|
isFullyOpaque,
|
||||||
|
isFullyTransparent,
|
||||||
|
} from '$lib/canvas/transforms/opacity';
|
||||||
|
import {
|
||||||
|
applyGreyscale,
|
||||||
|
removeGreyscale,
|
||||||
|
toggleGreyscale,
|
||||||
|
isGreyscaleApplied,
|
||||||
|
setGreyscale,
|
||||||
|
} from '$lib/canvas/transforms/greyscale';
|
||||||
|
import {
|
||||||
|
cropImage,
|
||||||
|
removeCrop,
|
||||||
|
getCropRegion,
|
||||||
|
isCropped,
|
||||||
|
cropToSquare,
|
||||||
|
} from '$lib/canvas/transforms/crop';
|
||||||
|
import {
|
||||||
|
resetAllTransformations,
|
||||||
|
resetGeometricTransformations,
|
||||||
|
resetVisualTransformations,
|
||||||
|
hasTransformations,
|
||||||
|
getTransformationSummary,
|
||||||
|
} from '$lib/canvas/transforms/reset';
|
||||||
|
|
||||||
|
describe('Image Rotation', () => {
|
||||||
|
let image: Konva.Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates to specific angle', () => {
|
||||||
|
rotateImageTo(image, 45);
|
||||||
|
expect(getImageRotation(image)).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes rotation to 0-360', () => {
|
||||||
|
rotateImageTo(image, 450);
|
||||||
|
expect(getImageRotation(image)).toBe(90);
|
||||||
|
|
||||||
|
rotateImageTo(image, -90);
|
||||||
|
expect(getImageRotation(image)).toBe(270);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates by delta', () => {
|
||||||
|
rotateImageTo(image, 30);
|
||||||
|
rotateImageBy(image, 15);
|
||||||
|
expect(getImageRotation(image)).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates 90° clockwise', () => {
|
||||||
|
rotateImage90CW(image);
|
||||||
|
expect(getImageRotation(image)).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates 90° counter-clockwise', () => {
|
||||||
|
rotateImageTo(image, 90);
|
||||||
|
rotateImage90CCW(image);
|
||||||
|
expect(getImageRotation(image)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates to 180°', () => {
|
||||||
|
rotateImage180(image);
|
||||||
|
expect(getImageRotation(image)).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets rotation to 0', () => {
|
||||||
|
rotateImageTo(image, 135);
|
||||||
|
resetImageRotation(image);
|
||||||
|
expect(getImageRotation(image)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Image Scaling', () => {
|
||||||
|
let image: Konva.Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales to specific factor', () => {
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
expect(getImageScale(image)).toBe(2.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps scale to minimum', () => {
|
||||||
|
scaleImageTo(image, 0.001);
|
||||||
|
expect(getImageScale(image)).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps scale to maximum', () => {
|
||||||
|
scaleImageTo(image, 15.0);
|
||||||
|
expect(getImageScale(image)).toBe(10.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales by factor', () => {
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
scaleImageBy(image, 1.5);
|
||||||
|
expect(getImageScale(image)).toBe(3.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doubles image size', () => {
|
||||||
|
scaleImageTo(image, 1.0);
|
||||||
|
doubleImageSize(image);
|
||||||
|
expect(getImageScale(image)).toBe(2.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('halves image size', () => {
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
halfImageSize(image);
|
||||||
|
expect(getImageScale(image)).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets scale to 1.0', () => {
|
||||||
|
scaleImageTo(image, 3.5);
|
||||||
|
resetImageScale(image);
|
||||||
|
expect(getImageScale(image)).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects minimum scale', () => {
|
||||||
|
scaleImageTo(image, 0.01);
|
||||||
|
expect(isAtMinScale(image)).toBe(true);
|
||||||
|
|
||||||
|
scaleImageTo(image, 1.0);
|
||||||
|
expect(isAtMinScale(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects maximum scale', () => {
|
||||||
|
scaleImageTo(image, 10.0);
|
||||||
|
expect(isAtMaxScale(image)).toBe(true);
|
||||||
|
|
||||||
|
scaleImageTo(image, 1.0);
|
||||||
|
expect(isAtMaxScale(image)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Image Flipping', () => {
|
||||||
|
let image: Konva.Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips horizontally', () => {
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(true);
|
||||||
|
expect(image.scaleX()).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips vertically', () => {
|
||||||
|
flipImageVertical(image);
|
||||||
|
expect(isFlippedVertical(image)).toBe(true);
|
||||||
|
expect(image.scaleY()).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can flip both directions', () => {
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
flipImageVertical(image);
|
||||||
|
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(true);
|
||||||
|
expect(isFlippedVertical(image)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets horizontal flip', () => {
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
resetFlipHorizontal(image);
|
||||||
|
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(false);
|
||||||
|
expect(image.scaleX()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets vertical flip', () => {
|
||||||
|
flipImageVertical(image);
|
||||||
|
resetFlipVertical(image);
|
||||||
|
|
||||||
|
expect(isFlippedVertical(image)).toBe(false);
|
||||||
|
expect(image.scaleY()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets all flips', () => {
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
flipImageVertical(image);
|
||||||
|
resetAllFlips(image);
|
||||||
|
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(false);
|
||||||
|
expect(isFlippedVertical(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets flip state explicitly', () => {
|
||||||
|
setFlipState(image, true, false);
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(true);
|
||||||
|
expect(isFlippedVertical(image)).toBe(false);
|
||||||
|
|
||||||
|
setFlipState(image, false, true);
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(false);
|
||||||
|
expect(isFlippedVertical(image)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves scale when flipping', () => {
|
||||||
|
image.scale({ x: 2.0, y: 2.0 });
|
||||||
|
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
|
||||||
|
expect(Math.abs(image.scaleX())).toBe(2.0);
|
||||||
|
expect(image.scaleY()).toBe(2.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Image Opacity', () => {
|
||||||
|
let image: Konva.Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets opacity to specific value', () => {
|
||||||
|
setImageOpacity(image, 0.5);
|
||||||
|
expect(getImageOpacity(image)).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps opacity to 0-1 range', () => {
|
||||||
|
setImageOpacity(image, 1.5);
|
||||||
|
expect(getImageOpacity(image)).toBe(1.0);
|
||||||
|
|
||||||
|
setImageOpacity(image, -0.5);
|
||||||
|
expect(getImageOpacity(image)).toBe(0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets opacity by percentage', () => {
|
||||||
|
setImageOpacityPercent(image, 75);
|
||||||
|
expect(getImageOpacity(image)).toBe(0.75);
|
||||||
|
expect(getImageOpacityPercent(image)).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increases opacity', () => {
|
||||||
|
setImageOpacity(image, 0.5);
|
||||||
|
increaseOpacity(image, 0.2);
|
||||||
|
expect(getImageOpacity(image)).toBeCloseTo(0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decreases opacity', () => {
|
||||||
|
setImageOpacity(image, 0.8);
|
||||||
|
decreaseOpacity(image, 0.3);
|
||||||
|
expect(getImageOpacity(image)).toBeCloseTo(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets opacity to 1.0', () => {
|
||||||
|
setImageOpacity(image, 0.3);
|
||||||
|
resetImageOpacity(image);
|
||||||
|
expect(getImageOpacity(image)).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects fully opaque', () => {
|
||||||
|
setImageOpacity(image, 1.0);
|
||||||
|
expect(isFullyOpaque(image)).toBe(true);
|
||||||
|
|
||||||
|
setImageOpacity(image, 0.99);
|
||||||
|
expect(isFullyOpaque(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects fully transparent', () => {
|
||||||
|
setImageOpacity(image, 0.0);
|
||||||
|
expect(isFullyTransparent(image)).toBe(true);
|
||||||
|
|
||||||
|
setImageOpacity(image, 0.01);
|
||||||
|
expect(isFullyTransparent(image)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Image Greyscale', () => {
|
||||||
|
let image: 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);
|
||||||
|
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies greyscale filter', () => {
|
||||||
|
applyGreyscale(image);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes greyscale filter', () => {
|
||||||
|
applyGreyscale(image);
|
||||||
|
removeGreyscale(image);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles greyscale on and off', () => {
|
||||||
|
toggleGreyscale(image);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(true);
|
||||||
|
|
||||||
|
toggleGreyscale(image);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets greyscale state explicitly', () => {
|
||||||
|
setGreyscale(image, true);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(true);
|
||||||
|
|
||||||
|
setGreyscale(image, false);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply duplicate filter', () => {
|
||||||
|
applyGreyscale(image);
|
||||||
|
const filters1 = image.filters() || [];
|
||||||
|
|
||||||
|
applyGreyscale(image);
|
||||||
|
const filters2 = image.filters() || [];
|
||||||
|
|
||||||
|
expect(filters1.length).toBe(filters2.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Image Cropping', () => {
|
||||||
|
let image: Konva.Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies crop region', () => {
|
||||||
|
const cropRegion = { x: 10, y: 10, width: 100, height: 100 };
|
||||||
|
cropImage(image, cropRegion);
|
||||||
|
|
||||||
|
expect(isCropped(image)).toBe(true);
|
||||||
|
|
||||||
|
const crop = getCropRegion(image);
|
||||||
|
expect(crop).toEqual(cropRegion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes crop', () => {
|
||||||
|
const cropRegion = { x: 10, y: 10, width: 100, height: 100 };
|
||||||
|
cropImage(image, cropRegion);
|
||||||
|
removeCrop(image);
|
||||||
|
|
||||||
|
expect(isCropped(image)).toBe(false);
|
||||||
|
expect(getCropRegion(image)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates crop region bounds', () => {
|
||||||
|
// Try to crop outside image bounds
|
||||||
|
const invalidCrop = { x: 150, y: 150, width: 200, height: 200 };
|
||||||
|
cropImage(image, invalidCrop);
|
||||||
|
|
||||||
|
const crop = getCropRegion(image);
|
||||||
|
expect(crop).not.toBeNull();
|
||||||
|
|
||||||
|
// Crop should be adjusted to fit within image
|
||||||
|
if (crop) {
|
||||||
|
expect(crop.x).toBeLessThanOrEqual(200);
|
||||||
|
expect(crop.y).toBeLessThanOrEqual(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crops to square', () => {
|
||||||
|
cropToSquare(image);
|
||||||
|
|
||||||
|
const crop = getCropRegion(image);
|
||||||
|
expect(crop).not.toBeNull();
|
||||||
|
|
||||||
|
if (crop) {
|
||||||
|
expect(crop.width).toBe(crop.height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reset Transformations', () => {
|
||||||
|
let image: 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);
|
||||||
|
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.add(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects when transformations are applied', () => {
|
||||||
|
expect(hasTransformations(image)).toBe(false);
|
||||||
|
|
||||||
|
rotateImageTo(image, 45);
|
||||||
|
expect(hasTransformations(image)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets all transformations', () => {
|
||||||
|
rotateImageTo(image, 90);
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
setImageOpacity(image, 0.5);
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
applyGreyscale(image);
|
||||||
|
|
||||||
|
resetAllTransformations(image);
|
||||||
|
|
||||||
|
expect(getImageRotation(image)).toBe(0);
|
||||||
|
expect(getImageScale(image)).toBe(1.0);
|
||||||
|
expect(getImageOpacity(image)).toBe(1.0);
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(false);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets only geometric transformations', () => {
|
||||||
|
rotateImageTo(image, 90);
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
setImageOpacity(image, 0.5);
|
||||||
|
|
||||||
|
resetGeometricTransformations(image);
|
||||||
|
|
||||||
|
expect(getImageRotation(image)).toBe(0);
|
||||||
|
expect(getImageScale(image)).toBe(1.0);
|
||||||
|
expect(getImageOpacity(image)).toBe(0.5); // Unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets only visual transformations', () => {
|
||||||
|
rotateImageTo(image, 90);
|
||||||
|
setImageOpacity(image, 0.5);
|
||||||
|
applyGreyscale(image);
|
||||||
|
|
||||||
|
resetVisualTransformations(image);
|
||||||
|
|
||||||
|
expect(getImageRotation(image)).toBe(90); // Unchanged
|
||||||
|
expect(getImageOpacity(image)).toBe(1.0);
|
||||||
|
expect(isGreyscaleApplied(image)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets transformation summary', () => {
|
||||||
|
rotateImageTo(image, 45);
|
||||||
|
scaleImageTo(image, 1.5);
|
||||||
|
setImageOpacity(image, 0.8);
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
applyGreyscale(image);
|
||||||
|
|
||||||
|
const summary = getTransformationSummary(image);
|
||||||
|
|
||||||
|
expect(summary.rotation).toBe(45);
|
||||||
|
expect(summary.scale).toBe(1.5);
|
||||||
|
expect(summary.opacity).toBe(0.8);
|
||||||
|
expect(summary.flippedH).toBe(true);
|
||||||
|
expect(summary.flippedV).toBe(false);
|
||||||
|
expect(summary.greyscale).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Combined Transformations', () => {
|
||||||
|
let image: Konva.Image;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const imageElement = new Image();
|
||||||
|
imageElement.src =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
image = new Konva.Image({
|
||||||
|
image: imageElement,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies multiple transformations independently', () => {
|
||||||
|
rotateImageTo(image, 45);
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
setImageOpacity(image, 0.7);
|
||||||
|
flipImageHorizontal(image);
|
||||||
|
|
||||||
|
expect(getImageRotation(image)).toBe(45);
|
||||||
|
expect(Math.abs(getImageScale(image))).toBe(2.0);
|
||||||
|
expect(getImageOpacity(image)).toBe(0.7);
|
||||||
|
expect(isFlippedHorizontal(image)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transformations do not interfere with each other', () => {
|
||||||
|
scaleImageTo(image, 3.0);
|
||||||
|
const scale1 = getImageScale(image);
|
||||||
|
|
||||||
|
rotateImageTo(image, 90);
|
||||||
|
const scale2 = getImageScale(image);
|
||||||
|
|
||||||
|
expect(scale1).toBe(scale2); // Rotation doesn't affect scale
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can undo transformations individually', () => {
|
||||||
|
rotateImageTo(image, 90);
|
||||||
|
scaleImageTo(image, 2.0);
|
||||||
|
setImageOpacity(image, 0.5);
|
||||||
|
|
||||||
|
resetImageRotation(image);
|
||||||
|
expect(getImageRotation(image)).toBe(0);
|
||||||
|
expect(getImageScale(image)).toBe(2.0); // Unchanged
|
||||||
|
expect(getImageOpacity(image)).toBe(0.5); // Unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -502,4 +502,3 @@ describe('RegisterForm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -533,4 +533,3 @@ describe('DeleteConfirmModal', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -994,4 +994,3 @@ describe('ErrorDisplay', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,3 @@
|
|||||||
},
|
},
|
||||||
"exclude": ["tests/**/*", "node_modules/**/*", ".svelte-kit/**/*"]
|
"exclude": ["tests/**/*", "node_modules/**/*", ".svelte-kit/**/*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,4 +31,3 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -304,37 +304,37 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 8: Image Transformations (FR8 - Critical) (Week 6)
|
## Phase 8: Image Transformations (FR8 - Critical) (Week 6) ✅ COMPLETE
|
||||||
|
|
||||||
**User Story:** Users must be able to transform images non-destructively
|
**User Story:** Users must be able to transform images non-destructively
|
||||||
|
|
||||||
**Independent Test Criteria:**
|
**Independent Test Criteria:**
|
||||||
- [ ] Users can scale images (resize handles)
|
- [X] Users can scale images (resize handles)
|
||||||
- [ ] Users can rotate images (any angle)
|
- [X] Users can rotate images (any angle)
|
||||||
- [ ] Users can flip horizontal/vertical
|
- [X] Users can flip horizontal/vertical
|
||||||
- [ ] Users can crop to rectangular region
|
- [X] Users can crop to rectangular region
|
||||||
- [ ] Users can adjust opacity (0-100%)
|
- [X] Users can adjust opacity (0-100%)
|
||||||
- [ ] Users can convert to greyscale
|
- [X] Users can convert to greyscale
|
||||||
- [ ] Users can reset to original
|
- [X] Users can reset to original
|
||||||
- [ ] All transformations non-destructive
|
- [X] All transformations non-destructive
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
|
|
||||||
- [ ] T122 [US6] Implement image rotation in frontend/src/lib/canvas/transforms/rotate.ts
|
- [X] T122 [US6] Implement image rotation in frontend/src/lib/canvas/transforms/rotate.ts
|
||||||
- [ ] T123 [P] [US6] Implement image scaling in frontend/src/lib/canvas/transforms/scale.ts
|
- [X] T123 [P] [US6] Implement image scaling in frontend/src/lib/canvas/transforms/scale.ts
|
||||||
- [ ] T124 [P] [US6] Implement flip transformations in frontend/src/lib/canvas/transforms/flip.ts
|
- [X] T124 [P] [US6] Implement flip transformations in frontend/src/lib/canvas/transforms/flip.ts
|
||||||
- [ ] T125 [US6] Implement crop tool in frontend/src/lib/canvas/transforms/crop.ts
|
- [X] T125 [US6] Implement crop tool in frontend/src/lib/canvas/transforms/crop.ts
|
||||||
- [ ] T126 [P] [US6] Implement opacity adjustment in frontend/src/lib/canvas/transforms/opacity.ts
|
- [X] T126 [P] [US6] Implement opacity adjustment in frontend/src/lib/canvas/transforms/opacity.ts
|
||||||
- [ ] T127 [P] [US6] Implement greyscale filter in frontend/src/lib/canvas/transforms/greyscale.ts
|
- [X] T127 [P] [US6] Implement greyscale filter in frontend/src/lib/canvas/transforms/greyscale.ts
|
||||||
- [ ] T128 [US6] Create transformation panel UI in frontend/src/lib/components/canvas/TransformPanel.svelte
|
- [X] T128 [US6] Create transformation panel UI in frontend/src/lib/components/canvas/TransformPanel.svelte
|
||||||
- [ ] T129 [US6] Implement reset to original function in frontend/src/lib/canvas/transforms/reset.ts
|
- [X] T129 [US6] Implement reset to original function in frontend/src/lib/canvas/transforms/reset.ts
|
||||||
- [ ] T130 [US6] Sync transformations to backend (debounced)
|
- [X] T130 [US6] Sync transformations to backend (debounced)
|
||||||
- [ ] T131 [P] [US6] Write transformation tests in frontend/tests/canvas/transforms.test.ts
|
- [X] T131 [P] [US6] Write transformation tests in frontend/tests/canvas/transforms.test.ts
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
|
|
||||||
- [ ] T132 [US6] Update transformations endpoint PATCH /boards/{id}/images/{image_id} to handle all transform types
|
- [X] T132 [US6] Update transformations endpoint PATCH /boards/{id}/images/{image_id} to handle all transform types
|
||||||
- [ ] T133 [P] [US6] Write transformation validation tests in backend/tests/images/test_transformations.py
|
- [X] T133 [P] [US6] Write transformation validation tests in backend/tests/images/test_transformations.py
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- All transformations functional
|
- All transformations functional
|
||||||
@@ -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