001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
23 changed files with 2049 additions and 50 deletions
Showing only changes of commit ce0b692aee - Show all commits

View File

@@ -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)

View File

@@ -4,28 +4,28 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended', 'plugin:svelte/recommended',
'prettier' 'prettier',
], ],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'], plugins: ['@typescript-eslint'],
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020,
extraFileExtensions: ['.svelte'] extraFileExtensions: ['.svelte'],
}, },
env: { env: {
browser: true, browser: true,
es2017: true, es2017: true,
node: true node: true,
}, },
overrides: [ overrides: [
{ {
files: ['*.svelte'], files: ['*.svelte'],
parser: 'svelte-eslint-parser', parser: 'svelte-eslint-parser',
parserOptions: { parserOptions: {
parser: '@typescript-eslint/parser' parser: '@typescript-eslint/parser',
} },
} },
], ],
rules: { rules: {
// TypeScript rules // TypeScript rules
@@ -33,18 +33,18 @@ module.exports = {
'error', 'error',
{ {
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
varsIgnorePattern: '^_' varsIgnorePattern: '^_',
} },
], ],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
// General rules // General rules
'no-console': ['warn', { allow: ['warn', 'error'] }], 'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error', 'prefer-const': 'error',
'no-var': 'error', 'no-var': 'error',
// Svelte specific // Svelte specific
'svelte/no-at-html-tags': 'error', 'svelte/no-at-html-tags': 'error',
'svelte/no-target-blank': 'error' 'svelte/no-target-blank': 'error',
} },
}; };

View File

@@ -15,4 +15,3 @@
} }
] ]
} }

View File

@@ -60,4 +60,3 @@ export default [
}, },
}, },
]; ];

View File

@@ -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<MouseEvent>) {
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<MouseEvent>) {
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();
}
};
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -0,0 +1,401 @@
<script lang="ts">
/**
* Transformation panel for canvas image manipulation
* Provides UI controls for rotate, scale, flip, crop, opacity, greyscale
*/
import { createEventDispatcher } from 'svelte';
export let rotation: number = 0;
export let scale: number = 1.0;
export let opacity: number = 1.0;
export let flippedH: boolean = false;
export let flippedV: boolean = false;
export let greyscale: boolean = false;
export let hasCrop: boolean = false;
export let disabled: boolean = false;
const dispatch = createEventDispatcher();
function handleRotationChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
dispatch('rotation-change', { rotation: value });
}
function handleScaleChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
dispatch('scale-change', { scale: value });
}
function handleOpacityChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
dispatch('opacity-change', { opacity: value });
}
function handleFlipH() {
dispatch('flip-horizontal', { flipped: !flippedH });
}
function handleFlipV() {
dispatch('flip-vertical', { flipped: !flippedV });
}
function handleGreyscaleToggle() {
dispatch('greyscale-toggle', { enabled: !greyscale });
}
function handleCropStart() {
dispatch('crop-start');
}
function handleRemoveCrop() {
dispatch('crop-remove');
}
function handleRotate90CW() {
dispatch('rotate-90-cw');
}
function handleRotate90CCW() {
dispatch('rotate-90-ccw');
}
function handleResetAll() {
dispatch('reset-all');
}
</script>
<div class="transform-panel" class:disabled>
<div class="panel-header">
<h3>Transform</h3>
<button
class="reset-button"
on:click={handleResetAll}
{disabled}
title="Reset all transformations"
>
Reset All
</button>
</div>
<div class="panel-content">
<!-- Rotation Controls -->
<div class="control-group">
<label for="rotation">
Rotation
<span class="value">{Math.round(rotation)}°</span>
</label>
<div class="control-row">
<button class="icon-button" on:click={handleRotate90CCW} {disabled} title="Rotate 90° CCW">
</button>
<input
id="rotation"
type="range"
min="0"
max="360"
step="1"
value={rotation}
on:input={handleRotationChange}
{disabled}
/>
<button class="icon-button" on:click={handleRotate90CW} {disabled} title="Rotate 90° CW">
</button>
</div>
</div>
<!-- Scale Controls -->
<div class="control-group">
<label for="scale">
Scale
<span class="value">{(scale * 100).toFixed(0)}%</span>
</label>
<input
id="scale"
type="range"
min="0.01"
max="10"
step="0.01"
value={scale}
on:input={handleScaleChange}
{disabled}
/>
</div>
<!-- Opacity Controls -->
<div class="control-group">
<label for="opacity">
Opacity
<span class="value">{Math.round(opacity * 100)}%</span>
</label>
<input
id="opacity"
type="range"
min="0"
max="1"
step="0.01"
value={opacity}
on:input={handleOpacityChange}
{disabled}
/>
</div>
<!-- Flip Controls -->
<div class="control-group">
<div class="label-text">Flip</div>
<div class="button-row">
<button
class="toggle-button"
class:active={flippedH}
on:click={handleFlipH}
{disabled}
title="Flip Horizontal"
>
⇄ Horizontal
</button>
<button
class="toggle-button"
class:active={flippedV}
on:click={handleFlipV}
{disabled}
title="Flip Vertical"
>
⇅ Vertical
</button>
</div>
</div>
<!-- Filters -->
<div class="control-group">
<div class="label-text">Filters</div>
<div class="button-row">
<button
class="toggle-button"
class:active={greyscale}
on:click={handleGreyscaleToggle}
{disabled}
title="Toggle Greyscale"
>
Greyscale
</button>
</div>
</div>
<!-- Crop Controls -->
<div class="control-group">
<div class="label-text">Crop</div>
<div class="button-row">
{#if hasCrop}
<button class="action-button" on:click={handleRemoveCrop} {disabled}>
Remove Crop
</button>
{:else}
<button class="action-button" on:click={handleCropStart} {disabled}>
Start Crop Tool
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.transform-panel {
background-color: var(--color-bg, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
min-width: 280px;
}
.transform-panel.disabled {
opacity: 0.6;
pointer-events: none;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.reset-button {
padding: 0.375rem 0.75rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.reset-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #e5e7eb);
}
.reset-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.panel-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
}
.label-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.25rem;
}
.value {
font-weight: 600;
color: var(--color-text, #374151);
}
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type='range'] {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--color-bg-secondary, #e5e7eb);
outline: none;
appearance: none;
-webkit-appearance: none;
}
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
}
input[type='range']::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
border: none;
}
input[type='range']:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-button {
width: 32px;
height: 32px;
padding: 0;
background-color: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #e5e7eb);
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.button-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.toggle-button {
flex: 1;
padding: 0.5rem 0.75rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.toggle-button:hover:not(:disabled) {
background-color: var(--color-bg-hover, #e5e7eb);
}
.toggle-button.active {
background-color: var(--color-primary, #3b82f6);
border-color: var(--color-primary, #3b82f6);
color: white;
}
.toggle-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.action-button {
width: 100%;
padding: 0.5rem 0.75rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.action-button:hover:not(:disabled) {
background-color: var(--color-primary-hover, #2563eb);
}
.action-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>

View File

@@ -19,4 +19,3 @@ const config = {
}; };
export default config; export default config;

View File

@@ -624,4 +624,3 @@ describe('Integration Tests', () => {
expect(state.zoom).toBe(1.5); expect(state.zoom).toBe(1.5);
}); });
}); });

View File

@@ -7,7 +7,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import Konva from 'konva'; import Konva from 'konva';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { selection } from '$lib/stores/selection'; 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', () => { describe('Image Dragging', () => {
let stage: Konva.Stage; let stage: Konva.Stage;
@@ -33,8 +38,9 @@ describe('Image Dragging', () => {
// Create test image // Create test image
const imageElement = new 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 = new Konva.Image({
image: imageElement, image: imageElement,
x: 100, x: 100,
@@ -300,4 +306,3 @@ describe('Image Dragging', () => {
}); });
}); });
}); });

View File

@@ -44,8 +44,9 @@ describe('Image Selection', () => {
// Create test image // Create test image
const imageElement = new 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 = new Konva.Image({
image: imageElement, image: imageElement,
x: 100, x: 100,
@@ -79,7 +80,7 @@ describe('Image Selection', () => {
it('cleanup function removes click handlers', () => { it('cleanup function removes click handlers', () => {
const cleanup = setupImageSelection(image, imageId); const cleanup = setupImageSelection(image, imageId);
// Select the image // Select the image
image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); image.fire('click', { evt: { ctrlKey: false, metaKey: false } });
expect(get(selection).selectedIds.has(imageId)).toBe(true); expect(get(selection).selectedIds.has(imageId)).toBe(true);
@@ -420,4 +421,3 @@ describe('Image Selection', () => {
}); });
}); });
}); });

View File

@@ -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
});
});

View File

@@ -502,4 +502,3 @@ describe('RegisterForm', () => {
}); });
}); });
}); });

View File

@@ -533,4 +533,3 @@ describe('DeleteConfirmModal', () => {
}); });
}); });
}); });

View File

@@ -994,4 +994,3 @@ describe('ErrorDisplay', () => {
}); });
}); });
}); });

View File

@@ -13,4 +13,3 @@
}, },
"exclude": ["tests/**/*", "node_modules/**/*", ".svelte-kit/**/*"] "exclude": ["tests/**/*", "node_modules/**/*", ".svelte-kit/**/*"]
} }

View File

@@ -31,4 +31,3 @@ export default defineConfig({
}, },
}, },
}); });

View File

@@ -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 **User Story:** Users must be able to transform images non-destructively
**Independent Test Criteria:** **Independent Test Criteria:**
- [ ] Users can scale images (resize handles) - [X] Users can scale images (resize handles)
- [ ] Users can rotate images (any angle) - [X] Users can rotate images (any angle)
- [ ] Users can flip horizontal/vertical - [X] Users can flip horizontal/vertical
- [ ] Users can crop to rectangular region - [X] Users can crop to rectangular region
- [ ] Users can adjust opacity (0-100%) - [X] Users can adjust opacity (0-100%)
- [ ] Users can convert to greyscale - [X] Users can convert to greyscale
- [ ] Users can reset to original - [X] Users can reset to original
- [ ] All transformations non-destructive - [X] All transformations non-destructive
**Frontend Tasks:** **Frontend Tasks:**
- [ ] T122 [US6] Implement image rotation in frontend/src/lib/canvas/transforms/rotate.ts - [X] 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 - [X] 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 - [X] 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 - [X] 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 - [X] 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 - [X] 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 - [X] 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 - [X] T129 [US6] Implement reset to original function in frontend/src/lib/canvas/transforms/reset.ts
- [ ] T130 [US6] Sync transformations to backend (debounced) - [X] T130 [US6] Sync transformations to backend (debounced)
- [ ] T131 [P] [US6] Write transformation tests in frontend/tests/canvas/transforms.test.ts - [X] T131 [P] [US6] Write transformation tests in frontend/tests/canvas/transforms.test.ts
**Backend Tasks:** **Backend Tasks:**
- [ ] T132 [US6] Update transformations endpoint PATCH /boards/{id}/images/{image_id} to handle all transform types - [X] 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] T133 [P] [US6] Write transformation validation tests in backend/tests/images/test_transformations.py
**Deliverables:** **Deliverables:**
- All transformations functional - All transformations functional