001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
18 changed files with 3079 additions and 32 deletions
Showing only changes of commit 3eb3d977f9 - Show all commits

View File

@@ -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,

View File

@@ -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."""

View 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

View 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()

View 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;
}

View 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;
}

View 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),
}));
}

View 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');
}

View 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,
};
}

View 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);
}

View 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();
}
}

View 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;
}

View 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>

View 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>

View 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';
});

View 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');
});
});

View 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);
});
});

View File

@@ -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