diff --git a/backend/app/api/images.py b/backend/app/api/images.py index 5c75975..c4014f1 100644 --- a/backend/app/api/images.py +++ b/backend/app/api/images.py @@ -15,6 +15,8 @@ from app.images.schemas import ( BoardImageCreate, BoardImageResponse, BoardImageUpdate, + BulkImageUpdate, + BulkUpdateResponse, ImageListResponse, ImageResponse, 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") +@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]) async def get_board_images( board_id: UUID, diff --git a/backend/app/images/schemas.py b/backend/app/images/schemas.py index dd462cf..dfa12c3 100644 --- a/backend/app/images/schemas.py +++ b/backend/app/images/schemas.py @@ -120,6 +120,31 @@ class BoardImageResponse(BaseModel): 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): """Paginated list of images.""" diff --git a/backend/tests/api/test_bulk_operations.py b/backend/tests/api/test_bulk_operations.py new file mode 100644 index 0000000..789ee29 --- /dev/null +++ b/backend/tests/api/test_bulk_operations.py @@ -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 + diff --git a/backend/tests/api/test_image_delete.py b/backend/tests/api/test_image_delete.py new file mode 100644 index 0000000..41f5a38 --- /dev/null +++ b/backend/tests/api/test_image_delete.py @@ -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() + diff --git a/frontend/src/lib/canvas/clipboard/copy.ts b/frontend/src/lib/canvas/clipboard/copy.ts new file mode 100644 index 0000000..7e5cd8c --- /dev/null +++ b/frontend/src/lib/canvas/clipboard/copy.ts @@ -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; +} diff --git a/frontend/src/lib/canvas/clipboard/cut.ts b/frontend/src/lib/canvas/clipboard/cut.ts new file mode 100644 index 0000000..71d6a5e --- /dev/null +++ b/frontend/src/lib/canvas/clipboard/cut.ts @@ -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; +} diff --git a/frontend/src/lib/canvas/clipboard/paste.ts b/frontend/src/lib/canvas/clipboard/paste.ts new file mode 100644 index 0000000..01132df --- /dev/null +++ b/frontend/src/lib/canvas/clipboard/paste.ts @@ -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), + })); +} diff --git a/frontend/src/lib/canvas/keyboard.ts b/frontend/src/lib/canvas/keyboard.ts new file mode 100644 index 0000000..c13c72b --- /dev/null +++ b/frontend/src/lib/canvas/keyboard.ts @@ -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'); +} diff --git a/frontend/src/lib/canvas/operations/bulk-move.ts b/frontend/src/lib/canvas/operations/bulk-move.ts new file mode 100644 index 0000000..c089b18 --- /dev/null +++ b/frontend/src/lib/canvas/operations/bulk-move.ts @@ -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, + 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, + 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, + 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, + 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, + }; +} diff --git a/frontend/src/lib/canvas/operations/bulk-rotate.ts b/frontend/src/lib/canvas/operations/bulk-rotate.ts new file mode 100644 index 0000000..c2d674c --- /dev/null +++ b/frontend/src/lib/canvas/operations/bulk-rotate.ts @@ -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, + 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, + 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, + selectedIds: string[], + options: BulkRotateOptions = {} +): void { + bulkRotateBy(images, selectedIds, 90, options); +} + +/** + * Rotate multiple images 90° counter-clockwise + */ +export function bulkRotate90CCW( + images: Map, + selectedIds: string[], + options: BulkRotateOptions = {} +): void { + bulkRotateBy(images, selectedIds, -90, options); +} + +/** + * Rotate multiple images 180° + */ +export function bulkRotate180( + images: Map, + selectedIds: string[], + options: BulkRotateOptions = {} +): void { + bulkRotateBy(images, selectedIds, 180, options); +} + +/** + * Reset rotation for multiple images + */ +export function bulkResetRotation( + images: Map, + selectedIds: string[], + options: BulkRotateOptions = {} +): void { + bulkRotateTo(images, selectedIds, 0, options); +} diff --git a/frontend/src/lib/canvas/operations/bulk-scale.ts b/frontend/src/lib/canvas/operations/bulk-scale.ts new file mode 100644 index 0000000..7181441 --- /dev/null +++ b/frontend/src/lib/canvas/operations/bulk-scale.ts @@ -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, + 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, + 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, + selectedIds: string[], + options: BulkScaleOptions = {} +): void { + bulkScaleBy(images, selectedIds, 2.0, options); +} + +/** + * Half size of multiple images + */ +export function bulkHalfSize( + images: Map, + selectedIds: string[], + options: BulkScaleOptions = {} +): void { + bulkScaleBy(images, selectedIds, 0.5, options); +} + +/** + * Reset scale for multiple images + */ +export function bulkResetScale( + images: Map, + selectedIds: string[], + options: BulkScaleOptions = {} +): void { + bulkScaleTo(images, selectedIds, 1.0, options); +} + +/** + * Scale uniformly while maintaining relative positions + */ +export function bulkScaleUniform( + images: Map, + 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(); + } +} diff --git a/frontend/src/lib/canvas/operations/delete.ts b/frontend/src/lib/canvas/operations/delete.ts new file mode 100644 index 0000000..8b4a37b --- /dev/null +++ b/frontend/src/lib/canvas/operations/delete.ts @@ -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; // 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 { + 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; +} diff --git a/frontend/src/lib/components/canvas/DeleteConfirmModal.svelte b/frontend/src/lib/components/canvas/DeleteConfirmModal.svelte new file mode 100644 index 0000000..b4bef4e --- /dev/null +++ b/frontend/src/lib/components/canvas/DeleteConfirmModal.svelte @@ -0,0 +1,211 @@ + + +{#if show} + +{/if} + + diff --git a/frontend/src/lib/components/canvas/SelectionCounter.svelte b/frontend/src/lib/components/canvas/SelectionCounter.svelte new file mode 100644 index 0000000..9cd3024 --- /dev/null +++ b/frontend/src/lib/components/canvas/SelectionCounter.svelte @@ -0,0 +1,107 @@ + + + + +{#if $hasSelection} +
+
+ + + + + {$selectionCount} + + {$selectionCount === 1 ? 'image selected' : 'images selected'} + +
+ +
+{/if} + + diff --git a/frontend/src/lib/stores/clipboard.ts b/frontend/src/lib/stores/clipboard.ts new file mode 100644 index 0000000..70fa750 --- /dev/null +++ b/frontend/src/lib/stores/clipboard.ts @@ -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; + 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 = 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'; +}); diff --git a/frontend/tests/canvas/clipboard.test.ts b/frontend/tests/canvas/clipboard.test.ts new file mode 100644 index 0000000..2a5ff63 --- /dev/null +++ b/frontend/tests/canvas/clipboard.test.ts @@ -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'); + }); +}); diff --git a/frontend/tests/canvas/multiselect.test.ts b/frontend/tests/canvas/multiselect.test.ts new file mode 100644 index 0000000..9d42fa9 --- /dev/null +++ b/frontend/tests/canvas/multiselect.test.ts @@ -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; + 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; + + 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; + + 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; + 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); + }); +}); diff --git a/specs/001-reference-board-viewer/tasks.md b/specs/001-reference-board-viewer/tasks.md index e4aad09..f7d2d2a 100644 --- a/specs/001-reference-board-viewer/tasks.md +++ b/specs/001-reference-board-viewer/tasks.md @@ -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 **Independent Test Criteria:** -- [ ] Selection rectangle selects multiple images -- [ ] Ctrl+Click adds to selection -- [ ] Ctrl+A selects all -- [ ] Bulk move works on selected images -- [ ] Bulk transformations apply correctly +- [X] Selection rectangle selects multiple images +- [X] Ctrl+Click adds to selection +- [X] Ctrl+A selects all +- [X] Bulk move works on selected images +- [X] Bulk transformations apply correctly **Frontend Tasks:** -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] T142 [P] [US7] Write multi-selection tests in frontend/tests/canvas/multiselect.test.ts +- [X] T134 [US7] Enhance selection rectangle in frontend/src/lib/canvas/interactions/multiselect.ts +- [X] T135 [US7] Implement Ctrl+Click add-to-selection in frontend/src/lib/canvas/interactions/select.ts +- [X] T136 [US7] Implement select all (Ctrl+A) in frontend/src/lib/canvas/keyboard.ts +- [X] T137 [US7] Implement deselect all (Escape) in frontend/src/lib/canvas/keyboard.ts +- [X] T138 [US7] Implement bulk move in frontend/src/lib/canvas/operations/bulk-move.ts +- [X] T139 [P] [US7] Implement bulk rotate in frontend/src/lib/canvas/operations/bulk-rotate.ts +- [X] T140 [P] [US7] Implement bulk scale in frontend/src/lib/canvas/operations/bulk-scale.ts +- [X] T141 [P] [US7] Create selection count indicator in frontend/src/lib/components/canvas/SelectionCounter.svelte +- [X] T142 [P] [US7] Write multi-selection tests in frontend/tests/canvas/multiselect.test.ts **Backend Tasks:** -- [ ] 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] T143 [US7] Implement bulk update endpoint PATCH /boards/{id}/images/bulk in backend/app/api/images.py +- [X] T144 [P] [US7] Write bulk operation tests in backend/tests/api/test_bulk_operations.py **Deliverables:** - 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 **Independent Test Criteria:** -- [ ] Copy (Ctrl+C) copies selected images -- [ ] Cut (Ctrl+X) copies and removes -- [ ] Paste (Ctrl+V) inserts at viewport center -- [ ] Delete (Del) removes with confirmation (>10 images) +- [X] Copy (Ctrl+C) copies selected images +- [X] Cut (Ctrl+X) copies and removes +- [X] Paste (Ctrl+V) inserts at viewport center +- [X] Delete (Del) removes with confirmation (>10 images) **Frontend Tasks:** -- [ ] 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 -- [ ] 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 -- [ ] 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 -- [ ] 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] T145 [US8] Implement copy operation in frontend/src/lib/canvas/clipboard/copy.ts +- [X] T146 [US8] Implement cut operation in frontend/src/lib/canvas/clipboard/cut.ts +- [X] T147 [US8] Implement paste operation in frontend/src/lib/canvas/clipboard/paste.ts +- [X] T148 [US8] Implement delete operation in frontend/src/lib/canvas/operations/delete.ts +- [X] T149 [US8] Create clipboard store in frontend/src/lib/stores/clipboard.ts +- [X] T150 [US8] Add keyboard shortcuts (Ctrl+C/X/V, Delete) in frontend/src/lib/canvas/keyboard.ts +- [X] T151 [P] [US8] Create delete confirmation modal in frontend/src/lib/components/canvas/DeleteConfirmModal.svelte +- [X] T152 [P] [US8] Write clipboard tests in frontend/tests/canvas/clipboard.test.ts **Backend Tasks:** -- [ ] 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] T153 [US8] Implement delete endpoint DELETE /boards/{id}/images/{image_id} in backend/app/api/images.py +- [X] T154 [P] [US8] Write delete endpoint tests in backend/tests/api/test_image_delete.py **Deliverables:** - All clipboard ops work