phase 12
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 8s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / VM Test - performance (push) Successful in 7s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 2s
CI/CD Pipeline / Frontend Linting (push) Successful in 16s
CI/CD Pipeline / Nix Flake Check (push) Successful in 38s
CI/CD Pipeline / CI Summary (push) Successful in 0s

This commit is contained in:
Danilo Reyes
2025-11-02 14:34:55 -06:00
parent 3eb3d977f9
commit e5abcced74
11 changed files with 2325 additions and 28 deletions

View File

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

View File

@@ -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<GridSettings> = 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);
};
}

View File

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

View File

@@ -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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
centerHorizontal(images, selectedIds, options);
centerVertical(images, selectedIds, options);
}

View File

@@ -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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
selectedIds: string[],
horizontal: boolean = true,
options: DistributeOptions = {}
): void {
if (horizontal) {
distributeHorizontal(images, selectedIds, options);
} else {
distributeVertical(images, selectedIds, options);
}
}

View File

@@ -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<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
selectedIds: string[],
allImages: Map<string, Konva.Image | Konva.Group>,
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<string, Konva.Image | Konva.Group>,
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();
}
}

View File

@@ -0,0 +1,268 @@
<script lang="ts">
/**
* Alignment toolbar component
* Provides UI buttons for alignment and distribution operations
*/
import { createEventDispatcher } from 'svelte';
import { selectionCount } from '$lib/stores/selection';
const dispatch = createEventDispatcher();
$: disabled = $selectionCount < 2;
$: distributeDisabled = $selectionCount < 3;
</script>
<div class="alignment-toolbar">
<div class="toolbar-section">
<div class="section-label">Align</div>
<div class="button-group">
<button
class="toolbar-button"
on:click={() => dispatch('align-left')}
{disabled}
title="Align Left"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="3" y2="18" />
<rect x="7" y="8" width="10" height="3" />
<rect x="7" y="13" width="7" height="3" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-center-h')}
{disabled}
title="Center Horizontal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="6" x2="12" y2="18" />
<rect x="7" y="8" width="10" height="3" />
<rect x="9" y="13" width="6" height="3" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-right')}
{disabled}
title="Align Right"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="21" y1="6" x2="21" y2="18" />
<rect x="7" y="8" width="10" height="3" />
<rect x="10" y="13" width="7" height="3" />
</svg>
</button>
<div class="separator" />
<button
class="toolbar-button"
on:click={() => dispatch('align-top')}
{disabled}
title="Align Top"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="6" y1="3" x2="18" y2="3" />
<rect x="8" y="7" width="3" height="10" />
<rect x="13" y="7" width="3" height="7" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-center-v')}
{disabled}
title="Center Vertical"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="6" y1="12" x2="18" y2="12" />
<rect x="8" y="7" width="3" height="10" />
<rect x="13" y="9" width="3" height="6" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('align-bottom')}
{disabled}
title="Align Bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="6" y1="21" x2="18" y2="21" />
<rect x="8" y="7" width="3" height="10" />
<rect x="13" y="10" width="3" height="7" />
</svg>
</button>
</div>
</div>
<div class="toolbar-section">
<div class="section-label">Distribute</div>
<div class="button-group">
<button
class="toolbar-button"
on:click={() => dispatch('distribute-h')}
disabled={distributeDisabled}
title="Distribute Horizontal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="5" y="8" width="3" height="8" />
<rect x="11" y="8" width="3" height="8" />
<rect x="17" y="8" width="3" height="8" />
</svg>
</button>
<button
class="toolbar-button"
on:click={() => dispatch('distribute-v')}
disabled={distributeDisabled}
title="Distribute Vertical"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="8" y="5" width="8" height="3" />
<rect x="8" y="11" width="8" height="3" />
<rect x="8" y="17" width="8" height="3" />
</svg>
</button>
</div>
</div>
</div>
<style>
.alignment-toolbar {
display: flex;
gap: 1.5rem;
padding: 0.75rem;
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.toolbar-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.button-group {
display: flex;
gap: 0.25rem;
align-items: center;
}
.toolbar-button {
width: 36px;
height: 36px;
padding: 0;
background-color: var(--color-bg-secondary, #f9fafb);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text, #374151);
}
.toolbar-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #f3f4f6);
border-color: var(--color-primary, #3b82f6);
}
.toolbar-button:active:not(:disabled) {
background-color: var(--color-bg-active, #e5e7eb);
transform: scale(0.95);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.separator {
width: 1px;
height: 24px;
background-color: var(--color-border, #d1d5db);
margin: 0 0.25rem;
}
svg {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,166 @@
<script lang="ts">
/**
* Grid settings UI component
* Configures grid size, visibility, and snap-to-grid
*/
import { grid } from '$lib/canvas/grid';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleSizeChange(event: Event) {
const value = parseInt((event.target as HTMLInputElement).value, 10);
grid.setSize(value);
dispatch('settings-change', { size: value });
}
function handleVisibleToggle() {
grid.toggleVisible();
dispatch('settings-change', { visible: !$grid.visible });
}
function handleSnapToggle() {
grid.toggleSnap();
dispatch('settings-change', { snap: !$grid.snapEnabled });
}
</script>
<div class="grid-settings">
<div class="settings-header">
<h4>Grid Settings</h4>
</div>
<div class="settings-content">
<!-- Grid Visibility -->
<div class="setting-row">
<label for="grid-visible">
<input
id="grid-visible"
type="checkbox"
checked={$grid.visible}
on:change={handleVisibleToggle}
/>
<span>Show Grid</span>
</label>
</div>
<!-- Snap to Grid -->
<div class="setting-row">
<label for="grid-snap">
<input
id="grid-snap"
type="checkbox"
checked={$grid.snapEnabled}
on:change={handleSnapToggle}
/>
<span>Snap to Grid</span>
</label>
</div>
<!-- Grid Size -->
<div class="setting-row">
<label for="grid-size">
Grid Size
<span class="value">{$grid.size}px</span>
</label>
<input
id="grid-size"
type="range"
min="5"
max="200"
step="5"
value={$grid.size}
on:input={handleSizeChange}
/>
</div>
</div>
</div>
<style>
.grid-settings {
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
min-width: 250px;
}
.settings-header {
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.settings-header h4 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.settings-content {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.setting-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
}
label span {
flex: 1;
}
.value {
font-weight: 600;
color: var(--color-text, #374151);
}
input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
input[type='range'] {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--color-bg-secondary, #e5e7eb);
outline: none;
appearance: none;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
border: none;
}
</style>

View File

@@ -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<string, Konva.Image>;
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<string, Konva.Image>;
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);
});
});
});

View File

@@ -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<string, Konva.Image>;
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);
});
});
});

View File

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