import { T, TLImageShape, Vec, createShapeId } from '@tldraw/editor' import { getCropBox, getCroppedImageDataForAspectRatio, getCroppedImageDataForReplacedImage, getCroppedImageDataWhenZooming, } from '../lib/shapes/shared/crop' import { TestEditor } from './TestEditor' let editor: TestEditor let shape: TLImageShape const initialSize = { w: 100, h: 100 } const initialCrop = { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, } beforeEach(() => { editor = new TestEditor() const id = createShapeId() as TLImageShape['id'] editor.createShapes([ { id, type: 'image', x: 100, y: 100, props: { ...initialSize, crop: initialCrop, }, }, ]) shape = editor.getShape(id)! }) describe('Crop box', () => { it('Crops from the top left', () => { const results = getCropBox(shape, { handle: 'top_left', change: new Vec(10, 20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, }) expect(results).toMatchObject({ x: 110, y: 120, props: { w: 90, h: 80, crop: { topLeft: { x: 0.1, y: 0.2 }, bottomRight: { x: 1, y: 1 }, }, }, }) }) it('Crops from the top right', () => { const results = getCropBox(shape, { handle: 'top_right', change: new Vec(-10, 20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, }) expect(results).toMatchObject({ x: 100, y: 120, props: { w: 90, h: 80, crop: { topLeft: { x: 0, y: 0.2 }, bottomRight: { x: 0.9, y: 1 }, }, }, }) }) it('Crops from the bottom right', () => { const results = getCropBox(shape, { handle: 'bottom_right', change: new Vec(-10, -20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, }) expect(results).toMatchObject({ x: 100, y: 100, props: { w: 90, h: 80, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 0.9, y: 0.8 }, }, }, }) }) it('Crops from the bottom left', () => { const results = getCropBox(shape, { handle: 'bottom_left', change: new Vec(10, -20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, }) expect(results).toMatchObject({ x: 110, y: 100, props: { w: 90, h: 80, crop: { topLeft: { x: 0.1, y: 0 }, bottomRight: { x: 1, y: 0.8 }, }, }, }) }) it('Crop returns undefined when the crop does not change', () => { const results = getCropBox(shape, { handle: 'top_left', change: new Vec(-10, 0), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, }) expect(results).toBeUndefined() }) it('Crop returns undefined if existing width and height is already less than minWidth and minHeight', () => { const results = getCropBox( shape, { handle: 'top_left', change: new Vec(10, 20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, }, { minWidth: 110, minHeight: 110, } ) expect(results).toBeUndefined() }) }) describe('getCroppedImageDataWhenZooming', () => { it('maintains the aspect ratio when zooming', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 50, // 2:1 aspect ratio crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } const result = getCroppedImageDataWhenZooming(0.5, imageShape) // Check that aspect ratio is preserved expect(result.w / result.h).toBeCloseTo(2, 5) // Check that crop dimensions are correct const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(cropWidth / cropHeight).toBe(1) }) it('maintains the aspect ratio of a non-default crop when zooming', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 80, h: 80, // 1:1 original image crop: { // 2:1 crop aspect ratio topLeft: { x: 0.25, y: 0.375 }, bottomRight: { x: 0.75, y: 0.625 }, }, }, } const result = getCroppedImageDataWhenZooming(0.5, imageShape) // Check that the crop aspect ratio is preserved (2:1) const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(cropWidth / cropHeight).toBeCloseTo(2, 5) expect(result.w / result.h).toBe(1) }) it('applies zoom scaling (max 3x)', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } // Zoom to 100% (max zoom) const result = getCroppedImageDataWhenZooming(1, imageShape) // At max zoom, the crop window should be smaller const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(cropWidth).toBeCloseTo(0.33, 2) expect(cropHeight).toBeCloseTo(0.33, 2) expect(result.w).toBeCloseTo(99.99, 1) expect(result.h).toBeCloseTo(99.99, 1) }) it('applies custom maxZoom scaling', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } // Apply zoom with a custom maxZoom of 2x const result = getCroppedImageDataWhenZooming(0.75, imageShape, 0.8) // Verify that the crop dimensions respect the custom maxZoom const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(cropWidth).toBe(0.25) expect(cropHeight).toBe(0.25) expect(result.w).toBeCloseTo(74.99, 1) expect(result.h).toBeCloseTo(74.99, 1) }) it('preserves circular crops', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, isCircle: true, }, }, } const result = getCroppedImageDataWhenZooming(0.5, imageShape) // Verify that isCircle property is preserved expect(result.crop.isCircle).toBe(true) }) it('preserves crop center when zooming with crop in bottom right quadrant', () => { // Create image with crop in bottom right quadrant const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { // Crop in bottom right quadrant topLeft: { x: 0.6, y: 0.6 }, bottomRight: { x: 0.9, y: 0.9 }, }, }, } // Calculate the center of the original crop const originalCropCenterX = (0.6 + 0.9) / 2 const originalCropCenterY = (0.6 + 0.9) / 2 // Apply zoom operation const result = getCroppedImageDataWhenZooming(0.5, imageShape) // Calculate center of the new crop const newCropCenterX = (result.crop.topLeft.x + result.crop.bottomRight.x) / 2 const newCropCenterY = (result.crop.topLeft.y + result.crop.bottomRight.y) / 2 // Center should be preserved expect(newCropCenterX).toBeCloseTo(originalCropCenterX, 5) expect(newCropCenterY).toBeCloseTo(originalCropCenterY, 5) }) }) describe('Circle crop preservation during resize', () => { it('preserves circle crop when resizing', () => { const circleShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, isCircle: true, }, }, } const results = getCropBox(circleShape, { handle: 'bottom_right', change: new Vec(-10, -15), crop: circleShape.props.crop!, uncroppedSize: initialSize, initialShape: circleShape, aspectRatioLocked: false, }) expect(results?.props.crop?.isCircle).toBe(true) }) it('preserves circle crop when resizing false', () => { const circleShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, isCircle: false, }, }, } const results = getCropBox(circleShape, { handle: 'bottom_right', change: new Vec(-10, -15), crop: circleShape.props.crop!, uncroppedSize: initialSize, initialShape: circleShape, aspectRatioLocked: false, }) expect(results?.props.crop?.isCircle).toBe(false) }) it('does not add isCircle when input crop has no isCircle property', () => { const cropWithoutCircle = { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, } const results = getCropBox(shape, { handle: 'bottom_right', change: new Vec(-10, -15), crop: cropWithoutCircle, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toBeDefined() // isCircle should NOT be present on the result crop when it was not // present on the input crop. If it is present (even as undefined), // custom shape validators that don't include isCircle in their crop // schema will throw an "Unexpected property" validation error. expect('isCircle' in results!.props.crop!).toBe(false) }) it('does not add isCircle when input crop has isCircle set to undefined', () => { const cropWithUndefinedCircle = { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, isCircle: undefined, } const results = getCropBox(shape, { handle: 'top_left', change: new Vec(10, 10), crop: cropWithUndefinedCircle, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toBeDefined() // isCircle should NOT be present when it was undefined expect('isCircle' in results!.props.crop!).toBe(false) }) it('result crop passes validation for a custom crop schema without isCircle', () => { // This test reproduces the actual bug from #7927: custom croppable shapes // that define their own crop validator without isCircle would crash when // getCropBox added `isCircle: undefined` to the result crop object. const vecValidator = T.object({ x: T.number, y: T.number }) const customCropValidator = T.object({ topLeft: vecValidator, bottomRight: vecValidator, // Note: no isCircle field — this is valid for custom shapes }) const cropWithoutCircle = { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, } const results = getCropBox(shape, { handle: 'bottom_right', change: new Vec(-10, -15), crop: cropWithoutCircle, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) // The result crop should pass validation against a custom crop schema // that does not include isCircle. Before the fix, this would throw // "Unexpected property" because isCircle was set to undefined. expect(() => customCropValidator.validate(results!.props.crop)).not.toThrow() }) }) describe('getCroppedImageDataForAspectRatio', () => { it('returns full image for original aspect ratio', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 80, h: 100, crop: { topLeft: { x: 0.2, y: 0.1 }, bottomRight: { x: 0.8, y: 0.9 }, }, }, } const result = getCroppedImageDataForAspectRatio('original', imageShape) expect(result?.crop).toEqual({ topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }) expect(result?.w).toBeCloseTo(133, 0) expect(result?.h).toBeCloseTo(125, 0) }) it('creates perfect squares for square aspect ratio', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 50, // 2:1 aspect ratio crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } const result = getCroppedImageDataForAspectRatio('square', imageShape) // Crop window should be square const aspectRatio = result.w / result.h expect(aspectRatio).toEqual(1) }) it('creates circular crops for circle aspect ratio', () => { const imageShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 80, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } const result = getCroppedImageDataForAspectRatio('circle', imageShape) // Should be marked as a circle expect(result?.crop.isCircle).toBe(true) // Crop window should be 1:1 const aspectRatio = result.w / result.h expect(aspectRatio).toEqual(1) }) it('applies landscape crop to a square image', () => { // Start with a square image (100x100) const squareImage: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { // Default crop (full image) topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } // Apply a landscape crop const result = getCroppedImageDataForAspectRatio('landscape', squareImage) // Should have 4:3 aspect ratio (landscape) expect((result?.w as number) / (result?.h as number)).toBeCloseTo(4 / 3, 5) // Should preserve the center of the image const cropCenterX = (result!.crop.topLeft.x + result!.crop.bottomRight.x) / 2 const cropCenterY = (result!.crop.topLeft.y + result!.crop.bottomRight.y) / 2 expect(cropCenterX).toBeCloseTo(0.5, 5) expect(cropCenterY).toBeCloseTo(0.5, 5) }) it('applies portrait crop to a square image', () => { // Start with a square image (100x100) const squareImage: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { // Default crop (full image) topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } // Apply a portrait crop const result = getCroppedImageDataForAspectRatio('portrait', squareImage) // Should have 3:4 aspect ratio (portrait) expect((result?.w as number) / (result?.h as number)).toBeCloseTo(3 / 4, 5) // Should preserve the center of the image const cropCenterX = (result!.crop.topLeft.x + result!.crop.bottomRight.x) / 2 const cropCenterY = (result!.crop.topLeft.y + result!.crop.bottomRight.y) / 2 expect(cropCenterX).toBeCloseTo(0.5, 5) expect(cropCenterY).toBeCloseTo(0.5, 5) }) it('preserves longest dimension when changing from circle to landscape aspect ratio', () => { // Start with a smaller circular crop on a larger image const imageWithCircleCrop: TLImageShape = { ...shape, props: { ...shape.props, w: 60, // Current displayed size h: 60, crop: { // Small circular crop in center, actual crop is 40x40 pixels of a 200x200 image topLeft: { x: 0.3, y: 0.3 }, bottomRight: { x: 0.7, y: 0.7 }, isCircle: true, }, }, } // The uncropped image would be 150x150 (60 / 0.4 = 150) // So the current crop represents 40x40 absolute pixels const result = getCroppedImageDataForAspectRatio('landscape', imageWithCircleCrop) // Should have 4:3 aspect ratio (landscape) expect((result?.w as number) / (result?.h as number)).toBeCloseTo(4 / 3, 5) // The longest dimension was 40 pixels (both width and height were equal) // For landscape (4:3), if we preserve 40 pixels as width: height = 40 * (3/4) = 30 // If we preserve 40 pixels as height: width = 40 * (4/3) = 53.33 // Since both current dimensions are equal, we should preserve the first one (width) // So we expect roughly: width preserved at ~40, height = 40 * (3/4) = 30 // Calculate the actual crop dimensions in absolute pixels const cropWidth = (result!.crop.bottomRight.x - result!.crop.topLeft.x) * 150 // uncropped width const cropHeight = (result!.crop.bottomRight.y - result!.crop.topLeft.y) * 150 // uncropped height // The width should be preserved (approximately 60 pixels, the current crop width) expect(cropWidth).toBeCloseTo(60, 1) // The height should be adjusted to maintain 4:3 ratio expect(cropHeight).toBeCloseTo(60 * (3 / 4), 1) }) it('preserves longest dimension when changing from wide rectangle to square', () => { // Start with a wide rectangular crop const imageWithWideCrop: TLImageShape = { ...shape, props: { ...shape.props, w: 120, // Current displayed size h: 60, crop: { // Wide crop: 80x40 pixels of a 100x100 image topLeft: { x: 0.1, y: 0.3 }, bottomRight: { x: 0.9, y: 0.7 }, }, }, } // The uncropped image would be 150x150 (120 / 0.8 = 150) // Current crop represents 120x60 absolute pixels (80% x 40% of 150x150) const result = getCroppedImageDataForAspectRatio('square', imageWithWideCrop) // Should have 1:1 aspect ratio (square) expect((result?.w as number) / (result?.h as number)).toBeCloseTo(1, 5) // The longest dimension was width (120 pixels), so it should be preserved // For square, both width and height should be 120 pixels // Calculate the actual crop dimensions in absolute pixels const cropWidth = (result!.crop.bottomRight.x - result!.crop.topLeft.x) * 150 const cropHeight = (result!.crop.bottomRight.y - result!.crop.topLeft.y) * 150 // Both should be equal to the preserved longest dimension expect(cropWidth).toBeCloseTo(120, 1) expect(cropHeight).toBeCloseTo(120, 1) }) it('preserves longest dimension when changing from tall rectangle to wide rectangle', () => { // Start with a tall rectangular crop const imageWithTallCrop: TLImageShape = { ...shape, props: { ...shape.props, w: 40, // Current displayed size h: 100, crop: { // Tall crop: 0.3 x 0.75 relative dimensions (30% width, 75% height) topLeft: { x: 0.35, y: 0.125 }, bottomRight: { x: 0.65, y: 0.875 }, }, }, } // Calculate uncropped size: if 0.3 relative width = 40 pixels, then uncropped = 40/0.3 = 133.33 // And if 0.75 relative height = 100 pixels, then uncropped = 100/0.75 = 133.33 ✓ const uncroppedSize = 40 / 0.3 // 133.33 // Current crop represents 40x100 absolute pixels const result = getCroppedImageDataForAspectRatio('wide', imageWithTallCrop) // Should have 16:9 aspect ratio (wide) expect((result?.w as number) / (result?.h as number)).toBeCloseTo(16 / 9, 5) // With the new zoom-level preserving logic, the function now tries to maintain // the current crop zoom level and adjusts dimensions accordingly. // The actual behavior may be different from the original expectation due to // zoom level preservation and boundary constraints. // Calculate the actual crop dimensions in absolute pixels const cropWidth = (result!.crop.bottomRight.x - result!.crop.topLeft.x) * uncroppedSize const cropHeight = (result!.crop.bottomRight.y - result!.crop.topLeft.y) * uncroppedSize // With zoom level preservation, the actual dimensions will be based on maintaining // the current zoom level while respecting the 16:9 aspect ratio expect(cropWidth).toBeCloseTo(100, 1) // Updated expectation based on new logic // Height adjusted to maintain 16:9 ratio expect(cropHeight).toBeCloseTo(100 * (9 / 16), 1) }) }) describe('Resizing crop box when not aspect-ratio locked', () => { it('Resizes from the top left corner', () => { const results = getCropBox(shape, { handle: 'top_left', change: new Vec(10, 20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 110, y: 120, props: { w: 90, h: 80, crop: { topLeft: { x: 0.1, y: 0.2 }, bottomRight: { x: 1, y: 1 }, }, }, }) }) it('Resizes from the top right corner', () => { const results = getCropBox(shape, { handle: 'top_right', change: new Vec(-10, 20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 100, y: 120, props: { w: 90, h: 80, crop: { topLeft: { x: 0, y: 0.2 }, bottomRight: { x: 0.9, y: 1 }, }, }, }) }) it('Resizes from the bottom right corner', () => { const results = getCropBox(shape, { handle: 'bottom_right', change: new Vec(-10, -20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 100, y: 100, props: { w: 90, h: 80, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 0.9, y: 0.8 }, }, }, }) }) it('Resizes from the bottom left corner', () => { const results = getCropBox(shape, { handle: 'bottom_left', change: new Vec(10, -20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 110, y: 100, props: { w: 90, h: 80, crop: { topLeft: { x: 0.1, y: 0 }, bottomRight: { x: 1, y: 0.8 }, }, }, }) }) it('Resizes from the top edge', () => { const results = getCropBox(shape, { handle: 'top', change: new Vec(0, 20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 100, y: 120, props: { w: 100, h: 80, crop: { topLeft: { x: 0, y: 0.2 }, bottomRight: { x: 1, y: 1 }, }, }, }) }) it('Resizes from the right edge', () => { const results = getCropBox(shape, { handle: 'right', change: new Vec(-10, 0), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 100, y: 100, props: { w: 90, h: 100, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 0.9, y: 1 }, }, }, }) }) it('Resizes from the bottom edge', () => { const results = getCropBox(shape, { handle: 'bottom', change: new Vec(0, -20), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 100, y: 100, props: { w: 100, h: 80, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 0.8 }, }, }, }) }) it('Resizes from the left edge', () => { const results = getCropBox(shape, { handle: 'left', change: new Vec(10, 0), crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) expect(results).toMatchObject({ x: 110, y: 100, props: { w: 90, h: 100, crop: { topLeft: { x: 0.1, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, }) }) it('When overlapping edges, does not produce a result with < 0 or > 1 for x or y crop dimensions', () => { // Test dragging top edge down beyond bottom edge const results1 = getCropBox(shape, { handle: 'top', change: new Vec(0, 150), // Try to drag top edge past bottom crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) if (results1?.props.crop) { expect(results1.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results1.props.crop.topLeft.y).toBeLessThan(results1.props.crop.bottomRight.y) expect(results1.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } // Test dragging left edge right beyond right edge const results2 = getCropBox(shape, { handle: 'left', change: new Vec(150, 0), // Try to drag left edge past right crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) if (results2?.props.crop) { expect(results2.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results2.props.crop.topLeft.x).toBeLessThan(results2.props.crop.bottomRight.x) expect(results2.props.crop.bottomRight.x).toBeLessThanOrEqual(1) } // Test dragging corner to extreme position const results3 = getCropBox(shape, { handle: 'top_left', change: new Vec(150, 150), // Try to drag top-left corner beyond bottom-right crop: initialCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: false, }) if (results3?.props.crop) { expect(results3.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results3.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results3.props.crop.topLeft.x).toBeLessThan(results3.props.crop.bottomRight.x) expect(results3.props.crop.topLeft.y).toBeLessThan(results3.props.crop.bottomRight.y) expect(results3.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results3.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) }) describe('Resizing crop box when aspect-ratio locked', () => { // When resizing from a corner, the opposite corner should be preserved // When resizing from an edge, the opposite edge's mid point should be preserved // The result should have the same aspect ratio as the initial crop // The result should never have any dimension < 0 or > 1 it('Resizes from the top left corner', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const results = getCropBox(shape, { handle: 'top_left', change: new Vec(10, 15), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Bottom right corner should be preserved expect(results.props.crop.bottomRight.x).toBeCloseTo(testCrop.bottomRight.x, 5) expect(results.props.crop.bottomRight.y).toBeCloseTo(testCrop.bottomRight.y, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the top right corner', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const results = getCropBox(shape, { handle: 'top_right', change: new Vec(-10, 15), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Bottom left corner should be preserved expect(results.props.crop.topLeft.x).toBeCloseTo(testCrop.topLeft.x, 5) expect(results.props.crop.bottomRight.y).toBeCloseTo(testCrop.bottomRight.y, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the bottom right corner', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const results = getCropBox(shape, { handle: 'bottom_right', change: new Vec(-10, -15), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Top left corner should be preserved expect(results.props.crop.topLeft.x).toBeCloseTo(testCrop.topLeft.x, 5) expect(results.props.crop.topLeft.y).toBeCloseTo(testCrop.topLeft.y, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the bottom left corner', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const results = getCropBox(shape, { handle: 'bottom_left', change: new Vec(10, -15), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Top right corner should be preserved expect(results.props.crop.bottomRight.x).toBeCloseTo(testCrop.bottomRight.x, 5) expect(results.props.crop.topLeft.y).toBeCloseTo(testCrop.topLeft.y, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the top edge', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const initialCenterX = (testCrop.topLeft.x + testCrop.bottomRight.x) / 2 const results = getCropBox(shape, { handle: 'top', change: new Vec(0, 15), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Bottom edge and horizontal center should be preserved expect(results.props.crop.bottomRight.y).toBeCloseTo(testCrop.bottomRight.y, 5) const newCenterX = (results.props.crop.topLeft.x + results.props.crop.bottomRight.x) / 2 expect(newCenterX).toBeCloseTo(initialCenterX, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the right edge', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const initialCenterY = (testCrop.topLeft.y + testCrop.bottomRight.y) / 2 const results = getCropBox(shape, { handle: 'right', change: new Vec(-15, 0), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Left edge and vertical center should be preserved expect(results.props.crop.topLeft.x).toBeCloseTo(testCrop.topLeft.x, 5) const newCenterY = (results.props.crop.topLeft.y + results.props.crop.bottomRight.y) / 2 expect(newCenterY).toBeCloseTo(initialCenterY, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the bottom edge', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const initialCenterX = (testCrop.topLeft.x + testCrop.bottomRight.x) / 2 const results = getCropBox(shape, { handle: 'bottom', change: new Vec(0, -15), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Top edge and horizontal center should be preserved expect(results.props.crop.topLeft.y).toBeCloseTo(testCrop.topLeft.y, 5) const newCenterX = (results.props.crop.topLeft.x + results.props.crop.bottomRight.x) / 2 expect(newCenterX).toBeCloseTo(initialCenterX, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Resizes from the left edge', () => { const testCrop = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.7 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const initialCenterY = (testCrop.topLeft.y + testCrop.bottomRight.y) / 2 const results = getCropBox(shape, { handle: 'left', change: new Vec(15, 0), crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Right edge and vertical center should be preserved expect(results.props.crop.bottomRight.x).toBeCloseTo(testCrop.bottomRight.x, 5) const newCenterY = (results.props.crop.topLeft.y + results.props.crop.bottomRight.y) / 2 expect(newCenterY).toBeCloseTo(initialCenterY, 5) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('When overlapping edges, does not produce a result with < 0 or > 1 for x or y crop dimensions and maintains the aspect ratio', () => { const testCrop = { topLeft: { x: 0.25, y: 0.25 }, bottomRight: { x: 0.75, y: 0.75 }, } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) // Test extreme resize that would normally cause invalid bounds const results = getCropBox(shape, { handle: 'top_left', change: new Vec(200, 200), // Extreme change crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // All coordinates should be within valid bounds expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) // Crop should have positive dimensions const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y expect(cropWidth).toBeGreaterThan(0) expect(cropHeight).toBeGreaterThan(0) // Aspect ratio should be maintained const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) } }) // 1. Boundary collision tests describe('Boundary collision scenarios', () => { it('Handles crop starting at left boundary (x=0)', () => { const boundaryTestCrop = { topLeft: { x: 0, y: 0.3 }, bottomRight: { x: 0.4, y: 0.7 }, } const initialAspectRatio = (boundaryTestCrop.bottomRight.x - boundaryTestCrop.topLeft.x) / (boundaryTestCrop.bottomRight.y - boundaryTestCrop.topLeft.y) const results = getCropBox(shape, { handle: 'left', change: new Vec(-50, 0), // Try to move left edge beyond boundary crop: boundaryTestCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Left edge should stay at boundary expect(results.props.crop.topLeft.x).toBe(0) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // Should not exceed boundaries expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) } }) it('Handles crop starting at right boundary (x=1)', () => { const boundaryTestCrop = { topLeft: { x: 0.6, y: 0.3 }, bottomRight: { x: 1, y: 0.7 }, } const initialAspectRatio = (boundaryTestCrop.bottomRight.x - boundaryTestCrop.topLeft.x) / (boundaryTestCrop.bottomRight.y - boundaryTestCrop.topLeft.y) const results = getCropBox(shape, { handle: 'right', change: new Vec(50, 0), // Try to move right edge beyond boundary crop: boundaryTestCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Right edge should stay at boundary expect(results.props.crop.bottomRight.x).toBe(1) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) // Should not go below boundaries expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) } }) it('Handles crop starting at top boundary (y=0)', () => { const boundaryTestCrop = { topLeft: { x: 0.3, y: 0 }, bottomRight: { x: 0.7, y: 0.4 }, } const initialAspectRatio = (boundaryTestCrop.bottomRight.x - boundaryTestCrop.topLeft.x) / (boundaryTestCrop.bottomRight.y - boundaryTestCrop.topLeft.y) const results = getCropBox(shape, { handle: 'top', change: new Vec(0, -50), // Try to move top edge beyond boundary crop: boundaryTestCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Top edge should stay at boundary expect(results.props.crop.topLeft.y).toBe(0) // Aspect ratio should be maintained const newAspectRatio = (results.props.crop.bottomRight.x - results.props.crop.topLeft.x) / (results.props.crop.bottomRight.y - results.props.crop.topLeft.y) expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) } }) it('Handles aspect ratio constraint conflicting with boundary constraints', () => { // Start with a very wide crop near the top-left corner const conflictTestCrop = { topLeft: { x: 0.05, y: 0.05 }, bottomRight: { x: 0.95, y: 0.25 }, // Very wide (4.5:1 ratio) } const initialAspectRatio = (conflictTestCrop.bottomRight.x - conflictTestCrop.topLeft.x) / (conflictTestCrop.bottomRight.y - conflictTestCrop.topLeft.y) const results = getCropBox(shape, { handle: 'top_left', change: new Vec(-20, -20), // Try to move beyond both x=0 and y=0 crop: conflictTestCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { // Should respect boundaries expect(results.props.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(results.props.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(results.props.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(results.props.crop.bottomRight.y).toBeLessThanOrEqual(1) // Should maintain positive dimensions const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y expect(cropWidth).toBeGreaterThan(0) expect(cropHeight).toBeGreaterThan(0) // Should attempt to maintain aspect ratio where possible const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 2) // Less precision due to boundary conflicts } }) }) // 2. Minimum size constraint tests with aspect ratio locked describe('Minimum size constraints with aspect ratio locked', () => { it('Enforces minimum size constraints even when they conflict with aspect ratio', () => { const smallCrop = { topLeft: { x: 0.4, y: 0.4 }, bottomRight: { x: 0.6, y: 0.6 }, // 20x20 crop } const results = getCropBox( shape, { handle: 'top_left', change: new Vec(15, 15), // Try to make it smaller crop: smallCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }, { minWidth: 25, // Larger than what resize would produce minHeight: 25, } ) // Should clamp to minimum size constraints if (results?.props.crop) { const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y // Minimum size should be respected expect(cropWidth * initialSize.w).toBeGreaterThanOrEqual(25) expect(cropHeight * initialSize.h).toBeGreaterThanOrEqual(25) } }) it('Respects minimum width constraint while maintaining aspect ratio', () => { const testCrop = { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.6, y: 0.8 }, // 40x60 crop (2:3 ratio) } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const results = getCropBox( shape, { handle: 'right', change: new Vec(-25, 0), // Try to make it narrower crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }, { minWidth: 20, // Enforce minimum width minHeight: 8, } ) if (results?.props.crop) { // Width should respect minimum const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x expect(cropWidth * initialSize.w).toBeGreaterThanOrEqual(20) // Aspect ratio should be maintained const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) } }) it('Respects minimum height constraint while maintaining aspect ratio', () => { const testCrop = { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.6 }, // 60x40 crop (3:2 ratio) } const initialAspectRatio = (testCrop.bottomRight.x - testCrop.topLeft.x) / (testCrop.bottomRight.y - testCrop.topLeft.y) const results = getCropBox( shape, { handle: 'bottom', change: new Vec(0, -25), // Try to make it shorter crop: testCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }, { minWidth: 8, minHeight: 20, // Enforce minimum height } ) if (results?.props.crop) { // Height should respect minimum const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y expect(cropHeight * initialSize.h).toBeGreaterThanOrEqual(20) // Aspect ratio should be maintained const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) } }) }) // 3. Different aspect ratio tests describe('Different starting aspect ratios', () => { it('Maintains very wide aspect ratio (4:1)', () => { const wideCrop = { topLeft: { x: 0.1, y: 0.4 }, bottomRight: { x: 0.9, y: 0.6 }, // 4:1 ratio } const initialAspectRatio = (wideCrop.bottomRight.x - wideCrop.topLeft.x) / (wideCrop.bottomRight.y - wideCrop.topLeft.y) expect(initialAspectRatio).toBeCloseTo(4, 1) // Verify it's actually 4:1 const results = getCropBox(shape, { handle: 'bottom_right', change: new Vec(-20, -5), crop: wideCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) } }) it('Maintains very tall aspect ratio (1:4)', () => { const tallCrop = { topLeft: { x: 0.4, y: 0.1 }, bottomRight: { x: 0.6, y: 0.9 }, // 1:4 ratio } const initialAspectRatio = (tallCrop.bottomRight.x - tallCrop.topLeft.x) / (tallCrop.bottomRight.y - tallCrop.topLeft.y) expect(initialAspectRatio).toBeCloseTo(0.25, 1) // Verify it's actually 1:4 const results = getCropBox(shape, { handle: 'top_left', change: new Vec(5, 20), crop: tallCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 5) } }) it('Maintains perfect square aspect ratio (1:1)', () => { const squareCrop = { topLeft: { x: 0.25, y: 0.25 }, bottomRight: { x: 0.75, y: 0.75 }, // 1:1 ratio } const initialAspectRatio = (squareCrop.bottomRight.x - squareCrop.topLeft.x) / (squareCrop.bottomRight.y - squareCrop.topLeft.y) expect(initialAspectRatio).toBe(1) // Verify it's actually 1:1 const results = getCropBox(shape, { handle: 'right', change: new Vec(-15, 0), crop: squareCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(1, 5) } }) it('Maintains extreme wide aspect ratio (16:1)', () => { const extremeWideCrop = { topLeft: { x: 0.05, y: 0.475 }, bottomRight: { x: 0.85, y: 0.525 }, // ~16:1 ratio } const initialAspectRatio = (extremeWideCrop.bottomRight.x - extremeWideCrop.topLeft.x) / (extremeWideCrop.bottomRight.y - extremeWideCrop.topLeft.y) expect(initialAspectRatio).toBeCloseTo(16, 1) // Verify it's extremely wide const results = getCropBox(shape, { handle: 'left', change: new Vec(20, 0), crop: extremeWideCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 3) // Less precision for extreme ratios } }) it('Maintains extreme tall aspect ratio (1:16)', () => { const extremeTallCrop = { topLeft: { x: 0.475, y: 0.05 }, bottomRight: { x: 0.525, y: 0.85 }, // ~1:16 ratio } const initialAspectRatio = (extremeTallCrop.bottomRight.x - extremeTallCrop.topLeft.x) / (extremeTallCrop.bottomRight.y - extremeTallCrop.topLeft.y) expect(initialAspectRatio).toBeCloseTo(0.0625, 1) // Verify it's extremely tall const results = getCropBox(shape, { handle: 'top', change: new Vec(0, 20), crop: extremeTallCrop, uncroppedSize: initialSize, initialShape: shape, aspectRatioLocked: true, }) if (results?.props.crop) { const cropWidth = results.props.crop.bottomRight.x - results.props.crop.topLeft.x const cropHeight = results.props.crop.bottomRight.y - results.props.crop.topLeft.y const newAspectRatio = cropWidth / cropHeight expect(newAspectRatio).toBeCloseTo(initialAspectRatio, 3) // Less precision for extreme ratios } }) }) }) describe('getCroppedImageDataForReplacedImage', () => { it('preserves aspect ratio when replacing with a wider image', () => { // Original: 100x100 square image with a 80x60 crop (4:3 aspect ratio) const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 80, h: 60, crop: { topLeft: { x: 0.1, y: 0.2 }, bottomRight: { x: 0.9, y: 0.8 }, }, }, } // Replace with a 200x100 image (2:1 aspect ratio - wider than original crop) const result = getCroppedImageDataForReplacedImage(originalShape, 200, 100) // Should maintain 4:3 aspect ratio of the display expect(result.w / result.h).toBeCloseTo(4 / 3, 2) // With the new implementation, the crop behavior is different const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y // Should maintain reasonable crop dimensions within bounds expect(cropWidth).toBeGreaterThan(0) expect(cropWidth).toBeLessThanOrEqual(1) expect(cropHeight).toBeGreaterThan(0) expect(cropHeight).toBeLessThanOrEqual(1) }) it('preserves aspect ratio when replacing with a taller image', () => { // Original: 100x100 square image with a 80x60 crop (4:3 aspect ratio) const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 80, h: 60, crop: { topLeft: { x: 0.1, y: 0.2 }, bottomRight: { x: 0.9, y: 0.8 }, }, }, } // Replace with a 100x200 image (1:2 aspect ratio - taller than original crop) const result = getCroppedImageDataForReplacedImage(originalShape, 100, 200) // Should maintain 4:3 aspect ratio of the display expect(result.w / result.h).toBeCloseTo(4 / 3, 2) // Should maintain reasonable crop dimensions within bounds const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(cropWidth).toBeGreaterThan(0) expect(cropWidth).toBeLessThanOrEqual(1) expect(cropHeight).toBeGreaterThan(0) expect(cropHeight).toBeLessThanOrEqual(1) }) it('preserves crop center position when possible', () => { // Original: crop centered at (0.5, 0.5) const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.3, y: 0.3 }, bottomRight: { x: 0.7, y: 0.7 }, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 200, 200) // Calculate the center of the new crop const newCenterX = (result.crop.topLeft.x + result.crop.bottomRight.x) / 2 const newCenterY = (result.crop.topLeft.y + result.crop.bottomRight.y) / 2 // Should be close to the original center (0.5, 0.5) expect(newCenterX).toBeCloseTo(0.5, 2) expect(newCenterY).toBeCloseTo(0.5, 2) }) it('clamps crop to bounds when center would go outside', () => { // Original: crop in bottom-right corner const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.7, y: 0.7 }, bottomRight: { x: 1.0, y: 1.0 }, }, }, } // Replace with larger image that would require a bigger crop const result = getCroppedImageDataForReplacedImage(originalShape, 50, 50) // All crop coordinates should be within bounds expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) // Crop should have positive dimensions const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(cropWidth).toBeGreaterThan(0) expect(cropHeight).toBeGreaterThan(0) }) it('preserves circular crop setting', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, isCircle: true, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 150, 150) expect(result.crop.isCircle).toBe(true) }) it('handles the user example case', () => { // Based on the user's example const originalShape: TLImageShape = { ...shape, x: 100, y: 100, props: { ...shape.props, w: 644.1820234207992, h: 892.1606431309451, crop: { topLeft: { x: 0.5600459726696188, y: 0 }, bottomRight: { x: 0.9942493696224837, y: 0.4479333333333331 }, isCircle: false, }, }, } // Replace with some new image dimensions (we don't know the exact new image size from the example) // Let's assume a roughly similar size image but different aspect ratio const result = getCroppedImageDataForReplacedImage(originalShape, 800, 1200) // Should preserve the aspect ratio of the original display const originalAspectRatio = 644.1820234207992 / 892.1606431309451 expect(result.w / result.h).toBeCloseTo(originalAspectRatio, 2) // Should preserve circular setting expect(result.crop.isCircle).toBe(false) // Crop should be within bounds expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) // Edge cases and regression tests it('handles extremely small images', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 50, h: 50, crop: { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 1, 1) // Should handle tiny images gracefully expect(result.w).toBeGreaterThan(0) expect(result.h).toBeGreaterThan(0) expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) it('handles extremely large images', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.4, y: 0.4 }, bottomRight: { x: 0.6, y: 0.6 }, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 10000, 10000) // Should handle large images gracefully expect(result.w).toBeGreaterThan(0) expect(result.h).toBeGreaterThan(0) expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) it('handles zero-width or zero-height images', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } // Test zero width const resultZeroWidth = getCroppedImageDataForReplacedImage(originalShape, 0, 100) expect(resultZeroWidth.w).toBeGreaterThanOrEqual(0) expect(resultZeroWidth.h).toBeGreaterThan(0) // Test zero height const resultZeroHeight = getCroppedImageDataForReplacedImage(originalShape, 100, 0) expect(resultZeroHeight.w).toBeGreaterThan(0) expect(resultZeroHeight.h).toBeGreaterThanOrEqual(0) }) it('handles invalid crop values (outside 0-1 bounds)', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: -0.1, y: -0.1 }, bottomRight: { x: 1.1, y: 1.1 }, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 200, 200) // Should clamp crop values to valid bounds expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) it('handles inverted crop values (topLeft > bottomRight)', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.8, y: 0.8 }, bottomRight: { x: 0.2, y: 0.2 }, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 200, 200) // Inverted crop values result in negative dimensions, which getUncroppedSize handles // The function should still return valid dimensions and position expect(result.w).toBeGreaterThan(0) expect(result.h).toBeGreaterThan(0) expect(Number.isFinite(result.x)).toBe(true) expect(Number.isFinite(result.y)).toBe(true) // Crop bounds should be valid expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) it('preserves zoom level when replacing with same aspect ratio', () => { // Create a cropped image with specific zoom level const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.25, y: 0.25 }, bottomRight: { x: 0.75, y: 0.75 }, }, }, } // Replace with image of same aspect ratio but different size const result = getCroppedImageDataForReplacedImage(originalShape, 200, 200) // Zoom level should be preserved (crop size should be similar) const originalCropWidth = 0.75 - 0.25 const originalCropHeight = 0.75 - 0.25 const newCropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const newCropHeight = result.crop.bottomRight.y - result.crop.topLeft.y expect(newCropWidth).toBeCloseTo(originalCropWidth, 2) expect(newCropHeight).toBeCloseTo(originalCropHeight, 2) }) it('handles extreme aspect ratio changes', () => { // Start with a square crop const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, }, }, } // Replace with extremely wide image const resultWide = getCroppedImageDataForReplacedImage(originalShape, 1000, 10) expect(resultWide.w).toBeGreaterThan(0) expect(resultWide.h).toBeGreaterThan(0) expect(resultWide.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(resultWide.crop.bottomRight.x).toBeLessThanOrEqual(1) // Replace with extremely tall image const resultTall = getCroppedImageDataForReplacedImage(originalShape, 10, 1000) expect(resultTall.w).toBeGreaterThan(0) expect(resultTall.h).toBeGreaterThan(0) expect(resultTall.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(resultTall.crop.bottomRight.y).toBeLessThanOrEqual(1) }) it('handles default crop (full image)', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 200, 150) // Should maintain original display dimensions when replacing full image expect(result.w).toBe(100) expect(result.h).toBe(75) // Adjusted for new aspect ratio expect(result.crop).toEqual({ topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }) }) it.skip('handles crop with zero width or height', () => { // This test is skipped because zero-dimension crops represent invalid input // that can cause division by zero and produce NaN values in the current implementation. // This is an edge case that would require additional input validation to handle properly. const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.5, y: 0.5 }, bottomRight: { x: 0.5, y: 0.5 }, // Zero width/height crop - invalid }, }, } const result = getCroppedImageDataForReplacedImage(originalShape, 200, 200) // This would be the ideal behavior if we added input validation: expect(Number.isFinite(result.w)).toBe(true) expect(Number.isFinite(result.h)).toBe(true) expect(Number.isFinite(result.x)).toBe(true) expect(Number.isFinite(result.y)).toBe(true) }) it('handles MAX_ZOOM constraint correctly', () => { // Create a heavily zoomed crop (near MAX_ZOOM limit) const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: { topLeft: { x: 0.4, y: 0.4 }, bottomRight: { x: 0.6, y: 0.6 }, // 0.2x0.2 = 5x zoom }, }, } // Replace with image that would exceed MAX_ZOOM const result = getCroppedImageDataForReplacedImage(originalShape, 50, 50) // Should respect MAX_ZOOM constraint where applicable const cropWidth = result.crop.bottomRight.x - result.crop.topLeft.x const cropHeight = result.crop.bottomRight.y - result.crop.topLeft.y // Ensure crop dimensions are valid (within bounds and positive) expect(cropWidth).toBeGreaterThan(0) expect(cropWidth).toBeLessThanOrEqual(1) expect(cropHeight).toBeGreaterThan(0) expect(cropHeight).toBeLessThanOrEqual(1) }) it('preserves visual center position on page', () => { const originalShape: TLImageShape = { ...shape, x: 100, y: 50, props: { ...shape.props, w: 200, h: 100, crop: { topLeft: { x: 0.2, y: 0.2 }, bottomRight: { x: 0.8, y: 0.8 }, }, }, } // Calculate original visual center const originalCenterX = 100 + 200 / 2 const originalCenterY = 50 + 100 / 2 const result = getCroppedImageDataForReplacedImage(originalShape, 300, 150) // Calculate new visual center const newCenterX = result.x + result.w / 2 const newCenterY = result.y + result.h / 2 // Visual center should be preserved expect(newCenterX).toBeCloseTo(originalCenterX, 1) expect(newCenterY).toBeCloseTo(originalCenterY, 1) }) it('handles null/undefined crop gracefully', () => { const originalShape: TLImageShape = { ...shape, props: { ...shape.props, w: 100, h: 100, crop: null as any, // Simulate null crop }, } const result = getCroppedImageDataForReplacedImage(originalShape, 200, 200) // Should treat null crop as default crop expect(result.crop).toEqual({ topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 }, }) }) it('handles real-world image replacement example', () => { // Real example from user: Bernie Sanders image being replaced with Twitter yeast image const originalShape: TLImageShape = { ...shape, x: 100, y: 100, props: { ...shape.props, w: 1816.0434827332786, h: 711.5166728916853, crop: { topLeft: { x: 0.33373112506807145, y: 0.6111179977678004, }, bottomRight: { x: 0.6670644584014045, y: 0.7083979367030662, }, isCircle: false, }, }, } // Original asset: 800x1074 (Bernie Sanders image) // New asset: 942x872 (Twitter yeast image) const result = getCroppedImageDataForReplacedImage(originalShape, 942, 872) // Should preserve display dimensions expect(result.w).toBeCloseTo(1816.0434827332786, 1) expect(result.h).toBeCloseTo(711.5166728916853, 1) // Should preserve the crop area but adjust coordinates for new image aspect ratio // The crop should be similar but adjusted for the new image's aspect ratio expect(result.crop.topLeft.x).toBeCloseTo(0.33373112506807145, 2) expect(result.crop.bottomRight.x).toBeCloseTo(0.6670644584014045, 2) // Y coordinates should shift slightly due to aspect ratio change // Original image: 800/1074 ≈ 0.745 aspect ratio // New image: 942/872 ≈ 1.081 aspect ratio expect(result.crop.topLeft.y).toBeCloseTo(0.589, 1) // Should be around 0.589 expect(result.crop.bottomRight.y).toBeCloseTo(0.73, 1) // Should be around 0.730 // Should preserve circle setting expect(result.crop.isCircle).toBe(false) // Crop should be within valid bounds expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) it('handles real-world image replacement example 2', () => { // Second real example: Twitter yeast image being replaced with screenshot const originalShape: TLImageShape = { ...shape, x: 100, y: 100, props: { ...shape.props, w: 205.04988142156844, h: 326.9950273786993, crop: { topLeft: { x: 0.23316482887716639, y: 0.38688411384568194, }, bottomRight: { x: 0.42665668980670746, y: 0.720217447179015, }, isCircle: false, }, }, } // Original asset: 942x872 (Twitter yeast image, aspect ratio ≈ 1.081) // New asset: 1285x765 (Screenshot, aspect ratio ≈ 1.679) const result = getCroppedImageDataForReplacedImage(originalShape, 1285, 765) // Should preserve display dimensions expect(result.w).toBeCloseTo(205.04988142156844, 1) expect(result.h).toBeCloseTo(326.9950273786993, 1) // Y coordinates should remain exactly the same (key requirement) expect(result.crop.topLeft.y).toBeCloseTo(0.38688411384568194, 6) expect(result.crop.bottomRight.y).toBeCloseTo(0.720217447179015, 6) // X coordinates should adjust for the new image aspect ratio // Going from wider to even wider image should adjust X coordinates expect(result.crop.topLeft.x).toBeCloseTo(0.2677, 3) // Should be around 0.2677 expect(result.crop.bottomRight.x).toBeCloseTo(0.3921, 3) // Should be around 0.3921 // Should preserve circle setting expect(result.crop.isCircle).toBe(false) // Crop should be within valid bounds expect(result.crop.topLeft.x).toBeGreaterThanOrEqual(0) expect(result.crop.topLeft.y).toBeGreaterThanOrEqual(0) expect(result.crop.bottomRight.x).toBeLessThanOrEqual(1) expect(result.crop.bottomRight.y).toBeLessThanOrEqual(1) }) })