From 4bf7470e27ebef1b5f40c31766eceecca838eef3 Mon Sep 17 00:00:00 2001 From: sharevb Date: Sun, 1 Sep 2024 18:43:56 +0200 Subject: [PATCH] feat(new tool): ICO <> PNG Converter Fix #1271 --- components.d.ts | 35 +-------- package.json | 2 + pnpm-lock.yaml | 31 ++++++-- src/composable/downloadBase64.ts | 88 +++++++++++++++++----- src/tools/ico-converter/ico-converter.vue | 92 +++++++++++++++++++++++ src/tools/ico-converter/index.ts | 12 +++ src/tools/index.ts | 9 ++- src/ui/c-file-upload/c-file-upload.vue | 68 ++++++++++++++++- 8 files changed, 276 insertions(+), 61 deletions(-) create mode 100644 src/tools/ico-converter/ico-converter.vue create mode 100644 src/tools/ico-converter/index.ts diff --git a/components.d.ts b/components.d.ts index d034fc780..59c69d777 100644 --- a/components.d.ts +++ b/components.d.ts @@ -87,30 +87,19 @@ declare module '@vue/runtime-core' { HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default'] HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default'] IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] + IcoConverter: typeof import('./src/tools/ico-converter/ico-converter.vue')['default'] 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] - 'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default'] 'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] - IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default'] - IconMdiArrowRight: typeof import('~icons/mdi/arrow-right')['default'] - IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['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'] - IconMdiRefresh: typeof import('~icons/mdi/refresh')['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'] 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'] @@ -137,42 +126,22 @@ 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'] - NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] - NColorPicker: typeof import('naive-ui')['NColorPicker'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] - NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] - NDynamicInput: typeof import('naive-ui')['NDynamicInput'] NEllipsis: typeof import('naive-ui')['NEllipsis'] - NForm: typeof import('naive-ui')['NForm'] - NFormItem: typeof import('naive-ui')['NFormItem'] NGi: typeof import('naive-ui')['NGi'] NGrid: typeof import('naive-ui')['NGrid'] NH1: typeof import('naive-ui')['NH1'] - NH2: typeof import('naive-ui')['NH2'] NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] - NImage: typeof import('naive-ui')['NImage'] - NInputGroup: typeof import('naive-ui')['NInputGroup'] - NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel'] - NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] - NProgress: typeof import('naive-ui')['NProgress'] - NScrollbar: typeof import('naive-ui')['NScrollbar'] - NSlider: typeof import('naive-ui')['NSlider'] - NStatistic: typeof import('naive-ui')['NStatistic'] - NSwitch: typeof import('naive-ui')['NSwitch'] - NTable: typeof import('naive-ui')['NTable'] + NSpin: typeof import('naive-ui')['NSpin'] NTag: typeof import('naive-ui')['NTag'] 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'] diff --git a/package.json b/package.json index ffa7bd4e2..7f1b6bbd0 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "highlight.js": "^11.7.0", "iarna-toml-esm": "^3.0.5", "ibantools": "^4.3.3", + "image-in-browser": "^3.1.0", + "js-base64": "^3.7.7", "json5": "^2.2.3", "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.28", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfacabd43..669678340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,12 @@ dependencies: ibantools: specifier: ^4.3.3 version: 4.3.3 + image-in-browser: + specifier: ^3.1.0 + version: 3.1.0 + js-base64: + specifier: ^3.7.7 + version: 3.7.7 json5: specifier: ^2.2.3 version: 2.2.3 @@ -3374,7 +3380,7 @@ packages: dependencies: '@unhead/dom': 0.5.1 '@unhead/schema': 0.5.1 - '@vueuse/shared': 10.6.1(vue@3.3.4) + '@vueuse/shared': 11.0.3(vue@3.3.4) unhead: 0.5.1 vue: 3.3.4 transitivePeerDependencies: @@ -4016,10 +4022,10 @@ packages: - vue dev: false - /@vueuse/shared@10.6.1(vue@3.3.4): - resolution: {integrity: sha512-TECVDTIedFlL0NUfHWncf3zF9Gc4VfdxfQc8JFwoVZQmxpONhLxFrlm0eHQeidHj4rdTPL3KXJa0TZCk1wnc5Q==} + /@vueuse/shared@11.0.3(vue@3.3.4): + resolution: {integrity: sha512-0rY2m6HS5t27n/Vp5cTDsKTlNnimCqsbh/fmT2LgE+aaU42EMfXo8+bNX91W9I7DDmxfuACXMmrd7d79JxkqWA==} dependencies: - vue-demi: 0.14.6(vue@3.3.4) + vue-demi: 0.14.10(vue@3.3.4) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -6135,6 +6141,10 @@ packages: engines: {node: '>= 4'} dev: true + /image-in-browser@3.1.0: + resolution: {integrity: sha512-8VYDQU/AzN6cLrPEjF1W3QFLZS9clmEbztXE6WMBoxBJxOzjzSvC1kIusEQEc+IBFxsCbMKREFw/Cn3xVE8VMA==} + dev: false + /image-size@0.5.5: resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} engines: {node: '>=0.10.0'} @@ -6490,6 +6500,10 @@ packages: hasBin: true dev: true + /js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + dev: false + /js-beautify@1.14.6: resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==} engines: {node: '>=10'} @@ -9185,8 +9199,8 @@ packages: vue: 3.3.4 dev: false - /vue-demi@0.14.5(vue@3.3.4): - resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==} + /vue-demi@0.14.10(vue@3.3.4): + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -9200,8 +9214,8 @@ packages: vue: 3.3.4 dev: false - /vue-demi@0.14.6(vue@3.3.4): - resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} + /vue-demi@0.14.5(vue@3.3.4): + resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -9497,6 +9511,7 @@ packages: /workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained dependencies: workbox-background-sync: 7.0.0 workbox-core: 7.0.0 diff --git a/src/composable/downloadBase64.ts b/src/composable/downloadBase64.ts index 37b0428d2..773541e25 100644 --- a/src/composable/downloadBase64.ts +++ b/src/composable/downloadBase64.ts @@ -1,8 +1,14 @@ -import { extension as getExtensionFromMime } from 'mime-types'; -import type { Ref } from 'vue'; +import { extension as getExtensionFromMimeType, extension as getMimeTypeFromExtension } from 'mime-types'; +import type { MaybeRef, Ref } from 'vue'; import _ from 'lodash'; +import { get } from '@vueuse/core'; -export { getMimeTypeFromBase64, useDownloadFileFromBase64 }; +export { + getMimeTypeFromBase64, + getMimeTypeFromExtension, getExtensionFromMimeType, + useDownloadFileFromBase64, useDownloadFileFromBase64Refs, + previewImageFromBase64, +}; const commonMimeTypesSignatures = { 'JVBERi0': 'application/pdf', @@ -36,30 +42,78 @@ function getFileExtensionFromMimeType({ defaultExtension?: string }) { if (mimeType) { - return getExtensionFromMime(mimeType) ?? defaultExtension; + return getExtensionFromMimeType(mimeType) ?? defaultExtension; } return defaultExtension; } -function useDownloadFileFromBase64({ source, filename }: { source: Ref; filename?: string }) { +function downloadFromBase64({ sourceValue, filename, extension, fileMimeType }: +{ sourceValue: string; filename?: string; extension?: string; fileMimeType?: string }) { + if (sourceValue === '') { + throw new Error('Base64 string is empty'); + } + + const defaultExtension = extension ?? 'txt'; + const { mimeType } = getMimeTypeFromBase64({ base64String: sourceValue }); + let base64String = sourceValue; + if (!mimeType) { + const targetMimeType = fileMimeType ?? getMimeTypeFromExtension(defaultExtension); + base64String = `data:${targetMimeType};base64,${sourceValue}`; + } + + const cleanExtension = extension ?? getFileExtensionFromMimeType( + { mimeType, defaultExtension }); + let cleanFileName = filename ?? `file.${cleanExtension}`; + if (extension && !cleanFileName.endsWith(`.${extension}`)) { + cleanFileName = `${cleanFileName}.${cleanExtension}`; + } + + const a = document.createElement('a'); + a.href = base64String; + a.download = cleanFileName; + a.click(); +} + +function useDownloadFileFromBase64( + { source, filename, extension }: + { source: MaybeRef; filename?: MaybeRef; extension?: MaybeRef }) { return { download() { - if (source.value === '') { - throw new Error('Base64 string is empty'); - } + downloadFromBase64({ sourceValue: get(source), filename: get(filename), extension: get(extension) }); + }, + }; +} + +function previewImageFromBase64(base64String: string): HTMLImageElement { + if (base64String === '') { + throw new Error('Base64 string is empty'); + } + + const img = document.createElement('img'); + img.src = base64String; + + const container = document.createElement('div'); + container.appendChild(img); - const { mimeType } = getMimeTypeFromBase64({ base64String: source.value }); - const base64String = mimeType - ? source.value - : `data:text/plain;base64,${source.value}`; + const previewContainer = document.getElementById('previewContainer'); + if (previewContainer) { + previewContainer.innerHTML = ''; + previewContainer.appendChild(container); + } + else { + throw new Error('Preview container element not found'); + } - const cleanFileName = filename ?? `file.${getFileExtensionFromMimeType({ mimeType })}`; + return img; +} - const a = document.createElement('a'); - a.href = base64String; - a.download = cleanFileName; - a.click(); +function useDownloadFileFromBase64Refs( + { source, filename, extension }: + { source: Ref; filename?: Ref; extension?: Ref }) { + return { + download() { + downloadFromBase64({ sourceValue: source.value, filename: filename?.value, extension: extension?.value }); }, }; } diff --git a/src/tools/ico-converter/ico-converter.vue b/src/tools/ico-converter/ico-converter.vue new file mode 100644 index 000000000..8acfe8ebd --- /dev/null +++ b/src/tools/ico-converter/ico-converter.vue @@ -0,0 +1,92 @@ + + + diff --git a/src/tools/ico-converter/index.ts b/src/tools/ico-converter/index.ts new file mode 100644 index 000000000..e30a82c87 --- /dev/null +++ b/src/tools/ico-converter/index.ts @@ -0,0 +1,12 @@ +import { PictureInPicture } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'ICO/PNG converter', + path: '/ico-converter', + description: 'Convert from PNG/JPEG to/from ICO', + keywords: ['ico', 'png', 'jpeg', 'icon', 'converter'], + component: () => import('./ico-converter.vue'), + icon: PictureInPicture, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 52bdf8e37..5e0aa7a7d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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 icoConverter } from './ico-converter'; import { tool as pdfSignatureChecker } from './pdf-signature-checker'; import { tool as numeronymGenerator } from './numeronym-generator'; import { tool as macAddressGenerator } from './mac-address-generator'; @@ -124,7 +125,13 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Images and videos', - components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder], + components: [ + qrCodeGenerator, + wifiQrCodeGenerator, + svgPlaceholderGenerator, + cameraRecorder, + icoConverter, + ], }, { name: 'Development', diff --git a/src/ui/c-file-upload/c-file-upload.vue b/src/ui/c-file-upload/c-file-upload.vue index b48d8c3bd..ecb7f1e43 100644 --- a/src/ui/c-file-upload/c-file-upload.vue +++ b/src/ui/c-file-upload/c-file-upload.vue @@ -5,10 +5,12 @@ const props = withDefaults(defineProps<{ multiple?: boolean accept?: string title?: string + pasteImage?: boolean }>(), { multiple: false, accept: undefined, title: 'Drag and drop files here, or click to select files', + pasteImage: false, }); const emit = defineEmits<{ @@ -16,11 +18,31 @@ const emit = defineEmits<{ (event: 'fileUpload', file: File): void }>(); -const { multiple } = toRefs(props); +const { multiple, pasteImage } = toRefs(props); const isOverDropZone = ref(false); +function toBase64(file: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result?.toString() ?? ''); + reader.onerror = error => reject(error); + }); +} + const fileInput = ref(null); +const imgPreview = ref(null); +async function handlePreview(image: File) { + if (imgPreview.value) { + imgPreview.value.src = await toBase64(image); + } +} +function clearPreview() { + if (imgPreview.value) { + imgPreview.value.src = ''; + } +} function triggerFileInput() { fileInput.value?.click(); @@ -39,7 +61,30 @@ function handleDrop(event: DragEvent) { handleUpload(files); } -function handleUpload(files: FileList | null | undefined) { +async function onPasteImage(evt: ClipboardEvent) { + if (!pasteImage.value) { + return false; + } + + const items = evt.clipboardData?.items; + if (!items) { + return false; + } + for (let i = 0; i < items.length; i++) { + if (items[i].type.includes('image')) { + const imageFile = items[i].getAsFile(); + if (imageFile) { + await handlePreview(imageFile); + emit('fileUpload', imageFile); + } + } + } + return true; +} + +async function handleUpload(files: FileList | null | undefined) { + clearPreview(); + if (_.isNil(files) || _.isEmpty(files)) { return; } @@ -49,6 +94,7 @@ function handleUpload(files: FileList | null | undefined) { return; } + await handlePreview(files[0]); emit('fileUpload', files[0]); } @@ -60,6 +106,7 @@ function handleUpload(files: FileList | null | undefined) { 'border-primary border-opacity-100': isOverDropZone, }" @click="triggerFileInput" + @paste.prevent="onPasteImage" @drop.prevent="handleDrop" @dragover.prevent @dragenter="isOverDropZone = true" @@ -73,6 +120,7 @@ function handleUpload(files: FileList | null | undefined) { :accept="accept" @change="handleFileInput" > + {{ title }} @@ -90,6 +138,22 @@ function handleUpload(files: FileList | null | undefined) { Browse files + +
+ +
+
+
+ or +
+
+
+ +

Paste an image from clipboard

+
+
+ +