- Fix router import path in main.js - Handle Django REST Framework pagination format in API calls - Add getTemplates function to project API - Restart frontend development server
1235 lines
35 KiB
TypeScript
1235 lines
35 KiB
TypeScript
import type { Transform } from 'fabric';
|
|
import { FabricImage, Canvas, Control, Point } from 'fabric';
|
|
import { createImageCroppingControls } from './croppingControls';
|
|
import {
|
|
changeImageWidth,
|
|
changeImageHeight,
|
|
changeImageCropX,
|
|
changeImageCropY,
|
|
cropPanMoveHandler,
|
|
ghostScalePositionHandler,
|
|
scaleEquallyCropGenerator,
|
|
renderGhostImage,
|
|
changeImageHeightWithAutoCover,
|
|
changeImageWidthWithAutoCover,
|
|
} from './croppingHandlers';
|
|
|
|
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
describe('croppingHandlers', () => {
|
|
let canvas: Canvas;
|
|
let image: FabricImage;
|
|
let transform: Transform;
|
|
let eventData: any;
|
|
|
|
function prepareTransform(target: FabricImage, corner: string): Transform {
|
|
const origin = canvas._getOriginFromCorner(target, corner);
|
|
return {
|
|
target,
|
|
corner,
|
|
originX: origin.x,
|
|
originY: origin.y,
|
|
} as unknown as Transform;
|
|
}
|
|
|
|
function createMockImage(
|
|
options: Partial<{
|
|
width: number;
|
|
height: number;
|
|
cropX: number;
|
|
cropY: number;
|
|
elementWidth: number;
|
|
elementHeight: number;
|
|
flipX: boolean;
|
|
flipY: boolean;
|
|
}> = {},
|
|
): FabricImage {
|
|
const {
|
|
width = 100,
|
|
height = 100,
|
|
cropX = 0,
|
|
cropY = 0,
|
|
elementWidth = 200,
|
|
elementHeight = 200,
|
|
flipX = false,
|
|
flipY = false,
|
|
} = options;
|
|
|
|
const imgElement = new Image(elementWidth, elementHeight);
|
|
const img = new FabricImage(imgElement, {
|
|
left: 50,
|
|
top: 50,
|
|
width,
|
|
height,
|
|
cropX,
|
|
cropY,
|
|
flipX,
|
|
flipY,
|
|
});
|
|
img.controls = createImageCroppingControls();
|
|
|
|
return img;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
canvas = new Canvas();
|
|
image = createMockImage();
|
|
canvas.add(image);
|
|
eventData = {};
|
|
transform = prepareTransform(image, 'mrc');
|
|
});
|
|
|
|
afterEach(() => {
|
|
canvas.off();
|
|
canvas.clear();
|
|
});
|
|
|
|
describe('changeImageWidth', () => {
|
|
test('changes width normally when within bounds', () => {
|
|
expect(image.width).toBe(100);
|
|
const changed = changeImageWidth(eventData, transform, 180, 50);
|
|
expect(changed).toBe(true);
|
|
expect(image.width).toBe(180);
|
|
});
|
|
|
|
test('constrains width to available width (upper limit)', () => {
|
|
// Image element is 200px wide, cropX is 0, so max available is 200
|
|
image = createMockImage({ width: 100, cropX: 50, elementWidth: 200 });
|
|
canvas.add(image);
|
|
transform = prepareTransform(image, 'mrc');
|
|
|
|
// Try to set width beyond available (200 - 50 = 150 available)
|
|
changeImageWidth(eventData, transform, 500, 50);
|
|
expect(image.width).toBe(150);
|
|
});
|
|
|
|
test('constrains width to minimum of 1 (lower limit)', () => {
|
|
image = createMockImage({ width: 100, cropX: 50, elementWidth: 200 });
|
|
transform = prepareTransform(image, 'mrc');
|
|
changeImageWidth(eventData, transform, 0.1, 50);
|
|
expect(image.width).toBe(1);
|
|
});
|
|
|
|
test('returns false when no modification occurred', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
cropX: 50,
|
|
elementWidth: 200,
|
|
});
|
|
transform = prepareTransform(image, 'mrc');
|
|
const changed = changeImageWidth(eventData, transform, 200, 50);
|
|
expect(changed).toBe(true);
|
|
const changed2 = changeImageWidth(eventData, transform, 200, 50);
|
|
expect(changed2).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('changeImageHeight', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
height: 100,
|
|
cropY: 50,
|
|
elementHeight: 200,
|
|
});
|
|
transform = prepareTransform(image, 'mbc');
|
|
});
|
|
|
|
test('changes height normally when within bounds', () => {
|
|
expect(image.height).toBe(100);
|
|
const changed = changeImageHeight(eventData, transform, 50, 130);
|
|
expect(changed).toBe(true);
|
|
expect(image.height).toBe(130);
|
|
});
|
|
|
|
test('constrains height to available height (upper limit)', () => {
|
|
// Try to set height beyond available (200 - 50 = 150 available
|
|
changeImageHeight(eventData, transform, 50, 500);
|
|
expect(image.height).toBeLessThanOrEqual(150);
|
|
});
|
|
|
|
test('constrains height to minimum of 1 (lower limit)', () => {
|
|
// Mock to simulate setting negative height
|
|
changeImageHeight(eventData, transform, 50, 0.1);
|
|
expect(image.height).toBe(1);
|
|
});
|
|
|
|
test('returns false when no modification occurred', () => {
|
|
const changed = changeImageHeight(eventData, transform, 50, 200);
|
|
expect(changed).toBe(true);
|
|
const changed2 = changeImageHeight(eventData, transform, 50, 200);
|
|
expect(changed2).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('changeImageCropX', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
cropX: 50,
|
|
elementWidth: 200,
|
|
});
|
|
// Use 'ml' corner for cropX - changing left side moves cropX
|
|
transform = prepareTransform(image, 'mlc');
|
|
});
|
|
|
|
test('changes cropX and width together', () => {
|
|
const changed = changeImageCropX(eventData, transform, 20, 50);
|
|
expect(image.cropX).toBe(70);
|
|
expect(image.width).toBe(80);
|
|
expect(changed).toBe(true);
|
|
});
|
|
|
|
test('constrains cropX to minimum of 0 and adjusts width accordingly', () => {
|
|
image = createMockImage({ width: 100, cropX: 10, elementWidth: 200 });
|
|
transform = prepareTransform(image, 'mlc');
|
|
|
|
changeImageCropX(eventData, transform, -10, 50);
|
|
|
|
// newCropX is clamped to 0 (was -10)
|
|
expect(image.cropX).toBe(0);
|
|
// width = 100 + 10 - 0 = 110
|
|
expect(image.width).toBe(110);
|
|
});
|
|
|
|
test('constrains cropX so image stays within element bounds and adjusts width accordingly', () => {
|
|
changeImageCropX(eventData, transform, 50, 50);
|
|
// newCropX = 100, but clamped to elementWidth - width = 200 - 100 = 100 (stays 100)
|
|
expect(image.cropX).toBe(100);
|
|
// width = 100 + 50 - 100 = 50
|
|
expect(image.width).toBe(50);
|
|
// cropX + width should not exceed element width (200)
|
|
expect(image.cropX + image.width).toBeLessThanOrEqual(200);
|
|
});
|
|
|
|
test('returns false when no modification occurred', () => {
|
|
const changed = changeImageCropX(eventData, transform, 0, 50);
|
|
expect(changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('changeImageCropY', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
height: 100,
|
|
cropY: 50,
|
|
elementHeight: 200,
|
|
});
|
|
// Use 'mt' corner for cropY - changing top side moves cropY
|
|
transform = prepareTransform(image, 'mtc');
|
|
});
|
|
|
|
test('changes cropY and height together', () => {
|
|
const changed = changeImageCropY(eventData, transform, 50, 20);
|
|
// newCropY = 50 + 100 - 80 = 70
|
|
// height = 100 + 50 - 70 = 80
|
|
expect(image.cropY).toBe(70);
|
|
expect(image.height).toBe(80);
|
|
expect(changed).toBe(true);
|
|
});
|
|
|
|
test('constrains cropY to minimum of 0 and adjusts height accordingly', () => {
|
|
image = createMockImage({ height: 100, cropY: 10, elementHeight: 200 });
|
|
canvas.add(image);
|
|
transform = prepareTransform(image, 'mtc');
|
|
|
|
changeImageCropY(eventData, transform, 50, -30);
|
|
|
|
// newCropY is clamped to 0 (was -10)
|
|
expect(image.cropY).toBe(0);
|
|
// height = 100 + 10 - 0 = 110
|
|
expect(image.height).toBe(110);
|
|
});
|
|
|
|
test('returns false when no modification occurred', () => {
|
|
const changed = changeImageCropY(eventData, transform, 50, 0);
|
|
expect(changed).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('cropPanMoveHandler', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
});
|
|
|
|
test('pans the image by adjusting cropX and cropY', () => {
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: image.cropX,
|
|
cropY: image.cropY,
|
|
};
|
|
|
|
// Simulate moving the image 10px to the right and 10px down
|
|
image.left = original.left + 10;
|
|
image.top = original.top + 10;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
// cropX should decrease (panning right means showing more of the left side)
|
|
expect(image.cropX).toBeLessThan(original.cropX);
|
|
// cropY should decrease (panning down means showing more of the top)
|
|
expect(image.cropY).toBeLessThan(original.cropY);
|
|
// Position should be restored to original
|
|
expect(image.left).toBe(original.left);
|
|
expect(image.top).toBe(original.top);
|
|
});
|
|
|
|
test('constrains cropX to minimum of 0', () => {
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: 10,
|
|
cropY: 50,
|
|
};
|
|
image.cropX = 10;
|
|
|
|
// Move far right to try to get negative cropX
|
|
image.left = original.left + 100;
|
|
image.top = original.top;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
expect(image.cropX).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test('constrains cropY to minimum of 0', () => {
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: 50,
|
|
cropY: 10,
|
|
};
|
|
image.cropY = 10;
|
|
|
|
// Move far down to try to get negative cropY
|
|
image.left = original.left;
|
|
image.top = original.top + 100;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
expect(image.cropY).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test('constrains cropX so crop area stays within element bounds', () => {
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: 150, // Near the right edge (element is 300px wide)
|
|
cropY: 50,
|
|
};
|
|
image.cropX = 150;
|
|
|
|
// Move far left to try to exceed element width
|
|
image.left = original.left - 200;
|
|
image.top = original.top;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
// cropX + width should not exceed element width
|
|
expect(image.cropX + image.width).toBeLessThanOrEqual(300);
|
|
});
|
|
|
|
test('constrains cropY so crop area stays within element bounds', () => {
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: 50,
|
|
cropY: 150, // Near the bottom edge (element is 300px tall)
|
|
};
|
|
image.cropY = 150;
|
|
|
|
// Move far up to try to exceed element height
|
|
image.left = original.left;
|
|
image.top = original.top - 200;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
// cropY + height should not exceed element height
|
|
expect(image.cropY + image.height).toBeLessThanOrEqual(300);
|
|
});
|
|
|
|
test('pans correctly when flipX is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 100,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
flipX: true,
|
|
});
|
|
canvas.add(image);
|
|
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: image.cropX,
|
|
cropY: image.cropY,
|
|
};
|
|
|
|
// Move the image 10px to the right
|
|
image.left = original.left + 10;
|
|
image.top = original.top;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
// With flipX, moving right should increase cropX (opposite of normal)
|
|
expect(image.cropX).toBeGreaterThan(original.cropX);
|
|
});
|
|
|
|
test('pans correctly when flipY is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 100,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
flipY: true,
|
|
});
|
|
canvas.add(image);
|
|
|
|
const original = {
|
|
left: image.left,
|
|
top: image.top,
|
|
cropX: image.cropX,
|
|
cropY: image.cropY,
|
|
};
|
|
|
|
// Move the image 10px down
|
|
image.left = original.left;
|
|
image.top = original.top + 10;
|
|
|
|
const moveEvent = {
|
|
transform: {
|
|
target: image,
|
|
original,
|
|
} as unknown as Transform,
|
|
};
|
|
|
|
cropPanMoveHandler(moveEvent as any);
|
|
|
|
// With flipY, moving down should increase cropY (opposite of normal)
|
|
expect(image.cropY).toBeGreaterThan(original.cropY);
|
|
});
|
|
});
|
|
|
|
describe('flip-aware crop controls', () => {
|
|
test('mlc control changes width when flipX is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 0,
|
|
elementWidth: 200,
|
|
elementHeight: 200,
|
|
flipX: true,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareTransform(image, 'mlc');
|
|
|
|
const initialCropX = image.cropX;
|
|
const initialWidth = image.width;
|
|
|
|
// Call the mlc action handler
|
|
image.controls.mlc.actionHandler(eventData, transform, 30, 50);
|
|
|
|
// When flipX is true, mlc should change width, not cropX
|
|
expect(image.cropX).toBe(initialCropX);
|
|
expect(image.width).not.toBe(initialWidth);
|
|
});
|
|
|
|
test('mrc control changes cropX when flipX is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 0,
|
|
elementWidth: 200,
|
|
elementHeight: 200,
|
|
flipX: true,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareTransform(image, 'mrc');
|
|
|
|
const initialCropX = image.cropX;
|
|
|
|
// Call the mrc action handler
|
|
image.controls.mrc.actionHandler(eventData, transform, 180, 50);
|
|
|
|
// When flipX is true, mrc should behave like mlc (change cropX)
|
|
expect(image.cropX).not.toBe(initialCropX);
|
|
});
|
|
|
|
test('mtc control changes height when flipY is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 0,
|
|
cropY: 50,
|
|
elementWidth: 200,
|
|
elementHeight: 200,
|
|
flipY: true,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareTransform(image, 'mtc');
|
|
|
|
const initialCropY = image.cropY;
|
|
const initialHeight = image.height;
|
|
|
|
// Call the mtc action handler
|
|
image.controls.mtc.actionHandler(eventData, transform, 50, 30);
|
|
|
|
// When flipY is true, mtc should change height, not cropY
|
|
expect(image.cropY).toBe(initialCropY);
|
|
expect(image.height).not.toBe(initialHeight);
|
|
});
|
|
|
|
test('mbc control changes cropY when flipY is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 0,
|
|
cropY: 50,
|
|
elementWidth: 200,
|
|
elementHeight: 200,
|
|
flipY: true,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareTransform(image, 'mbc');
|
|
|
|
const initialCropY = image.cropY;
|
|
|
|
// Call the mbc action handler
|
|
image.controls.mbc.actionHandler(eventData, transform, 50, 180);
|
|
|
|
// When flipY is true, mbc should behave like mtc (change cropY)
|
|
expect(image.cropY).not.toBe(initialCropY);
|
|
});
|
|
});
|
|
|
|
describe('ghostScalePositionHandler', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
});
|
|
|
|
test('positions top-left corner control correctly', () => {
|
|
const control = new Control({ x: -0.5, y: -0.5 });
|
|
const result = ghostScalePositionHandler.call(
|
|
control,
|
|
new Point(100, 100),
|
|
[1, 2, 3, 4, 5, 6], // this matrix is not used
|
|
image,
|
|
);
|
|
|
|
expect(result).toEqual({ x: -50, y: -50 });
|
|
});
|
|
|
|
test('positions bottom-right corner control correctly', () => {
|
|
const control = new Control({ x: 0.5, y: 0.5 });
|
|
const result = ghostScalePositionHandler.call(
|
|
control,
|
|
new Point(100, 100),
|
|
[1, 2, 3, 4, 5, 6], // this matrix is not used
|
|
image,
|
|
);
|
|
|
|
expect(result).toEqual({ x: 250, y: 250 });
|
|
});
|
|
|
|
test('positions top-right corner control correctly', () => {
|
|
const control = new Control({ x: 0.5, y: -0.5 });
|
|
const result = ghostScalePositionHandler.call(
|
|
control,
|
|
new Point(100, 100),
|
|
[1, 2, 3, 4, 5, 6], // this matrix is not used
|
|
image,
|
|
);
|
|
|
|
expect(result).toEqual({ x: 250, y: -50 });
|
|
});
|
|
|
|
test('positions bottom-left corner control correctly', () => {
|
|
const control = new Control({ x: -0.5, y: 0.5 });
|
|
const result = ghostScalePositionHandler.call(
|
|
control,
|
|
new Point(100, 100),
|
|
[1, 2, 3, 4, 5, 6], // this matrix is not used
|
|
image,
|
|
);
|
|
|
|
expect(result).toEqual({ x: -50, y: 250 });
|
|
});
|
|
});
|
|
|
|
describe('scaleEquallyCropGenerator', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
});
|
|
|
|
test('returns a TransformActionHandler function', () => {
|
|
const handler = scaleEquallyCropGenerator(-0.5, -0.5);
|
|
expect(typeof handler).toBe('function');
|
|
});
|
|
|
|
test('scales image uniformly from top-left corner', () => {
|
|
const handler = scaleEquallyCropGenerator(-0.5, -0.5);
|
|
transform = prepareTransform(image, 'tls');
|
|
expect(image.scaleX).toBe(1);
|
|
// Simulate dragging to scale up
|
|
const result = handler(eventData, transform, -400, -400);
|
|
|
|
// The handler should return a boolean
|
|
expect(result).toBe(true);
|
|
expect(image.scaleX.toFixed(2)).toBe('2.17');
|
|
expect(image.scaleX).toBe(image.scaleY);
|
|
});
|
|
|
|
test('scales image uniformly from bottom-right corner', () => {
|
|
const handler = scaleEquallyCropGenerator(0.5, 0.5);
|
|
transform = prepareTransform(image, 'brs');
|
|
expect(image.scaleX).toBe(1);
|
|
const result = handler(eventData, transform, 400, 400);
|
|
expect(result).toBe(true);
|
|
expect(image.scaleX).toBe(1.5);
|
|
expect(image.scaleX).toBe(image.scaleY);
|
|
});
|
|
|
|
test('returns false when scaling would exceed element bounds', () => {
|
|
// Set up image near the edge of element
|
|
image = createMockImage({
|
|
width: 250,
|
|
height: 250,
|
|
cropX: 25,
|
|
cropY: 25,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
|
|
const handler = scaleEquallyCropGenerator(-0.5, -0.5);
|
|
transform = prepareTransform(image, 'tls');
|
|
|
|
// Try to scale down significantly which might push bounds
|
|
const result = handler(eventData, transform, 10, 10);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
test('adjusts cropX and cropY when scaling from negative corner', () => {
|
|
image = createMockImage({
|
|
width: 90,
|
|
height: 90,
|
|
cropX: 25,
|
|
cropY: 25,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
const handler = scaleEquallyCropGenerator(-0.5, -0.5);
|
|
transform = prepareTransform(image, 'tls');
|
|
expect(image.cropX).toBe(25);
|
|
expect(image.cropY).toBe(25);
|
|
const result = handler(eventData, transform, 5, 5);
|
|
expect(result).toBe(true);
|
|
// When scaling from top-left, cropX and cropY should be recalculated
|
|
expect(image.cropX).toBe(0);
|
|
expect(image.cropY).toBe(0);
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
controlName: 'tls',
|
|
oppositeControlName: 'brs',
|
|
flipX: true,
|
|
flipY: false,
|
|
},
|
|
{
|
|
controlName: 'tls',
|
|
oppositeControlName: 'brs',
|
|
flipX: false,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'tls',
|
|
oppositeControlName: 'brs',
|
|
flipX: true,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'trs',
|
|
oppositeControlName: 'bls',
|
|
flipX: true,
|
|
flipY: false,
|
|
},
|
|
{
|
|
controlName: 'trs',
|
|
oppositeControlName: 'bls',
|
|
flipX: false,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'trs',
|
|
oppositeControlName: 'bls',
|
|
flipX: true,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'brs',
|
|
oppositeControlName: 'tls',
|
|
flipX: true,
|
|
flipY: false,
|
|
},
|
|
{
|
|
controlName: 'brs',
|
|
oppositeControlName: 'tls',
|
|
flipX: false,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'brs',
|
|
oppositeControlName: 'tls',
|
|
flipX: true,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'bls',
|
|
oppositeControlName: 'trs',
|
|
flipX: true,
|
|
flipY: false,
|
|
},
|
|
{
|
|
controlName: 'bls',
|
|
oppositeControlName: 'trs',
|
|
flipX: false,
|
|
flipY: true,
|
|
},
|
|
{
|
|
controlName: 'bls',
|
|
oppositeControlName: 'trs',
|
|
flipX: true,
|
|
flipY: true,
|
|
},
|
|
])(
|
|
'keeps the opposite ghost corner fixed for $controlName when flipX=$flipX flipY=$flipY',
|
|
({ controlName, oppositeControlName, flipX, flipY }) => {
|
|
image = createMockImage({
|
|
width: 120,
|
|
height: 100,
|
|
cropX: 40,
|
|
cropY: 50,
|
|
elementWidth: 320,
|
|
elementHeight: 260,
|
|
flipX,
|
|
flipY,
|
|
});
|
|
canvas.add(image);
|
|
|
|
const getControlPoint = (name: string) =>
|
|
ghostScalePositionHandler.call(
|
|
image.controls[name],
|
|
new Point(image.width, image.height),
|
|
[1, 0, 0, 1, 0, 0],
|
|
image,
|
|
);
|
|
|
|
const pointBefore = getControlPoint(controlName);
|
|
const oppositePointBefore = getControlPoint(oppositeControlName);
|
|
const center = image.getCenterPoint();
|
|
const dx = pointBefore.x < center.x ? 40 : -40;
|
|
const dy = pointBefore.y < center.y ? 30 : -30;
|
|
|
|
const handler = image.controls[controlName].actionHandler;
|
|
const localTransform = prepareTransform(image, controlName);
|
|
const changed = handler(
|
|
eventData,
|
|
localTransform,
|
|
pointBefore.x + dx,
|
|
pointBefore.y + dy,
|
|
);
|
|
|
|
expect(changed).toBe(true);
|
|
|
|
const oppositePointAfter = getControlPoint(oppositeControlName);
|
|
expect(oppositePointAfter.x).toBeCloseTo(oppositePointBefore.x, 6);
|
|
expect(oppositePointAfter.y).toBeCloseTo(oppositePointBefore.y, 6);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('renderGhostImage', () => {
|
|
beforeEach(() => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
});
|
|
|
|
test('draws image at correct position based on crop values', () => {
|
|
const mockCtx = {
|
|
globalAlpha: 1,
|
|
strokeStyle: '',
|
|
lineWidth: 0,
|
|
drawImage: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
} as unknown as CanvasRenderingContext2D;
|
|
|
|
renderGhostImage.call(image, { ctx: mockCtx });
|
|
|
|
// Should draw at (-width/2 - cropX, -height/2 - cropY)
|
|
// = (-50 - 50, -50 - 50) = (-100, -100)
|
|
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
|
image._element,
|
|
-100,
|
|
-100,
|
|
);
|
|
});
|
|
|
|
test('temporarily reduces globalAlpha by 50%', () => {
|
|
let alphaWhenDrawing: number | undefined;
|
|
const mockCtx = {
|
|
globalAlpha: 0.8,
|
|
strokeStyle: '',
|
|
lineWidth: 0,
|
|
drawImage: vi.fn(() => {
|
|
alphaWhenDrawing = mockCtx.globalAlpha;
|
|
}),
|
|
strokeRect: vi.fn(),
|
|
} as unknown as CanvasRenderingContext2D;
|
|
|
|
renderGhostImage.call(image, { ctx: mockCtx });
|
|
|
|
// During draw, alpha should be 0.8 * 0.5 = 0.4
|
|
expect(alphaWhenDrawing).toBe(0.4);
|
|
// After render, alpha should be restored
|
|
expect(mockCtx.globalAlpha).toBe(0.8);
|
|
});
|
|
|
|
test('draws border using borderColor', () => {
|
|
image.borderColor = 'blue';
|
|
const mockCtx = {
|
|
globalAlpha: 1,
|
|
strokeStyle: '',
|
|
lineWidth: 0,
|
|
drawImage: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
} as unknown as CanvasRenderingContext2D;
|
|
|
|
renderGhostImage.call(image, { ctx: mockCtx });
|
|
|
|
expect(mockCtx.strokeStyle).toBe('blue');
|
|
expect(mockCtx.strokeRect).toHaveBeenCalledWith(-100, -100, 300, 300);
|
|
});
|
|
});
|
|
|
|
describe('changeImageEdgeWidth', () => {
|
|
function prepareEdgeTransform(
|
|
target: FabricImage,
|
|
originX: 'left' | 'center' | 'right',
|
|
originY: 'top' | 'center' | 'bottom',
|
|
corner = 'mr',
|
|
): Transform {
|
|
target.controls[corner] = new Control({
|
|
x: originX === 'left' ? 0.5 : originX === 'right' ? -0.5 : 0,
|
|
y: originY === 'top' ? 0.5 : originY === 'bottom' ? -0.5 : 0,
|
|
});
|
|
return {
|
|
target,
|
|
corner,
|
|
originX,
|
|
originY,
|
|
width: target.width,
|
|
height: target.height,
|
|
original: {
|
|
cropX: target.cropX,
|
|
cropY: target.cropY,
|
|
scaleX: target.scaleX,
|
|
scaleY: target.scaleY,
|
|
},
|
|
} as unknown as Transform;
|
|
}
|
|
|
|
test('increases width within available space (right edge)', () => {
|
|
// 100px wide, cropX=50, element=300 -> 150px available on right
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'left', 'center');
|
|
|
|
const changed = changeImageWidthWithAutoCover(
|
|
eventData,
|
|
transform,
|
|
180,
|
|
50,
|
|
);
|
|
expect(changed).toBe(true);
|
|
expect(image.width).toBeGreaterThan(100);
|
|
expect(image.scaleX).toBe(1);
|
|
});
|
|
|
|
test('constrains width to element boundary (right edge)', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
cropX: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'left', 'center');
|
|
|
|
changeImageWidthWithAutoCover(eventData, transform, 500, 50);
|
|
expect(image.width).toBeLessThanOrEqual(250); // 300 - 50
|
|
});
|
|
|
|
test('triggers cover scale when beyond element bounds', () => {
|
|
// Already at max width, no crop space left
|
|
image = createMockImage({
|
|
width: 300,
|
|
height: 200,
|
|
cropX: 0,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'left', 'center');
|
|
|
|
changeImageWidthWithAutoCover(eventData, transform, 500, 100);
|
|
expect(image.scaleX).toBeGreaterThan(1);
|
|
expect(image.scaleX).toBe(image.scaleY); // uniform
|
|
expect(image.width).toBe(300);
|
|
});
|
|
|
|
test('expands into cropX space (left edge)', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
cropX: 100,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'right', 'center');
|
|
|
|
changeImageWidthWithAutoCover(eventData, transform, -250, 50);
|
|
expect(image.cropX).toBe(0);
|
|
expect(image.width).toBe(200); // original 100 + cropX 100
|
|
});
|
|
|
|
test('triggers cover scale from left edge when cropX exhausted', () => {
|
|
image = createMockImage({
|
|
width: 200,
|
|
height: 200,
|
|
cropX: 0,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'right', 'center');
|
|
|
|
changeImageWidthWithAutoCover(eventData, transform, -400, 100);
|
|
expect(image.scaleX).toBeGreaterThan(1);
|
|
expect(image.cropX).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('changeImageEdgeHeight', () => {
|
|
function prepareEdgeTransform(
|
|
target: FabricImage,
|
|
originX: 'left' | 'center' | 'right',
|
|
originY: 'top' | 'center' | 'bottom',
|
|
corner = 'mb',
|
|
): Transform {
|
|
target.controls[corner] = new Control({
|
|
x: originX === 'left' ? 0.5 : originX === 'right' ? -0.5 : 0,
|
|
y: originY === 'top' ? 0.5 : originY === 'bottom' ? -0.5 : 0,
|
|
});
|
|
return {
|
|
target,
|
|
corner,
|
|
originX,
|
|
originY,
|
|
width: target.width,
|
|
height: target.height,
|
|
original: {
|
|
cropX: target.cropX,
|
|
cropY: target.cropY,
|
|
scaleX: target.scaleX,
|
|
scaleY: target.scaleY,
|
|
},
|
|
} as unknown as Transform;
|
|
}
|
|
|
|
test('increases height within available space (bottom edge)', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'top');
|
|
|
|
changeImageHeightWithAutoCover(eventData, transform, 50, 180);
|
|
expect(image.height).toBeGreaterThan(100);
|
|
expect(image.scaleY).toBe(1);
|
|
});
|
|
|
|
test('constrains height to element boundary (bottom edge)', () => {
|
|
image = createMockImage({
|
|
height: 100,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'top');
|
|
|
|
changeImageHeightWithAutoCover(eventData, transform, 50, 500);
|
|
expect(image.height).toBeLessThanOrEqual(250); // 300 - 50
|
|
});
|
|
|
|
test('triggers cover scale when beyond element bounds', () => {
|
|
image = createMockImage({
|
|
width: 200,
|
|
height: 300,
|
|
cropX: 50,
|
|
cropY: 0,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'top');
|
|
|
|
changeImageHeightWithAutoCover(eventData, transform, 100, 500);
|
|
expect(image.scaleY).toBeGreaterThan(1);
|
|
expect(image.scaleX).toBe(image.scaleY); // uniform
|
|
expect(image.height).toBe(300);
|
|
});
|
|
|
|
test('expands into cropY space (top edge)', () => {
|
|
image = createMockImage({
|
|
height: 100,
|
|
cropY: 100,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'bottom');
|
|
|
|
changeImageHeightWithAutoCover(eventData, transform, 50, -250);
|
|
expect(image.cropY).toBe(0);
|
|
expect(image.height).toBe(200); // original 100 + cropY 100
|
|
});
|
|
|
|
test('triggers cover scale from top edge when cropY exhausted', () => {
|
|
image = createMockImage({
|
|
width: 200,
|
|
height: 200,
|
|
cropX: 50,
|
|
cropY: 0,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'bottom');
|
|
|
|
changeImageHeightWithAutoCover(eventData, transform, 100, -400);
|
|
expect(image.scaleY).toBeGreaterThan(1);
|
|
expect(image.cropY).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('edge resize with flipped images', () => {
|
|
function prepareEdgeTransform(
|
|
target: FabricImage,
|
|
originX: 'left' | 'center' | 'right',
|
|
originY: 'top' | 'center' | 'bottom',
|
|
corner: string,
|
|
): Transform {
|
|
target.controls[corner] = new Control({
|
|
x: originX === 'left' ? 0.5 : originX === 'right' ? -0.5 : 0,
|
|
y: originY === 'top' ? 0.5 : originY === 'bottom' ? -0.5 : 0,
|
|
});
|
|
return {
|
|
target,
|
|
corner,
|
|
originX,
|
|
originY,
|
|
width: target.width,
|
|
height: target.height,
|
|
original: {
|
|
cropX: target.cropX,
|
|
cropY: target.cropY,
|
|
scaleX: target.scaleX,
|
|
scaleY: target.scaleY,
|
|
},
|
|
} as unknown as Transform;
|
|
}
|
|
|
|
test('right edge expands width when flipX is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
flipX: true,
|
|
});
|
|
canvas.add(image);
|
|
// Right edge: originX='left' (anchor opposite side)
|
|
transform = prepareEdgeTransform(image, 'left', 'center', 'mr');
|
|
|
|
const initialWidth = image.width;
|
|
// Drag outward (positive x in local coords after flip transform)
|
|
changeImageWidthWithAutoCover(eventData, transform, 180, 50);
|
|
expect(image.width).toBeGreaterThan(initialWidth);
|
|
expect(image.width).toBe(150);
|
|
expect(image.cropX).toBe(0); // eaten all crop
|
|
expect(image.scaleX).toBe(1.2); // 20% of 150 to get to 180
|
|
});
|
|
|
|
test('left edge expands into cropX when flipX is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 100,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
flipX: true,
|
|
});
|
|
canvas.add(image);
|
|
// Left edge: originX='right' (anchor opposite side)
|
|
transform = prepareEdgeTransform(image, 'right', 'center', 'ml');
|
|
|
|
const initialCropX = image.cropX;
|
|
// Drag outward (negative x expands left edge)
|
|
changeImageWidthWithAutoCover(eventData, transform, -180, 50);
|
|
expect(image.cropX).toBe(initialCropX);
|
|
expect(image.width).toBe(200);
|
|
expect(image.scaleX).toBe(1.4); // 40% of 200 to go from 100 to 280
|
|
});
|
|
|
|
test('bottom edge expands height when flipY is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 50,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
flipY: true,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'top', 'mb');
|
|
|
|
const initialHeight = image.height;
|
|
changeImageHeightWithAutoCover(eventData, transform, 50, 180);
|
|
expect(image.height).toBeGreaterThan(initialHeight);
|
|
expect(image.height).toBe(150);
|
|
expect(image.cropY).toBe(0);
|
|
expect(image.scaleY).toBe(1.2);
|
|
});
|
|
|
|
test('top edge expands into cropY when flipY is true', () => {
|
|
image = createMockImage({
|
|
width: 100,
|
|
height: 100,
|
|
cropX: 50,
|
|
cropY: 100,
|
|
elementWidth: 300,
|
|
elementHeight: 300,
|
|
flipY: true,
|
|
});
|
|
canvas.add(image);
|
|
transform = prepareEdgeTransform(image, 'center', 'bottom', 'mt');
|
|
|
|
const initialCropY = image.cropY;
|
|
changeImageHeightWithAutoCover(eventData, transform, 50, -180);
|
|
expect(image.cropY).toBe(initialCropY);
|
|
expect(image.height).toBe(200);
|
|
expect(image.scaleY).toBe(1.4);
|
|
});
|
|
});
|
|
});
|