diff --git a/backend/tests/api/test_z_order.py b/backend/tests/api/test_z_order.py new file mode 100644 index 0000000..5ad9db3 --- /dev/null +++ b/backend/tests/api/test_z_order.py @@ -0,0 +1,298 @@ +"""Integration tests for Z-order persistence.""" + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession +from uuid import uuid4 + +from app.database.models.user import User +from app.database.models.board import Board +from app.database.models.image import Image +from app.database.models.board_image import BoardImage + + +@pytest.mark.asyncio +async def test_update_z_order(client: AsyncClient, test_user: User, db: AsyncSession): + """Test updating Z-order of an image.""" + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + image = Image( + id=uuid4(), + user_id=test_user.id, + filename="test.jpg", + storage_path=f"{test_user.id}/test.jpg", + file_size=1024, + mime_type="image/jpeg", + width=800, + height=600, + metadata={"format": "jpeg", "checksum": "abc123"}, + ) + db.add(image) + + board_image = BoardImage( + id=uuid4(), + board_id=board.id, + image_id=image.id, + position={"x": 100, "y": 100}, + transformations={ + "scale": 1.0, + "rotation": 0, + "opacity": 1.0, + "flipped_h": False, + "flipped_v": False, + "greyscale": False, + }, + z_order=0, + ) + db.add(board_image) + await db.commit() + + # Update Z-order + response = await client.patch( + f"/api/images/boards/{board.id}/images/{image.id}", + json={"z_order": 5}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["z_order"] == 5 + + +@pytest.mark.asyncio +async def test_z_order_persists_across_requests( + client: AsyncClient, test_user: User, db: AsyncSession +): + """Test that Z-order changes persist.""" + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + image = Image( + id=uuid4(), + user_id=test_user.id, + filename="test.jpg", + storage_path=f"{test_user.id}/test.jpg", + file_size=1024, + mime_type="image/jpeg", + width=800, + height=600, + metadata={"format": "jpeg", "checksum": "abc123"}, + ) + db.add(image) + + board_image = BoardImage( + id=uuid4(), + board_id=board.id, + image_id=image.id, + position={"x": 100, "y": 100}, + transformations={ + "scale": 1.0, + "rotation": 0, + "opacity": 1.0, + "flipped_h": False, + "flipped_v": False, + "greyscale": False, + }, + z_order=0, + ) + db.add(board_image) + await db.commit() + + # Update Z-order + await client.patch( + f"/api/images/boards/{board.id}/images/{image.id}", + json={"z_order": 10}, + ) + + # Fetch board images to verify persistence + response = await client.get(f"/api/images/boards/{board.id}/images") + + assert response.status_code == 200 + board_images = response.json() + assert len(board_images) == 1 + assert board_images[0]["z_order"] == 10 + + +@pytest.mark.asyncio +async def test_multiple_images_z_order(client: AsyncClient, test_user: User, db: AsyncSession): + """Test Z-order with multiple 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) + + 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() + + # Update Z-order of middle image to be highest + await client.patch( + f"/api/images/boards/{board.id}/images/{images[1].id}", + json={"z_order": 10}, + ) + + # Verify + response = await client.get(f"/api/images/boards/{board.id}/images") + board_images = response.json() + + # Find the updated image + updated = next((bi for bi in board_images if str(bi["image_id"]) == str(images[1].id)), None) + assert updated is not None + assert updated["z_order"] == 10 + + +@pytest.mark.asyncio +async def test_z_order_negative_value(client: AsyncClient, test_user: User, db: AsyncSession): + """Test that negative Z-order is allowed (for layering below 0).""" + board = Board( + id=uuid4(), + user_id=test_user.id, + title="Test Board", + viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, + ) + db.add(board) + + image = Image( + id=uuid4(), + user_id=test_user.id, + filename="test.jpg", + storage_path=f"{test_user.id}/test.jpg", + file_size=1024, + mime_type="image/jpeg", + width=800, + height=600, + metadata={"format": "jpeg", "checksum": "abc123"}, + ) + db.add(image) + + board_image = BoardImage( + id=uuid4(), + board_id=board.id, + image_id=image.id, + position={"x": 100, "y": 100}, + transformations={ + "scale": 1.0, + "rotation": 0, + "opacity": 1.0, + "flipped_h": False, + "flipped_v": False, + "greyscale": False, + }, + z_order=0, + ) + db.add(board_image) + await db.commit() + + # Set negative Z-order + response = await client.patch( + f"/api/images/boards/{board.id}/images/{image.id}", + json={"z_order": -1}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["z_order"] == -1 + + +@pytest.mark.asyncio +async def test_z_order_with_other_updates(client: AsyncClient, test_user: User, db: AsyncSession): + """Test updating Z-order along with position and 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) + + image = Image( + id=uuid4(), + user_id=test_user.id, + filename="test.jpg", + storage_path=f"{test_user.id}/test.jpg", + file_size=1024, + mime_type="image/jpeg", + width=800, + height=600, + metadata={"format": "jpeg", "checksum": "abc123"}, + ) + db.add(image) + + board_image = BoardImage( + id=uuid4(), + board_id=board.id, + image_id=image.id, + position={"x": 100, "y": 100}, + transformations={ + "scale": 1.0, + "rotation": 0, + "opacity": 1.0, + "flipped_h": False, + "flipped_v": False, + "greyscale": False, + }, + z_order=0, + ) + db.add(board_image) + await db.commit() + + # Update everything including Z-order + response = await client.patch( + f"/api/images/boards/{board.id}/images/{image.id}", + json={ + "position": {"x": 200, "y": 200}, + "transformations": {"scale": 2.0}, + "z_order": 15, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["position"]["x"] == 200 + assert data["transformations"]["scale"] == 2.0 + assert data["z_order"] == 15 + diff --git a/frontend/src/lib/canvas/grid.ts b/frontend/src/lib/canvas/grid.ts new file mode 100644 index 0000000..e472bc3 --- /dev/null +++ b/frontend/src/lib/canvas/grid.ts @@ -0,0 +1,195 @@ +/** + * Grid and snap-to-grid functionality for canvas + * Provides visual grid and snapping behavior + */ + +import Konva from 'konva'; +import { writable } from 'svelte/store'; +import type { Writable } from 'svelte/store'; + +export interface GridSettings { + enabled: boolean; + size: number; // Grid cell size in pixels + visible: boolean; // Show visual grid + snapEnabled: boolean; // Enable snap-to-grid + color: string; // Grid line color + opacity: number; // Grid line opacity +} + +const DEFAULT_GRID: GridSettings = { + enabled: true, + size: 20, + visible: false, + snapEnabled: false, + color: '#d1d5db', + opacity: 0.5, +}; + +/** + * Create grid settings store + */ +function createGridStore() { + const { subscribe, set, update }: Writable = writable(DEFAULT_GRID); + + return { + subscribe, + set, + update, + + /** + * Toggle grid visibility + */ + toggleVisible: () => { + update((settings) => ({ + ...settings, + visible: !settings.visible, + })); + }, + + /** + * Toggle snap-to-grid + */ + toggleSnap: () => { + update((settings) => ({ + ...settings, + snapEnabled: !settings.snapEnabled, + })); + }, + + /** + * Set grid size + */ + setSize: (size: number) => { + update((settings) => ({ + ...settings, + size: Math.max(5, Math.min(200, size)), // Clamp to 5-200 + })); + }, + + /** + * Enable/disable grid + */ + setEnabled: (enabled: boolean) => { + update((settings) => ({ + ...settings, + enabled, + })); + }, + + /** + * Reset to defaults + */ + reset: () => { + set(DEFAULT_GRID); + }, + }; +} + +export const grid = createGridStore(); + +/** + * Snap position to grid + */ +export function snapToGrid(x: number, y: number, gridSize: number): { x: number; y: number } { + return { + x: Math.round(x / gridSize) * gridSize, + y: Math.round(y / gridSize) * gridSize, + }; +} + +/** + * Draw visual grid on layer + */ +export function drawGrid( + layer: Konva.Layer, + width: number, + height: number, + gridSize: number, + color: string = '#d1d5db', + opacity: number = 0.5 +): Konva.Group { + const gridGroup = new Konva.Group({ + listening: false, + name: 'grid', + }); + + // Draw vertical lines + for (let x = 0; x <= width; x += gridSize) { + const line = new Konva.Line({ + points: [x, 0, x, height], + stroke: color, + strokeWidth: 1, + opacity, + listening: false, + }); + gridGroup.add(line); + } + + // Draw horizontal lines + for (let y = 0; y <= height; y += gridSize) { + const line = new Konva.Line({ + points: [0, y, width, y], + stroke: color, + strokeWidth: 1, + opacity, + listening: false, + }); + gridGroup.add(line); + } + + layer.add(gridGroup); + gridGroup.moveToBottom(); // Grid should be behind all images + + return gridGroup; +} + +/** + * Remove grid from layer + */ +export function removeGrid(layer: Konva.Layer): void { + const grids = layer.find('.grid'); + grids.forEach((grid) => grid.destroy()); + layer.batchDraw(); +} + +/** + * Update grid visual + */ +export function updateGrid( + layer: Konva.Layer, + settings: GridSettings, + viewportWidth: number, + viewportHeight: number +): void { + // Remove existing grid + removeGrid(layer); + + // Draw new grid if visible + if (settings.visible && settings.enabled) { + drawGrid(layer, viewportWidth, viewportHeight, settings.size, settings.color, settings.opacity); + layer.batchDraw(); + } +} + +/** + * Setup drag with snap-to-grid + */ +export function setupSnapDrag( + image: Konva.Image | Konva.Group, + gridSettings: GridSettings +): () => void { + function handleDragMove() { + if (!gridSettings.snapEnabled || !gridSettings.enabled) return; + + const pos = image.position(); + const snapped = snapToGrid(pos.x, pos.y, gridSettings.size); + + image.position(snapped); + } + + image.on('dragmove', handleDragMove); + + return () => { + image.off('dragmove', handleDragMove); + }; +} diff --git a/frontend/src/lib/canvas/keyboard.ts b/frontend/src/lib/canvas/keyboard.ts index c13c72b..4df107d 100644 --- a/frontend/src/lib/canvas/keyboard.ts +++ b/frontend/src/lib/canvas/keyboard.ts @@ -14,6 +14,10 @@ export interface KeyboardShortcutHandlers { onPaste?: () => void; onUndo?: () => void; onRedo?: () => void; + onBringToFront?: () => void; + onSendToBack?: () => void; + onBringForward?: () => void; + onSendBackward?: () => void; } /** @@ -129,6 +133,46 @@ export function setupKeyboardShortcuts( } return; } + + // Ctrl+] - Bring to front + if (isCtrlOrCmd && e.key === ']') { + e.preventDefault(); + + if (handlers.onBringToFront) { + handlers.onBringToFront(); + } + return; + } + + // Ctrl+[ - Send to back + if (isCtrlOrCmd && e.key === '[') { + e.preventDefault(); + + if (handlers.onSendToBack) { + handlers.onSendToBack(); + } + return; + } + + // PageUp - Bring forward + if (e.key === 'PageUp') { + e.preventDefault(); + + if (handlers.onBringForward) { + handlers.onBringForward(); + } + return; + } + + // PageDown - Send backward + if (e.key === 'PageDown') { + e.preventDefault(); + + if (handlers.onSendBackward) { + handlers.onSendBackward(); + } + return; + } } // Attach event listener diff --git a/frontend/src/lib/canvas/operations/align.ts b/frontend/src/lib/canvas/operations/align.ts new file mode 100644 index 0000000..a8dd3d6 --- /dev/null +++ b/frontend/src/lib/canvas/operations/align.ts @@ -0,0 +1,256 @@ +/** + * Alignment operations for canvas images + * Aligns multiple images relative to each other or to canvas + */ + +import type Konva from 'konva'; + +export interface AlignOptions { + onAlignComplete?: (imageIds: string[]) => void; +} + +/** + * Get bounding box of multiple images + */ +function getBounds( + images: Map, + imageIds: string[] +): { + minX: number; + minY: number; + maxX: number; + maxY: number; + width: number; + height: number; +} | null { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + imageIds.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 { + minX, + minY, + maxX, + maxY, + width: maxX - minX, + height: maxY - minY, + }; +} + +/** + * Align images to top edge + */ +export function alignTop( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + const bounds = getBounds(images, selectedIds); + if (!bounds) return; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + const offsetY = bounds.minY - box.y; + + image.y(image.y() + offsetY); + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (options.onAlignComplete) { + options.onAlignComplete(selectedIds); + } +} + +/** + * Align images to bottom edge + */ +export function alignBottom( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + const bounds = getBounds(images, selectedIds); + if (!bounds) return; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + const offsetY = bounds.maxY - (box.y + box.height); + + image.y(image.y() + offsetY); + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (options.onAlignComplete) { + options.onAlignComplete(selectedIds); + } +} + +/** + * Align images to left edge + */ +export function alignLeft( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + const bounds = getBounds(images, selectedIds); + if (!bounds) return; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + const offsetX = bounds.minX - box.x; + + image.x(image.x() + offsetX); + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (options.onAlignComplete) { + options.onAlignComplete(selectedIds); + } +} + +/** + * Align images to right edge + */ +export function alignRight( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + const bounds = getBounds(images, selectedIds); + if (!bounds) return; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + const offsetX = bounds.maxX - (box.x + box.width); + + image.x(image.x() + offsetX); + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (options.onAlignComplete) { + options.onAlignComplete(selectedIds); + } +} + +/** + * Center images horizontally within their bounding box + */ +export function centerHorizontal( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + const bounds = getBounds(images, selectedIds); + if (!bounds) return; + + const centerX = bounds.minX + bounds.width / 2; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + const imageCenterX = box.x + box.width / 2; + const offsetX = centerX - imageCenterX; + + image.x(image.x() + offsetX); + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (options.onAlignComplete) { + options.onAlignComplete(selectedIds); + } +} + +/** + * Center images vertically within their bounding box + */ +export function centerVertical( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + const bounds = getBounds(images, selectedIds); + if (!bounds) return; + + const centerY = bounds.minY + bounds.height / 2; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + const imageCenterY = box.y + box.height / 2; + const offsetY = centerY - imageCenterY; + + image.y(image.y() + offsetY); + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } + + if (options.onAlignComplete) { + options.onAlignComplete(selectedIds); + } +} + +/** + * Center images both horizontally and vertically + */ +export function centerBoth( + images: Map, + selectedIds: string[], + options: AlignOptions = {} +): void { + centerHorizontal(images, selectedIds, options); + centerVertical(images, selectedIds, options); +} diff --git a/frontend/src/lib/canvas/operations/distribute.ts b/frontend/src/lib/canvas/operations/distribute.ts new file mode 100644 index 0000000..bca15e7 --- /dev/null +++ b/frontend/src/lib/canvas/operations/distribute.ts @@ -0,0 +1,150 @@ +/** + * Distribution operations for canvas images + * Distributes images with equal spacing + */ + +import type Konva from 'konva'; + +export interface DistributeOptions { + onDistributeComplete?: (imageIds: string[]) => void; +} + +interface ImageWithBounds { + id: string; + image: Konva.Image | Konva.Group; + bounds: { x: number; y: number; width: number; height: number }; +} + +/** + * Distribute images horizontally with equal spacing + */ +export function distributeHorizontal( + images: Map, + selectedIds: string[], + options: DistributeOptions = {} +): void { + if (selectedIds.length < 3) return; // Need at least 3 images to distribute + + // Get image bounds + const imagesWithBounds: ImageWithBounds[] = []; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + imagesWithBounds.push({ + id, + image, + bounds: { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }, + }); + }); + + // Sort by X position + imagesWithBounds.sort((a, b) => a.bounds.x - b.bounds.x); + + // Calculate total space and spacing + const first = imagesWithBounds[0]; + const last = imagesWithBounds[imagesWithBounds.length - 1]; + + const totalSpace = last.bounds.x - (first.bounds.x + first.bounds.width); + const spacing = totalSpace / (imagesWithBounds.length - 1); + + // Distribute (skip first and last) + let currentX = first.bounds.x + first.bounds.width + spacing; + + for (let i = 1; i < imagesWithBounds.length - 1; i++) { + const item = imagesWithBounds[i]; + const offsetX = currentX - item.bounds.x; + + item.image.x(item.image.x() + offsetX); + currentX += item.bounds.width + spacing; + } + + const firstImage = imagesWithBounds[0].image; + firstImage.getLayer()?.batchDraw(); + + if (options.onDistributeComplete) { + options.onDistributeComplete(selectedIds); + } +} + +/** + * Distribute images vertically with equal spacing + */ +export function distributeVertical( + images: Map, + selectedIds: string[], + options: DistributeOptions = {} +): void { + if (selectedIds.length < 3) return; // Need at least 3 images to distribute + + // Get image bounds + const imagesWithBounds: ImageWithBounds[] = []; + + selectedIds.forEach((id) => { + const image = images.get(id); + if (!image) return; + + const box = image.getClientRect(); + imagesWithBounds.push({ + id, + image, + bounds: { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }, + }); + }); + + // Sort by Y position + imagesWithBounds.sort((a, b) => a.bounds.y - b.bounds.y); + + // Calculate total space and spacing + const first = imagesWithBounds[0]; + const last = imagesWithBounds[imagesWithBounds.length - 1]; + + const totalSpace = last.bounds.y - (first.bounds.y + first.bounds.height); + const spacing = totalSpace / (imagesWithBounds.length - 1); + + // Distribute (skip first and last) + let currentY = first.bounds.y + first.bounds.height + spacing; + + for (let i = 1; i < imagesWithBounds.length - 1; i++) { + const item = imagesWithBounds[i]; + const offsetY = currentY - item.bounds.y; + + item.image.y(item.image.y() + offsetY); + currentY += item.bounds.height + spacing; + } + + const firstImage = imagesWithBounds[0].image; + firstImage.getLayer()?.batchDraw(); + + if (options.onDistributeComplete) { + options.onDistributeComplete(selectedIds); + } +} + +/** + * Distribute evenly across available space + */ +export function distributeEvenly( + images: Map, + selectedIds: string[], + horizontal: boolean = true, + options: DistributeOptions = {} +): void { + if (horizontal) { + distributeHorizontal(images, selectedIds, options); + } else { + distributeVertical(images, selectedIds, options); + } +} diff --git a/frontend/src/lib/canvas/operations/z-order.ts b/frontend/src/lib/canvas/operations/z-order.ts new file mode 100644 index 0000000..f524edb --- /dev/null +++ b/frontend/src/lib/canvas/operations/z-order.ts @@ -0,0 +1,180 @@ +/** + * Z-order (layering) operations for canvas images + * Controls which images appear in front of or behind others + */ + +import type Konva from 'konva'; + +export interface ZOrderOptions { + onZOrderChange?: (imageId: string, newZOrder: number) => void; +} + +/** + * Bring image to front (highest Z-order) + */ +export function bringToFront( + image: Konva.Image | Konva.Group, + imageId: string, + allImages: Map, + options: ZOrderOptions = {} +): void { + // Find maximum Z-order + let maxZOrder = 0; + allImages.forEach((img) => { + const zIndex = img.zIndex(); + if (zIndex > maxZOrder) { + maxZOrder = zIndex; + } + }); + + // Set to max + 1 + const newZOrder = maxZOrder + 1; + image.zIndex(newZOrder); + + image.getLayer()?.batchDraw(); + + if (options.onZOrderChange) { + options.onZOrderChange(imageId, newZOrder); + } +} + +/** + * Send image to back (lowest Z-order) + */ +export function sendToBack( + image: Konva.Image | Konva.Group, + imageId: string, + options: ZOrderOptions = {} +): void { + image.zIndex(0); + image.getLayer()?.batchDraw(); + + if (options.onZOrderChange) { + options.onZOrderChange(imageId, 0); + } +} + +/** + * Bring image forward (increase Z-order by 1) + */ +export function bringForward( + image: Konva.Image | Konva.Group, + imageId: string, + options: ZOrderOptions = {} +): void { + const currentZIndex = image.zIndex(); + const newZOrder = currentZIndex + 1; + + image.zIndex(newZOrder); + image.getLayer()?.batchDraw(); + + if (options.onZOrderChange) { + options.onZOrderChange(imageId, newZOrder); + } +} + +/** + * Send image backward (decrease Z-order by 1) + */ +export function sendBackward( + image: Konva.Image | Konva.Group, + imageId: string, + options: ZOrderOptions = {} +): void { + const currentZIndex = image.zIndex(); + const newZOrder = Math.max(0, currentZIndex - 1); + + image.zIndex(newZOrder); + image.getLayer()?.batchDraw(); + + if (options.onZOrderChange) { + options.onZOrderChange(imageId, newZOrder); + } +} + +/** + * Set specific Z-order + */ +export function setZOrder( + image: Konva.Image | Konva.Group, + imageId: string, + zOrder: number, + options: ZOrderOptions = {} +): void { + image.zIndex(Math.max(0, zOrder)); + image.getLayer()?.batchDraw(); + + if (options.onZOrderChange) { + options.onZOrderChange(imageId, zOrder); + } +} + +/** + * Get current Z-order + */ +export function getZOrder(image: Konva.Image | Konva.Group): number { + return image.zIndex(); +} + +/** + * Bulk bring to front (multiple images) + */ +export function bulkBringToFront( + images: Map, + selectedIds: string[], + allImages: Map, + options: ZOrderOptions = {} +): void { + // Find maximum Z-order + let maxZOrder = 0; + allImages.forEach((img) => { + const zIndex = img.zIndex(); + if (zIndex > maxZOrder) { + maxZOrder = zIndex; + } + }); + + // Set selected images to top, maintaining relative order + selectedIds.forEach((id, index) => { + const image = images.get(id); + if (!image) return; + + const newZOrder = maxZOrder + 1 + index; + image.zIndex(newZOrder); + + if (options.onZOrderChange) { + options.onZOrderChange(id, newZOrder); + } + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } +} + +/** + * Bulk send to back (multiple images) + */ +export function bulkSendToBack( + images: Map, + selectedIds: string[], + options: ZOrderOptions = {} +): void { + // Set selected images to bottom, maintaining relative order + selectedIds.forEach((id, index) => { + const image = images.get(id); + if (!image) return; + + image.zIndex(index); + + if (options.onZOrderChange) { + options.onZOrderChange(id, index); + } + }); + + const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null; + if (firstImage) { + firstImage.getLayer()?.batchDraw(); + } +} diff --git a/frontend/src/lib/components/canvas/AlignmentToolbar.svelte b/frontend/src/lib/components/canvas/AlignmentToolbar.svelte new file mode 100644 index 0000000..4821a1f --- /dev/null +++ b/frontend/src/lib/components/canvas/AlignmentToolbar.svelte @@ -0,0 +1,268 @@ + + +
+
+ +
+ + + + + + +
+ + + + + + +
+
+ +
+ +
+ + + +
+
+
+ + diff --git a/frontend/src/lib/components/canvas/GridSettings.svelte b/frontend/src/lib/components/canvas/GridSettings.svelte new file mode 100644 index 0000000..ca5ffb5 --- /dev/null +++ b/frontend/src/lib/components/canvas/GridSettings.svelte @@ -0,0 +1,166 @@ + + +
+
+

Grid Settings

+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + diff --git a/frontend/tests/canvas/align.test.ts b/frontend/tests/canvas/align.test.ts new file mode 100644 index 0000000..6ba1800 --- /dev/null +++ b/frontend/tests/canvas/align.test.ts @@ -0,0 +1,460 @@ +/** + * Tests for alignment and distribution operations + * Tests align (top/bottom/left/right/center) and distribute + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Konva from 'konva'; +import { + alignTop, + alignBottom, + alignLeft, + alignRight, + centerHorizontal, + centerVertical, + centerBoth, +} from '$lib/canvas/operations/align'; +import { distributeHorizontal, distributeVertical } from '$lib/canvas/operations/distribute'; +import { grid, snapToGrid, drawGrid, removeGrid, updateGrid } from '$lib/canvas/grid'; +import { get } from 'svelte/store'; + +describe('Alignment Operations', () => { + let stage: Konva.Stage; + let layer: Konva.Layer; + let images: Map; + + 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); + + images = new Map(); + + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + // Create 3 images at different positions + const positions = [ + { x: 100, y: 100 }, + { x: 300, y: 150 }, + { x: 200, y: 250 }, + ]; + + positions.forEach((pos, index) => { + const img = new Konva.Image({ + image: imageElement, + x: pos.x, + y: pos.y, + width: 100, + height: 100, + }); + + layer.add(img); + images.set(`img${index + 1}`, img); + }); + + layer.draw(); + }); + + afterEach(() => { + stage.destroy(); + document.body.innerHTML = ''; + }); + + describe('Align Top', () => { + it('aligns images to topmost edge', () => { + alignTop(images, ['img1', 'img2', 'img3']); + + const img1Y = images.get('img1')!.getClientRect().y; + const img2Y = images.get('img2')!.getClientRect().y; + const img3Y = images.get('img3')!.getClientRect().y; + + expect(img1Y).toBeCloseTo(img2Y); + expect(img1Y).toBeCloseTo(img3Y); + expect(img1Y).toBe(100); // Should align to img1 (topmost) + }); + + it('calls callback on completion', () => { + const callback = vi.fn(); + + alignTop(images, ['img1', 'img2'], { onAlignComplete: callback }); + + expect(callback).toHaveBeenCalledWith(['img1', 'img2']); + }); + }); + + describe('Align Bottom', () => { + it('aligns images to bottommost edge', () => { + alignBottom(images, ['img1', 'img2', 'img3']); + + const img1Bottom = images.get('img1')!.getClientRect().y + 100; + const img2Bottom = images.get('img2')!.getClientRect().y + 100; + const img3Bottom = images.get('img3')!.getClientRect().y + 100; + + expect(img1Bottom).toBeCloseTo(img2Bottom); + expect(img1Bottom).toBeCloseTo(img3Bottom); + }); + }); + + describe('Align Left', () => { + it('aligns images to leftmost edge', () => { + alignLeft(images, ['img1', 'img2', 'img3']); + + const img1X = images.get('img1')!.getClientRect().x; + const img2X = images.get('img2')!.getClientRect().x; + const img3X = images.get('img3')!.getClientRect().x; + + expect(img1X).toBeCloseTo(img2X); + expect(img1X).toBeCloseTo(img3X); + }); + }); + + describe('Align Right', () => { + it('aligns images to rightmost edge', () => { + alignRight(images, ['img1', 'img2', 'img3']); + + const img1Right = images.get('img1')!.getClientRect().x + 100; + const img2Right = images.get('img2')!.getClientRect().x + 100; + const img3Right = images.get('img3')!.getClientRect().x + 100; + + expect(img1Right).toBeCloseTo(img2Right); + expect(img1Right).toBeCloseTo(img3Right); + }); + }); + + describe('Center Horizontal', () => { + it('centers images horizontally within selection bounds', () => { + centerHorizontal(images, ['img1', 'img2', 'img3']); + + const img1CenterX = images.get('img1')!.getClientRect().x + 50; + const img2CenterX = images.get('img2')!.getClientRect().x + 50; + const img3CenterX = images.get('img3')!.getClientRect().x + 50; + + expect(img1CenterX).toBeCloseTo(img2CenterX); + expect(img1CenterX).toBeCloseTo(img3CenterX); + }); + }); + + describe('Center Vertical', () => { + it('centers images vertically within selection bounds', () => { + centerVertical(images, ['img1', 'img2', 'img3']); + + const img1CenterY = images.get('img1')!.getClientRect().y + 50; + const img2CenterY = images.get('img2')!.getClientRect().y + 50; + const img3CenterY = images.get('img3')!.getClientRect().y + 50; + + expect(img1CenterY).toBeCloseTo(img2CenterY); + expect(img1CenterY).toBeCloseTo(img3CenterY); + }); + }); + + describe('Center Both', () => { + it('centers images both horizontally and vertically', () => { + centerBoth(images, ['img1', 'img2', 'img3']); + + const img1Center = { + x: images.get('img1')!.getClientRect().x + 50, + y: images.get('img1')!.getClientRect().y + 50, + }; + + const img2Center = { + x: images.get('img2')!.getClientRect().x + 50, + y: images.get('img2')!.getClientRect().y + 50, + }; + + expect(img1Center.x).toBeCloseTo(img2Center.x); + expect(img1Center.y).toBeCloseTo(img2Center.y); + }); + }); + + describe('Edge Cases', () => { + it('handles single image gracefully', () => { + alignTop(images, ['img1']); + + // Should not throw, position may change + expect(images.get('img1')!.y()).toBeDefined(); + }); + + it('handles empty selection', () => { + alignTop(images, []); + alignLeft(images, []); + + // Should not throw + }); + }); +}); + +describe('Distribution Operations', () => { + let stage: Konva.Stage; + let layer: Konva.Layer; + let images: Map; + + 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); + + images = new Map(); + + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + // Create 4 images for distribution + [100, 200, 500, 600].forEach((x, index) => { + const img = new Konva.Image({ + image: imageElement, + x, + y: 100, + width: 50, + height: 50, + }); + + layer.add(img); + images.set(`img${index + 1}`, img); + }); + + layer.draw(); + }); + + afterEach(() => { + stage.destroy(); + document.body.innerHTML = ''; + }); + + describe('Distribute Horizontal', () => { + it('distributes images with equal horizontal spacing', () => { + distributeHorizontal(images, ['img1', 'img2', 'img3', 'img4']); + + // Get positions after distribution + const positions = ['img1', 'img2', 'img3', 'img4'].map( + (id) => images.get(id)!.getClientRect().x + ); + + // Check that spacing between consecutive images is equal + const spacing1 = positions[1] - (positions[0] + 50); + const spacing2 = positions[2] - (positions[1] + 50); + + expect(spacing1).toBeCloseTo(spacing2, 1); + }); + + it('preserves first and last image positions', () => { + const firstX = images.get('img1')!.x(); + const lastX = images.get('img4')!.x(); + + distributeHorizontal(images, ['img1', 'img2', 'img3', 'img4']); + + expect(images.get('img1')!.x()).toBe(firstX); + expect(images.get('img4')!.x()).toBe(lastX); + }); + + it('does nothing with less than 3 images', () => { + const img1X = images.get('img1')!.x(); + const img2X = images.get('img2')!.x(); + + distributeHorizontal(images, ['img1', 'img2']); + + expect(images.get('img1')!.x()).toBe(img1X); + expect(images.get('img2')!.x()).toBe(img2X); + }); + }); + + describe('Distribute Vertical', () => { + it('distributes images with equal vertical spacing', () => { + // Reposition images vertically + images.get('img1')!.y(100); + images.get('img2')!.y(200); + images.get('img3')!.y(400); + images.get('img4')!.y(500); + + distributeVertical(images, ['img1', 'img2', 'img3', 'img4']); + + // Get positions after distribution + const positions = ['img1', 'img2', 'img3', 'img4'].map( + (id) => images.get(id)!.getClientRect().y + ); + + // Check spacing + const spacing1 = positions[1] - (positions[0] + 50); + const spacing2 = positions[2] - (positions[1] + 50); + + expect(spacing1).toBeCloseTo(spacing2, 1); + }); + }); +}); + +describe('Grid Functionality', () => { + 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); + layer.draw(); + + grid.reset(); + }); + + afterEach(() => { + stage.destroy(); + document.body.innerHTML = ''; + }); + + describe('Grid Store', () => { + it('starts with default settings', () => { + const settings = get(grid); + + expect(settings.enabled).toBe(true); + expect(settings.size).toBe(20); + expect(settings.visible).toBe(false); + expect(settings.snapEnabled).toBe(false); + }); + + it('toggles visibility', () => { + grid.toggleVisible(); + expect(get(grid).visible).toBe(true); + + grid.toggleVisible(); + expect(get(grid).visible).toBe(false); + }); + + it('toggles snap', () => { + grid.toggleSnap(); + expect(get(grid).snapEnabled).toBe(true); + + grid.toggleSnap(); + expect(get(grid).snapEnabled).toBe(false); + }); + + it('sets grid size with bounds', () => { + grid.setSize(50); + expect(get(grid).size).toBe(50); + + grid.setSize(1); // Below min + expect(get(grid).size).toBe(5); + + grid.setSize(300); // Above max + expect(get(grid).size).toBe(200); + }); + + it('resets to defaults', () => { + grid.setSize(100); + grid.toggleVisible(); + grid.toggleSnap(); + + grid.reset(); + + const settings = get(grid); + expect(settings.size).toBe(20); + expect(settings.visible).toBe(false); + expect(settings.snapEnabled).toBe(false); + }); + }); + + describe('Snap to Grid', () => { + it('snaps position to grid', () => { + const snapped = snapToGrid(123, 456, 20); + + expect(snapped.x).toBe(120); + expect(snapped.y).toBe(460); + }); + + it('handles exact grid positions', () => { + const snapped = snapToGrid(100, 200, 20); + + expect(snapped.x).toBe(100); + expect(snapped.y).toBe(200); + }); + + it('rounds to nearest grid point', () => { + const snapped1 = snapToGrid(19, 19, 20); + expect(snapped1.x).toBe(20); + + const snapped2 = snapToGrid(11, 11, 20); + expect(snapped2.x).toBe(20); + + const snapped3 = snapToGrid(9, 9, 20); + expect(snapped3.x).toBe(0); + }); + + it('works with different grid sizes', () => { + const snapped50 = snapToGrid(123, 456, 50); + expect(snapped50.x).toBe(100); + expect(snapped50.y).toBe(450); + + const snapped10 = snapToGrid(123, 456, 10); + expect(snapped10.x).toBe(120); + expect(snapped10.y).toBe(460); + }); + }); + + describe('Visual Grid', () => { + it('draws grid on layer', () => { + const gridGroup = drawGrid(layer, 800, 600, 20); + + expect(gridGroup).toBeDefined(); + expect(gridGroup.children.length).toBeGreaterThan(0); + }); + + it('removes grid from layer', () => { + drawGrid(layer, 800, 600, 20); + + removeGrid(layer); + + const grids = layer.find('.grid'); + expect(grids.length).toBe(0); + }); + + it('updates grid when settings change', () => { + updateGrid(layer, get(grid), 800, 600); + + // Grid should not be visible by default + const grids = layer.find('.grid'); + expect(grids.length).toBe(0); + + // Enable visibility + grid.toggleVisible(); + updateGrid(layer, get(grid), 800, 600); + + const gridsAfter = layer.find('.grid'); + expect(gridsAfter.length).toBeGreaterThan(0); + }); + + it('grid lines respect opacity', () => { + const gridGroup = drawGrid(layer, 800, 600, 20, '#000000', 0.3); + + // Check first child (a line) + const firstLine = gridGroup.children[0] as Konva.Line; + expect(firstLine.opacity()).toBe(0.3); + }); + }); +}); diff --git a/frontend/tests/canvas/z-order.test.ts b/frontend/tests/canvas/z-order.test.ts new file mode 100644 index 0000000..d999738 --- /dev/null +++ b/frontend/tests/canvas/z-order.test.ts @@ -0,0 +1,280 @@ +/** + * Tests for Z-order (layering) operations + * Tests bring to front/back, forward/backward + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import Konva from 'konva'; +import { + bringToFront, + sendToBack, + bringForward, + sendBackward, + setZOrder, + getZOrder, + bulkBringToFront, + bulkSendToBack, +} from '$lib/canvas/operations/z-order'; + +describe('Z-Order Operations', () => { + let stage: Konva.Stage; + let layer: Konva.Layer; + let images: Map; + + 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); + + 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, + width: 100, + height: 100, + }); + + img.zIndex(index); + layer.add(img); + images.set(id, img); + }); + + layer.draw(); + }); + + afterEach(() => { + stage.destroy(); + document.body.innerHTML = ''; + }); + + describe('Bring to Front', () => { + it('brings image to highest Z-order', () => { + const img1 = images.get('img1')!; + + bringToFront(img1, 'img1', images); + + expect(img1.zIndex()).toBeGreaterThan(images.get('img2')!.zIndex()); + expect(img1.zIndex()).toBeGreaterThan(images.get('img3')!.zIndex()); + }); + + it('calls callback with new Z-order', () => { + const callback = vi.fn(); + const img1 = images.get('img1')!; + + bringToFront(img1, 'img1', images, { onZOrderChange: callback }); + + expect(callback).toHaveBeenCalled(); + expect(callback.mock.calls[0][0]).toBe('img1'); + expect(typeof callback.mock.calls[0][1]).toBe('number'); + }); + + it('handles already front image', () => { + const img3 = images.get('img3')!; + const initialZ = img3.zIndex(); + + bringToFront(img3, 'img3', images); + + expect(img3.zIndex()).toBeGreaterThanOrEqual(initialZ); + }); + }); + + describe('Send to Back', () => { + it('sends image to Z-order 0', () => { + const img3 = images.get('img3')!; + + sendToBack(img3, 'img3'); + + expect(img3.zIndex()).toBe(0); + }); + + it('calls callback', () => { + const callback = vi.fn(); + const img3 = images.get('img3')!; + + sendToBack(img3, 'img3', { onZOrderChange: callback }); + + expect(callback).toHaveBeenCalledWith('img3', 0); + }); + + it('handles already back image', () => { + const img1 = images.get('img1')!; + + sendToBack(img1, 'img1'); + + expect(img1.zIndex()).toBe(0); + }); + }); + + describe('Bring Forward', () => { + it('increases Z-order by 1', () => { + const img1 = images.get('img1')!; + const initialZ = img1.zIndex(); + + bringForward(img1, 'img1'); + + expect(img1.zIndex()).toBe(initialZ + 1); + }); + + it('can be called multiple times', () => { + const img1 = images.get('img1')!; + const initialZ = img1.zIndex(); + + bringForward(img1, 'img1'); + bringForward(img1, 'img1'); + bringForward(img1, 'img1'); + + expect(img1.zIndex()).toBe(initialZ + 3); + }); + + it('calls callback', () => { + const callback = vi.fn(); + const img1 = images.get('img1')!; + + bringForward(img1, 'img1', { onZOrderChange: callback }); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('Send Backward', () => { + it('decreases Z-order by 1', () => { + const img3 = images.get('img3')!; + const initialZ = img3.zIndex(); + + sendBackward(img3, 'img3'); + + expect(img3.zIndex()).toBe(initialZ - 1); + }); + + it('does not go below 0', () => { + const img1 = images.get('img1')!; + + sendBackward(img1, 'img1'); + sendBackward(img1, 'img1'); + + expect(img1.zIndex()).toBe(0); + }); + + it('calls callback', () => { + const callback = vi.fn(); + const img3 = images.get('img3')!; + + sendBackward(img3, 'img3', { onZOrderChange: callback }); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('Set Z-Order', () => { + it('sets specific Z-order', () => { + const img1 = images.get('img1')!; + + setZOrder(img1, 'img1', 10); + + expect(img1.zIndex()).toBe(10); + }); + + it('does not allow negative Z-order', () => { + const img1 = images.get('img1')!; + + setZOrder(img1, 'img1', -5); + + expect(img1.zIndex()).toBe(0); + }); + + it('calls callback', () => { + const callback = vi.fn(); + const img1 = images.get('img1')!; + + setZOrder(img1, 'img1', 5, { onZOrderChange: callback }); + + expect(callback).toHaveBeenCalledWith('img1', 5); + }); + }); + + describe('Get Z-Order', () => { + it('returns current Z-order', () => { + const img2 = images.get('img2')!; + + expect(getZOrder(img2)).toBe(1); + }); + }); + + describe('Bulk Z-Order Operations', () => { + it('bulk brings multiple images to front', () => { + bulkBringToFront(images, ['img1', 'img2'], images); + + const img1Z = images.get('img1')!.zIndex(); + const img2Z = images.get('img2')!.zIndex(); + const img3Z = images.get('img3')!.zIndex(); + + expect(img1Z).toBeGreaterThan(img3Z); + expect(img2Z).toBeGreaterThan(img3Z); + }); + + it('bulk sends multiple images to back', () => { + bulkSendToBack(images, ['img2', 'img3'], images); + + expect(images.get('img2')!.zIndex()).toBeLessThan(images.get('img1')!.zIndex()); + expect(images.get('img3')!.zIndex()).toBeLessThan(images.get('img1')!.zIndex()); + }); + + it('maintains relative order in bulk operations', () => { + bulkBringToFront(images, ['img1', 'img2'], images); + + const img1Z = images.get('img1')!.zIndex(); + const img2Z = images.get('img2')!.zIndex(); + + // img2 should be in front of img1 (relative order preserved) + expect(img2Z).toBeGreaterThan(img1Z); + }); + + it('calls callback for each image in bulk operation', () => { + const callback = vi.fn(); + + bulkBringToFront(images, ['img1', 'img2'], images, { onZOrderChange: callback }); + + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe('Edge Cases', () => { + it('handles large Z-order values', () => { + const img1 = images.get('img1')!; + + setZOrder(img1, 'img1', 999999); + + expect(img1.zIndex()).toBe(999999); + }); + + it('handles empty selection in bulk operations', () => { + bulkBringToFront(images, [], images); + bulkSendToBack(images, [], images); + + // Should not throw + }); + + it('handles single image in bulk operations', () => { + const img1 = images.get('img1')!; + const initialZ = img1.zIndex(); + + bulkBringToFront(images, ['img1'], images); + + expect(img1.zIndex()).toBeGreaterThan(initialZ); + }); + }); +}); diff --git a/specs/001-reference-board-viewer/tasks.md b/specs/001-reference-board-viewer/tasks.md index f7d2d2a..c4a3a50 100644 --- a/specs/001-reference-board-viewer/tasks.md +++ b/specs/001-reference-board-viewer/tasks.md @@ -412,29 +412,29 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu --- -## Phase 11: Z-Order & Layering (Week 8) +## Phase 11: Z-Order & Layering (Week 8) ✅ COMPLETE **User Story:** Control image stacking order (bring to front/back) **Independent Test Criteria:** -- [ ] Bring to front moves image to top layer -- [ ] Send to back moves image to bottom -- [ ] Forward/backward moves one layer -- [ ] Z-order persists +- [X] Bring to front moves image to top layer +- [X] Send to back moves image to bottom +- [X] Forward/backward moves one layer +- [X] Z-order persists **Frontend Tasks:** -- [ ] T155 [US5] Implement bring to front in frontend/src/lib/canvas/operations/z-order.ts -- [ ] T156 [P] [US5] Implement send to back in frontend/src/lib/canvas/operations/z-order.ts -- [ ] T157 [P] [US5] Implement bring forward/send backward in frontend/src/lib/canvas/operations/z-order.ts -- [ ] T158 [US5] Add Z-order keyboard shortcuts in frontend/src/lib/canvas/keyboard.ts -- [ ] T159 [US5] Sync Z-order changes to backend -- [ ] T160 [P] [US5] Write Z-order tests in frontend/tests/canvas/z-order.test.ts +- [X] T155 [US5] Implement bring to front in frontend/src/lib/canvas/operations/z-order.ts +- [X] T156 [P] [US5] Implement send to back in frontend/src/lib/canvas/operations/z-order.ts +- [X] T157 [P] [US5] Implement bring forward/send backward in frontend/src/lib/canvas/operations/z-order.ts +- [X] T158 [US5] Add Z-order keyboard shortcuts in frontend/src/lib/canvas/keyboard.ts +- [X] T159 [US5] Sync Z-order changes to backend +- [X] T160 [P] [US5] Write Z-order tests in frontend/tests/canvas/z-order.test.ts **Backend Tasks:** -- [ ] T161 [US5] Update Z-order field in position update endpoint backend/app/api/images.py -- [ ] T162 [P] [US5] Write Z-order persistence tests in backend/tests/api/test_z_order.py +- [X] T161 [US5] Update Z-order field in position update endpoint backend/app/api/images.py +- [X] T162 [P] [US5] Write Z-order persistence tests in backend/tests/api/test_z_order.py **Deliverables:** - Full layering control @@ -443,28 +443,28 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu --- -## Phase 12: Alignment & Distribution (FR6 - High) (Week 10) +## Phase 12: Alignment & Distribution (FR6 - High) (Week 10) ✅ COMPLETE **User Story:** Users must be able to precisely align and distribute images **Independent Test Criteria:** -- [ ] Align top/bottom/left/right works -- [ ] Center horizontal/vertical works -- [ ] Distribute horizontal/vertical creates equal spacing -- [ ] Snap-to-grid assists alignment -- [ ] Grid size configurable +- [X] Align top/bottom/left/right works +- [X] Center horizontal/vertical works +- [X] Distribute horizontal/vertical creates equal spacing +- [X] Snap-to-grid assists alignment +- [X] Grid size configurable **Frontend Tasks:** -- [ ] T163 [US9] Implement align top/bottom in frontend/src/lib/canvas/operations/align.ts -- [ ] T164 [P] [US9] Implement align left/right in frontend/src/lib/canvas/operations/align.ts -- [ ] T165 [P] [US9] Implement center horizontal/vertical in frontend/src/lib/canvas/operations/align.ts -- [ ] T166 [US9] Implement distribute horizontal in frontend/src/lib/canvas/operations/distribute.ts -- [ ] T167 [P] [US9] Implement distribute vertical in frontend/src/lib/canvas/operations/distribute.ts -- [ ] T168 [US9] Implement snap-to-grid in frontend/src/lib/canvas/grid.ts -- [ ] T169 [P] [US9] Create grid settings UI in frontend/src/lib/components/canvas/GridSettings.svelte -- [ ] T170 [P] [US9] Create alignment toolbar in frontend/src/lib/components/canvas/AlignmentToolbar.svelte -- [ ] T171 [P] [US9] Write alignment calculation tests in frontend/tests/canvas/align.test.ts +- [X] T163 [US9] Implement align top/bottom in frontend/src/lib/canvas/operations/align.ts +- [X] T164 [P] [US9] Implement align left/right in frontend/src/lib/canvas/operations/align.ts +- [X] T165 [P] [US9] Implement center horizontal/vertical in frontend/src/lib/canvas/operations/align.ts +- [X] T166 [US9] Implement distribute horizontal in frontend/src/lib/canvas/operations/distribute.ts +- [X] T167 [P] [US9] Implement distribute vertical in frontend/src/lib/canvas/operations/distribute.ts +- [X] T168 [US9] Implement snap-to-grid in frontend/src/lib/canvas/grid.ts +- [X] T169 [P] [US9] Create grid settings UI in frontend/src/lib/components/canvas/GridSettings.svelte +- [X] T170 [P] [US9] Create alignment toolbar in frontend/src/lib/components/canvas/AlignmentToolbar.svelte +- [X] T171 [P] [US9] Write alignment calculation tests in frontend/tests/canvas/align.test.ts **Deliverables:** - All alignment commands work