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

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

View File

@@ -0,0 +1,195 @@
/**
* Grid and snap-to-grid functionality for canvas
* Provides visual grid and snapping behavior
*/
import Konva from 'konva';
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface GridSettings {
enabled: boolean;
size: number; // Grid cell size in pixels
visible: boolean; // Show visual grid
snapEnabled: boolean; // Enable snap-to-grid
color: string; // Grid line color
opacity: number; // Grid line opacity
}
const DEFAULT_GRID: GridSettings = {
enabled: true,
size: 20,
visible: false,
snapEnabled: false,
color: '#d1d5db',
opacity: 0.5,
};
/**
* Create grid settings store
*/
function createGridStore() {
const { subscribe, set, update }: Writable<GridSettings> = writable(DEFAULT_GRID);
return {
subscribe,
set,
update,
/**
* Toggle grid visibility
*/
toggleVisible: () => {
update((settings) => ({
...settings,
visible: !settings.visible,
}));
},
/**
* Toggle snap-to-grid
*/
toggleSnap: () => {
update((settings) => ({
...settings,
snapEnabled: !settings.snapEnabled,
}));
},
/**
* Set grid size
*/
setSize: (size: number) => {
update((settings) => ({
...settings,
size: Math.max(5, Math.min(200, size)), // Clamp to 5-200
}));
},
/**
* Enable/disable grid
*/
setEnabled: (enabled: boolean) => {
update((settings) => ({
...settings,
enabled,
}));
},
/**
* Reset to defaults
*/
reset: () => {
set(DEFAULT_GRID);
},
};
}
export const grid = createGridStore();
/**
* Snap position to grid
*/
export function snapToGrid(x: number, y: number, gridSize: number): { x: number; y: number } {
return {
x: Math.round(x / gridSize) * gridSize,
y: Math.round(y / gridSize) * gridSize,
};
}
/**
* Draw visual grid on layer
*/
export function drawGrid(
layer: Konva.Layer,
width: number,
height: number,
gridSize: number,
color: string = '#d1d5db',
opacity: number = 0.5
): Konva.Group {
const gridGroup = new Konva.Group({
listening: false,
name: 'grid',
});
// Draw vertical lines
for (let x = 0; x <= width; x += gridSize) {
const line = new Konva.Line({
points: [x, 0, x, height],
stroke: color,
strokeWidth: 1,
opacity,
listening: false,
});
gridGroup.add(line);
}
// Draw horizontal lines
for (let y = 0; y <= height; y += gridSize) {
const line = new Konva.Line({
points: [0, y, width, y],
stroke: color,
strokeWidth: 1,
opacity,
listening: false,
});
gridGroup.add(line);
}
layer.add(gridGroup);
gridGroup.moveToBottom(); // Grid should be behind all images
return gridGroup;
}
/**
* Remove grid from layer
*/
export function removeGrid(layer: Konva.Layer): void {
const grids = layer.find('.grid');
grids.forEach((grid) => grid.destroy());
layer.batchDraw();
}
/**
* Update grid visual
*/
export function updateGrid(
layer: Konva.Layer,
settings: GridSettings,
viewportWidth: number,
viewportHeight: number
): void {
// Remove existing grid
removeGrid(layer);
// Draw new grid if visible
if (settings.visible && settings.enabled) {
drawGrid(layer, viewportWidth, viewportHeight, settings.size, settings.color, settings.opacity);
layer.batchDraw();
}
}
/**
* Setup drag with snap-to-grid
*/
export function setupSnapDrag(
image: Konva.Image | Konva.Group,
gridSettings: GridSettings
): () => void {
function handleDragMove() {
if (!gridSettings.snapEnabled || !gridSettings.enabled) return;
const pos = image.position();
const snapped = snapToGrid(pos.x, pos.y, gridSettings.size);
image.position(snapped);
}
image.on('dragmove', handleDragMove);
return () => {
image.off('dragmove', handleDragMove);
};
}

View File

@@ -14,6 +14,10 @@ export interface KeyboardShortcutHandlers {
onPaste?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onBringToFront?: () => void;
onSendToBack?: () => void;
onBringForward?: () => void;
onSendBackward?: () => void;
}
/**
@@ -129,6 +133,46 @@ export function setupKeyboardShortcuts(
}
return;
}
// Ctrl+] - Bring to front
if (isCtrlOrCmd && e.key === ']') {
e.preventDefault();
if (handlers.onBringToFront) {
handlers.onBringToFront();
}
return;
}
// Ctrl+[ - Send to back
if (isCtrlOrCmd && e.key === '[') {
e.preventDefault();
if (handlers.onSendToBack) {
handlers.onSendToBack();
}
return;
}
// PageUp - Bring forward
if (e.key === 'PageUp') {
e.preventDefault();
if (handlers.onBringForward) {
handlers.onBringForward();
}
return;
}
// PageDown - Send backward
if (e.key === 'PageDown') {
e.preventDefault();
if (handlers.onSendBackward) {
handlers.onSendBackward();
}
return;
}
}
// Attach event listener

View File

@@ -0,0 +1,256 @@
/**
* Alignment operations for canvas images
* Aligns multiple images relative to each other or to canvas
*/
import type Konva from 'konva';
export interface AlignOptions {
onAlignComplete?: (imageIds: string[]) => void;
}
/**
* Get bounding box of multiple images
*/
function getBounds(
images: Map<string, Konva.Image | Konva.Group>,
imageIds: string[]
): {
minX: number;
minY: number;
maxX: number;
maxY: number;
width: number;
height: number;
} | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
imageIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
maxX = Math.max(maxX, box.x + box.width);
maxY = Math.max(maxY, box.y + box.height);
});
if (!isFinite(minX) || !isFinite(minY)) return null;
return {
minX,
minY,
maxX,
maxY,
width: maxX - minX,
height: maxY - minY,
};
}
/**
* Align images to top edge
*/
export function alignTop(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetY = bounds.minY - box.y;
image.y(image.y() + offsetY);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Align images to bottom edge
*/
export function alignBottom(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetY = bounds.maxY - (box.y + box.height);
image.y(image.y() + offsetY);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Align images to left edge
*/
export function alignLeft(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetX = bounds.minX - box.x;
image.x(image.x() + offsetX);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Align images to right edge
*/
export function alignRight(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const offsetX = bounds.maxX - (box.x + box.width);
image.x(image.x() + offsetX);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Center images horizontally within their bounding box
*/
export function centerHorizontal(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
const centerX = bounds.minX + bounds.width / 2;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const imageCenterX = box.x + box.width / 2;
const offsetX = centerX - imageCenterX;
image.x(image.x() + offsetX);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Center images vertically within their bounding box
*/
export function centerVertical(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
const bounds = getBounds(images, selectedIds);
if (!bounds) return;
const centerY = bounds.minY + bounds.height / 2;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
const imageCenterY = box.y + box.height / 2;
const offsetY = centerY - imageCenterY;
image.y(image.y() + offsetY);
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (options.onAlignComplete) {
options.onAlignComplete(selectedIds);
}
}
/**
* Center images both horizontally and vertically
*/
export function centerBoth(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: AlignOptions = {}
): void {
centerHorizontal(images, selectedIds, options);
centerVertical(images, selectedIds, options);
}

View File

@@ -0,0 +1,150 @@
/**
* Distribution operations for canvas images
* Distributes images with equal spacing
*/
import type Konva from 'konva';
export interface DistributeOptions {
onDistributeComplete?: (imageIds: string[]) => void;
}
interface ImageWithBounds {
id: string;
image: Konva.Image | Konva.Group;
bounds: { x: number; y: number; width: number; height: number };
}
/**
* Distribute images horizontally with equal spacing
*/
export function distributeHorizontal(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: DistributeOptions = {}
): void {
if (selectedIds.length < 3) return; // Need at least 3 images to distribute
// Get image bounds
const imagesWithBounds: ImageWithBounds[] = [];
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
imagesWithBounds.push({
id,
image,
bounds: {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
},
});
});
// Sort by X position
imagesWithBounds.sort((a, b) => a.bounds.x - b.bounds.x);
// Calculate total space and spacing
const first = imagesWithBounds[0];
const last = imagesWithBounds[imagesWithBounds.length - 1];
const totalSpace = last.bounds.x - (first.bounds.x + first.bounds.width);
const spacing = totalSpace / (imagesWithBounds.length - 1);
// Distribute (skip first and last)
let currentX = first.bounds.x + first.bounds.width + spacing;
for (let i = 1; i < imagesWithBounds.length - 1; i++) {
const item = imagesWithBounds[i];
const offsetX = currentX - item.bounds.x;
item.image.x(item.image.x() + offsetX);
currentX += item.bounds.width + spacing;
}
const firstImage = imagesWithBounds[0].image;
firstImage.getLayer()?.batchDraw();
if (options.onDistributeComplete) {
options.onDistributeComplete(selectedIds);
}
}
/**
* Distribute images vertically with equal spacing
*/
export function distributeVertical(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: DistributeOptions = {}
): void {
if (selectedIds.length < 3) return; // Need at least 3 images to distribute
// Get image bounds
const imagesWithBounds: ImageWithBounds[] = [];
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
imagesWithBounds.push({
id,
image,
bounds: {
x: box.x,
y: box.y,
width: box.width,
height: box.height,
},
});
});
// Sort by Y position
imagesWithBounds.sort((a, b) => a.bounds.y - b.bounds.y);
// Calculate total space and spacing
const first = imagesWithBounds[0];
const last = imagesWithBounds[imagesWithBounds.length - 1];
const totalSpace = last.bounds.y - (first.bounds.y + first.bounds.height);
const spacing = totalSpace / (imagesWithBounds.length - 1);
// Distribute (skip first and last)
let currentY = first.bounds.y + first.bounds.height + spacing;
for (let i = 1; i < imagesWithBounds.length - 1; i++) {
const item = imagesWithBounds[i];
const offsetY = currentY - item.bounds.y;
item.image.y(item.image.y() + offsetY);
currentY += item.bounds.height + spacing;
}
const firstImage = imagesWithBounds[0].image;
firstImage.getLayer()?.batchDraw();
if (options.onDistributeComplete) {
options.onDistributeComplete(selectedIds);
}
}
/**
* Distribute evenly across available space
*/
export function distributeEvenly(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
horizontal: boolean = true,
options: DistributeOptions = {}
): void {
if (horizontal) {
distributeHorizontal(images, selectedIds, options);
} else {
distributeVertical(images, selectedIds, options);
}
}

View File

@@ -0,0 +1,180 @@
/**
* Z-order (layering) operations for canvas images
* Controls which images appear in front of or behind others
*/
import type Konva from 'konva';
export interface ZOrderOptions {
onZOrderChange?: (imageId: string, newZOrder: number) => void;
}
/**
* Bring image to front (highest Z-order)
*/
export function bringToFront(
image: Konva.Image | Konva.Group,
imageId: string,
allImages: Map<string, Konva.Image | Konva.Group>,
options: ZOrderOptions = {}
): void {
// Find maximum Z-order
let maxZOrder = 0;
allImages.forEach((img) => {
const zIndex = img.zIndex();
if (zIndex > maxZOrder) {
maxZOrder = zIndex;
}
});
// Set to max + 1
const newZOrder = maxZOrder + 1;
image.zIndex(newZOrder);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, newZOrder);
}
}
/**
* Send image to back (lowest Z-order)
*/
export function sendToBack(
image: Konva.Image | Konva.Group,
imageId: string,
options: ZOrderOptions = {}
): void {
image.zIndex(0);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, 0);
}
}
/**
* Bring image forward (increase Z-order by 1)
*/
export function bringForward(
image: Konva.Image | Konva.Group,
imageId: string,
options: ZOrderOptions = {}
): void {
const currentZIndex = image.zIndex();
const newZOrder = currentZIndex + 1;
image.zIndex(newZOrder);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, newZOrder);
}
}
/**
* Send image backward (decrease Z-order by 1)
*/
export function sendBackward(
image: Konva.Image | Konva.Group,
imageId: string,
options: ZOrderOptions = {}
): void {
const currentZIndex = image.zIndex();
const newZOrder = Math.max(0, currentZIndex - 1);
image.zIndex(newZOrder);
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, newZOrder);
}
}
/**
* Set specific Z-order
*/
export function setZOrder(
image: Konva.Image | Konva.Group,
imageId: string,
zOrder: number,
options: ZOrderOptions = {}
): void {
image.zIndex(Math.max(0, zOrder));
image.getLayer()?.batchDraw();
if (options.onZOrderChange) {
options.onZOrderChange(imageId, zOrder);
}
}
/**
* Get current Z-order
*/
export function getZOrder(image: Konva.Image | Konva.Group): number {
return image.zIndex();
}
/**
* Bulk bring to front (multiple images)
*/
export function bulkBringToFront(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
allImages: Map<string, Konva.Image | Konva.Group>,
options: ZOrderOptions = {}
): void {
// Find maximum Z-order
let maxZOrder = 0;
allImages.forEach((img) => {
const zIndex = img.zIndex();
if (zIndex > maxZOrder) {
maxZOrder = zIndex;
}
});
// Set selected images to top, maintaining relative order
selectedIds.forEach((id, index) => {
const image = images.get(id);
if (!image) return;
const newZOrder = maxZOrder + 1 + index;
image.zIndex(newZOrder);
if (options.onZOrderChange) {
options.onZOrderChange(id, newZOrder);
}
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
}
/**
* Bulk send to back (multiple images)
*/
export function bulkSendToBack(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: ZOrderOptions = {}
): void {
// Set selected images to bottom, maintaining relative order
selectedIds.forEach((id, index) => {
const image = images.get(id);
if (!image) return;
image.zIndex(index);
if (options.onZOrderChange) {
options.onZOrderChange(id, index);
}
});
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
}

View File

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

View File

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