phase 10
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 7s
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 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 41s
CI/CD Pipeline / CI Summary (push) Successful in 1s

This commit is contained in:
Danilo Reyes
2025-11-02 14:26:15 -06:00
parent ce0b692aee
commit 3eb3d977f9
18 changed files with 3079 additions and 32 deletions

View File

@@ -0,0 +1,86 @@
/**
* Copy operation for canvas images
* Copies selected images to clipboard
*/
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
import { selection } from '$lib/stores/selection';
/**
* Copy selected images to clipboard
*/
export function copySelectedImages(
getImageData: (id: string) => ClipboardImageData | null
): number {
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
return 0;
}
const imagesToCopy: ClipboardImageData[] = [];
selectedIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCopy.push(imageData);
}
});
clipboard.copy(imagesToCopy);
return imagesToCopy.length;
}
/**
* Copy specific images to clipboard
*/
export function copyImages(
imageIds: string[],
getImageData: (id: string) => ClipboardImageData | null
): number {
const imagesToCopy: ClipboardImageData[] = [];
imageIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCopy.push(imageData);
}
});
clipboard.copy(imagesToCopy);
return imagesToCopy.length;
}
/**
* Copy single image to clipboard
*/
export function copySingleImage(
getImageData: (id: string) => ClipboardImageData | null,
imageId: string
): boolean {
const imageData = getImageData(imageId);
if (!imageData) {
return false;
}
clipboard.copy([imageData]);
return true;
}
/**
* Check if clipboard has content
*/
export function hasClipboardContent(): boolean {
return clipboard.hasContent();
}
/**
* Get clipboard count
*/
export function getClipboardCount(): number {
const state = clipboard.getClipboard();
return state.images.length;
}

View File

@@ -0,0 +1,69 @@
/**
* Cut operation for canvas images
* Cuts selected images to clipboard (copy + mark for deletion)
*/
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
import { selection } from '$lib/stores/selection';
/**
* Cut selected images to clipboard
*/
export function cutSelectedImages(getImageData: (id: string) => ClipboardImageData | null): number {
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
return 0;
}
const imagesToCut: ClipboardImageData[] = [];
selectedIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCut.push(imageData);
}
});
clipboard.cut(imagesToCut);
return imagesToCut.length;
}
/**
* Cut specific images to clipboard
*/
export function cutImages(
imageIds: string[],
getImageData: (id: string) => ClipboardImageData | null
): number {
const imagesToCut: ClipboardImageData[] = [];
imageIds.forEach((id) => {
const imageData = getImageData(id);
if (imageData) {
imagesToCut.push(imageData);
}
});
clipboard.cut(imagesToCut);
return imagesToCut.length;
}
/**
* Cut single image to clipboard
*/
export function cutSingleImage(
getImageData: (id: string) => ClipboardImageData | null,
imageId: string
): boolean {
const imageData = getImageData(imageId);
if (!imageData) {
return false;
}
clipboard.cut([imageData]);
return true;
}

View File

@@ -0,0 +1,139 @@
/**
* Paste operation for canvas images
* Pastes clipboard images at viewport center or specific position
*/
import { clipboard, type ClipboardImageData } from '$lib/stores/clipboard';
import { viewport } from '$lib/stores/viewport';
import { get } from 'svelte/store';
export interface PasteOptions {
position?: { x: number; y: number }; // Override default center position
clearClipboardAfter?: boolean; // Clear clipboard after paste (default: false for copy, true for cut)
onPasteComplete?: (pastedIds: string[]) => void;
}
export interface PastedImageData extends ClipboardImageData {
newPosition: { x: number; y: number };
}
/**
* Paste clipboard images at viewport center
*/
export function pasteFromClipboard(
viewportWidth: number,
viewportHeight: number,
options: PasteOptions = {}
): PastedImageData[] {
const clipboardState = clipboard.getClipboard();
if (clipboardState.images.length === 0) {
return [];
}
// Determine paste position
let pastePosition: { x: number; y: number };
if (options.position) {
pastePosition = options.position;
} else {
// Use viewport center
const viewportState = get(viewport);
pastePosition = {
x: -viewportState.x + viewportWidth / 2,
y: -viewportState.y + viewportHeight / 2,
};
}
// Calculate offset to paste at center
const pastedImages: PastedImageData[] = [];
// Calculate bounding box of clipboard images
let minX = Infinity;
let minY = Infinity;
clipboardState.images.forEach((img) => {
minX = Math.min(minX, img.position.x);
minY = Math.min(minY, img.position.y);
});
// Create pasted images with new positions
clipboardState.images.forEach((img) => {
const offsetX = img.position.x - minX;
const offsetY = img.position.y - minY;
pastedImages.push({
...img,
newPosition: {
x: pastePosition.x + offsetX,
y: pastePosition.y + offsetY,
},
});
});
// Clear clipboard if requested (default for cut operation)
const shouldClear = options.clearClipboardAfter ?? clipboardState.operation === 'cut';
if (shouldClear) {
clipboard.clear();
}
// Call callback if provided
if (options.onPasteComplete) {
options.onPasteComplete(pastedImages.map((img) => img.boardImageId));
}
return pastedImages;
}
/**
* Paste at specific position
*/
export function pasteAtPosition(
x: number,
y: number,
options: PasteOptions = {}
): PastedImageData[] {
return pasteFromClipboard(0, 0, {
...options,
position: { x, y },
});
}
/**
* Check if can paste (clipboard has content)
*/
export function canPaste(): boolean {
return clipboard.hasContent();
}
/**
* Get paste preview (positions where images will be pasted)
*/
export function getPastePreview(
viewportWidth: number,
viewportHeight: number
): Array<{ x: number; y: number }> {
const clipboardState = clipboard.getClipboard();
if (clipboardState.images.length === 0) {
return [];
}
const viewportState = get(viewport);
const centerX = -viewportState.x + viewportWidth / 2;
const centerY = -viewportState.y + viewportHeight / 2;
// Calculate offsets
let minX = Infinity;
let minY = Infinity;
clipboardState.images.forEach((img) => {
minX = Math.min(minX, img.position.x);
minY = Math.min(minY, img.position.y);
});
return clipboardState.images.map((img) => ({
x: centerX + (img.position.x - minX),
y: centerY + (img.position.y - minY),
}));
}

View File

@@ -0,0 +1,181 @@
/**
* Keyboard shortcuts for canvas operations
* Handles Ctrl+A (select all), Escape (deselect), and other shortcuts
*/
import { selection } from '$lib/stores/selection';
export interface KeyboardShortcutHandlers {
onSelectAll?: (allImageIds: string[]) => void;
onDeselectAll?: () => void;
onDelete?: () => void;
onCopy?: () => void;
onCut?: () => void;
onPaste?: () => void;
onUndo?: () => void;
onRedo?: () => void;
}
/**
* Setup keyboard shortcuts for canvas
*/
export function setupKeyboardShortcuts(
getAllImageIds: () => string[],
handlers: KeyboardShortcutHandlers = {}
): () => void {
/**
* Handle keyboard shortcuts
*/
function handleKeyDown(e: KeyboardEvent) {
// Ignore if typing in input/textarea
if (
document.activeElement?.tagName === 'INPUT' ||
document.activeElement?.tagName === 'TEXTAREA'
) {
return;
}
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
// Ctrl+A / Cmd+A - Select all
if (isCtrlOrCmd && e.key === 'a') {
e.preventDefault();
const allIds = getAllImageIds();
selection.selectAll(allIds);
if (handlers.onSelectAll) {
handlers.onSelectAll(allIds);
}
return;
}
// Escape - Deselect all
if (e.key === 'Escape') {
e.preventDefault();
selection.clearSelection();
if (handlers.onDeselectAll) {
handlers.onDeselectAll();
}
return;
}
// Delete / Backspace - Delete selected
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
if (handlers.onDelete) {
handlers.onDelete();
}
return;
}
// Ctrl+C / Cmd+C - Copy
if (isCtrlOrCmd && e.key === 'c') {
e.preventDefault();
if (handlers.onCopy) {
handlers.onCopy();
}
return;
}
// Ctrl+X / Cmd+X - Cut
if (isCtrlOrCmd && e.key === 'x') {
e.preventDefault();
if (handlers.onCut) {
handlers.onCut();
}
return;
}
// Ctrl+V / Cmd+V - Paste
if (isCtrlOrCmd && e.key === 'v') {
e.preventDefault();
if (handlers.onPaste) {
handlers.onPaste();
}
return;
}
// Ctrl+Z / Cmd+Z - Undo
if (isCtrlOrCmd && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (handlers.onUndo) {
handlers.onUndo();
}
return;
}
// Ctrl+Shift+Z / Cmd+Shift+Z - Redo
if (isCtrlOrCmd && e.key === 'z' && e.shiftKey) {
e.preventDefault();
if (handlers.onRedo) {
handlers.onRedo();
}
return;
}
// Ctrl+Y / Cmd+Y - Alternative Redo
if (isCtrlOrCmd && e.key === 'y') {
e.preventDefault();
if (handlers.onRedo) {
handlers.onRedo();
}
return;
}
}
// Attach event listener
window.addEventListener('keydown', handleKeyDown);
// Return cleanup function
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}
/**
* Select all images programmatically
*/
export function selectAllImages(allImageIds: string[]): void {
selection.selectAll(allImageIds);
}
/**
* Deselect all images programmatically
*/
export function deselectAllImages(): void {
selection.clearSelection();
}
/**
* Check if modifier key is pressed
*/
export function isModifierPressed(e: KeyboardEvent): boolean {
return e.ctrlKey || e.metaKey;
}
/**
* Check if shift key is pressed
*/
export function isShiftPressed(e: KeyboardEvent): boolean {
return e.shiftKey;
}
/**
* Get keyboard shortcut display string
*/
export function getShortcutDisplay(shortcut: string): string {
const isMac = typeof navigator !== 'undefined' && /Mac/.test(navigator.platform);
return shortcut
.replace('Ctrl', isMac ? '⌘' : 'Ctrl')
.replace('Alt', isMac ? '⌥' : 'Alt')
.replace('Shift', isMac ? '⇧' : 'Shift');
}

View File

@@ -0,0 +1,160 @@
/**
* Bulk move operations for multiple selected images
* Moves all selected images together by the same delta
*/
import type Konva from 'konva';
export interface BulkMoveOptions {
animate?: boolean;
onMoveComplete?: (imageIds: string[], deltaX: number, deltaY: number) => void;
}
/**
* Move multiple images by delta
*/
export function bulkMove(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
deltaX: number,
deltaY: number,
options: BulkMoveOptions = {}
): void {
const { animate = false, onMoveComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
const currentX = image.x();
const currentY = image.y();
const newX = currentX + deltaX;
const newY = currentY + deltaY;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw if layer exists
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onMoveComplete) {
onMoveComplete(selectedIds, deltaX, deltaY);
}
}
/**
* Move multiple images to specific position (aligns top-left corners)
*/
export function bulkMoveTo(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
targetX: number,
targetY: number,
options: BulkMoveOptions = {}
): void {
const { animate = false } = options;
// Calculate current bounding box
let minX = Infinity;
let minY = Infinity;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
minX = Math.min(minX, image.x());
minY = Math.min(minY, image.y());
});
if (!isFinite(minX) || !isFinite(minY)) return;
// Calculate delta to move top-left to target
const deltaX = targetX - minX;
const deltaY = targetY - minY;
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
}
/**
* Center multiple images at specific point
*/
export function bulkCenterAt(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
centerX: number,
centerY: number,
options: BulkMoveOptions = {}
): void {
const { animate = false } = options;
// Calculate current bounding box
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.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;
const currentCenterX = (minX + maxX) / 2;
const currentCenterY = (minY + maxY) / 2;
const deltaX = centerX - currentCenterX;
const deltaY = centerY - currentCenterY;
bulkMove(images, selectedIds, deltaX, deltaY, { ...options, animate });
}
/**
* Get bounding box of multiple images
*/
export function getBulkBounds(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[]
): { x: number; y: number; width: number; height: number } | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.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 {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}

View File

@@ -0,0 +1,117 @@
/**
* Bulk rotate operations for multiple selected images
* Rotates all selected images together
*/
import type Konva from 'konva';
import { rotateImageTo, rotateImageBy } from '../transforms/rotate';
export interface BulkRotateOptions {
animate?: boolean;
onRotateComplete?: (imageIds: string[], rotation: number) => void;
}
/**
* Rotate multiple images to same angle
*/
export function bulkRotateTo(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
degrees: number,
options: BulkRotateOptions = {}
): void {
const { animate = false, onRotateComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
rotateImageTo(image, degrees, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onRotateComplete) {
onRotateComplete(selectedIds, degrees);
}
}
/**
* Rotate multiple images by delta
*/
export function bulkRotateBy(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
degrees: number,
options: BulkRotateOptions = {}
): void {
const { animate = false, onRotateComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
rotateImageBy(image, degrees, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onRotateComplete) {
// Get average rotation for callback (or first image rotation)
const firstImage = images.get(selectedIds[0]);
const rotation = firstImage ? firstImage.rotation() : 0;
onRotateComplete(selectedIds, rotation);
}
}
/**
* Rotate multiple images 90° clockwise
*/
export function bulkRotate90CW(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateBy(images, selectedIds, 90, options);
}
/**
* Rotate multiple images 90° counter-clockwise
*/
export function bulkRotate90CCW(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateBy(images, selectedIds, -90, options);
}
/**
* Rotate multiple images 180°
*/
export function bulkRotate180(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateBy(images, selectedIds, 180, options);
}
/**
* Reset rotation for multiple images
*/
export function bulkResetRotation(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkRotateOptions = {}
): void {
bulkRotateTo(images, selectedIds, 0, options);
}

View File

@@ -0,0 +1,151 @@
/**
* Bulk scale operations for multiple selected images
* Scales all selected images together
*/
import type Konva from 'konva';
import { scaleImageTo, scaleImageBy } from '../transforms/scale';
export interface BulkScaleOptions {
animate?: boolean;
onScaleComplete?: (imageIds: string[], scale: number) => void;
}
/**
* Scale multiple images to same factor
*/
export function bulkScaleTo(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
scale: number,
options: BulkScaleOptions = {}
): void {
const { animate = false, onScaleComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
scaleImageTo(image, scale, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onScaleComplete) {
onScaleComplete(selectedIds, scale);
}
}
/**
* Scale multiple images by factor
*/
export function bulkScaleBy(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
factor: number,
options: BulkScaleOptions = {}
): void {
const { animate = false, onScaleComplete } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
scaleImageBy(image, factor, animate);
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onScaleComplete) {
// Get average scale for callback (or first image scale)
const firstImage = images.get(selectedIds[0]);
const scale = firstImage ? Math.abs(firstImage.scaleX()) : 1.0;
onScaleComplete(selectedIds, scale);
}
}
/**
* Double size of multiple images
*/
export function bulkDoubleSize(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkScaleOptions = {}
): void {
bulkScaleBy(images, selectedIds, 2.0, options);
}
/**
* Half size of multiple images
*/
export function bulkHalfSize(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkScaleOptions = {}
): void {
bulkScaleBy(images, selectedIds, 0.5, options);
}
/**
* Reset scale for multiple images
*/
export function bulkResetScale(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
options: BulkScaleOptions = {}
): void {
bulkScaleTo(images, selectedIds, 1.0, options);
}
/**
* Scale uniformly while maintaining relative positions
*/
export function bulkScaleUniform(
images: Map<string, Konva.Image | Konva.Group>,
selectedIds: string[],
factor: number,
centerX: number,
centerY: number,
options: BulkScaleOptions = {}
): void {
const { animate = false } = options;
selectedIds.forEach((id) => {
const image = images.get(id);
if (!image) return;
// Scale the image
scaleImageBy(image, factor, animate);
// Adjust position to scale around center point
const x = image.x();
const y = image.y();
const newX = centerX + (x - centerX) * factor;
const newY = centerY + (y - centerY) * factor;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw
const firstImage = selectedIds.length > 0 ? images.get(selectedIds[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
}

View File

@@ -0,0 +1,100 @@
/**
* Delete operation for canvas images
* Handles deletion with confirmation for large selections
*/
import { selection } from '$lib/stores/selection';
export interface DeleteOptions {
confirmationThreshold?: number; // Show confirmation if deleting more than this (default: 10)
onDeleteConfirm?: (imageIds: string[]) => Promise<boolean>; // Return true to proceed
onDeleteComplete?: (deletedIds: string[]) => void;
}
const DEFAULT_CONFIRMATION_THRESHOLD = 10;
/**
* Delete selected images
*/
export async function deleteSelectedImages(
options: DeleteOptions = {}
): Promise<{ deleted: string[]; cancelled: boolean }> {
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
return { deleted: [], cancelled: false };
}
return deleteImages(selectedIds, options);
}
/**
* Delete specific images
*/
export async function deleteImages(
imageIds: string[],
options: DeleteOptions = {}
): Promise<{ deleted: string[]; cancelled: boolean }> {
const {
confirmationThreshold = DEFAULT_CONFIRMATION_THRESHOLD,
onDeleteConfirm,
onDeleteComplete,
} = options;
// Check if confirmation needed
const needsConfirmation = imageIds.length > confirmationThreshold;
if (needsConfirmation && onDeleteConfirm) {
const confirmed = await onDeleteConfirm(imageIds);
if (!confirmed) {
return { deleted: [], cancelled: true };
}
}
// Proceed with deletion
const deletedIds = [...imageIds];
// Clear selection of deleted images
deletedIds.forEach((id) => {
selection.removeFromSelection(id);
});
// Call completion callback
if (onDeleteComplete) {
onDeleteComplete(deletedIds);
}
return { deleted: deletedIds, cancelled: false };
}
/**
* Delete single image
*/
export async function deleteSingleImage(
imageId: string,
options: DeleteOptions = {}
): Promise<boolean> {
const result = await deleteImages([imageId], options);
return !result.cancelled && result.deleted.length > 0;
}
/**
* Get delete confirmation message
*/
export function getDeleteConfirmationMessage(count: number): string {
if (count === 1) {
return 'Are you sure you want to delete this image from the board?';
}
return `Are you sure you want to delete ${count} images from the board?`;
}
/**
* Check if deletion needs confirmation
*/
export function needsDeleteConfirmation(
count: number,
threshold: number = DEFAULT_CONFIRMATION_THRESHOLD
): boolean {
return count > threshold;
}

View File

@@ -0,0 +1,211 @@
<script lang="ts">
/**
* Delete confirmation modal for canvas images
* Shows confirmation dialog when deleting multiple images
*/
import { createEventDispatcher } from 'svelte';
export let imageCount: number = 0;
export let show: boolean = false;
const dispatch = createEventDispatcher();
function handleConfirm() {
dispatch('confirm');
show = false;
}
function handleCancel() {
dispatch('cancel');
show = false;
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleCancel();
}
}
</script>
{#if show}
<div
class="modal-backdrop"
on:click={handleBackdropClick}
on:keydown={(e) => e.key === 'Escape' && handleCancel()}
role="button"
tabindex="-1"
>
<div class="modal-content" role="dialog" aria-labelledby="modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="modal-title">
{imageCount === 1 ? 'Delete Image?' : `Delete ${imageCount} Images?`}
</h2>
<button class="close-button" on:click={handleCancel} aria-label="Close">×</button>
</div>
<div class="modal-body">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="warning-icon"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<p>
{#if imageCount === 1}
Are you sure you want to delete this image from the board?
{:else}
Are you sure you want to delete {imageCount} images from the board?
{/if}
</p>
<p class="note">
This will remove {imageCount === 1 ? 'the image' : 'these images'} from the board.
{imageCount === 1 ? 'The image' : 'Images'} will remain in your library.
</p>
</div>
<div class="modal-footer">
<button class="button button-secondary" on:click={handleCancel}> Cancel </button>
<button class="button button-danger" on:click={handleConfirm}> Delete </button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-content {
background-color: var(--color-bg, #ffffff);
border-radius: 0.75rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.close-button {
background: none;
border: none;
font-size: 1.75rem;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
transition: background-color 0.2s;
}
.close-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
}
.warning-icon {
color: var(--color-warning, #f59e0b);
flex-shrink: 0;
}
.modal-body p {
margin: 0;
color: var(--color-text, #374151);
line-height: 1.5;
}
.note {
font-size: 0.875rem;
color: var(--color-text-secondary, #6b7280);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.button {
padding: 0.625rem 1.25rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.button-secondary {
background-color: var(--color-bg-secondary, #f3f4f6);
color: var(--color-text, #374151);
border-color: var(--color-border, #d1d5db);
}
.button-secondary:hover {
background-color: var(--color-bg-hover, #e5e7eb);
}
.button-danger {
background-color: var(--color-error, #ef4444);
color: white;
border: none;
}
.button-danger:hover {
background-color: var(--color-error-hover, #dc2626);
}
</style>

View File

@@ -0,0 +1,107 @@
<script context="module">
import { fade } from 'svelte/transition';
</script>
<script lang="ts">
/**
* Selection counter indicator
* Shows count of selected images in the canvas
*/
import { selectionCount, hasSelection } from '$lib/stores/selection';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleClearSelection() {
dispatch('clear-selection');
}
</script>
{#if $hasSelection}
<div class="selection-counter" transition:fade={{ duration: 200 }}>
<div class="counter-content">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<path d="M9 11l3 3L22 4" />
</svg>
<span class="count">{$selectionCount}</span>
<span class="label">
{$selectionCount === 1 ? 'image selected' : 'images selected'}
</span>
</div>
<button class="clear-button" on:click={handleClearSelection} title="Clear selection (Esc)">
×
</button>
</div>
{/if}
<style>
.selection-counter {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.25rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border-radius: 2rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 1000;
}
.counter-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.count {
font-size: 1.125rem;
font-weight: 700;
}
.label {
font-size: 0.875rem;
font-weight: 500;
}
.clear-button {
background: none;
border: none;
color: white;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s;
}
.clear-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
svg {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,104 @@
/**
* Clipboard store for copy/cut/paste operations
* Manages clipboard state for canvas images
*/
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface ClipboardImageData {
boardImageId: string;
imageId: string;
position: { x: number; y: number };
transformations: Record<string, unknown>;
zOrder: number;
}
export interface ClipboardState {
images: ClipboardImageData[];
operation: 'copy' | 'cut' | null;
}
const DEFAULT_CLIPBOARD: ClipboardState = {
images: [],
operation: null,
};
/**
* Create clipboard store
*/
function createClipboardStore() {
const { subscribe, set, update }: Writable<ClipboardState> = writable(DEFAULT_CLIPBOARD);
return {
subscribe,
set,
update,
/**
* Copy images to clipboard
*/
copy: (images: ClipboardImageData[]) => {
set({
images: [...images],
operation: 'copy',
});
},
/**
* Cut images to clipboard (copy + mark for deletion)
*/
cut: (images: ClipboardImageData[]) => {
set({
images: [...images],
operation: 'cut',
});
},
/**
* Clear clipboard
*/
clear: () => {
set(DEFAULT_CLIPBOARD);
},
/**
* Get clipboard contents
*/
getClipboard: (): ClipboardState => {
let result = DEFAULT_CLIPBOARD;
const unsubscribe = subscribe((state) => {
result = state;
});
unsubscribe();
return result;
},
/**
* Check if clipboard has content
*/
hasContent: (): boolean => {
let result = false;
const unsubscribe = subscribe((state) => {
result = state.images.length > 0;
});
unsubscribe();
return result;
},
};
}
export const clipboard = createClipboardStore();
// Derived stores
export const hasClipboardContent = derived(clipboard, ($clipboard) => {
return $clipboard.images.length > 0;
});
export const clipboardCount = derived(clipboard, ($clipboard) => {
return $clipboard.images.length;
});
export const isCutOperation = derived(clipboard, ($clipboard) => {
return $clipboard.operation === 'cut';
});

View File

@@ -0,0 +1,443 @@
/**
* Tests for clipboard operations (copy, cut, paste)
* Tests clipboard store and operations
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import {
clipboard,
hasClipboardContent,
clipboardCount,
isCutOperation,
} from '$lib/stores/clipboard';
import type { ClipboardImageData } from '$lib/stores/clipboard';
import {
copySelectedImages,
copyImages,
copySingleImage,
hasClipboardContent as hasContent,
getClipboardCount,
} from '$lib/canvas/clipboard/copy';
import { cutSelectedImages, cutImages, cutSingleImage } from '$lib/canvas/clipboard/cut';
import {
pasteFromClipboard,
pasteAtPosition,
canPaste,
getPastePreview,
} from '$lib/canvas/clipboard/paste';
import { selection } from '$lib/stores/selection';
import { viewport } from '$lib/stores/viewport';
describe('Clipboard Store', () => {
beforeEach(() => {
clipboard.clear();
selection.clearSelection();
});
it('starts empty', () => {
const state = get(clipboard);
expect(state.images).toEqual([]);
expect(state.operation).toBeNull();
});
it('stores copied images', () => {
const images: ClipboardImageData[] = [
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
];
clipboard.copy(images);
const state = get(clipboard);
expect(state.images).toHaveLength(1);
expect(state.operation).toBe('copy');
});
it('stores cut images', () => {
const images: ClipboardImageData[] = [
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
];
clipboard.cut(images);
const state = get(clipboard);
expect(state.images).toHaveLength(1);
expect(state.operation).toBe('cut');
});
it('clears clipboard', () => {
const images: ClipboardImageData[] = [
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
];
clipboard.copy(images);
clipboard.clear();
const state = get(clipboard);
expect(state.images).toEqual([]);
expect(state.operation).toBeNull();
});
it('hasClipboardContent reflects state', () => {
expect(get(hasClipboardContent)).toBe(false);
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(get(hasClipboardContent)).toBe(true);
});
it('clipboardCount reflects count', () => {
expect(get(clipboardCount)).toBe(0);
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
{
boardImageId: 'bi2',
imageId: 'img2',
position: { x: 200, y: 200 },
transformations: {},
zOrder: 1,
},
]);
expect(get(clipboardCount)).toBe(2);
});
it('isCutOperation reflects operation type', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(get(isCutOperation)).toBe(false);
clipboard.cut([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(get(isCutOperation)).toBe(true);
});
});
describe('Copy Operations', () => {
beforeEach(() => {
clipboard.clear();
selection.clearSelection();
});
it('copies selected images', () => {
selection.selectMultiple(['img1', 'img2']);
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = copySelectedImages(getImageData);
expect(count).toBe(2);
expect(get(clipboardCount)).toBe(2);
expect(get(isCutOperation)).toBe(false);
});
it('copies specific images', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = copyImages(['img1', 'img2', 'img3'], getImageData);
expect(count).toBe(3);
expect(get(clipboardCount)).toBe(3);
});
it('copies single image', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const success = copySingleImage(getImageData, 'img1');
expect(success).toBe(true);
expect(get(clipboardCount)).toBe(1);
});
it('returns 0 when copying empty selection', () => {
const getImageData = (): ClipboardImageData | null => null;
const count = copySelectedImages(getImageData);
expect(count).toBe(0);
});
it('hasClipboardContent returns true after copy', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
copyImages(['img1'], getImageData);
expect(hasContent()).toBe(true);
expect(getClipboardCount()).toBe(1);
});
});
describe('Cut Operations', () => {
beforeEach(() => {
clipboard.clear();
selection.clearSelection();
});
it('cuts selected images', () => {
selection.selectMultiple(['img1', 'img2']);
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = cutSelectedImages(getImageData);
expect(count).toBe(2);
expect(get(clipboardCount)).toBe(2);
expect(get(isCutOperation)).toBe(true);
});
it('cuts specific images', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = cutImages(['img1', 'img2'], getImageData);
expect(count).toBe(2);
expect(get(isCutOperation)).toBe(true);
});
it('cuts single image', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const success = cutSingleImage(getImageData, 'img1');
expect(success).toBe(true);
expect(get(clipboardCount)).toBe(1);
expect(get(isCutOperation)).toBe(true);
});
});
describe('Paste Operations', () => {
beforeEach(() => {
clipboard.clear();
viewport.reset();
});
it('pastes images at viewport center', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
{
boardImageId: 'bi2',
imageId: 'img2',
position: { x: 200, y: 100 },
transformations: {},
zOrder: 1,
},
]);
const pasted = pasteFromClipboard(800, 600);
expect(pasted).toHaveLength(2);
expect(pasted[0].newPosition).toBeDefined();
expect(pasted[1].newPosition).toBeDefined();
});
it('pastes at specific position', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
const pasted = pasteAtPosition(500, 500);
expect(pasted).toHaveLength(1);
expect(pasted[0].newPosition.x).toBe(500);
expect(pasted[0].newPosition.y).toBe(500);
});
it('preserves relative positions when pasting', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
{
boardImageId: 'bi2',
imageId: 'img2',
position: { x: 200, y: 150 },
transformations: {},
zOrder: 1,
},
]);
const pasted = pasteAtPosition(0, 0);
// Relative distance should be preserved
const deltaX1 = pasted[0].newPosition.x;
const deltaX2 = pasted[1].newPosition.x;
expect(deltaX2 - deltaX1).toBe(100); // Original was 200 - 100 = 100
});
it('clears clipboard after cut paste', () => {
clipboard.cut([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
pasteAtPosition(200, 200);
expect(get(hasClipboardContent)).toBe(false);
});
it('preserves clipboard after copy paste', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
pasteAtPosition(200, 200);
expect(get(hasClipboardContent)).toBe(true);
});
it('returns empty array when pasting empty clipboard', () => {
const pasted = pasteFromClipboard(800, 600);
expect(pasted).toEqual([]);
});
it('canPaste reflects clipboard state', () => {
expect(canPaste()).toBe(false);
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(canPaste()).toBe(true);
});
it('getPastePreview shows where images will be pasted', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
const preview = getPastePreview(800, 600);
expect(preview).toHaveLength(1);
expect(preview[0]).toHaveProperty('x');
expect(preview[0]).toHaveProperty('y');
});
});

View File

@@ -0,0 +1,478 @@
/**
* Tests for multi-selection functionality
* Tests rectangle selection, Ctrl+A, and bulk operations
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Konva from 'konva';
import { get } from 'svelte/store';
import { selection } from '$lib/stores/selection';
import {
setupRectangleSelection,
isRectangleSelecting,
getCurrentSelectionRect,
cancelRectangleSelection,
} from '$lib/canvas/interactions/multiselect';
import { setupKeyboardShortcuts, selectAllImages, deselectAllImages } from '$lib/canvas/keyboard';
import {
bulkMove,
bulkMoveTo,
bulkCenterAt,
getBulkBounds,
} from '$lib/canvas/operations/bulk-move';
import { bulkRotateTo, bulkRotateBy, bulkRotate90CW } from '$lib/canvas/operations/bulk-rotate';
import { bulkScaleTo, bulkScaleBy, bulkDoubleSize } from '$lib/canvas/operations/bulk-scale';
describe('Rectangle Selection', () => {
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);
selection.clearSelection();
});
afterEach(() => {
stage.destroy();
document.body.innerHTML = '';
});
it('sets up rectangle selection on stage', () => {
const getImageBounds = () => [];
const cleanup = setupRectangleSelection(stage, layer, getImageBounds);
expect(typeof cleanup).toBe('function');
cleanup();
});
it('starts selecting on background click', () => {
const getImageBounds = () => [];
setupRectangleSelection(stage, layer, getImageBounds);
expect(isRectangleSelecting()).toBe(false);
// Note: Actual mouse events would trigger this
// This test verifies the function exists
});
it('cancels rectangle selection', () => {
cancelRectangleSelection(layer);
expect(isRectangleSelecting()).toBe(false);
expect(getCurrentSelectionRect()).toBeNull();
});
});
describe('Keyboard Shortcuts', () => {
beforeEach(() => {
selection.clearSelection();
});
it('sets up keyboard shortcuts', () => {
const getAllIds = () => ['img1', 'img2', 'img3'];
const cleanup = setupKeyboardShortcuts(getAllIds);
expect(typeof cleanup).toBe('function');
cleanup();
});
it('selects all images programmatically', () => {
const allIds = ['img1', 'img2', 'img3'];
selectAllImages(allIds);
expect(get(selection).selectedIds.size).toBe(3);
});
it('deselects all images programmatically', () => {
selection.selectMultiple(['img1', 'img2']);
deselectAllImages();
expect(get(selection).selectedIds.size).toBe(0);
});
});
describe('Bulk Move Operations', () => {
let images: Map<string, Konva.Image>;
let layer: Konva.Layer;
beforeEach(() => {
const container = document.createElement('div');
document.body.appendChild(container);
const stage = new Konva.Stage({
container,
width: 800,
height: 600,
});
layer = new Konva.Layer();
stage.add(layer);
images = new Map();
// Create test images
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,
x: 100 + index * 150,
y: 100,
width: 100,
height: 100,
});
layer.add(img);
images.set(id, img);
});
layer.draw();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('moves multiple images by delta', () => {
bulkMove(images, ['img1', 'img2'], 50, 75);
expect(images.get('img1')?.x()).toBe(150);
expect(images.get('img1')?.y()).toBe(175);
expect(images.get('img2')?.x()).toBe(300);
expect(images.get('img2')?.y()).toBe(175);
expect(images.get('img3')?.x()).toBe(400); // Unchanged
});
it('moves multiple images to position', () => {
bulkMoveTo(images, ['img1', 'img2'], 200, 200);
const img1 = images.get('img1');
const img2 = images.get('img2');
// One of them should be at 200,200 (the top-left one)
const minX = Math.min(img1?.x() || 0, img2?.x() || 0);
expect(minX).toBe(200);
});
it('centers multiple images at point', () => {
bulkCenterAt(images, ['img1', 'img2', 'img3'], 400, 300);
const bounds = getBulkBounds(images, ['img1', 'img2', 'img3']);
expect(bounds).not.toBeNull();
if (bounds) {
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
expect(centerX).toBeCloseTo(400, 0);
expect(centerY).toBeCloseTo(300, 0);
}
});
it('calculates bulk bounds correctly', () => {
const bounds = getBulkBounds(images, ['img1', 'img2', 'img3']);
expect(bounds).not.toBeNull();
if (bounds) {
expect(bounds.x).toBe(100);
expect(bounds.width).toBeGreaterThan(300);
}
});
it('returns null for empty selection', () => {
const bounds = getBulkBounds(images, []);
expect(bounds).toBeNull();
});
it('calls onMoveComplete callback', () => {
const callback = vi.fn();
bulkMove(images, ['img1', 'img2'], 50, 50, { onMoveComplete: callback });
expect(callback).toHaveBeenCalledWith(['img1', 'img2'], 50, 50);
});
});
describe('Bulk Rotate Operations', () => {
let images: Map<string, Konva.Image>;
beforeEach(() => {
images = new Map();
const imageElement = new Image();
imageElement.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
['img1', 'img2'].forEach((id) => {
const img = new Konva.Image({
image: imageElement,
width: 100,
height: 100,
});
images.set(id, img);
});
});
it('rotates multiple images to angle', () => {
bulkRotateTo(images, ['img1', 'img2'], 45);
expect(images.get('img1')?.rotation()).toBe(45);
expect(images.get('img2')?.rotation()).toBe(45);
});
it('rotates multiple images by delta', () => {
images.get('img1')?.rotation(30);
images.get('img2')?.rotation(60);
bulkRotateBy(images, ['img1', 'img2'], 15);
expect(images.get('img1')?.rotation()).toBe(45);
expect(images.get('img2')?.rotation()).toBe(75);
});
it('rotates 90° clockwise', () => {
bulkRotate90CW(images, ['img1', 'img2']);
expect(images.get('img1')?.rotation()).toBe(90);
expect(images.get('img2')?.rotation()).toBe(90);
});
it('calls onRotateComplete callback', () => {
const callback = vi.fn();
bulkRotateTo(images, ['img1', 'img2'], 90, { onRotateComplete: callback });
expect(callback).toHaveBeenCalled();
});
});
describe('Bulk Scale Operations', () => {
let images: Map<string, Konva.Image>;
beforeEach(() => {
images = new Map();
const imageElement = new Image();
imageElement.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
['img1', 'img2'].forEach((id) => {
const img = new Konva.Image({
image: imageElement,
width: 100,
height: 100,
});
images.set(id, img);
});
});
it('scales multiple images to factor', () => {
bulkScaleTo(images, ['img1', 'img2'], 2.0);
expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(2.0);
expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(2.0);
});
it('scales multiple images by factor', () => {
images.get('img1')?.scale({ x: 1.5, y: 1.5 });
images.get('img2')?.scale({ x: 2.0, y: 2.0 });
bulkScaleBy(images, ['img1', 'img2'], 2.0);
expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(3.0);
expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(4.0);
});
it('doubles size of multiple images', () => {
bulkDoubleSize(images, ['img1', 'img2']);
expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(2.0);
expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(2.0);
});
it('calls onScaleComplete callback', () => {
const callback = vi.fn();
bulkScaleTo(images, ['img1', 'img2'], 1.5, { onScaleComplete: callback });
expect(callback).toHaveBeenCalled();
});
});
describe('Bulk Operations Integration', () => {
let images: Map<string, Konva.Image>;
let layer: Konva.Layer;
beforeEach(() => {
const container = document.createElement('div');
document.body.appendChild(container);
const stage = new Konva.Stage({
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,
x: 100 + index * 150,
y: 100,
width: 100,
height: 100,
});
layer.add(img);
images.set(id, img);
});
layer.draw();
selection.clearSelection();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('applies multiple transformations to selection', () => {
selection.selectMultiple(['img1', 'img2']);
bulkMove(images, ['img1', 'img2'], 50, 50);
bulkRotateTo(images, ['img1', 'img2'], 45);
bulkScaleTo(images, ['img1', 'img2'], 1.5);
const img1 = images.get('img1');
const img2 = images.get('img2');
expect(img1?.x()).toBe(150);
expect(img1?.rotation()).toBe(45);
expect(Math.abs(img1?.scaleX() || 0)).toBe(1.5);
expect(img2?.x()).toBe(300);
expect(img2?.rotation()).toBe(45);
expect(Math.abs(img2?.scaleX() || 0)).toBe(1.5);
});
it('preserves relative positions during bulk operations', () => {
const initialDist = (images.get('img2')?.x() || 0) - (images.get('img1')?.x() || 0);
bulkMove(images, ['img1', 'img2'], 100, 100);
const finalDist = (images.get('img2')?.x() || 0) - (images.get('img1')?.x() || 0);
expect(finalDist).toBe(initialDist);
});
it('handles empty selection gracefully', () => {
bulkMove(images, [], 50, 50);
bulkRotateTo(images, [], 90);
bulkScaleTo(images, [], 2.0);
// Should not throw, images should be unchanged
expect(images.get('img1')?.x()).toBe(100);
});
});
describe('Keyboard Shortcut Integration', () => {
beforeEach(() => {
selection.clearSelection();
});
it('Ctrl+A callback receives all IDs', () => {
const allIds = ['img1', 'img2', 'img3'];
const callback = vi.fn();
const cleanup = setupKeyboardShortcuts(() => allIds, {
onSelectAll: callback,
});
// Simulate Ctrl+A
const event = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true,
});
window.dispatchEvent(event);
expect(callback).toHaveBeenCalledWith(allIds);
cleanup();
});
it('Escape callback is called on deselect', () => {
selection.selectMultiple(['img1', 'img2']);
const callback = vi.fn();
const cleanup = setupKeyboardShortcuts(() => [], {
onDeselectAll: callback,
});
// Simulate Escape
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
});
window.dispatchEvent(event);
expect(callback).toHaveBeenCalled();
expect(get(selection).selectedIds.size).toBe(0);
cleanup();
});
it('ignores shortcuts when typing in input', () => {
const callback = vi.fn();
// Create and focus an input
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
const cleanup = setupKeyboardShortcuts(() => ['img1'], {
onSelectAll: callback,
});
// Try Ctrl+A while focused on input
const event = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true,
});
window.dispatchEvent(event);
// Callback should not be called
expect(callback).not.toHaveBeenCalled();
cleanup();
document.body.removeChild(input);
});
});