001-reference-board-viewer #1
298
backend/tests/api/test_z_order.py
Normal file
298
backend/tests/api/test_z_order.py
Normal 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
|
||||
|
||||
195
frontend/src/lib/canvas/grid.ts
Normal file
195
frontend/src/lib/canvas/grid.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
256
frontend/src/lib/canvas/operations/align.ts
Normal file
256
frontend/src/lib/canvas/operations/align.ts
Normal 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);
|
||||
}
|
||||
150
frontend/src/lib/canvas/operations/distribute.ts
Normal file
150
frontend/src/lib/canvas/operations/distribute.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
180
frontend/src/lib/canvas/operations/z-order.ts
Normal file
180
frontend/src/lib/canvas/operations/z-order.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
268
frontend/src/lib/components/canvas/AlignmentToolbar.svelte
Normal file
268
frontend/src/lib/components/canvas/AlignmentToolbar.svelte
Normal 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>
|
||||
166
frontend/src/lib/components/canvas/GridSettings.svelte
Normal file
166
frontend/src/lib/components/canvas/GridSettings.svelte
Normal 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>
|
||||
460
frontend/tests/canvas/align.test.ts
Normal file
460
frontend/tests/canvas/align.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
280
frontend/tests/canvas/z-order.test.ts
Normal file
280
frontend/tests/canvas/z-order.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user