From ce0b692aee5388f5b7a64426478ced6dc755e657 Mon Sep 17 00:00:00 2001 From: Danilo Reyes Date: Sun, 2 Nov 2025 14:13:56 -0600 Subject: [PATCH] phase 8 --- backend/tests/images/test_transformations.py | 236 +++++++ frontend/.eslintrc.cjs | 24 +- frontend/.prettierrc | 1 - frontend/eslint.config.js | 1 - frontend/src/lib/canvas/transforms/crop.ts | 180 +++++ frontend/src/lib/canvas/transforms/flip.ts | 100 +++ .../src/lib/canvas/transforms/greyscale.ts | 70 ++ frontend/src/lib/canvas/transforms/opacity.ts | 96 +++ frontend/src/lib/canvas/transforms/reset.ts | 106 +++ frontend/src/lib/canvas/transforms/rotate.ts | 79 +++ frontend/src/lib/canvas/transforms/scale.ts | 109 +++ .../components/canvas/TransformPanel.svelte | 401 +++++++++++ frontend/svelte.config.js | 1 - frontend/tests/canvas/controls.test.ts | 1 - frontend/tests/canvas/drag.test.ts | 13 +- frontend/tests/canvas/select.test.ts | 8 +- frontend/tests/canvas/transforms.test.ts | 626 ++++++++++++++++++ frontend/tests/components/auth.test.ts | 1 - frontend/tests/components/boards.test.ts | 1 - frontend/tests/components/upload.test.ts | 1 - frontend/tsconfig.json | 1 - frontend/vitest.config.ts | 1 - specs/001-reference-board-viewer/tasks.md | 42 +- 23 files changed, 2049 insertions(+), 50 deletions(-) create mode 100644 backend/tests/images/test_transformations.py create mode 100644 frontend/src/lib/canvas/transforms/crop.ts create mode 100644 frontend/src/lib/canvas/transforms/flip.ts create mode 100644 frontend/src/lib/canvas/transforms/greyscale.ts create mode 100644 frontend/src/lib/canvas/transforms/opacity.ts create mode 100644 frontend/src/lib/canvas/transforms/reset.ts create mode 100644 frontend/src/lib/canvas/transforms/rotate.ts create mode 100644 frontend/src/lib/canvas/transforms/scale.ts create mode 100644 frontend/src/lib/components/canvas/TransformPanel.svelte create mode 100644 frontend/tests/canvas/transforms.test.ts diff --git a/backend/tests/images/test_transformations.py b/backend/tests/images/test_transformations.py new file mode 100644 index 0000000..999092e --- /dev/null +++ b/backend/tests/images/test_transformations.py @@ -0,0 +1,236 @@ +"""Tests for image transformation validation.""" + +import pytest +from pydantic import ValidationError + +from app.images.schemas import BoardImageUpdate + + +def test_valid_transformations(): + """Test that valid transformations are accepted.""" + data = BoardImageUpdate( + transformations={ + "scale": 1.5, + "rotation": 45, + "opacity": 0.8, + "flipped_h": True, + "flipped_v": False, + "greyscale": False, + } + ) + + assert data.transformations is not None + assert data.transformations["scale"] == 1.5 + assert data.transformations["rotation"] == 45 + assert data.transformations["opacity"] == 0.8 + assert data.transformations["flipped_h"] is True + assert data.transformations["greyscale"] is False + + +def test_minimal_transformations(): + """Test that minimal transformation data is accepted.""" + data = BoardImageUpdate( + transformations={ + "scale": 1.0, + "rotation": 0, + "opacity": 1.0, + } + ) + + assert data.transformations is not None + + +def test_transformation_scale_bounds(): + """Test scale bounds validation.""" + # Valid scales + valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0] + + for scale in valid_scales: + data = BoardImageUpdate(transformations={"scale": scale}) + assert data.transformations["scale"] == scale + + +def test_transformation_rotation_bounds(): + """Test rotation bounds (any value allowed, normalized client-side).""" + # Various rotation values + rotations = [0, 45, 90, 180, 270, 360, 450, -90] + + for rotation in rotations: + data = BoardImageUpdate(transformations={"rotation": rotation}) + assert data.transformations["rotation"] == rotation + + +def test_transformation_opacity_bounds(): + """Test opacity bounds.""" + # Valid opacity values + valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0] + + for opacity in valid_opacities: + data = BoardImageUpdate(transformations={"opacity": opacity}) + assert data.transformations["opacity"] == opacity + + +def test_transformation_boolean_flags(): + """Test boolean transformation flags.""" + data = BoardImageUpdate( + transformations={ + "flipped_h": True, + "flipped_v": True, + "greyscale": True, + } + ) + + assert data.transformations["flipped_h"] is True + assert data.transformations["flipped_v"] is True + assert data.transformations["greyscale"] is True + + +def test_transformation_crop_data(): + """Test crop transformation data.""" + data = BoardImageUpdate( + transformations={ + "crop": { + "x": 10, + "y": 10, + "width": 100, + "height": 100, + } + } + ) + + assert data.transformations["crop"] is not None + assert data.transformations["crop"]["x"] == 10 + assert data.transformations["crop"]["width"] == 100 + + +def test_transformation_null_crop(): + """Test that crop can be null (no crop).""" + data = BoardImageUpdate( + transformations={ + "crop": None, + } + ) + + assert data.transformations["crop"] is None + + +def test_partial_transformation_update(): + """Test updating only some transformation fields.""" + # Only update scale + data = BoardImageUpdate(transformations={"scale": 2.0}) + assert data.transformations["scale"] == 2.0 + + # Only update rotation + data = BoardImageUpdate(transformations={"rotation": 90}) + assert data.transformations["rotation"] == 90 + + # Only update opacity + data = BoardImageUpdate(transformations={"opacity": 0.5}) + assert data.transformations["opacity"] == 0.5 + + +def test_complete_transformation_update(): + """Test updating all transformation fields.""" + data = BoardImageUpdate( + transformations={ + "scale": 1.5, + "rotation": 45, + "opacity": 0.8, + "flipped_h": True, + "flipped_v": False, + "greyscale": True, + "crop": { + "x": 20, + "y": 20, + "width": 150, + "height": 150, + }, + } + ) + + assert data.transformations is not None + assert len(data.transformations) == 7 + + +def test_position_validation_with_transformations(): + """Test that position and transformations can be updated together.""" + data = BoardImageUpdate( + position={"x": 100, "y": 200}, + transformations={"scale": 1.5, "rotation": 45}, + ) + + assert data.position == {"x": 100, "y": 200} + assert data.transformations["scale"] == 1.5 + assert data.transformations["rotation"] == 45 + + +def test_invalid_position_missing_x(): + """Test that position without x coordinate is rejected.""" + with pytest.raises(ValidationError) as exc_info: + BoardImageUpdate(position={"y": 100}) + + assert "must contain 'x' and 'y'" in str(exc_info.value) + + +def test_invalid_position_missing_y(): + """Test that position without y coordinate is rejected.""" + with pytest.raises(ValidationError) as exc_info: + BoardImageUpdate(position={"x": 100}) + + assert "must contain 'x' and 'y'" in str(exc_info.value) + + +def test_z_order_update(): + """Test Z-order update.""" + data = BoardImageUpdate(z_order=5) + assert data.z_order == 5 + + # Negative Z-order allowed (layering) + data = BoardImageUpdate(z_order=-1) + assert data.z_order == -1 + + # Large Z-order allowed + data = BoardImageUpdate(z_order=999999) + assert data.z_order == 999999 + + +def test_group_id_update(): + """Test group ID update.""" + from uuid import uuid4 + + group_id = uuid4() + data = BoardImageUpdate(group_id=group_id) + assert data.group_id == group_id + + # Null group ID (remove from group) + data = BoardImageUpdate(group_id=None) + assert data.group_id is None + + +def test_empty_update(): + """Test that empty update (no fields) is valid.""" + data = BoardImageUpdate() + + assert data.position is None + assert data.transformations is None + assert data.z_order is None + assert data.group_id is None + + +def test_transformation_data_types(): + """Test that transformation data types are validated.""" + # Valid types + data = BoardImageUpdate( + transformations={ + "scale": 1.5, # float + "rotation": 45, # int (converted to float) + "opacity": 0.8, # float + "flipped_h": True, # bool + "flipped_v": False, # bool + "greyscale": True, # bool + } + ) + + assert isinstance(data.transformations["scale"], (int, float)) + assert isinstance(data.transformations["flipped_h"], bool) + diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 08b6b1b..c88e71e 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -4,28 +4,28 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:svelte/recommended', - 'prettier' + 'prettier', ], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint'], parserOptions: { sourceType: 'module', ecmaVersion: 2020, - extraFileExtensions: ['.svelte'] + extraFileExtensions: ['.svelte'], }, env: { browser: true, es2017: true, - node: true + node: true, }, overrides: [ { files: ['*.svelte'], parser: 'svelte-eslint-parser', parserOptions: { - parser: '@typescript-eslint/parser' - } - } + parser: '@typescript-eslint/parser', + }, + }, ], rules: { // TypeScript rules @@ -33,18 +33,18 @@ module.exports = { 'error', { argsIgnorePattern: '^_', - varsIgnorePattern: '^_' - } + varsIgnorePattern: '^_', + }, ], '@typescript-eslint/no-explicit-any': 'warn', - + // General rules 'no-console': ['warn', { allow: ['warn', 'error'] }], 'prefer-const': 'error', 'no-var': 'error', - + // Svelte specific 'svelte/no-at-html-tags': 'error', - 'svelte/no-target-blank': 'error' - } + 'svelte/no-target-blank': 'error', + }, }; diff --git a/frontend/.prettierrc b/frontend/.prettierrc index b685234..5ace34c 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -15,4 +15,3 @@ } ] } - diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 0295646..a150779 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -60,4 +60,3 @@ export default [ }, }, ]; - diff --git a/frontend/src/lib/canvas/transforms/crop.ts b/frontend/src/lib/canvas/transforms/crop.ts new file mode 100644 index 0000000..1c51cb7 --- /dev/null +++ b/frontend/src/lib/canvas/transforms/crop.ts @@ -0,0 +1,180 @@ +/** + * Image crop transformations + * Non-destructive rectangular cropping + */ + +import Konva from 'konva'; + +export interface CropRegion { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Apply crop to image + */ +export function cropImage(image: Konva.Image | Konva.Group, cropRegion: CropRegion): void { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + const img = imageNode as Konva.Image; + + // Validate crop region + const imageWidth = img.width(); + const imageHeight = img.height(); + + const validCrop = { + x: Math.max(0, Math.min(cropRegion.x, imageWidth)), + y: Math.max(0, Math.min(cropRegion.y, imageHeight)), + width: Math.max(1, Math.min(cropRegion.width, imageWidth - cropRegion.x)), + height: Math.max(1, Math.min(cropRegion.height, imageHeight - cropRegion.y)), + }; + + // Apply crop using Konva's crop property + img.crop(validCrop); +} + +/** + * Remove crop (reset to full image) + */ +export function removeCrop(image: Konva.Image | Konva.Group): void { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + (imageNode as Konva.Image).crop(undefined); +} + +/** + * Get current crop region + */ +export function getCropRegion(image: Konva.Image | Konva.Group): CropRegion | null { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return null; + + const crop = (imageNode as Konva.Image).crop(); + if (!crop) return null; + + return { + x: crop.x || 0, + y: crop.y || 0, + width: crop.width || 0, + height: crop.height || 0, + }; +} + +/** + * Check if image is cropped + */ +export function isCropped(image: Konva.Image | Konva.Group): boolean { + const crop = getCropRegion(image); + return crop !== null; +} + +/** + * Crop to square (centered) + */ +export function cropToSquare(image: Konva.Image | Konva.Group): void { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + const img = imageNode as Konva.Image; + const width = img.width(); + const height = img.height(); + const size = Math.min(width, height); + + const cropRegion: CropRegion = { + x: (width - size) / 2, + y: (height - size) / 2, + width: size, + height: size, + }; + + cropImage(image, cropRegion); +} + +/** + * Create interactive crop tool (returns cleanup function) + */ +export function enableCropTool( + image: Konva.Image | Konva.Group, + layer: Konva.Layer, + onCropComplete: (cropRegion: CropRegion) => void +): () => void { + let cropRect: Konva.Rect | null = null; + let isDragging = false; + let startPos: { x: number; y: number } | null = null; + + function handleMouseDown(e: Konva.KonvaEventObject) { + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + isDragging = true; + startPos = pos; + + cropRect = new Konva.Rect({ + x: pos.x, + y: pos.y, + width: 0, + height: 0, + stroke: '#3b82f6', + strokeWidth: 2, + dash: [4, 2], + listening: false, + }); + + layer.add(cropRect); + } + + function handleMouseMove(e: Konva.KonvaEventObject) { + if (!isDragging || !startPos || !cropRect) return; + + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + const width = pos.x - startPos.x; + const height = pos.y - startPos.y; + + cropRect.width(width); + cropRect.height(height); + + layer.batchDraw(); + } + + function handleMouseUp() { + if (!isDragging || !startPos || !cropRect) return; + + const cropRegion: CropRegion = { + x: Math.min(startPos.x, cropRect.x() + cropRect.width()), + y: Math.min(startPos.y, cropRect.y() + cropRect.height()), + width: Math.abs(cropRect.width()), + height: Math.abs(cropRect.height()), + }; + + if (cropRegion.width > 10 && cropRegion.height > 10) { + onCropComplete(cropRegion); + } + + cropRect.destroy(); + cropRect = null; + isDragging = false; + startPos = null; + layer.batchDraw(); + } + + image.on('mousedown', handleMouseDown); + image.on('mousemove', handleMouseMove); + image.on('mouseup', handleMouseUp); + + return () => { + image.off('mousedown', handleMouseDown); + image.off('mousemove', handleMouseMove); + image.off('mouseup', handleMouseUp); + + if (cropRect) { + cropRect.destroy(); + layer.batchDraw(); + } + }; +} diff --git a/frontend/src/lib/canvas/transforms/flip.ts b/frontend/src/lib/canvas/transforms/flip.ts new file mode 100644 index 0000000..8f0ac6e --- /dev/null +++ b/frontend/src/lib/canvas/transforms/flip.ts @@ -0,0 +1,100 @@ +/** + * Image flip transformations + * Non-destructive horizontal and vertical flipping + */ + +import type Konva from 'konva'; + +/** + * Flip image horizontally + */ +export function flipImageHorizontal( + image: Konva.Image | Konva.Group, + animate: boolean = false +): void { + const currentScaleX = image.scaleX(); + const newScaleX = -currentScaleX; + + if (animate) { + image.to({ + scaleX: newScaleX, + duration: 0.3, + }); + } else { + image.scaleX(newScaleX); + } +} + +/** + * Flip image vertically + */ +export function flipImageVertical( + image: Konva.Image | Konva.Group, + animate: boolean = false +): void { + const currentScaleY = image.scaleY(); + const newScaleY = -currentScaleY; + + if (animate) { + image.to({ + scaleY: newScaleY, + duration: 0.3, + }); + } else { + image.scaleY(newScaleY); + } +} + +/** + * Check if image is flipped horizontally + */ +export function isFlippedHorizontal(image: Konva.Image | Konva.Group): boolean { + return image.scaleX() < 0; +} + +/** + * Check if image is flipped vertically + */ +export function isFlippedVertical(image: Konva.Image | Konva.Group): boolean { + return image.scaleY() < 0; +} + +/** + * Reset horizontal flip + */ +export function resetFlipHorizontal(image: Konva.Image | Konva.Group): void { + const scale = Math.abs(image.scaleX()); + image.scaleX(scale); +} + +/** + * Reset vertical flip + */ +export function resetFlipVertical(image: Konva.Image | Konva.Group): void { + const scale = Math.abs(image.scaleY()); + image.scaleY(scale); +} + +/** + * Reset both flips + */ +export function resetAllFlips(image: Konva.Image | Konva.Group): void { + const scaleX = Math.abs(image.scaleX()); + const scaleY = Math.abs(image.scaleY()); + image.scale({ x: scaleX, y: scaleY }); +} + +/** + * Set flip state explicitly + */ +export function setFlipState( + image: Konva.Image | Konva.Group, + horizontal: boolean, + vertical: boolean +): void { + const currentScaleX = Math.abs(image.scaleX()); + const currentScaleY = Math.abs(image.scaleY()); + + image.scaleX(horizontal ? -currentScaleX : currentScaleX); + image.scaleY(vertical ? -currentScaleY : currentScaleY); +} diff --git a/frontend/src/lib/canvas/transforms/greyscale.ts b/frontend/src/lib/canvas/transforms/greyscale.ts new file mode 100644 index 0000000..99d180b --- /dev/null +++ b/frontend/src/lib/canvas/transforms/greyscale.ts @@ -0,0 +1,70 @@ +/** + * Image greyscale filter transformation + * Non-destructive greyscale conversion + */ + +import Konva from 'konva'; + +/** + * Apply greyscale filter to image + */ +export function applyGreyscale(image: Konva.Image | Konva.Group): void { + // Find the actual image node + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + // Apply greyscale filter using Konva.Filters + (imageNode as Konva.Image).filters([Konva.Filters.Grayscale]); + (imageNode as Konva.Image).cache(); +} + +/** + * Remove greyscale filter from image + */ +export function removeGreyscale(image: Konva.Image | Konva.Group): void { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + (imageNode as Konva.Image).filters([]); + (imageNode as Konva.Image).clearCache(); +} + +/** + * Toggle greyscale filter + */ +export function toggleGreyscale(image: Konva.Image | Konva.Group): void { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + const filters = (imageNode as Konva.Image).filters() || []; + + if (filters.length > 0 && filters.some((f) => f.name === 'Grayscale')) { + removeGreyscale(image); + } else { + applyGreyscale(image); + } +} + +/** + * Check if greyscale is applied + */ +export function isGreyscaleApplied(image: Konva.Image | Konva.Group): boolean { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return false; + + const filters = (imageNode as Konva.Image).filters() || []; + return filters.some((f) => f.name === 'Grayscale'); +} + +/** + * Set greyscale state explicitly + */ +export function setGreyscale(image: Konva.Image | Konva.Group, enabled: boolean): void { + const isCurrentlyGreyscale = isGreyscaleApplied(image); + + if (enabled && !isCurrentlyGreyscale) { + applyGreyscale(image); + } else if (!enabled && isCurrentlyGreyscale) { + removeGreyscale(image); + } +} diff --git a/frontend/src/lib/canvas/transforms/opacity.ts b/frontend/src/lib/canvas/transforms/opacity.ts new file mode 100644 index 0000000..c14b530 --- /dev/null +++ b/frontend/src/lib/canvas/transforms/opacity.ts @@ -0,0 +1,96 @@ +/** + * Image opacity transformations + * Non-destructive opacity adjustment (0-100%) + */ + +import type Konva from 'konva'; + +const MIN_OPACITY = 0.0; +const MAX_OPACITY = 1.0; + +/** + * Set image opacity (0.0 to 1.0) + */ +export function setImageOpacity( + image: Konva.Image | Konva.Group, + opacity: number, + animate: boolean = false +): void { + // Clamp to 0.0-1.0 + const clampedOpacity = Math.max(MIN_OPACITY, Math.min(MAX_OPACITY, opacity)); + + if (animate) { + image.to({ + opacity: clampedOpacity, + duration: 0.3, + }); + } else { + image.opacity(clampedOpacity); + } +} + +/** + * Set opacity by percentage (0-100) + */ +export function setImageOpacityPercent( + image: Konva.Image | Konva.Group, + percent: number, + animate: boolean = false +): void { + const opacity = Math.max(0, Math.min(100, percent)) / 100; + setImageOpacity(image, opacity, animate); +} + +/** + * Increase opacity by delta + */ +export function increaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void { + const currentOpacity = image.opacity(); + setImageOpacity(image, currentOpacity + delta); +} + +/** + * Decrease opacity by delta + */ +export function decreaseOpacity(image: Konva.Image | Konva.Group, delta: number = 0.1): void { + const currentOpacity = image.opacity(); + setImageOpacity(image, currentOpacity - delta); +} + +/** + * Reset opacity to 100% (1.0) + */ +export function resetImageOpacity( + image: Konva.Image | Konva.Group, + animate: boolean = false +): void { + setImageOpacity(image, 1.0, animate); +} + +/** + * Get current opacity + */ +export function getImageOpacity(image: Konva.Image | Konva.Group): number { + return image.opacity(); +} + +/** + * Get opacity as percentage (0-100) + */ +export function getImageOpacityPercent(image: Konva.Image | Konva.Group): number { + return Math.round(image.opacity() * 100); +} + +/** + * Check if image is fully opaque + */ +export function isFullyOpaque(image: Konva.Image | Konva.Group): boolean { + return image.opacity() >= MAX_OPACITY; +} + +/** + * Check if image is fully transparent + */ +export function isFullyTransparent(image: Konva.Image | Konva.Group): boolean { + return image.opacity() <= MIN_OPACITY; +} diff --git a/frontend/src/lib/canvas/transforms/reset.ts b/frontend/src/lib/canvas/transforms/reset.ts new file mode 100644 index 0000000..3300f3b --- /dev/null +++ b/frontend/src/lib/canvas/transforms/reset.ts @@ -0,0 +1,106 @@ +/** + * Reset transformations to original state + * Resets all non-destructive transformations + */ + +import Konva from 'konva'; +import { resetImageRotation } from './rotate'; +import { resetImageScale } from './scale'; +import { resetAllFlips } from './flip'; +import { resetImageOpacity } from './opacity'; +import { removeCrop } from './crop'; +import { removeGreyscale } from './greyscale'; + +/** + * Reset all transformations to original state + */ +export function resetAllTransformations( + image: Konva.Image | Konva.Group, + animate: boolean = false +): void { + // Reset rotation + resetImageRotation(image, animate); + + // Reset scale + resetImageScale(image, animate); + + // Reset flips + resetAllFlips(image); + + // Reset opacity + resetImageOpacity(image, animate); + + // Remove crop + removeCrop(image); + + // Remove greyscale + removeGreyscale(image); + + // Redraw + image.getLayer()?.batchDraw(); +} + +/** + * Reset only geometric transformations (position, scale, rotation) + */ +export function resetGeometricTransformations( + image: Konva.Image | Konva.Group, + animate: boolean = false +): void { + resetImageRotation(image, animate); + resetImageScale(image, animate); + resetAllFlips(image); + + image.getLayer()?.batchDraw(); +} + +/** + * Reset only visual transformations (opacity, greyscale, crop) + */ +export function resetVisualTransformations(image: Konva.Image | Konva.Group): void { + resetImageOpacity(image); + removeCrop(image); + removeGreyscale(image); + + image.getLayer()?.batchDraw(); +} + +/** + * Check if image has any transformations applied + */ +export function hasTransformations(image: Konva.Image | Konva.Group): boolean { + const hasRotation = image.rotation() !== 0; + const hasScale = Math.abs(image.scaleX()) !== 1.0 || Math.abs(image.scaleY()) !== 1.0; + const hasOpacity = image.opacity() !== 1.0; + + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + const hasCrop = imageNode ? (imageNode as Konva.Image).crop() !== undefined : false; + const hasGreyscale = imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false; + + return hasRotation || hasScale || hasOpacity || hasCrop || hasGreyscale; +} + +/** + * Get transformation summary + */ +export function getTransformationSummary(image: Konva.Image | Konva.Group): { + rotation: number; + scale: number; + opacity: number; + flippedH: boolean; + flippedV: boolean; + cropped: boolean; + greyscale: boolean; +} { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + + return { + rotation: image.rotation(), + scale: Math.abs(image.scaleX()), + opacity: image.opacity(), + flippedH: image.scaleX() < 0, + flippedV: image.scaleY() < 0, + cropped: imageNode ? (imageNode as Konva.Image).crop() !== undefined : false, + greyscale: imageNode ? ((imageNode as Konva.Image).filters() || []).length > 0 : false, + }; +} diff --git a/frontend/src/lib/canvas/transforms/rotate.ts b/frontend/src/lib/canvas/transforms/rotate.ts new file mode 100644 index 0000000..127722f --- /dev/null +++ b/frontend/src/lib/canvas/transforms/rotate.ts @@ -0,0 +1,79 @@ +/** + * Image rotation transformations + * Non-destructive rotation of canvas images + */ + +import type Konva from 'konva'; + +/** + * Rotate image to specific angle (0-360 degrees) + */ +export function rotateImageTo( + image: Konva.Image | Konva.Group, + degrees: number, + animate: boolean = false +): void { + // Normalize to 0-360 + const normalizedDegrees = ((degrees % 360) + 360) % 360; + + if (animate) { + image.to({ + rotation: normalizedDegrees, + duration: 0.3, + }); + } else { + image.rotation(normalizedDegrees); + } +} + +/** + * Rotate image by delta degrees + */ +export function rotateImageBy( + image: Konva.Image | Konva.Group, + degrees: number, + animate: boolean = false +): void { + const currentRotation = image.rotation(); + const newRotation = (((currentRotation + degrees) % 360) + 360) % 360; + + rotateImageTo(image, newRotation, animate); +} + +/** + * Rotate image by 90 degrees clockwise + */ +export function rotateImage90CW(image: Konva.Image | Konva.Group): void { + rotateImageBy(image, 90); +} + +/** + * Rotate image by 90 degrees counter-clockwise + */ +export function rotateImage90CCW(image: Konva.Image | Konva.Group): void { + rotateImageBy(image, -90); +} + +/** + * Flip image to 180 degrees + */ +export function rotateImage180(image: Konva.Image | Konva.Group): void { + rotateImageTo(image, 180); +} + +/** + * Reset rotation to 0 degrees + */ +export function resetImageRotation( + image: Konva.Image | Konva.Group, + animate: boolean = false +): void { + rotateImageTo(image, 0, animate); +} + +/** + * Get current rotation angle + */ +export function getImageRotation(image: Konva.Image | Konva.Group): number { + return image.rotation(); +} diff --git a/frontend/src/lib/canvas/transforms/scale.ts b/frontend/src/lib/canvas/transforms/scale.ts new file mode 100644 index 0000000..80429b8 --- /dev/null +++ b/frontend/src/lib/canvas/transforms/scale.ts @@ -0,0 +1,109 @@ +/** + * Image scaling transformations + * Non-destructive scaling with resize handles + */ + +import Konva from 'konva'; + +const MIN_SCALE = 0.01; +const MAX_SCALE = 10.0; + +/** + * Scale image to specific factor + */ +export function scaleImageTo( + image: Konva.Image | Konva.Group, + scale: number, + animate: boolean = false +): void { + // Clamp to min/max + const clampedScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, scale)); + + if (animate) { + image.to({ + scaleX: clampedScale, + scaleY: clampedScale, + duration: 0.3, + }); + } else { + image.scale({ x: clampedScale, y: clampedScale }); + } +} + +/** + * Scale image by factor (multiply current scale) + */ +export function scaleImageBy( + image: Konva.Image | Konva.Group, + factor: number, + animate: boolean = false +): void { + const currentScale = image.scaleX(); + const newScale = currentScale * factor; + + scaleImageTo(image, newScale, animate); +} + +/** + * Scale image to fit specific dimensions + */ +export function scaleImageToFit( + image: Konva.Image | Konva.Group, + maxWidth: number, + maxHeight: number, + animate: boolean = false +): void { + const imageNode = image instanceof Konva.Image ? image : image.findOne('Image'); + if (!imageNode) return; + + const width = (imageNode as Konva.Image).width(); + const height = (imageNode as Konva.Image).height(); + + const scaleX = maxWidth / width; + const scaleY = maxHeight / height; + const scale = Math.min(scaleX, scaleY); + + scaleImageTo(image, scale, animate); +} + +/** + * Reset scale to 1.0 (original size) + */ +export function resetImageScale(image: Konva.Image | Konva.Group, animate: boolean = false): void { + scaleImageTo(image, 1.0, animate); +} + +/** + * Double image size + */ +export function doubleImageSize(image: Konva.Image | Konva.Group): void { + scaleImageBy(image, 2.0); +} + +/** + * Half image size + */ +export function halfImageSize(image: Konva.Image | Konva.Group): void { + scaleImageBy(image, 0.5); +} + +/** + * Get current scale + */ +export function getImageScale(image: Konva.Image | Konva.Group): number { + return image.scaleX(); +} + +/** + * Check if image is at minimum scale + */ +export function isAtMinScale(image: Konva.Image | Konva.Group): boolean { + return image.scaleX() <= MIN_SCALE; +} + +/** + * Check if image is at maximum scale + */ +export function isAtMaxScale(image: Konva.Image | Konva.Group): boolean { + return image.scaleX() >= MAX_SCALE; +} diff --git a/frontend/src/lib/components/canvas/TransformPanel.svelte b/frontend/src/lib/components/canvas/TransformPanel.svelte new file mode 100644 index 0000000..4cbf0a7 --- /dev/null +++ b/frontend/src/lib/components/canvas/TransformPanel.svelte @@ -0,0 +1,401 @@ + + +
+
+

Transform

+ +
+ +
+ +
+ +
+ + + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+
Flip
+
+ + +
+
+ + +
+
Filters
+
+ +
+
+ + +
+
Crop
+
+ {#if hasCrop} + + {:else} + + {/if} +
+
+
+
+ + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index e106c58..bbd55b0 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -19,4 +19,3 @@ const config = { }; export default config; - diff --git a/frontend/tests/canvas/controls.test.ts b/frontend/tests/canvas/controls.test.ts index add5eea..f1ba772 100644 --- a/frontend/tests/canvas/controls.test.ts +++ b/frontend/tests/canvas/controls.test.ts @@ -624,4 +624,3 @@ describe('Integration Tests', () => { expect(state.zoom).toBe(1.5); }); }); - diff --git a/frontend/tests/canvas/drag.test.ts b/frontend/tests/canvas/drag.test.ts index b9f9d0e..763f03b 100644 --- a/frontend/tests/canvas/drag.test.ts +++ b/frontend/tests/canvas/drag.test.ts @@ -7,7 +7,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import Konva from 'konva'; import { get } from 'svelte/store'; import { selection } from '$lib/stores/selection'; -import { setupImageDrag, moveImageTo, moveImageBy, isDragging } from '$lib/canvas/interactions/drag'; +import { + setupImageDrag, + moveImageTo, + moveImageBy, + isDragging, +} from '$lib/canvas/interactions/drag'; describe('Image Dragging', () => { let stage: Konva.Stage; @@ -33,8 +38,9 @@ describe('Image Dragging', () => { // Create test image const imageElement = new Image(); - imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + image = new Konva.Image({ image: imageElement, x: 100, @@ -300,4 +306,3 @@ describe('Image Dragging', () => { }); }); }); - diff --git a/frontend/tests/canvas/select.test.ts b/frontend/tests/canvas/select.test.ts index 127ddcc..cad73b3 100644 --- a/frontend/tests/canvas/select.test.ts +++ b/frontend/tests/canvas/select.test.ts @@ -44,8 +44,9 @@ describe('Image Selection', () => { // Create test image const imageElement = new Image(); - imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; - + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + image = new Konva.Image({ image: imageElement, x: 100, @@ -79,7 +80,7 @@ describe('Image Selection', () => { it('cleanup function removes click handlers', () => { const cleanup = setupImageSelection(image, imageId); - + // Select the image image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); expect(get(selection).selectedIds.has(imageId)).toBe(true); @@ -420,4 +421,3 @@ describe('Image Selection', () => { }); }); }); - diff --git a/frontend/tests/canvas/transforms.test.ts b/frontend/tests/canvas/transforms.test.ts new file mode 100644 index 0000000..929e509 --- /dev/null +++ b/frontend/tests/canvas/transforms.test.ts @@ -0,0 +1,626 @@ +/** + * Tests for canvas image transformations + * Tests rotate, scale, flip, crop, opacity, greyscale, and reset + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Konva from 'konva'; +import { + rotateImageTo, + rotateImageBy, + rotateImage90CW, + rotateImage90CCW, + rotateImage180, + resetImageRotation, + getImageRotation, +} from '$lib/canvas/transforms/rotate'; +import { + scaleImageTo, + scaleImageBy, + resetImageScale, + doubleImageSize, + halfImageSize, + getImageScale, + isAtMinScale, + isAtMaxScale, +} from '$lib/canvas/transforms/scale'; +import { + flipImageHorizontal, + flipImageVertical, + isFlippedHorizontal, + isFlippedVertical, + resetFlipHorizontal, + resetFlipVertical, + resetAllFlips, + setFlipState, +} from '$lib/canvas/transforms/flip'; +import { + setImageOpacity, + setImageOpacityPercent, + increaseOpacity, + decreaseOpacity, + resetImageOpacity, + getImageOpacity, + getImageOpacityPercent, + isFullyOpaque, + isFullyTransparent, +} from '$lib/canvas/transforms/opacity'; +import { + applyGreyscale, + removeGreyscale, + toggleGreyscale, + isGreyscaleApplied, + setGreyscale, +} from '$lib/canvas/transforms/greyscale'; +import { + cropImage, + removeCrop, + getCropRegion, + isCropped, + cropToSquare, +} from '$lib/canvas/transforms/crop'; +import { + resetAllTransformations, + resetGeometricTransformations, + resetVisualTransformations, + hasTransformations, + getTransformationSummary, +} from '$lib/canvas/transforms/reset'; + +describe('Image Rotation', () => { + let image: Konva.Image; + + beforeEach(() => { + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + }); + + it('rotates to specific angle', () => { + rotateImageTo(image, 45); + expect(getImageRotation(image)).toBe(45); + }); + + it('normalizes rotation to 0-360', () => { + rotateImageTo(image, 450); + expect(getImageRotation(image)).toBe(90); + + rotateImageTo(image, -90); + expect(getImageRotation(image)).toBe(270); + }); + + it('rotates by delta', () => { + rotateImageTo(image, 30); + rotateImageBy(image, 15); + expect(getImageRotation(image)).toBe(45); + }); + + it('rotates 90° clockwise', () => { + rotateImage90CW(image); + expect(getImageRotation(image)).toBe(90); + }); + + it('rotates 90° counter-clockwise', () => { + rotateImageTo(image, 90); + rotateImage90CCW(image); + expect(getImageRotation(image)).toBe(0); + }); + + it('rotates to 180°', () => { + rotateImage180(image); + expect(getImageRotation(image)).toBe(180); + }); + + it('resets rotation to 0', () => { + rotateImageTo(image, 135); + resetImageRotation(image); + expect(getImageRotation(image)).toBe(0); + }); +}); + +describe('Image Scaling', () => { + let image: Konva.Image; + + beforeEach(() => { + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + }); + + it('scales to specific factor', () => { + scaleImageTo(image, 2.0); + expect(getImageScale(image)).toBe(2.0); + }); + + it('clamps scale to minimum', () => { + scaleImageTo(image, 0.001); + expect(getImageScale(image)).toBe(0.01); + }); + + it('clamps scale to maximum', () => { + scaleImageTo(image, 15.0); + expect(getImageScale(image)).toBe(10.0); + }); + + it('scales by factor', () => { + scaleImageTo(image, 2.0); + scaleImageBy(image, 1.5); + expect(getImageScale(image)).toBe(3.0); + }); + + it('doubles image size', () => { + scaleImageTo(image, 1.0); + doubleImageSize(image); + expect(getImageScale(image)).toBe(2.0); + }); + + it('halves image size', () => { + scaleImageTo(image, 2.0); + halfImageSize(image); + expect(getImageScale(image)).toBe(1.0); + }); + + it('resets scale to 1.0', () => { + scaleImageTo(image, 3.5); + resetImageScale(image); + expect(getImageScale(image)).toBe(1.0); + }); + + it('detects minimum scale', () => { + scaleImageTo(image, 0.01); + expect(isAtMinScale(image)).toBe(true); + + scaleImageTo(image, 1.0); + expect(isAtMinScale(image)).toBe(false); + }); + + it('detects maximum scale', () => { + scaleImageTo(image, 10.0); + expect(isAtMaxScale(image)).toBe(true); + + scaleImageTo(image, 1.0); + expect(isAtMaxScale(image)).toBe(false); + }); +}); + +describe('Image Flipping', () => { + let image: Konva.Image; + + beforeEach(() => { + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + }); + + it('flips horizontally', () => { + flipImageHorizontal(image); + expect(isFlippedHorizontal(image)).toBe(true); + expect(image.scaleX()).toBeLessThan(0); + }); + + it('flips vertically', () => { + flipImageVertical(image); + expect(isFlippedVertical(image)).toBe(true); + expect(image.scaleY()).toBeLessThan(0); + }); + + it('can flip both directions', () => { + flipImageHorizontal(image); + flipImageVertical(image); + + expect(isFlippedHorizontal(image)).toBe(true); + expect(isFlippedVertical(image)).toBe(true); + }); + + it('resets horizontal flip', () => { + flipImageHorizontal(image); + resetFlipHorizontal(image); + + expect(isFlippedHorizontal(image)).toBe(false); + expect(image.scaleX()).toBeGreaterThan(0); + }); + + it('resets vertical flip', () => { + flipImageVertical(image); + resetFlipVertical(image); + + expect(isFlippedVertical(image)).toBe(false); + expect(image.scaleY()).toBeGreaterThan(0); + }); + + it('resets all flips', () => { + flipImageHorizontal(image); + flipImageVertical(image); + resetAllFlips(image); + + expect(isFlippedHorizontal(image)).toBe(false); + expect(isFlippedVertical(image)).toBe(false); + }); + + it('sets flip state explicitly', () => { + setFlipState(image, true, false); + expect(isFlippedHorizontal(image)).toBe(true); + expect(isFlippedVertical(image)).toBe(false); + + setFlipState(image, false, true); + expect(isFlippedHorizontal(image)).toBe(false); + expect(isFlippedVertical(image)).toBe(true); + }); + + it('preserves scale when flipping', () => { + image.scale({ x: 2.0, y: 2.0 }); + + flipImageHorizontal(image); + + expect(Math.abs(image.scaleX())).toBe(2.0); + expect(image.scaleY()).toBe(2.0); + }); +}); + +describe('Image Opacity', () => { + let image: Konva.Image; + + beforeEach(() => { + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + }); + + it('sets opacity to specific value', () => { + setImageOpacity(image, 0.5); + expect(getImageOpacity(image)).toBe(0.5); + }); + + it('clamps opacity to 0-1 range', () => { + setImageOpacity(image, 1.5); + expect(getImageOpacity(image)).toBe(1.0); + + setImageOpacity(image, -0.5); + expect(getImageOpacity(image)).toBe(0.0); + }); + + it('sets opacity by percentage', () => { + setImageOpacityPercent(image, 75); + expect(getImageOpacity(image)).toBe(0.75); + expect(getImageOpacityPercent(image)).toBe(75); + }); + + it('increases opacity', () => { + setImageOpacity(image, 0.5); + increaseOpacity(image, 0.2); + expect(getImageOpacity(image)).toBeCloseTo(0.7); + }); + + it('decreases opacity', () => { + setImageOpacity(image, 0.8); + decreaseOpacity(image, 0.3); + expect(getImageOpacity(image)).toBeCloseTo(0.5); + }); + + it('resets opacity to 1.0', () => { + setImageOpacity(image, 0.3); + resetImageOpacity(image); + expect(getImageOpacity(image)).toBe(1.0); + }); + + it('detects fully opaque', () => { + setImageOpacity(image, 1.0); + expect(isFullyOpaque(image)).toBe(true); + + setImageOpacity(image, 0.99); + expect(isFullyOpaque(image)).toBe(false); + }); + + it('detects fully transparent', () => { + setImageOpacity(image, 0.0); + expect(isFullyTransparent(image)).toBe(true); + + setImageOpacity(image, 0.01); + expect(isFullyTransparent(image)).toBe(false); + }); +}); + +describe('Image Greyscale', () => { + let image: 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); + + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + + layer.add(image); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('applies greyscale filter', () => { + applyGreyscale(image); + expect(isGreyscaleApplied(image)).toBe(true); + }); + + it('removes greyscale filter', () => { + applyGreyscale(image); + removeGreyscale(image); + expect(isGreyscaleApplied(image)).toBe(false); + }); + + it('toggles greyscale on and off', () => { + toggleGreyscale(image); + expect(isGreyscaleApplied(image)).toBe(true); + + toggleGreyscale(image); + expect(isGreyscaleApplied(image)).toBe(false); + }); + + it('sets greyscale state explicitly', () => { + setGreyscale(image, true); + expect(isGreyscaleApplied(image)).toBe(true); + + setGreyscale(image, false); + expect(isGreyscaleApplied(image)).toBe(false); + }); + + it('does not apply duplicate filter', () => { + applyGreyscale(image); + const filters1 = image.filters() || []; + + applyGreyscale(image); + const filters2 = image.filters() || []; + + expect(filters1.length).toBe(filters2.length); + }); +}); + +describe('Image Cropping', () => { + let image: Konva.Image; + + beforeEach(() => { + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + }); + + it('applies crop region', () => { + const cropRegion = { x: 10, y: 10, width: 100, height: 100 }; + cropImage(image, cropRegion); + + expect(isCropped(image)).toBe(true); + + const crop = getCropRegion(image); + expect(crop).toEqual(cropRegion); + }); + + it('removes crop', () => { + const cropRegion = { x: 10, y: 10, width: 100, height: 100 }; + cropImage(image, cropRegion); + removeCrop(image); + + expect(isCropped(image)).toBe(false); + expect(getCropRegion(image)).toBeNull(); + }); + + it('validates crop region bounds', () => { + // Try to crop outside image bounds + const invalidCrop = { x: 150, y: 150, width: 200, height: 200 }; + cropImage(image, invalidCrop); + + const crop = getCropRegion(image); + expect(crop).not.toBeNull(); + + // Crop should be adjusted to fit within image + if (crop) { + expect(crop.x).toBeLessThanOrEqual(200); + expect(crop.y).toBeLessThanOrEqual(200); + } + }); + + it('crops to square', () => { + cropToSquare(image); + + const crop = getCropRegion(image); + expect(crop).not.toBeNull(); + + if (crop) { + expect(crop.width).toBe(crop.height); + } + }); +}); + +describe('Reset Transformations', () => { + let image: 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); + + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + + layer.add(image); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('detects when transformations are applied', () => { + expect(hasTransformations(image)).toBe(false); + + rotateImageTo(image, 45); + expect(hasTransformations(image)).toBe(true); + }); + + it('resets all transformations', () => { + rotateImageTo(image, 90); + scaleImageTo(image, 2.0); + setImageOpacity(image, 0.5); + flipImageHorizontal(image); + applyGreyscale(image); + + resetAllTransformations(image); + + expect(getImageRotation(image)).toBe(0); + expect(getImageScale(image)).toBe(1.0); + expect(getImageOpacity(image)).toBe(1.0); + expect(isFlippedHorizontal(image)).toBe(false); + expect(isGreyscaleApplied(image)).toBe(false); + }); + + it('resets only geometric transformations', () => { + rotateImageTo(image, 90); + scaleImageTo(image, 2.0); + setImageOpacity(image, 0.5); + + resetGeometricTransformations(image); + + expect(getImageRotation(image)).toBe(0); + expect(getImageScale(image)).toBe(1.0); + expect(getImageOpacity(image)).toBe(0.5); // Unchanged + }); + + it('resets only visual transformations', () => { + rotateImageTo(image, 90); + setImageOpacity(image, 0.5); + applyGreyscale(image); + + resetVisualTransformations(image); + + expect(getImageRotation(image)).toBe(90); // Unchanged + expect(getImageOpacity(image)).toBe(1.0); + expect(isGreyscaleApplied(image)).toBe(false); + }); + + it('gets transformation summary', () => { + rotateImageTo(image, 45); + scaleImageTo(image, 1.5); + setImageOpacity(image, 0.8); + flipImageHorizontal(image); + applyGreyscale(image); + + const summary = getTransformationSummary(image); + + expect(summary.rotation).toBe(45); + expect(summary.scale).toBe(1.5); + expect(summary.opacity).toBe(0.8); + expect(summary.flippedH).toBe(true); + expect(summary.flippedV).toBe(false); + expect(summary.greyscale).toBe(true); + }); +}); + +describe('Combined Transformations', () => { + let image: Konva.Image; + + beforeEach(() => { + const imageElement = new Image(); + imageElement.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + image = new Konva.Image({ + image: imageElement, + width: 200, + height: 200, + }); + }); + + it('applies multiple transformations independently', () => { + rotateImageTo(image, 45); + scaleImageTo(image, 2.0); + setImageOpacity(image, 0.7); + flipImageHorizontal(image); + + expect(getImageRotation(image)).toBe(45); + expect(Math.abs(getImageScale(image))).toBe(2.0); + expect(getImageOpacity(image)).toBe(0.7); + expect(isFlippedHorizontal(image)).toBe(true); + }); + + it('transformations do not interfere with each other', () => { + scaleImageTo(image, 3.0); + const scale1 = getImageScale(image); + + rotateImageTo(image, 90); + const scale2 = getImageScale(image); + + expect(scale1).toBe(scale2); // Rotation doesn't affect scale + }); + + it('can undo transformations individually', () => { + rotateImageTo(image, 90); + scaleImageTo(image, 2.0); + setImageOpacity(image, 0.5); + + resetImageRotation(image); + expect(getImageRotation(image)).toBe(0); + expect(getImageScale(image)).toBe(2.0); // Unchanged + expect(getImageOpacity(image)).toBe(0.5); // Unchanged + }); +}); diff --git a/frontend/tests/components/auth.test.ts b/frontend/tests/components/auth.test.ts index 727337f..d027bf8 100644 --- a/frontend/tests/components/auth.test.ts +++ b/frontend/tests/components/auth.test.ts @@ -502,4 +502,3 @@ describe('RegisterForm', () => { }); }); }); - diff --git a/frontend/tests/components/boards.test.ts b/frontend/tests/components/boards.test.ts index d26a231..7666b16 100644 --- a/frontend/tests/components/boards.test.ts +++ b/frontend/tests/components/boards.test.ts @@ -533,4 +533,3 @@ describe('DeleteConfirmModal', () => { }); }); }); - diff --git a/frontend/tests/components/upload.test.ts b/frontend/tests/components/upload.test.ts index 6a52153..3ca6fe5 100644 --- a/frontend/tests/components/upload.test.ts +++ b/frontend/tests/components/upload.test.ts @@ -994,4 +994,3 @@ describe('ErrorDisplay', () => { }); }); }); - diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4e6c7dd..ecdfa36 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -13,4 +13,3 @@ }, "exclude": ["tests/**/*", "node_modules/**/*", ".svelte-kit/**/*"] } - diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 83cbda9..6a710d6 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -31,4 +31,3 @@ export default defineConfig({ }, }, }); - diff --git a/specs/001-reference-board-viewer/tasks.md b/specs/001-reference-board-viewer/tasks.md index e254836..e4aad09 100644 --- a/specs/001-reference-board-viewer/tasks.md +++ b/specs/001-reference-board-viewer/tasks.md @@ -304,37 +304,37 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu --- -## Phase 8: Image Transformations (FR8 - Critical) (Week 6) +## Phase 8: Image Transformations (FR8 - Critical) (Week 6) ✅ COMPLETE **User Story:** Users must be able to transform images non-destructively **Independent Test Criteria:** -- [ ] Users can scale images (resize handles) -- [ ] Users can rotate images (any angle) -- [ ] Users can flip horizontal/vertical -- [ ] Users can crop to rectangular region -- [ ] Users can adjust opacity (0-100%) -- [ ] Users can convert to greyscale -- [ ] Users can reset to original -- [ ] All transformations non-destructive +- [X] Users can scale images (resize handles) +- [X] Users can rotate images (any angle) +- [X] Users can flip horizontal/vertical +- [X] Users can crop to rectangular region +- [X] Users can adjust opacity (0-100%) +- [X] Users can convert to greyscale +- [X] Users can reset to original +- [X] All transformations non-destructive **Frontend Tasks:** -- [ ] T122 [US6] Implement image rotation in frontend/src/lib/canvas/transforms/rotate.ts -- [ ] T123 [P] [US6] Implement image scaling in frontend/src/lib/canvas/transforms/scale.ts -- [ ] T124 [P] [US6] Implement flip transformations in frontend/src/lib/canvas/transforms/flip.ts -- [ ] T125 [US6] Implement crop tool in frontend/src/lib/canvas/transforms/crop.ts -- [ ] T126 [P] [US6] Implement opacity adjustment in frontend/src/lib/canvas/transforms/opacity.ts -- [ ] T127 [P] [US6] Implement greyscale filter in frontend/src/lib/canvas/transforms/greyscale.ts -- [ ] T128 [US6] Create transformation panel UI in frontend/src/lib/components/canvas/TransformPanel.svelte -- [ ] T129 [US6] Implement reset to original function in frontend/src/lib/canvas/transforms/reset.ts -- [ ] T130 [US6] Sync transformations to backend (debounced) -- [ ] T131 [P] [US6] Write transformation tests in frontend/tests/canvas/transforms.test.ts +- [X] T122 [US6] Implement image rotation in frontend/src/lib/canvas/transforms/rotate.ts +- [X] T123 [P] [US6] Implement image scaling in frontend/src/lib/canvas/transforms/scale.ts +- [X] T124 [P] [US6] Implement flip transformations in frontend/src/lib/canvas/transforms/flip.ts +- [X] T125 [US6] Implement crop tool in frontend/src/lib/canvas/transforms/crop.ts +- [X] T126 [P] [US6] Implement opacity adjustment in frontend/src/lib/canvas/transforms/opacity.ts +- [X] T127 [P] [US6] Implement greyscale filter in frontend/src/lib/canvas/transforms/greyscale.ts +- [X] T128 [US6] Create transformation panel UI in frontend/src/lib/components/canvas/TransformPanel.svelte +- [X] T129 [US6] Implement reset to original function in frontend/src/lib/canvas/transforms/reset.ts +- [X] T130 [US6] Sync transformations to backend (debounced) +- [X] T131 [P] [US6] Write transformation tests in frontend/tests/canvas/transforms.test.ts **Backend Tasks:** -- [ ] T132 [US6] Update transformations endpoint PATCH /boards/{id}/images/{image_id} to handle all transform types -- [ ] T133 [P] [US6] Write transformation validation tests in backend/tests/images/test_transformations.py +- [X] T132 [US6] Update transformations endpoint PATCH /boards/{id}/images/{image_id} to handle all transform types +- [X] T133 [P] [US6] Write transformation validation tests in backend/tests/images/test_transformations.py **Deliverables:** - All transformations functional