From 93faaa4b3416a68f33bc986fb6d375f4c1bc832f Mon Sep 17 00:00:00 2001 From: Maya Date: Mon, 8 Sep 2025 09:00:20 +0300 Subject: [PATCH] feat: default conversion format also improves formatdropdown and thumbnail generation --- messages/en.json | 6 + .../functional/FormatDropdown.svelte | 81 ++++++--- src/lib/sections/settings/Conversion.svelte | 87 ++++++++- src/lib/sections/settings/Privacy.svelte | 2 +- src/lib/sections/settings/Vertd.svelte | 2 +- src/lib/sections/settings/index.svelte.ts | 16 ++ src/lib/store/index.svelte.ts | 44 +++-- src/routes/convert/+page.svelte | 170 +++++++++++++----- src/routes/settings/+page.svelte | 6 +- 9 files changed, 325 insertions(+), 89 deletions(-) diff --git a/messages/en.json b/messages/en.json index fb43805..bb7cca1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -104,6 +104,12 @@ "filename_format": "File name format", "filename_description": "This will determine the name of the file on download, not including the file extension. You can put these following templates in the format, which will be replaced with the relevant information: %name% for the original file name, %extension% for the original file extension, and %date% for a date string of when the file was converted.", "placeholder": "VERT_%name%", + "default_format": "Default conversion format", + "default_format_description": "This will change the default format selected when you upload a file of this file type.", + "default_format_image": "Images", + "default_format_video": "Videos", + "default_format_audio": "Audio", + "default_format_document": "Documents", "metadata": "File metadata", "metadata_description": "This changes whether any metadata (EXIF, song info, etc.) on the original file is preserved in converted files.", "keep": "Keep", diff --git a/src/lib/components/functional/FormatDropdown.svelte b/src/lib/components/functional/FormatDropdown.svelte index c9525da..69027ac 100644 --- a/src/lib/components/functional/FormatDropdown.svelte +++ b/src/lib/components/functional/FormatDropdown.svelte @@ -31,6 +31,7 @@ let searchQuery = $state(""); let dropdownMenu: HTMLElement | undefined = $state(); let rootCategory: string | null = null; + let dropdownPosition = $state<"left" | "center" | "right">("center"); // initialize current category $effect(() => { @@ -86,13 +87,15 @@ // if no query, return formats for current category if (!searchQuery) { + let formats = currentCategory + ? categories[currentCategory].formats.filter((format) => + shouldInclude(format, currentCategory!), + ) + : []; + return { categories: availableCategories, - formats: currentCategory - ? categories[currentCategory].formats.filter((format) => - shouldInclude(format, currentCategory!), - ) - : [], + formats, }; } const searchLower = normalize(searchQuery); @@ -213,6 +216,34 @@ const clickDropdown = () => { open = !open; if (!open) return; + + // keep within viewport + if (dropdown) { + const rect = dropdown.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + + let dropdownWidth: number; + if (dropdownSize === "large") { + dropdownWidth = rect.width * 3.2; + } else if (dropdownSize === "default") { + dropdownWidth = rect.width * 2.5; + } else { + dropdownWidth = rect.width * 1.5; + } + + const centerX = rect.left + rect.width / 2; + const leftEdge = centerX - dropdownWidth / 2; + const rightEdge = centerX + dropdownWidth / 2; + + if (leftEdge < 0) { + dropdownPosition = "left"; + } else if (rightEdge > viewportWidth) { + dropdownPosition = "right"; + } else { + dropdownPosition = "center"; + } + } + setTimeout(() => { if (!dropdownMenu) return; const searchInput = dropdownMenu.querySelector( @@ -232,23 +263,20 @@ } }; - window.addEventListener("click", handleClickOutside); - return () => window.removeEventListener("click", handleClickOutside); - }); + const handleResize = () => { + if (open) { + // recalculate dropdown position on resize + clickDropdown(); + open = true; + } + }; - // initialize selected format if none chosen - $effect(() => { - if ( - !selected && - currentCategory && - categories[currentCategory]?.formats?.length > 0 - ) { - const from = files.files[0]?.from; - const firstDiff = categories[currentCategory].formats.find( - (f) => f !== from, - ); - selected = firstDiff || categories[currentCategory].formats[0]; - } + window.addEventListener("click", handleClickOutside); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("click", handleClickOutside); + window.removeEventListener("resize", handleResize); + }; }); @@ -258,7 +286,7 @@ > + + + + +
+
+

+ {m["settings.conversion.default_format_image"]()} +

+ +
+
+

+ {m["settings.conversion.default_format_audio"]()} +

+ +
+
+

+ {m["settings.conversion.default_format_video"]()} +

+ +
+
+

+ {m["settings.conversion.default_format_document"]()} +

+ +
+
+

diff --git a/src/lib/sections/settings/Privacy.svelte b/src/lib/sections/settings/Privacy.svelte index 5748d5b..8df55dc 100644 --- a/src/lib/sections/settings/Privacy.svelte +++ b/src/lib/sections/settings/Privacy.svelte @@ -6,7 +6,7 @@ import { m } from "$lib/paraglide/messages"; import { link } from "$lib/store/index.svelte"; - const { settings }: { settings: ISettings } = $props(); + const { settings = $bindable() }: { settings: ISettings } = $props(); diff --git a/src/lib/sections/settings/Vertd.svelte b/src/lib/sections/settings/Vertd.svelte index 7963177..ff223c2 100644 --- a/src/lib/sections/settings/Vertd.svelte +++ b/src/lib/sections/settings/Vertd.svelte @@ -13,7 +13,7 @@ let vertdCommit = $state(null); let abortController: AbortController | null = null; - const { settings }: { settings: ISettings } = $props(); + const { settings = $bindable() }: { settings: ISettings } = $props(); $effect(() => { if (settings.vertdURL) { diff --git a/src/lib/sections/settings/index.svelte.ts b/src/lib/sections/settings/index.svelte.ts index ac902df..6ef5f7f 100644 --- a/src/lib/sections/settings/index.svelte.ts +++ b/src/lib/sections/settings/index.svelte.ts @@ -8,8 +8,17 @@ export { default as Vertd } from "./Vertd.svelte"; export { default as Privacy } from "./Privacy.svelte"; // TODO: clean up settings & button code (componetize) + +export interface DefaultFormats { + image: string; + video: string; + audio: string; + document: string; +} export interface ISettings { filenameFormat: string; + defaultFormat: DefaultFormats; + useDefaultFormat: boolean; metadata: boolean; plausible: boolean; vertdURL: string; @@ -25,6 +34,13 @@ export class Settings { public settings: ISettings = $state({ filenameFormat: "VERT_%name%", + defaultFormat: { + image: ".png", + video: ".mp4", + audio: ".mp3", + document: ".docx", + }, + useDefaultFormat: false, metadata: true, plausible: true, vertdURL: PUB_VERTD_URL, diff --git a/src/lib/store/index.svelte.ts b/src/lib/store/index.svelte.ts index 4d9f697..88bb358 100644 --- a/src/lib/store/index.svelte.ts +++ b/src/lib/store/index.svelte.ts @@ -50,9 +50,10 @@ class Files { }); const cover = selectCover(common.picture); if (cover) { - const arrayBuffer = cover.data.buffer instanceof ArrayBuffer - ? cover.data.buffer - : new Uint8Array(cover.data).buffer; + const arrayBuffer = + cover.data.buffer instanceof ArrayBuffer + ? cover.data.buffer + : new Uint8Array(cover.data).buffer; const blob = new Blob([new Uint8Array(arrayBuffer)], { type: cover.format, }); @@ -114,15 +115,23 @@ class Files { ? (mediaElement as HTMLVideoElement).videoHeight : (mediaElement as HTMLImageElement).height; - if (width === 0 || height === 0) { - URL.revokeObjectURL(mediaElement.src); - return undefined; - } - const scale = Math.max(maxSize / width, maxSize / height); canvas.width = width * scale; canvas.height = height * scale; ctx.drawImage(mediaElement, 0, 0, canvas.width, canvas.height); + + // check if completely transparent + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const isTransparent = Array.from(imageData.data).every( + (value, index) => { + return (index + 1) % 4 !== 0 || value === 0; + }, + ); + if (isTransparent) { + canvas.remove(); + return undefined; + } + const url = canvas.toDataURL(); canvas.remove(); return url; @@ -313,9 +322,9 @@ export const effects = writable(true); export const theme = writable<"light" | "dark">("light"); export const locale = writable(getLocale()); export const availableLocales = { - "en": "English", - "es": "Español", -} + en: "English", + es: "Español", +}; export function updateLocale(newLocale: string) { log(["locale"], `set to ${newLocale}`); @@ -331,7 +340,7 @@ export function link( text: string, links: string | string[], newTab?: boolean | boolean[], - className?: string | string[] + className?: string | string[], ) { if (!text) return ""; @@ -344,14 +353,17 @@ export function link( tags.forEach((t, i) => { const link = linksArr[i] ?? "#"; - const target = newTabArr[i] ? 'target="_blank" rel="noopener noreferrer"' : ""; + const target = newTabArr[i] + ? 'target="_blank" rel="noopener noreferrer"' + : ""; const cls = classArr[i] ? `class="${classArr[i]}"` : ""; const regex = new RegExp(`\\[${t}\\](.*?)\\[\\/${t}\\]`, "g"); - result = result.replace(regex, (_, inner) => - `${inner}` + result = result.replace( + regex, + (_, inner) => `${inner}`, ); }); return result; -} \ No newline at end of file +} diff --git a/src/routes/convert/+page.svelte b/src/routes/convert/+page.svelte index 6a76aee..1460d04 100644 --- a/src/routes/convert/+page.svelte +++ b/src/routes/convert/+page.svelte @@ -27,21 +27,79 @@ RotateCwIcon, XIcon, } from "lucide-svelte"; - import { onMount } from "svelte"; import { m } from "$lib/paraglide/messages"; + import { Settings } from "$lib/sections/settings/index.svelte"; + + let processedFileIds = $state(new Set()); + + $effect(() => { + if (!Settings.instance.settings || files.files.length === 0) return; - onMount(() => { - // depending on format, select right category and format files.files.forEach((file) => { + const settings = Settings.instance.settings; + if (processedFileIds.has(file.id)) return; + const converter = file.findConverter(); - if (converter) { - const category = Object.keys(categories).find((cat) => - categories[cat].formats.includes(file.to), - ); - if (category) { - file.to = file.to || categories[category].formats[0]; + if (!converter) return; + + let category: string | undefined; + const isImage = converters + .find((c) => c.name === "imagemagick") + ?.formatStrings((f) => f.fromSupported) + .includes(file.from); + const isAudio = converters + .find((c) => c.name === "ffmpeg") + ?.supportedFormats.filter((f) => f.isNative) + .map((f) => f.name) + .includes(file.from); + const isVideo = converters + .find((c) => c.name === "vertd") + ?.supportedFormats.filter((f) => f.isNative) + .map((f) => f.name) + .includes(file.from); + const isDocument = converters + .find((c) => c.name === "pandoc") + ?.supportedFormats.filter((f) => f.isNative) + .map((f) => f.name) + .includes(file.from); + + if (isImage) category = "image"; + else if (isAudio) category = "audio"; + else if (isVideo) category = "video"; + else if (isDocument) category = "doc"; + if (!category) return; + + let targetFormat: string | undefined; + + // use default format if enabled + if (settings.useDefaultFormat) { + let defaultFormat: string | undefined; + const df = settings.defaultFormat; + if (category === "image") defaultFormat = df.image; + else if (category === "audio") defaultFormat = df.audio; + else if (category === "video") defaultFormat = df.video; + else if (category === "doc") defaultFormat = df.document; + + if ( + defaultFormat && + defaultFormat !== file.from && + categories[category]?.formats.includes(defaultFormat) + ) { + targetFormat = defaultFormat; } } + + // else use first available format (or if default format is same as input) + if (!targetFormat) { + const firstDiff = categories[category]?.formats.find( + (f) => f !== file.from, + ); + targetFormat = + firstDiff || categories[category]?.formats[0] || ""; + } + + file.to = targetFormat; + processedFileIds.add(file.id); }); }); @@ -132,23 +190,38 @@

{#if !converters.length} - + {:else if isAudio} - + {:else if isVideo} - + {:else if isDocument} - + {:else} - + {/if} @@ -206,16 +279,18 @@
-

{m["convert.errors.cant_convert"]()}

+

+ {m["convert.errors.cant_convert"]()} +

- {m["convert.errors.worker_downloading"]({ - type: isAudio - ? m["convert.errors.audio"]() - : isVideo - ? "Video" - : isDocument - ? m["convert.errors.doc"]() - : m["convert.errors.image"]() + {m["convert.errors.worker_downloading"]({ + type: isAudio + ? m["convert.errors.audio"]() + : isVideo + ? "Video" + : isDocument + ? m["convert.errors.doc"]() + : m["convert.errors.image"](), })}

@@ -223,16 +298,18 @@
-

{m["convert.errors.cant_convert"]()}

+

+ {m["convert.errors.cant_convert"]()} +

- {m["convert.errors.worker_error"]({ - type: isAudio - ? m["convert.errors.audio"]() - : isVideo - ? "Video" - : isDocument - ? m["convert.errors.doc"]() - : m["convert.errors.image"]() + {m["convert.errors.worker_error"]({ + type: isAudio + ? m["convert.errors.audio"]() + : isVideo + ? "Video" + : isDocument + ? m["convert.errors.doc"]() + : m["convert.errors.image"](), })}

@@ -240,16 +317,18 @@
-

{m["convert.errors.cant_convert"]()}

+

+ {m["convert.errors.cant_convert"]()} +

- {m["convert.errors.worker_timeout"]({ - type: isAudio - ? m["convert.errors.audio"]() - : isVideo - ? "Video" - : isDocument - ? m["convert.errors.doc"]() - : m["convert.errors.image"]() + {m["convert.errors.worker_timeout"]({ + type: isAudio + ? m["convert.errors.audio"]() + : isVideo + ? "Video" + : isDocument + ? m["convert.errors.doc"]() + : m["convert.errors.image"](), })}

@@ -257,7 +336,9 @@
-

{m["convert.errors.cant_convert"]()}

+

+ {m["convert.errors.cant_convert"]()} +

{m["convert.errors.vertd_not_found"]()}

@@ -311,7 +392,10 @@ onselect={(option) => handleSelect(option, file)} />
- +