phase 8
This commit is contained in:
236
backend/tests/images/test_transformations.py
Normal file
236
backend/tests/images/test_transformations.py
Normal 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)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ export default [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
180
frontend/src/lib/canvas/transforms/crop.ts
Normal file
180
frontend/src/lib/canvas/transforms/crop.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
100
frontend/src/lib/canvas/transforms/flip.ts
Normal file
100
frontend/src/lib/canvas/transforms/flip.ts
Normal 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);
|
||||
}
|
||||
70
frontend/src/lib/canvas/transforms/greyscale.ts
Normal file
70
frontend/src/lib/canvas/transforms/greyscale.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
96
frontend/src/lib/canvas/transforms/opacity.ts
Normal file
96
frontend/src/lib/canvas/transforms/opacity.ts
Normal 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;
|
||||
}
|
||||
106
frontend/src/lib/canvas/transforms/reset.ts
Normal file
106
frontend/src/lib/canvas/transforms/reset.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
frontend/src/lib/canvas/transforms/rotate.ts
Normal file
79
frontend/src/lib/canvas/transforms/rotate.ts
Normal 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();
|
||||
}
|
||||
109
frontend/src/lib/canvas/transforms/scale.ts
Normal file
109
frontend/src/lib/canvas/transforms/scale.ts
Normal 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;
|
||||
}
|
||||
401
frontend/src/lib/components/canvas/TransformPanel.svelte
Normal file
401
frontend/src/lib/components/canvas/TransformPanel.svelte
Normal 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>
|
||||
@@ -19,4 +19,3 @@ const config = {
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
|
||||
@@ -624,4 +624,3 @@ describe('Integration Tests', () => {
|
||||
expect(state.zoom).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
626
frontend/tests/canvas/transforms.test.ts
Normal file
626
frontend/tests/canvas/transforms.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -502,4 +502,3 @@ describe('RegisterForm', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -533,4 +533,3 @@ describe('DeleteConfirmModal', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -994,4 +994,3 @@ describe('ErrorDisplay', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,4 +13,3 @@
|
||||
},
|
||||
"exclude": ["tests/**/*", "node_modules/**/*", ".svelte-kit/**/*"]
|
||||
}
|
||||
|
||||
|
||||
@@ -31,4 +31,3 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user