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, } 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; }> = {}, ): FabricImage { const { width = 100, height = 100, cropX = 0, cropY = 0, elementWidth = 200, elementHeight = 200, } = options; const imgElement = new Image(elementWidth, elementHeight); const img = new FabricImage(imgElement, { left: 50, top: 50, width, height, cropX, cropY, }); 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); }); }); 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); }); }); 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, drawImage: 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, drawImage: vi.fn(() => { alphaWhenDrawing = mockCtx.globalAlpha; }), } 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); }); }); });