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
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:
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>
|
||||
Reference in New Issue
Block a user