From a455ee0a8f33898ec5a9940ad85ae6891100b290 Mon Sep 17 00:00:00 2001 From: Chris Tran <6634746+sYnVerse@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:21:41 -0700 Subject: [PATCH 1/6] Add crop-image tool Introduce a new Crop Image tool: adds an interactive Vue cropper component (src/tools/crop-image/crop-image.vue) with drag/drop upload, viewport cropping, zoom, rotation, horizontal/vertical flip, aspect-ratio presets/custom ratio, rule-of-thirds grid, background color, and export options (PNG/JPEG/WebP, quality and width modes). Also adds tool registration (src/tools/crop-image/index.ts), a placeholder service (crop-image.service.ts), a Vitest unit test (crop-image.service.test.ts) and a Playwright e2e spec stub (crop-image.e2e.spec.ts). --- src/tools/crop-image/crop-image.e2e.spec.ts | 15 + .../crop-image/crop-image.service.test.ts | 7 + src/tools/crop-image/crop-image.service.ts | 0 src/tools/crop-image/crop-image.vue | 604 ++++++++++++++++++ src/tools/crop-image/index.ts | 14 + 5 files changed, 640 insertions(+) create mode 100644 src/tools/crop-image/crop-image.e2e.spec.ts create mode 100644 src/tools/crop-image/crop-image.service.test.ts create mode 100644 src/tools/crop-image/crop-image.service.ts create mode 100644 src/tools/crop-image/crop-image.vue create mode 100644 src/tools/crop-image/index.ts diff --git a/src/tools/crop-image/crop-image.e2e.spec.ts b/src/tools/crop-image/crop-image.e2e.spec.ts new file mode 100644 index 0000000000..e614293fab --- /dev/null +++ b/src/tools/crop-image/crop-image.e2e.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Crop image', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/crop-image'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('Crop image - IT Tools'); + }); + + test('', async ({ page }) => { + + }); +}); diff --git a/src/tools/crop-image/crop-image.service.test.ts b/src/tools/crop-image/crop-image.service.test.ts new file mode 100644 index 0000000000..451fbde1d1 --- /dev/null +++ b/src/tools/crop-image/crop-image.service.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; + +describe('crop-image service', () => { + it('should pass', () => { + expect(true).toBe(true); + }); +}); diff --git a/src/tools/crop-image/crop-image.service.ts b/src/tools/crop-image/crop-image.service.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/tools/crop-image/crop-image.vue b/src/tools/crop-image/crop-image.vue new file mode 100644 index 0000000000..d5f5300729 --- /dev/null +++ b/src/tools/crop-image/crop-image.vue @@ -0,0 +1,604 @@ + + + + + diff --git a/src/tools/crop-image/index.ts b/src/tools/crop-image/index.ts new file mode 100644 index 0000000000..a0ce936aa3 --- /dev/null +++ b/src/tools/crop-image/index.ts @@ -0,0 +1,14 @@ +import { Crop } from '@vicons/tabler'; +import { defineTool } from '../tool'; +import { translate } from '@/plugins/i18n.plugin'; + +export const tool = defineTool({ + name: translate('tools.crop-image.title'), + path: '/crop-image', + description: translate('tools.crop-image.description'), + keywords: ['crop', 'image', 'resize', 'canvas'], + component: () => import('./crop-image.vue'), + icon: Crop, + createdAt: new Date('2026-06-06'), + category: 'Images', +}); From b688eb27d0e59bc99768e003bdd8fb4f48060f84 Mon Sep 17 00:00:00 2001 From: Chris Tran <6634746+sYnVerse@users.noreply.github.com> Date: Sat, 6 Jun 2026 14:24:25 -0700 Subject: [PATCH 2/6] Add crop-image sizing utilities and tests Introduce getViewportDimensions and getBaseDimensions utility functions (src/tools/crop-image/crop-image.service.ts) to centralize viewport/base sizing logic and handle missing natural dimensions. Refactor crop-image.vue to consume these utilities for computed viewport and base dimensions. Add unit tests covering landscape/portrait calculations and cover scaling (src/tools/crop-image/crop-image.service.test.ts) and expand the e2e spec to upload a 1x1 PNG and assert presence of the cropper, sliders and control buttons (src/tools/crop-image/crop-image.e2e.spec.ts). Improves testability and keeps sizing logic consistent across the component. --- src/tools/crop-image/crop-image.e2e.spec.ts | 34 ++++++++++++- .../crop-image/crop-image.service.test.ts | 46 +++++++++++++++-- src/tools/crop-image/crop-image.service.ts | 50 +++++++++++++++++++ src/tools/crop-image/crop-image.vue | 44 ++++------------ 4 files changed, 135 insertions(+), 39 deletions(-) diff --git a/src/tools/crop-image/crop-image.e2e.spec.ts b/src/tools/crop-image/crop-image.e2e.spec.ts index e614293fab..446bbeec25 100644 --- a/src/tools/crop-image/crop-image.e2e.spec.ts +++ b/src/tools/crop-image/crop-image.e2e.spec.ts @@ -9,7 +9,39 @@ test.describe('Tool - Crop image', () => { await expect(page).toHaveTitle('Crop image - IT Tools'); }); - test('', async ({ page }) => { + test('Upload an image and interact with crop controls', async ({ page }) => { + // Check that we start in upload mode + const fileInput = page.locator('input[type="file"]'); + await expect(fileInput).toBeAttached(); + // 1x1 transparent PNG buffer + const pixelPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + + // Upload the file + await fileInput.setInputFiles({ + name: 'test.png', + mimeType: 'image/png', + buffer: pixelPng, + }); + + // Interactive cropper grid should now be visible + const viewportBox = page.locator('.viewport-box'); + await expect(viewportBox).toBeVisible(); + + // Check sliders exist for zooming and rotation + const sliders = page.locator('.n-slider'); + await expect(sliders).toHaveCount(2); + + // Export button should be visible + const exportButton = page.getByRole('button', { name: /Export & Download Cropped Image/i }); + await expect(exportButton).toBeVisible(); + + // Change image button should be visible + const changeImageButton = page.getByRole('button', { name: /Change Image/i }); + await expect(changeImageButton).toBeVisible(); }); }); + diff --git a/src/tools/crop-image/crop-image.service.test.ts b/src/tools/crop-image/crop-image.service.test.ts index 451fbde1d1..126d7b3a1e 100644 --- a/src/tools/crop-image/crop-image.service.test.ts +++ b/src/tools/crop-image/crop-image.service.test.ts @@ -1,7 +1,47 @@ import { describe, expect, it } from 'vitest'; +import { getViewportDimensions, getBaseDimensions } from './crop-image.service'; -describe('crop-image service', () => { - it('should pass', () => { - expect(true).toBe(true); +describe('crop-image service utilities', () => { + describe('getViewportDimensions', () => { + it('calculates landscape/square ratios correctly', () => { + // 1:1 ratio + const sq = getViewportDimensions(1, 400, 400); + expect(sq).toEqual({ width: 400, height: 400 }); + + // 2:1 ratio (landscape) + const ls = getViewportDimensions(2, 400, 400); + expect(ls).toEqual({ width: 400, height: 200 }); + }); + + it('calculates portrait ratios correctly', () => { + // 1:2 ratio (portrait) + const pt = getViewportDimensions(0.5, 400, 400); + expect(pt).toEqual({ width: 200, height: 400 }); + }); + }); + + describe('getBaseDimensions', () => { + it('returns viewport dimensions when natural dimensions are zero or missing', () => { + const fallback = getBaseDimensions(300, 200, 0, 0); + expect(fallback).toEqual({ width: 300, height: 200 }); + }); + + it('calculates cover dimensions for landscape image in portrait viewport', () => { + // Image: 800x600 (4:3 landscape, ratio 1.33) + // Viewport: 300x400 (3:4 portrait, ratio 0.75) + // Image should fit to height (400) and scale width to 400 * 1.3333 = 533.33 + const cover = getBaseDimensions(300, 400, 800, 600); + expect(cover.height).toBe(400); + expect(cover.width).toBeCloseTo(533.33, 1); + }); + + it('calculates cover dimensions for portrait image in landscape viewport', () => { + // Image: 600x800 (3:4 portrait, ratio 0.75) + // Viewport: 400x300 (4:3 landscape, ratio 1.33) + // Image should fit to width (400) and scale height to 400 / 0.75 = 533.33 + const cover = getBaseDimensions(400, 300, 600, 800); + expect(cover.width).toBe(400); + expect(cover.height).toBeCloseTo(533.33, 1); + }); }); }); diff --git a/src/tools/crop-image/crop-image.service.ts b/src/tools/crop-image/crop-image.service.ts index e69de29bb2..4e41902d47 100644 --- a/src/tools/crop-image/crop-image.service.ts +++ b/src/tools/crop-image/crop-image.service.ts @@ -0,0 +1,50 @@ +/** + * Calculates viewport dimensions bounded by max width and height. + */ +export function getViewportDimensions( + ratio: number, + maxWidth: number = 450, + maxHeight: number = 450 +): { width: number; height: number } { + if (ratio >= 1) { + return { + width: maxWidth, + height: maxWidth / ratio, + }; + } else { + return { + width: maxHeight * ratio, + height: maxHeight, + }; + } +} + +/** + * Calculates base dimensions of the image when fit to cover the viewport. + */ +export function getBaseDimensions( + vW: number, + vH: number, + naturalWidth: number, + naturalHeight: number +): { width: number; height: number } { + if (!naturalWidth || !naturalHeight) { + return { width: vW, height: vH }; + } + const vRatio = vW / vH; + const iRatio = naturalWidth / naturalHeight; + + if (iRatio > vRatio) { + // Landscape relative to viewport + return { + width: vH * iRatio, + height: vH, + }; + } else { + // Portrait relative to viewport + return { + width: vW, + height: vW / iRatio, + }; + } +} diff --git a/src/tools/crop-image/crop-image.vue b/src/tools/crop-image/crop-image.vue index d5f5300729..1fbc30ec6c 100644 --- a/src/tools/crop-image/crop-image.vue +++ b/src/tools/crop-image/crop-image.vue @@ -11,6 +11,8 @@ import { RotateClockwise, } from '@vicons/tabler'; +import { getViewportDimensions, getBaseDimensions } from './crop-image.service'; + // Image references const imageSrc = ref(null); const fileName = ref('image'); @@ -90,45 +92,17 @@ const maxViewportWidth = 450; const maxViewportHeight = 450; const viewportDimensions = computed(() => { - const ratio = currentRatio.value; - if (ratio >= 1) { - return { - width: maxViewportWidth, - height: maxViewportWidth / ratio, - }; - } - else { - return { - width: maxViewportHeight * ratio, - height: maxViewportHeight, - }; - } + return getViewportDimensions(currentRatio.value, maxViewportWidth, maxViewportHeight); }); // Image base dimensions when fitting cover const baseDimensions = computed(() => { - const vW = viewportDimensions.value.width; - const vH = viewportDimensions.value.height; - if (!imgNaturalWidth.value || !imgNaturalHeight.value) { - return { width: vW, height: vH }; - } - const vRatio = vW / vH; - const iRatio = imgNaturalWidth.value / imgNaturalHeight.value; - - if (iRatio > vRatio) { - // Landscape relative to viewport - return { - width: vH * iRatio, - height: vH, - }; - } - else { - // Portrait relative to viewport - return { - width: vW, - height: vW / iRatio, - }; - } + return getBaseDimensions( + viewportDimensions.value.width, + viewportDimensions.value.height, + imgNaturalWidth.value, + imgNaturalHeight.value + ); }); // Reset image to center with cover scale From 7e152bc831e0906439bf468b3b3ab40ccf6d113d Mon Sep 17 00:00:00 2001 From: Chris Tran <6634746+sYnVerse@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:06:08 -0700 Subject: [PATCH 3/6] fix(crop-image): fix crop coordinate misalignment on small viewports Resolves an issue where exporting cropped images on small/mobile screens resulted in a different crop than shown in the preview. - Added reactive viewport scaling tracking using `@vueuse/core` `useElementSize`. - Wrapped the cropper viewport in a container that scales down via CSS `transform: scale()` to maintain aspect ratios and layout without shrinking coordinates. - Scaled pointer dragging deltas (`dx`, `dy`) by the scale factor to preserve movement tracking accuracy. - Cleaned up related ESLint warnings/errors and unused imports in the `crop-image` files." --- src/tools/crop-image/crop-image.e2e.spec.ts | 12 +- .../crop-image/crop-image.service.test.ts | 2 +- src/tools/crop-image/crop-image.service.ts | 10 +- src/tools/crop-image/crop-image.vue | 121 +++++++++++------- 4 files changed, 91 insertions(+), 54 deletions(-) diff --git a/src/tools/crop-image/crop-image.e2e.spec.ts b/src/tools/crop-image/crop-image.e2e.spec.ts index 446bbeec25..fdf75bff7b 100644 --- a/src/tools/crop-image/crop-image.e2e.spec.ts +++ b/src/tools/crop-image/crop-image.e2e.spec.ts @@ -1,7 +1,16 @@ +import { Buffer } from 'node:buffer'; import { expect, test } from '@playwright/test'; test.describe('Tool - Crop image', () => { test.beforeEach(async ({ page }) => { + page.on('console', (msg) => { + // eslint-disable-next-line no-console + console.log(`[BROWSER CONSOLE] ${msg.type()}: ${msg.text()}`); + }); + page.on('pageerror', (err) => { + // eslint-disable-next-line no-console + console.log(`[BROWSER ERROR] ${err.message}`); + }); await page.goto('/crop-image'); }); @@ -17,7 +26,7 @@ test.describe('Tool - Crop image', () => { // 1x1 transparent PNG buffer const pixelPng = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - 'base64' + 'base64', ); // Upload the file @@ -44,4 +53,3 @@ test.describe('Tool - Crop image', () => { await expect(changeImageButton).toBeVisible(); }); }); - diff --git a/src/tools/crop-image/crop-image.service.test.ts b/src/tools/crop-image/crop-image.service.test.ts index 126d7b3a1e..80c5179e07 100644 --- a/src/tools/crop-image/crop-image.service.test.ts +++ b/src/tools/crop-image/crop-image.service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getViewportDimensions, getBaseDimensions } from './crop-image.service'; +import { getBaseDimensions, getViewportDimensions } from './crop-image.service'; describe('crop-image service utilities', () => { describe('getViewportDimensions', () => { diff --git a/src/tools/crop-image/crop-image.service.ts b/src/tools/crop-image/crop-image.service.ts index 4e41902d47..8df724e395 100644 --- a/src/tools/crop-image/crop-image.service.ts +++ b/src/tools/crop-image/crop-image.service.ts @@ -4,14 +4,15 @@ export function getViewportDimensions( ratio: number, maxWidth: number = 450, - maxHeight: number = 450 + maxHeight: number = 450, ): { width: number; height: number } { if (ratio >= 1) { return { width: maxWidth, height: maxWidth / ratio, }; - } else { + } + else { return { width: maxHeight * ratio, height: maxHeight, @@ -26,7 +27,7 @@ export function getBaseDimensions( vW: number, vH: number, naturalWidth: number, - naturalHeight: number + naturalHeight: number, ): { width: number; height: number } { if (!naturalWidth || !naturalHeight) { return { width: vW, height: vH }; @@ -40,7 +41,8 @@ export function getBaseDimensions( width: vH * iRatio, height: vH, }; - } else { + } + else { // Portrait relative to viewport return { width: vW, diff --git a/src/tools/crop-image/crop-image.vue b/src/tools/crop-image/crop-image.vue index 1fbc30ec6c..a8ed4c7509 100644 --- a/src/tools/crop-image/crop-image.vue +++ b/src/tools/crop-image/crop-image.vue @@ -1,5 +1,6 @@