Merge 663fb318ad into 07eea0f484
				
					
				
			This commit is contained in:
		
						commit
						853c791d76
					
				
							
								
								
									
										23
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -91,16 +91,24 @@ declare module '@vue/runtime-core' { | |||||||
|     IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] |     IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] | ||||||
|     'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] |     'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] | ||||||
|     'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] |     'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] | ||||||
|  |     IconMdiCamera: typeof import('~icons/mdi/camera')['default'] | ||||||
|     IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] |     IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] | ||||||
|     IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] |     IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] | ||||||
|     IconMdiClose: typeof import('~icons/mdi/close')['default'] |     IconMdiClose: typeof import('~icons/mdi/close')['default'] | ||||||
|     IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] |     IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] | ||||||
|  |     IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] | ||||||
|  |     IconMdiDownload: typeof import('~icons/mdi/download')['default'] | ||||||
|     IconMdiEye: typeof import('~icons/mdi/eye')['default'] |     IconMdiEye: typeof import('~icons/mdi/eye')['default'] | ||||||
|     IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default'] |     IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default'] | ||||||
|     IconMdiHeart: typeof import('~icons/mdi/heart')['default'] |     IconMdiHeart: typeof import('~icons/mdi/heart')['default'] | ||||||
|  |     IconMdiPause: typeof import('~icons/mdi/pause')['default'] | ||||||
|  |     IconMdiPlay: typeof import('~icons/mdi/play')['default'] | ||||||
|  |     IconMdiRecord: typeof import('~icons/mdi/record')['default'] | ||||||
|     IconMdiSearch: typeof import('~icons/mdi/search')['default'] |     IconMdiSearch: typeof import('~icons/mdi/search')['default'] | ||||||
|     IconMdiTranslate: typeof import('~icons/mdi/translate')['default'] |     IconMdiTranslate: typeof import('~icons/mdi/translate')['default'] | ||||||
|     IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default'] |     IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default'] | ||||||
|  |     IconMdiVideo: typeof import('~icons/mdi/video')['default'] | ||||||
|  |     ImageColorInverter: typeof import('./src/tools/image-color-inverter/image-color-inverter.vue')['default'] | ||||||
|     InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] |     InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] | ||||||
|     IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] |     IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] | ||||||
|     Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default'] |     Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default'] | ||||||
| @ -129,21 +137,32 @@ declare module '@vue/runtime-core' { | |||||||
|     MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] |     MenuLayout: typeof import('./src/components/MenuLayout.vue')['default'] | ||||||
|     MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] |     MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] | ||||||
|     MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] |     MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] | ||||||
|  |     NAlert: typeof import('naive-ui')['NAlert'] | ||||||
|     NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] |     NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] | ||||||
|     NCheckbox: typeof import('naive-ui')['NCheckbox'] |     NButton: typeof import('naive-ui')['NButton'] | ||||||
|  |     NButtonGroup: typeof import('naive-ui')['NButtonGroup'] | ||||||
|  |     NCard: typeof import('naive-ui')['NCard'] | ||||||
|     NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] |     NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] | ||||||
|     NConfigProvider: typeof import('naive-ui')['NConfigProvider'] |     NConfigProvider: typeof import('naive-ui')['NConfigProvider'] | ||||||
|     NDivider: typeof import('naive-ui')['NDivider'] |     NDivider: typeof import('naive-ui')['NDivider'] | ||||||
|     NEllipsis: typeof import('naive-ui')['NEllipsis'] |     NEllipsis: typeof import('naive-ui')['NEllipsis'] | ||||||
|  |     NFormItem: typeof import('naive-ui')['NFormItem'] | ||||||
|  |     NGi: typeof import('naive-ui')['NGi'] | ||||||
|  |     NGrid: typeof import('naive-ui')['NGrid'] | ||||||
|     NH1: typeof import('naive-ui')['NH1'] |     NH1: typeof import('naive-ui')['NH1'] | ||||||
|     NH3: typeof import('naive-ui')['NH3'] |     NH3: typeof import('naive-ui')['NH3'] | ||||||
|     NIcon: typeof import('naive-ui')['NIcon'] |     NIcon: typeof import('naive-ui')['NIcon'] | ||||||
|  |     NInputGroup: typeof import('naive-ui')['NInputGroup'] | ||||||
|     NLayout: typeof import('naive-ui')['NLayout'] |     NLayout: typeof import('naive-ui')['NLayout'] | ||||||
|     NLayoutSider: typeof import('naive-ui')['NLayoutSider'] |     NLayoutSider: typeof import('naive-ui')['NLayoutSider'] | ||||||
|     NMenu: typeof import('naive-ui')['NMenu'] |     NMenu: typeof import('naive-ui')['NMenu'] | ||||||
|  |     NP: typeof import('naive-ui')['NP'] | ||||||
|     NSpace: typeof import('naive-ui')['NSpace'] |     NSpace: typeof import('naive-ui')['NSpace'] | ||||||
|     NTable: typeof import('naive-ui')['NTable'] |     NSpin: typeof import('naive-ui')['NSpin'] | ||||||
|  |     NText: typeof import('naive-ui')['NText'] | ||||||
|     NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] |     NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] | ||||||
|  |     NUpload: typeof import('naive-ui')['NUpload'] | ||||||
|  |     NUploadDragger: typeof import('naive-ui')['NUploadDragger'] | ||||||
|     OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] |     OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] | ||||||
|     PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] |     PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] | ||||||
|     PdfSignatureChecker: typeof import('./src/tools/pdf-signature-checker/pdf-signature-checker.vue')['default'] |     PdfSignatureChecker: typeof import('./src/tools/pdf-signature-checker/pdf-signature-checker.vue')['default'] | ||||||
|  | |||||||
| @ -454,3 +454,7 @@ tools: | |||||||
|   text-to-binary: |   text-to-binary: | ||||||
|     title: Text zu ASCII-Binär |     title: Text zu ASCII-Binär | ||||||
|     description: Konvertiere Text in seine ASCII-Binärrepräsentation und umgekehrt. |     description: Konvertiere Text in seine ASCII-Binärrepräsentation und umgekehrt. | ||||||
|  | 
 | ||||||
|  |   image-color-inverter: | ||||||
|  |     title: Bild-Farben-Inverter | ||||||
|  |     description: Laden Sie ein Bild hoch und invertieren Sie seine Farben sofort in Ihrem Browser. Laden Sie das Ergebnis als PNG herunter. | ||||||
|  | |||||||
| @ -392,3 +392,7 @@ tools: | |||||||
|   text-to-binary: |   text-to-binary: | ||||||
|     title: Text to ASCII binary |     title: Text to ASCII binary | ||||||
|     description: Convert text to its ASCII binary representation and vice-versa. |     description: Convert text to its ASCII binary representation and vice-versa. | ||||||
|  | 
 | ||||||
|  |   image-color-inverter: | ||||||
|  |     title: Image color inverter | ||||||
|  |     description: Upload an image and invert its colors instantly in your browser. Download the result as PNG. | ||||||
|  | |||||||
| @ -80,3 +80,7 @@ tools: | |||||||
|     copied: Le token a été copié |     copied: Le token a été copié | ||||||
|     length: Longueur |     length: Longueur | ||||||
|     tokenPlaceholder: Le token... |     tokenPlaceholder: Le token... | ||||||
|  | 
 | ||||||
|  |   image-color-inverter: | ||||||
|  |     title: Inverseur de couleurs d'image | ||||||
|  |     description: Téléchargez une image et inversez ses couleurs instantanément dans votre navigateur. Téléchargez le résultat en PNG. | ||||||
|  | |||||||
| @ -388,3 +388,7 @@ tools: | |||||||
|   text-to-binary: |   text-to-binary: | ||||||
|     title: 文本到 ASCII 二进制 |     title: 文本到 ASCII 二进制 | ||||||
|     description: 将文本转换为其 ASCII 二进制表示形式,反之亦然。 |     description: 将文本转换为其 ASCII 二进制表示形式,反之亦然。 | ||||||
|  | 
 | ||||||
|  |   image-color-inverter: | ||||||
|  |     title: 图像颜色反转器 | ||||||
|  |     description: 上传图像并在浏览器中立即反转其颜色。下载PNG格式的结果。 | ||||||
|  | |||||||
| @ -128,7 +128,7 @@ function activateOption(option: PaletteOption) { | |||||||
|       <c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable /> |       <c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable /> | ||||||
| 
 | 
 | ||||||
|       <div v-for="(options, category) in filteredSearchResult" :key="category"> |       <div v-for="(options, category) in filteredSearchResult" :key="category"> | ||||||
|         <div ml-3 mt-3 text-sm font-bold text-primary op-60> |         <div ml-3 mt-3 text-sm text-primary font-bold op-60> | ||||||
|           {{ category }} |           {{ category }} | ||||||
|         </div> |         </div> | ||||||
|         <command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" /> |         <command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" /> | ||||||
|  | |||||||
							
								
								
									
										123
									
								
								src/tools/image-color-inverter/image-color-inverter.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								src/tools/image-color-inverter/image-color-inverter.e2e.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,123 @@ | |||||||
|  | import { promises as fs } from 'node:fs'; | ||||||
|  | import path from 'node:path'; | ||||||
|  | import process from 'node:process'; | ||||||
|  | import { expect, test } from '@playwright/test'; | ||||||
|  | 
 | ||||||
|  | test.describe('Tool - Image color inverter', () => { | ||||||
|  |   test.beforeEach(async ({ page }) => { | ||||||
|  |     await page.goto('/image-color-inverter'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Has correct title', async ({ page }) => { | ||||||
|  |     await expect(page).toHaveTitle('Image color inverter - IT Tools'); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Displays file upload area', async ({ page }) => { | ||||||
|  |     // Check if the file upload component is visible
 | ||||||
|  |     const uploadArea = page.locator('text=Drag and drop an image here, or click to select'); | ||||||
|  |     await expect(uploadArea).toBeVisible(); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Shows error for invalid file type', async ({ page }) => { | ||||||
|  |     // Create a temporary non-image file
 | ||||||
|  |     const testFilePath = path.join(process.cwd(), 'test-file.txt'); | ||||||
|  |     await fs.writeFile(testFilePath, 'This is not an image'); | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       // Upload the text file
 | ||||||
|  |       const fileInput = page.locator('input[type="file"]'); | ||||||
|  |       await fileInput.setInputFiles(testFilePath); | ||||||
|  | 
 | ||||||
|  |       // Should show error message
 | ||||||
|  |       const errorAlert = page.locator('.n-alert--error'); | ||||||
|  |       await expect(errorAlert).toBeVisible(); | ||||||
|  |       await expect(errorAlert).toContainText('File must be an image'); | ||||||
|  |     } | ||||||
|  |     finally { | ||||||
|  |       // Clean up
 | ||||||
|  |       await fs.unlink(testFilePath).catch(() => {}); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Processes image upload successfully', async ({ page }) => { | ||||||
|  |     // Create a simple test image (1x1 pixel PNG in base64)
 | ||||||
|  |     const testImageDataUrl | ||||||
|  |       = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; | ||||||
|  | 
 | ||||||
|  |     // Create a blob from the data URL
 | ||||||
|  |     const response = await fetch(testImageDataUrl); | ||||||
|  |     const blob = await response.blob(); | ||||||
|  | 
 | ||||||
|  |     // Create a temporary file
 | ||||||
|  |     const buffer = await blob.arrayBuffer(); | ||||||
|  |     const testFilePath = path.join(process.cwd(), 'test-image.png'); | ||||||
|  |     await fs.writeFile(testFilePath, new Uint8Array(buffer)); | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       // Upload the image
 | ||||||
|  |       const fileInput = page.locator('input[type="file"]'); | ||||||
|  |       await fileInput.setInputFiles(testFilePath); | ||||||
|  | 
 | ||||||
|  |       // Wait for processing to complete
 | ||||||
|  |       await page.waitForSelector('text=Original Image', { timeout: 5000 }); | ||||||
|  |       await page.waitForSelector('text=Inverted Image', { timeout: 5000 }); | ||||||
|  | 
 | ||||||
|  |       // Check that both images are displayed
 | ||||||
|  |       const originalImage = page.locator('text=Original Image'); | ||||||
|  |       const invertedImage = page.locator('text=Inverted Image'); | ||||||
|  | 
 | ||||||
|  |       await expect(originalImage).toBeVisible(); | ||||||
|  |       await expect(invertedImage).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |       // Check that download button is available
 | ||||||
|  |       const downloadButton = page.locator('text=Download PNG'); | ||||||
|  |       await expect(downloadButton).toBeVisible(); | ||||||
|  | 
 | ||||||
|  |       // Check that copy button is available
 | ||||||
|  |       const copyButton = page.locator('text=Copy Base64'); | ||||||
|  |       await expect(copyButton).toBeVisible(); | ||||||
|  |     } | ||||||
|  |     finally { | ||||||
|  |       // Clean up
 | ||||||
|  |       await fs.unlink(testFilePath).catch(() => {}); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   test('Clear button resets the tool', async ({ page }) => { | ||||||
|  |     // Create a simple test image
 | ||||||
|  |     const testImageDataUrl | ||||||
|  |       = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; | ||||||
|  | 
 | ||||||
|  |     const response = await fetch(testImageDataUrl); | ||||||
|  |     const blob = await response.blob(); | ||||||
|  |     const buffer = await blob.arrayBuffer(); | ||||||
|  |     const testFilePath = path.join(process.cwd(), 'test-image.png'); | ||||||
|  |     await fs.writeFile(testFilePath, new Uint8Array(buffer)); | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|  |       // Upload and process image
 | ||||||
|  |       const fileInput = page.locator('input[type="file"]'); | ||||||
|  |       await fileInput.setInputFiles(testFilePath); | ||||||
|  | 
 | ||||||
|  |       await page.waitForSelector('text=Inverted Image', { timeout: 5000 }); | ||||||
|  | 
 | ||||||
|  |       // Click clear button
 | ||||||
|  |       const clearButton = page.locator('text=Clear & Upload New'); | ||||||
|  |       await clearButton.click(); | ||||||
|  | 
 | ||||||
|  |       // Check that images are cleared
 | ||||||
|  |       const originalImage = page.locator('text=Original Image'); | ||||||
|  |       const invertedImage = page.locator('text=Inverted Image'); | ||||||
|  | 
 | ||||||
|  |       await expect(originalImage).not.toBeVisible(); | ||||||
|  |       await expect(invertedImage).not.toBeVisible(); | ||||||
|  | 
 | ||||||
|  |       // Check that upload area is visible again
 | ||||||
|  |       const uploadArea = page.locator('text=Drag and drop an image here, or click to select'); | ||||||
|  |       await expect(uploadArea).toBeVisible(); | ||||||
|  |     } | ||||||
|  |     finally { | ||||||
|  |       await fs.unlink(testFilePath).catch(() => {}); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -0,0 +1,133 @@ | |||||||
|  | import { describe, expect, it } from 'vitest'; | ||||||
|  | import { invertImageColors } from './image-color-inverter.service'; | ||||||
|  | 
 | ||||||
|  | // Mock ImageData for Node.js environment
 | ||||||
|  | Object.defineProperty(globalThis, 'ImageData', { | ||||||
|  |   value: class ImageData { | ||||||
|  |     data: Uint8ClampedArray; | ||||||
|  |     width: number; | ||||||
|  |     height: number; | ||||||
|  | 
 | ||||||
|  |     constructor(data: Uint8ClampedArray | number, width: number, height?: number) { | ||||||
|  |       if (typeof data === 'number') { | ||||||
|  |         // ImageData(width, height)
 | ||||||
|  |         this.width = data; | ||||||
|  |         this.height = width; | ||||||
|  |         this.data = new Uint8ClampedArray(data * width * 4); | ||||||
|  |       } | ||||||
|  |       else { | ||||||
|  |         // ImageData(data, width, height)
 | ||||||
|  |         this.data = data; | ||||||
|  |         this.width = width; | ||||||
|  |         this.height = height || data.length / (width * 4); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | // Mock other browser APIs for completeness
 | ||||||
|  | Object.defineProperty(globalThis, 'HTMLCanvasElement', { | ||||||
|  |   value: class { | ||||||
|  |     width = 0; | ||||||
|  |     height = 0; | ||||||
|  | 
 | ||||||
|  |     getContext() { | ||||||
|  |       return { | ||||||
|  |         drawImage: () => {}, | ||||||
|  |         getImageData: () => new (globalThis as any).ImageData(new Uint8ClampedArray([255, 0, 128, 255]), 1, 1), | ||||||
|  |         putImageData: () => {}, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     toDataURL() { | ||||||
|  |       return 'data:image/png;base64,mock'; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | Object.defineProperty(globalThis, 'Image', { | ||||||
|  |   value: class { | ||||||
|  |     onload: () => void = () => {}; | ||||||
|  |     onerror: () => void = () => {}; | ||||||
|  |     width = 100; | ||||||
|  |     height = 100; | ||||||
|  | 
 | ||||||
|  |     set src(value: string) { | ||||||
|  |       setTimeout(() => this.onload(), 0); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     get src(): string { | ||||||
|  |       return ''; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | describe('image-color-inverter service', () => { | ||||||
|  |   describe('invertImageColors', () => { | ||||||
|  |     it('should invert RGB colors while preserving alpha', () => { | ||||||
|  |       // Create test image data with known values
 | ||||||
|  |       const originalData = new Uint8ClampedArray([ | ||||||
|  |         255, | ||||||
|  |         0, | ||||||
|  |         128, | ||||||
|  |         255, // Red=255, Green=0, Blue=128, Alpha=255
 | ||||||
|  |         0, | ||||||
|  |         255, | ||||||
|  |         64, | ||||||
|  |         128, // Red=0, Green=255, Blue=64, Alpha=128
 | ||||||
|  |       ]); | ||||||
|  |       const imageData = new (globalThis as any).ImageData(originalData, 2, 1); | ||||||
|  | 
 | ||||||
|  |       const inverted = invertImageColors(imageData); | ||||||
|  | 
 | ||||||
|  |       // Check that colors are inverted correctly
 | ||||||
|  |       expect(inverted.data[0]).toBe(0); // 255 - 255 = 0
 | ||||||
|  |       expect(inverted.data[1]).toBe(255); // 255 - 0 = 255
 | ||||||
|  |       expect(inverted.data[2]).toBe(127); // 255 - 128 = 127
 | ||||||
|  |       expect(inverted.data[3]).toBe(255); // Alpha unchanged
 | ||||||
|  | 
 | ||||||
|  |       expect(inverted.data[4]).toBe(255); // 255 - 0 = 255
 | ||||||
|  |       expect(inverted.data[5]).toBe(0); // 255 - 255 = 0
 | ||||||
|  |       expect(inverted.data[6]).toBe(191); // 255 - 64 = 191
 | ||||||
|  |       expect(inverted.data[7]).toBe(128); // Alpha unchanged
 | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should preserve image dimensions', () => { | ||||||
|  |       const originalData = new Uint8ClampedArray([255, 0, 128, 255]); | ||||||
|  |       const imageData = new (globalThis as any).ImageData(originalData, 1, 1); | ||||||
|  | 
 | ||||||
|  |       const inverted = invertImageColors(imageData); | ||||||
|  | 
 | ||||||
|  |       expect(inverted.width).toBe(1); | ||||||
|  |       expect(inverted.height).toBe(1); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should handle pure black and white pixels', () => { | ||||||
|  |       const originalData = new Uint8ClampedArray([ | ||||||
|  |         0, | ||||||
|  |         0, | ||||||
|  |         0, | ||||||
|  |         255, // Pure black
 | ||||||
|  |         255, | ||||||
|  |         255, | ||||||
|  |         255, | ||||||
|  |         255, // Pure white
 | ||||||
|  |       ]); | ||||||
|  |       const imageData = new (globalThis as any).ImageData(originalData, 2, 1); | ||||||
|  | 
 | ||||||
|  |       const inverted = invertImageColors(imageData); | ||||||
|  | 
 | ||||||
|  |       // Black should become white
 | ||||||
|  |       expect(inverted.data[0]).toBe(255); | ||||||
|  |       expect(inverted.data[1]).toBe(255); | ||||||
|  |       expect(inverted.data[2]).toBe(255); | ||||||
|  |       expect(inverted.data[3]).toBe(255); | ||||||
|  | 
 | ||||||
|  |       // White should become black
 | ||||||
|  |       expect(inverted.data[4]).toBe(0); | ||||||
|  |       expect(inverted.data[5]).toBe(0); | ||||||
|  |       expect(inverted.data[6]).toBe(0); | ||||||
|  |       expect(inverted.data[7]).toBe(255); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -0,0 +1,87 @@ | |||||||
|  | export function invertImageFile(file: File): Promise<string> { | ||||||
|  |   return new Promise((resolve, reject) => { | ||||||
|  |     // Validate file type
 | ||||||
|  |     if (!file.type.startsWith('image/')) { | ||||||
|  |       reject(new Error('File must be an image')); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Check file size (limit to 10MB)
 | ||||||
|  |     const maxSize = 10 * 1024 * 1024; // 10MB
 | ||||||
|  |     if (file.size > maxSize) { | ||||||
|  |       reject(new Error('Image file size must be less than 10MB')); | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const reader = new FileReader(); | ||||||
|  | 
 | ||||||
|  |     reader.onload = () => { | ||||||
|  |       const img = new Image(); | ||||||
|  |       img.src = reader.result as string; | ||||||
|  | 
 | ||||||
|  |       img.onload = () => { | ||||||
|  |         try { | ||||||
|  |           const canvas = document.createElement('canvas'); | ||||||
|  |           const ctx = canvas.getContext('2d'); | ||||||
|  | 
 | ||||||
|  |           if (!ctx) { | ||||||
|  |             reject(new Error('Failed to get canvas context')); | ||||||
|  |             return; | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           canvas.width = img.width; | ||||||
|  |           canvas.height = img.height; | ||||||
|  | 
 | ||||||
|  |           // Draw the original image
 | ||||||
|  |           ctx.drawImage(img, 0, 0); | ||||||
|  | 
 | ||||||
|  |           // Get image data
 | ||||||
|  |           const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); | ||||||
|  |           const data = imageData.data; | ||||||
|  | 
 | ||||||
|  |           // Invert colors (RGB channels only, preserve alpha)
 | ||||||
|  |           for (let i = 0; i < data.length; i += 4) { | ||||||
|  |             data[i] = 255 - data[i]; // Red
 | ||||||
|  |             data[i + 1] = 255 - data[i + 1]; // Green
 | ||||||
|  |             data[i + 2] = 255 - data[i + 2]; // Blue
 | ||||||
|  |             // data[i + 3] is alpha - keep unchanged
 | ||||||
|  |           } | ||||||
|  | 
 | ||||||
|  |           // Put the modified image data back
 | ||||||
|  |           ctx.putImageData(imageData, 0, 0); | ||||||
|  | 
 | ||||||
|  |           // Convert to base64 PNG
 | ||||||
|  |           const invertedDataUrl = canvas.toDataURL('image/png'); | ||||||
|  |           resolve(invertedDataUrl); | ||||||
|  |         } | ||||||
|  |         catch (err) { | ||||||
|  |           reject(new Error(`Failed to process image: ${err}`)); | ||||||
|  |         } | ||||||
|  |       }; | ||||||
|  | 
 | ||||||
|  |       img.onerror = () => { | ||||||
|  |         reject(new Error('Failed to load image')); | ||||||
|  |       }; | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     reader.onerror = () => { | ||||||
|  |       reject(new Error('Failed to read file')); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     reader.readAsDataURL(file); | ||||||
|  |   }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export function invertImageColors(imageData: ImageData): ImageData { | ||||||
|  |   const data = new Uint8ClampedArray(imageData.data); | ||||||
|  | 
 | ||||||
|  |   // Invert RGB values while preserving alpha
 | ||||||
|  |   for (let i = 0; i < data.length; i += 4) { | ||||||
|  |     data[i] = 255 - data[i]; // Red
 | ||||||
|  |     data[i + 1] = 255 - data[i + 1]; // Green
 | ||||||
|  |     data[i + 2] = 255 - data[i + 2]; // Blue
 | ||||||
|  |     // data[i + 3] is alpha - keep unchanged
 | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return new ImageData(data, imageData.width, imageData.height); | ||||||
|  | } | ||||||
							
								
								
									
										129
									
								
								src/tools/image-color-inverter/image-color-inverter.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/tools/image-color-inverter/image-color-inverter.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,129 @@ | |||||||
|  | <script setup lang="ts"> | ||||||
|  | import { invertImageFile } from './image-color-inverter.service'; | ||||||
|  | import { useCopy } from '@/composable/copy'; | ||||||
|  | import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; | ||||||
|  | import { withDefaultOnError } from '@/utils/defaults'; | ||||||
|  | 
 | ||||||
|  | const originalImageSrc = ref<string>(''); | ||||||
|  | const invertedImageSrc = ref<string>(''); | ||||||
|  | const isProcessing = ref(false); | ||||||
|  | const error = ref<string | null>(null); | ||||||
|  | 
 | ||||||
|  | const { copy: copyInvertedImage } = useCopy({ | ||||||
|  |   source: invertedImageSrc, | ||||||
|  |   text: 'Inverted image base64 copied to clipboard', | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const { download: downloadInvertedImage } = useDownloadFileFromBase64({ | ||||||
|  |   source: invertedImageSrc, | ||||||
|  |   filename: 'inverted-image', | ||||||
|  |   extension: 'png', | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | function handleFileUpload(file: File) { | ||||||
|  |   if (!file) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   error.value = null; | ||||||
|  |   isProcessing.value = true; | ||||||
|  | 
 | ||||||
|  |   // Show original image | ||||||
|  |   const reader = new FileReader(); | ||||||
|  |   reader.onload = (e) => { | ||||||
|  |     originalImageSrc.value = e.target?.result as string; | ||||||
|  |   }; | ||||||
|  |   reader.readAsDataURL(file); | ||||||
|  | 
 | ||||||
|  |   // Process the image | ||||||
|  |   invertImageFile(file) | ||||||
|  |     .then((result) => { | ||||||
|  |       invertedImageSrc.value = result; | ||||||
|  |     }) | ||||||
|  |     .catch((err) => { | ||||||
|  |       error.value = withDefaultOnError(err, 'Failed to process image'); | ||||||
|  |     }) | ||||||
|  |     .finally(() => { | ||||||
|  |       isProcessing.value = false; | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function clearImages() { | ||||||
|  |   originalImageSrc.value = ''; | ||||||
|  |   invertedImageSrc.value = ''; | ||||||
|  |   error.value = null; | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <c-card> | ||||||
|  |     <c-file-upload | ||||||
|  |       title="Drag and drop an image here, or click to select" | ||||||
|  |       accept="image/*" | ||||||
|  |       @file-upload="handleFileUpload" | ||||||
|  |     /> | ||||||
|  | 
 | ||||||
|  |     <n-spin :show="isProcessing" style="width: 100%"> | ||||||
|  |       <n-alert v-if="error" style="margin-top: 16px" type="error" :show-icon="false"> | ||||||
|  |         {{ error }} | ||||||
|  |       </n-alert> | ||||||
|  | 
 | ||||||
|  |       <n-divider v-if="originalImageSrc || invertedImageSrc" /> | ||||||
|  | 
 | ||||||
|  |       <n-grid v-if="originalImageSrc || invertedImageSrc" :cols="2" :x-gap="16"> | ||||||
|  |         <n-gi v-if="originalImageSrc"> | ||||||
|  |           <div> | ||||||
|  |             <n-h3>Original Image</n-h3> | ||||||
|  |             <div class="image-container"> | ||||||
|  |               <img :src="originalImageSrc" alt="Original"> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </n-gi> | ||||||
|  | 
 | ||||||
|  |         <n-gi v-if="invertedImageSrc"> | ||||||
|  |           <div> | ||||||
|  |             <n-h3>Inverted Image</n-h3> | ||||||
|  |             <div class="image-container"> | ||||||
|  |               <img :src="invertedImageSrc" alt="Inverted"> | ||||||
|  |             </div> | ||||||
|  |             <n-space style="margin-top: 12px" justify="start"> | ||||||
|  |               <c-button @click="downloadInvertedImage()"> | ||||||
|  |                 Download PNG | ||||||
|  |               </c-button> | ||||||
|  |               <c-button @click="copyInvertedImage()"> | ||||||
|  |                 Copy Base64 | ||||||
|  |               </c-button> | ||||||
|  |             </n-space> | ||||||
|  |           </div> | ||||||
|  |         </n-gi> | ||||||
|  |       </n-grid> | ||||||
|  | 
 | ||||||
|  |       <div v-if="originalImageSrc || invertedImageSrc" style="margin-top: 16px; text-align: center"> | ||||||
|  |         <c-button secondary @click="clearImages()"> | ||||||
|  |           Clear & Upload New | ||||||
|  |         </c-button> | ||||||
|  |       </div> | ||||||
|  |     </n-spin> | ||||||
|  |   </c-card> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <style lang="less" scoped> | ||||||
|  | .image-container { | ||||||
|  |   position: relative; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: center; | ||||||
|  |   align-items: center; | ||||||
|  |   min-height: 200px; | ||||||
|  |   border: 1px solid var(--border-color); | ||||||
|  |   border-radius: 6px; | ||||||
|  |   background-color: var(--n-color-embedded); | ||||||
|  |   overflow: hidden; | ||||||
|  | 
 | ||||||
|  |   img { | ||||||
|  |     max-width: 100%; | ||||||
|  |     max-height: 400px; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										13
									
								
								src/tools/image-color-inverter/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/tools/image-color-inverter/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | |||||||
|  | import { Palette } from '@vicons/tabler'; | ||||||
|  | import { defineTool } from '../tool'; | ||||||
|  | import { translate } from '@/plugins/i18n.plugin'; | ||||||
|  | 
 | ||||||
|  | export const tool = defineTool({ | ||||||
|  |   name: translate('tools.image-color-inverter.title'), | ||||||
|  |   path: '/image-color-inverter', | ||||||
|  |   description: translate('tools.image-color-inverter.description'), | ||||||
|  |   keywords: ['image', 'color', 'invert', 'negative', 'photo', 'filter', 'effect', 'png', 'jpg', 'jpeg'], | ||||||
|  |   component: () => import('./image-color-inverter.vue'), | ||||||
|  |   icon: Palette, | ||||||
|  |   createdAt: new Date('2025-08-25'), | ||||||
|  | }); | ||||||
| @ -1,6 +1,7 @@ | |||||||
| import { tool as base64FileConverter } from './base64-file-converter'; | import { tool as base64FileConverter } from './base64-file-converter'; | ||||||
| import { tool as base64StringConverter } from './base64-string-converter'; | import { tool as base64StringConverter } from './base64-string-converter'; | ||||||
| import { tool as basicAuthGenerator } from './basic-auth-generator'; | import { tool as basicAuthGenerator } from './basic-auth-generator'; | ||||||
|  | import { tool as imageColorInverter } from './image-color-inverter'; | ||||||
| import { tool as emailNormalizer } from './email-normalizer'; | import { tool as emailNormalizer } from './email-normalizer'; | ||||||
| 
 | 
 | ||||||
| import { tool as asciiTextDrawer } from './ascii-text-drawer'; | import { tool as asciiTextDrawer } from './ascii-text-drawer'; | ||||||
| @ -91,7 +92,19 @@ import { tool as yamlViewer } from './yaml-viewer'; | |||||||
| export const toolsByCategory: ToolCategory[] = [ | export const toolsByCategory: ToolCategory[] = [ | ||||||
|   { |   { | ||||||
|     name: 'Crypto', |     name: 'Crypto', | ||||||
|     components: [tokenGenerator, hashText, bcrypt, uuidGenerator, ulidGenerator, cypher, bip39, hmacGenerator, rsaKeyPairGenerator, passwordStrengthAnalyser, pdfSignatureChecker], |     components: [ | ||||||
|  |       tokenGenerator, | ||||||
|  |       hashText, | ||||||
|  |       bcrypt, | ||||||
|  |       uuidGenerator, | ||||||
|  |       ulidGenerator, | ||||||
|  |       cypher, | ||||||
|  |       bip39, | ||||||
|  |       hmacGenerator, | ||||||
|  |       rsaKeyPairGenerator, | ||||||
|  |       passwordStrengthAnalyser, | ||||||
|  |       pdfSignatureChecker, | ||||||
|  |     ], | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     name: 'Converter', |     name: 'Converter', | ||||||
| @ -141,7 +154,7 @@ export const toolsByCategory: ToolCategory[] = [ | |||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     name: 'Images and videos', |     name: 'Images and videos', | ||||||
|     components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder], |     components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder, imageColorInverter], | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     name: 'Development', |     name: 'Development', | ||||||
| @ -164,7 +177,14 @@ export const toolsByCategory: ToolCategory[] = [ | |||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     name: 'Network', |     name: 'Network', | ||||||
|     components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator], |     components: [ | ||||||
|  |       ipv4SubnetCalculator, | ||||||
|  |       ipv4AddressConverter, | ||||||
|  |       ipv4RangeExpander, | ||||||
|  |       macAddressLookup, | ||||||
|  |       macAddressGenerator, | ||||||
|  |       ipv6UlaGenerator, | ||||||
|  |     ], | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     name: 'Math', |     name: 'Math', | ||||||
|  | |||||||
| @ -151,7 +151,7 @@ function onSearchInput() { | |||||||
|       > |       > | ||||||
|         <div flex-1 truncate> |         <div flex-1 truncate> | ||||||
|           <slot name="displayed-value"> |           <slot name="displayed-value"> | ||||||
|             <input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput"> |             <input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full color-current lh-normal @input="onSearchInput"> | ||||||
|             <span v-else-if="selectedOption" lh-normal> |             <span v-else-if="selectedOption" lh-normal> | ||||||
|               {{ selectedOption.label }} |               {{ selectedOption.label }} | ||||||
|             </span> |             </span> | ||||||
|  | |||||||
| @ -39,7 +39,7 @@ const headers = computed(() => { | |||||||
| <template> | <template> | ||||||
|   <div class="relative overflow-x-auto rounded"> |   <div class="relative overflow-x-auto rounded"> | ||||||
|     <table class="w-full border-collapse text-left text-sm text-gray-500 dark:text-gray-400" role="table" :aria-label="description"> |     <table class="w-full border-collapse text-left text-sm text-gray-500 dark:text-gray-400" role="table" :aria-label="description"> | ||||||
|       <thead v-if="!hideHeaders" class="bg-#ffffff uppercase text-gray-700 dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5"> |       <thead v-if="!hideHeaders" class="bg-#ffffff text-gray-700 uppercase dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5"> | ||||||
|         <tr> |         <tr> | ||||||
|           <th v-for="header in headers" :key="header.key" scope="col" class="px-6 py-3 text-xs"> |           <th v-for="header in headers" :key="header.key" scope="col" class="px-6 py-3 text-xs"> | ||||||
|             {{ header.label }} |             {{ header.label }} | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user