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
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:
86
frontend/src/lib/canvas/clipboard/copy.ts
Normal file
86
frontend/src/lib/canvas/clipboard/copy.ts
Normal 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;
|
||||
}
|
||||
69
frontend/src/lib/canvas/clipboard/cut.ts
Normal file
69
frontend/src/lib/canvas/clipboard/cut.ts
Normal 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;
|
||||
}
|
||||
139
frontend/src/lib/canvas/clipboard/paste.ts
Normal file
139
frontend/src/lib/canvas/clipboard/paste.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
181
frontend/src/lib/canvas/keyboard.ts
Normal file
181
frontend/src/lib/canvas/keyboard.ts
Normal 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');
|
||||
}
|
||||
160
frontend/src/lib/canvas/operations/bulk-move.ts
Normal file
160
frontend/src/lib/canvas/operations/bulk-move.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
117
frontend/src/lib/canvas/operations/bulk-rotate.ts
Normal file
117
frontend/src/lib/canvas/operations/bulk-rotate.ts
Normal 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);
|
||||
}
|
||||
151
frontend/src/lib/canvas/operations/bulk-scale.ts
Normal file
151
frontend/src/lib/canvas/operations/bulk-scale.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
100
frontend/src/lib/canvas/operations/delete.ts
Normal file
100
frontend/src/lib/canvas/operations/delete.ts
Normal 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;
|
||||
}
|
||||
211
frontend/src/lib/components/canvas/DeleteConfirmModal.svelte
Normal file
211
frontend/src/lib/components/canvas/DeleteConfirmModal.svelte
Normal 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>
|
||||
107
frontend/src/lib/components/canvas/SelectionCounter.svelte
Normal file
107
frontend/src/lib/components/canvas/SelectionCounter.svelte
Normal 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>
|
||||
104
frontend/src/lib/stores/clipboard.ts
Normal file
104
frontend/src/lib/stores/clipboard.ts
Normal 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';
|
||||
});
|
||||
443
frontend/tests/canvas/clipboard.test.ts
Normal file
443
frontend/tests/canvas/clipboard.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
478
frontend/tests/canvas/multiselect.test.ts
Normal file
478
frontend/tests/canvas/multiselect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user