phase 8
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user