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'] | ||||
|     'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['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'] | ||||
|     IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] | ||||
|     IconMdiClose: typeof import('~icons/mdi/close')['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'] | ||||
|     IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['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'] | ||||
|     IconMdiTranslate: typeof import('~icons/mdi/translate')['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'] | ||||
|     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'] | ||||
| @ -129,21 +137,32 @@ declare module '@vue/runtime-core' { | ||||
|     MenuLayout: typeof import('./src/components/MenuLayout.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'] | ||||
|     NAlert: typeof import('naive-ui')['NAlert'] | ||||
|     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'] | ||||
|     NConfigProvider: typeof import('naive-ui')['NConfigProvider'] | ||||
|     NDivider: typeof import('naive-ui')['NDivider'] | ||||
|     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'] | ||||
|     NH3: typeof import('naive-ui')['NH3'] | ||||
|     NIcon: typeof import('naive-ui')['NIcon'] | ||||
|     NInputGroup: typeof import('naive-ui')['NInputGroup'] | ||||
|     NLayout: typeof import('naive-ui')['NLayout'] | ||||
|     NLayoutSider: typeof import('naive-ui')['NLayoutSider'] | ||||
|     NMenu: typeof import('naive-ui')['NMenu'] | ||||
|     NP: typeof import('naive-ui')['NP'] | ||||
|     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'] | ||||
|     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'] | ||||
|     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'] | ||||
|  | ||||
| @ -454,3 +454,7 @@ tools: | ||||
|   text-to-binary: | ||||
|     title: Text zu ASCII-Binär | ||||
|     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: | ||||
|     title: Text to ASCII binary | ||||
|     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é | ||||
|     length: Longueur | ||||
|     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: | ||||
|     title: 文本到 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 /> | ||||
| 
 | ||||
|       <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 }} | ||||
|         </div> | ||||
|         <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 base64StringConverter } from './base64-string-converter'; | ||||
| 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 asciiTextDrawer } from './ascii-text-drawer'; | ||||
| @ -91,7 +92,19 @@ import { tool as yamlViewer } from './yaml-viewer'; | ||||
| export const toolsByCategory: ToolCategory[] = [ | ||||
|   { | ||||
|     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', | ||||
| @ -141,7 +154,7 @@ export const toolsByCategory: ToolCategory[] = [ | ||||
|   }, | ||||
|   { | ||||
|     name: 'Images and videos', | ||||
|     components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder], | ||||
|     components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder, imageColorInverter], | ||||
|   }, | ||||
|   { | ||||
|     name: 'Development', | ||||
| @ -164,7 +177,14 @@ export const toolsByCategory: ToolCategory[] = [ | ||||
|   }, | ||||
|   { | ||||
|     name: 'Network', | ||||
|     components: [ipv4SubnetCalculator, ipv4AddressConverter, ipv4RangeExpander, macAddressLookup, macAddressGenerator, ipv6UlaGenerator], | ||||
|     components: [ | ||||
|       ipv4SubnetCalculator, | ||||
|       ipv4AddressConverter, | ||||
|       ipv4RangeExpander, | ||||
|       macAddressLookup, | ||||
|       macAddressGenerator, | ||||
|       ipv6UlaGenerator, | ||||
|     ], | ||||
|   }, | ||||
|   { | ||||
|     name: 'Math', | ||||
|  | ||||
| @ -151,7 +151,7 @@ function onSearchInput() { | ||||
|       > | ||||
|         <div flex-1 truncate> | ||||
|           <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> | ||||
|               {{ selectedOption.label }} | ||||
|             </span> | ||||
|  | ||||
| @ -39,7 +39,7 @@ const headers = computed(() => { | ||||
| <template> | ||||
|   <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"> | ||||
|       <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> | ||||
|           <th v-for="header in headers" :key="header.key" scope="col" class="px-6 py-3 text-xs"> | ||||
|             {{ header.label }} | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user