mirror of https://github.com/VERT-sh/VERT.git
640 lines
18 KiB
Svelte
640 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { duration, fade, transition } from "$lib/util/animation";
|
|
import { m } from "$lib/paraglide/messages";
|
|
import { isMobile, files, dropdownStates } from "$lib/store/index.svelte";
|
|
import type { Categories } from "$lib/types";
|
|
import clsx from "clsx";
|
|
import { ChevronDown, SearchIcon } from "lucide-svelte";
|
|
import { onMount } from "svelte";
|
|
import { quintOut } from "svelte/easing";
|
|
import { VertFile } from "$lib/types";
|
|
import SettingsModal from "./popups/SettingsModal.svelte";
|
|
import { log } from "$lib/util/logger";
|
|
import FancyInput from "./FancyInput.svelte";
|
|
|
|
type Props = {
|
|
categories: Categories;
|
|
from?: string;
|
|
selected?: string;
|
|
onselect?: (option: string) => void;
|
|
disabled?: boolean;
|
|
dropdownSize?: "default" | "large" | "small";
|
|
file?: VertFile;
|
|
};
|
|
|
|
let {
|
|
categories,
|
|
from,
|
|
selected = $bindable(""),
|
|
onselect,
|
|
disabled,
|
|
dropdownSize = "default",
|
|
file,
|
|
}: Props = $props();
|
|
|
|
let open = $state(false);
|
|
let dropdown = $state<HTMLDivElement>();
|
|
let dropdownMenu: HTMLElement | undefined = $state();
|
|
let dropdownPosition = $state<"left" | "center" | "right">("center");
|
|
let showSettingsModal = $state(false);
|
|
let currentCategory = $state<string | null>(null);
|
|
let searchQuery = $state("");
|
|
let rootCategory: string | null = null;
|
|
|
|
// svelte-ignore state_referenced_locally
|
|
let imageSequence = $state(
|
|
file?.conversionSettings?.imageSequence ?? false,
|
|
);
|
|
// svelte-ignore state_referenced_locally
|
|
let imageSequenceFPS = $state(
|
|
file?.conversionSettings?.imageSequenceFPS ?? 15,
|
|
);
|
|
// svelte-ignore state_referenced_locally
|
|
let imageSequenceTransparency = $state(
|
|
file?.conversionSettings?.imageSequenceTransparency ?? false,
|
|
);
|
|
|
|
$effect(() => {
|
|
if (!file) return;
|
|
file.conversionSettings.imageSequence = imageSequence;
|
|
file.conversionSettings.imageSequenceFPS = imageSequenceFPS;
|
|
file.conversionSettings.imageSequenceTransparency = imageSequenceTransparency;
|
|
});
|
|
|
|
const normalize = (str: string) => str.replace(/^\./, "").toLowerCase();
|
|
|
|
const shouldExclude = (format: string): boolean =>
|
|
!!(
|
|
categories["audio"]?.formats.includes(from ?? "") &&
|
|
format === ".gif"
|
|
);
|
|
|
|
const getFormats = (cat: string) => {
|
|
let formats = (categories[cat]?.formats ?? []).filter(
|
|
(f) => !shouldExclude(f),
|
|
);
|
|
|
|
// if imageSequence is checked, filter image category to animated formats only
|
|
if (imageSequence && cat === "image") {
|
|
const animatedFormats = [".webp", ".gif", ".apng"]; // .apng not supported by magick-wasm rn
|
|
formats = formats.filter((f) => animatedFormats.includes(f));
|
|
}
|
|
|
|
return formats;
|
|
};
|
|
|
|
const detectCategory = (): string => {
|
|
if (from) {
|
|
// find category containing the input format, if any
|
|
const match = Object.keys(categories).find((cat) =>
|
|
categories[cat].formats.includes(from),
|
|
);
|
|
if (match) return match;
|
|
}
|
|
|
|
// else, fall back by finding the category whose formats overlap most with the converters for this file
|
|
// this finds the best matching category based on the formats supported by the converters
|
|
const converters = file
|
|
? file.findConverters()
|
|
: files.files.flatMap((f) => f.findConverters());
|
|
|
|
let best: string | null = null;
|
|
let maxOverlap = 0;
|
|
for (const cat of Object.keys(categories)) {
|
|
const count = categories[cat].formats.filter((fmt) =>
|
|
converters.some((c) => c.formatStrings().includes(fmt)),
|
|
).length;
|
|
if (count > maxOverlap) {
|
|
maxOverlap = count;
|
|
best = cat;
|
|
}
|
|
}
|
|
|
|
return best ?? Object.keys(categories)[0];
|
|
};
|
|
|
|
$effect(() => {
|
|
if (currentCategory) return;
|
|
const detected = detectCategory();
|
|
log(
|
|
["dropdown", "init"],
|
|
`root category: ${detected} (file: ${file?.name}, from: ${from})`,
|
|
);
|
|
currentCategory = detected;
|
|
rootCategory = detected;
|
|
});
|
|
|
|
// other available categories based on current category (e.g. converting between video and audio)
|
|
const availableCategories = $derived.by(() => {
|
|
if (!rootCategory) return Object.keys(categories);
|
|
|
|
let cats = Object.keys(categories).filter(
|
|
(cat) =>
|
|
cat === rootCategory ||
|
|
categories[rootCategory!]?.canConvertTo?.includes(cat),
|
|
);
|
|
|
|
if (imageSequence) {
|
|
// this is to allow image sequence -> video on vertd (soon:tm:)
|
|
// though i feel like this could also be done with ffmpeg-wasm in the browser? or mediabunny?
|
|
// TODO: image sequence locally w/ ffmpeg-wasm or mediabunny
|
|
cats.push("video");
|
|
} else {
|
|
// handle special cases
|
|
if (from === ".gif" || from === ".webp") cats.push("video");
|
|
if (from === ".apng") {
|
|
//cats.push("image"); // -- buggy, magick can't convert from or to apng properly
|
|
cats = cats.filter((cat) => cat !== "audio");
|
|
}
|
|
|
|
// large videos can't be extracted to audio (browser/device limitations)
|
|
if (file && file.isLarge() && rootCategory === "video")
|
|
cats = cats.filter((cat) => cat !== "audio");
|
|
}
|
|
|
|
return cats.filter(
|
|
(cat) => (categories[cat]?.formats?.length ?? 0) > 0,
|
|
);
|
|
});
|
|
|
|
const filteredData = $derived.by(() => {
|
|
const activeCategory =
|
|
currentCategory && availableCategories.includes(currentCategory)
|
|
? currentCategory
|
|
: availableCategories[0];
|
|
|
|
// if no query, return formats for current category
|
|
if (!searchQuery) {
|
|
const formats = getFormats(activeCategory ?? "");
|
|
|
|
// if no formats & categories for some reason, fall back and show all categories/formats
|
|
if (formats.length === 0 && availableCategories.length === 0) {
|
|
log(
|
|
["dropdown", "filter"],
|
|
`no formats or available categories found for file ${file?.name}, falling back to all categories and formats`,
|
|
);
|
|
return {
|
|
categories: Object.keys(categories),
|
|
formats: categories[currentCategory ?? ""]?.formats ?? [],
|
|
isFallback: true,
|
|
resolvedCategory: currentCategory,
|
|
};
|
|
}
|
|
|
|
return {
|
|
categories: availableCategories,
|
|
formats,
|
|
isFallback: false,
|
|
resolvedCategory: activeCategory ?? currentCategory,
|
|
};
|
|
}
|
|
|
|
const query = normalize(searchQuery);
|
|
const animatedFormats = [".webp", ".apng", ".gif"];
|
|
|
|
const matches = (f: string, cat?: string) => {
|
|
if (!normalize(f).includes(query) || shouldExclude(f)) return false;
|
|
// if imageSequence and image category, only show animated formats
|
|
if (imageSequence && (cat ?? activeCategory) === "image") {
|
|
return animatedFormats.includes(f);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const matchingCategories = availableCategories.filter((cat) =>
|
|
(categories[cat]?.formats ?? []).some((f) => matches(f, cat)),
|
|
);
|
|
|
|
if (matchingCategories.length === 0) {
|
|
return {
|
|
categories: availableCategories,
|
|
formats: [],
|
|
isFallback: false,
|
|
resolvedCategory: currentCategory,
|
|
};
|
|
}
|
|
|
|
// stay on current category if it matches, else move to first matched category
|
|
const resolvedCategory =
|
|
currentCategory && matchingCategories.includes(currentCategory)
|
|
? currentCategory
|
|
: matchingCategories[0];
|
|
|
|
const formats = (categories[resolvedCategory ?? ""]?.formats ?? [])
|
|
.filter((f) => matches(f, resolvedCategory))
|
|
.sort((a, b) => {
|
|
// exact matches first, then original order
|
|
const aExact = normalize(a) === query;
|
|
const bExact = normalize(b) === query;
|
|
if (aExact !== bExact) return aExact ? -1 : 1;
|
|
return 0;
|
|
});
|
|
|
|
// show categories with matches, formats from within resolved category
|
|
return {
|
|
categories: matchingCategories,
|
|
formats,
|
|
isFallback: false,
|
|
resolvedCategory,
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
if (
|
|
filteredData.resolvedCategory &&
|
|
filteredData.resolvedCategory !== currentCategory
|
|
)
|
|
currentCategory = filteredData.resolvedCategory;
|
|
});
|
|
|
|
$effect(() => {
|
|
// scroll automatically when opening dropdown so you don't have to scroll to see entire dropdown lol
|
|
if (!open || !dropdownMenu) return;
|
|
scrollView();
|
|
});
|
|
|
|
$effect(() => {
|
|
// this thing checks if selected format is still valid with the current filters (imageSequence or search query) and falls back if not
|
|
const allUnfilteredFormats = availableCategories.flatMap((cat) =>
|
|
getFormats(cat),
|
|
);
|
|
|
|
if (!allUnfilteredFormats.includes(selected)) {
|
|
// check if formats available in filteredData, else fall back to all available formats, else keep previous selection
|
|
if (filteredData.formats.length > 0) {
|
|
selected = filteredData.formats[0];
|
|
onselect?.(selected);
|
|
} else if (allUnfilteredFormats.length > 0) {
|
|
selected = allUnfilteredFormats[0];
|
|
onselect?.(allUnfilteredFormats[0]);
|
|
} else {
|
|
// no formats available, keeping previous selection
|
|
// i feel like this is all very scuffed and we need a better search and filtering system
|
|
}
|
|
}
|
|
});
|
|
|
|
const selectOption = (option: string) => {
|
|
selected = option;
|
|
open = false;
|
|
|
|
// save user's selection to dropdownStates for this session
|
|
if (file) {
|
|
dropdownStates.update((states) => ({
|
|
...states,
|
|
[file.name]: option,
|
|
}));
|
|
}
|
|
|
|
onselect?.(option);
|
|
};
|
|
|
|
const selectCategory = (category: string) => {
|
|
if (!categories[category]) return;
|
|
currentCategory = category;
|
|
};
|
|
|
|
const handleSearch = (event: Event) => {
|
|
searchQuery = (event.target as HTMLInputElement).value;
|
|
};
|
|
|
|
const onEnter = (event: KeyboardEvent) => {
|
|
if (event.key !== "Enter") return;
|
|
event.preventDefault();
|
|
if (filteredData.formats.length > 0)
|
|
selectOption(filteredData.formats[0]);
|
|
};
|
|
|
|
const scrollView = () => {
|
|
if (!dropdownMenu) return;
|
|
const selectedOption = dropdownMenu.querySelector(
|
|
"[data-selected='true']",
|
|
) as HTMLButtonElement | null;
|
|
if (!selectedOption) return;
|
|
selectedOption.scrollIntoView({ block: "start" });
|
|
};
|
|
|
|
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(
|
|
"#format-search",
|
|
) as HTMLInputElement;
|
|
if (searchInput) {
|
|
searchInput.focus();
|
|
searchInput.select();
|
|
}
|
|
scrollView();
|
|
}, 0); // let dropdown open first
|
|
};
|
|
|
|
const extract = async () => {
|
|
// extract all files in zip, then add all extracted files to files store
|
|
if (!file) return;
|
|
const { extractZip } = await import("$lib/util/file");
|
|
const extractedFiles = await extractZip(file.file);
|
|
|
|
if (!Array.isArray(extractedFiles) || extractedFiles.length === 0)
|
|
return;
|
|
|
|
const newFiles = extractedFiles
|
|
.map(({ filename, data }) => {
|
|
try {
|
|
const f = new File([new Uint8Array(data)], filename, {
|
|
type: "application/octet-stream",
|
|
});
|
|
const ext = filename.split(".").pop() ?? "";
|
|
return new VertFile(f, ext);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean);
|
|
|
|
files.files = files.files.filter((f) => f !== file);
|
|
newFiles.forEach((f) => files.add(f));
|
|
};
|
|
|
|
const settings = () => {
|
|
log(
|
|
["dropdown", "settings"],
|
|
`opening settings modal for ${file?.name ?? "all files"}`,
|
|
);
|
|
showSettingsModal = true;
|
|
};
|
|
|
|
onMount(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (dropdown && !dropdown.contains(e.target as Node)) open = false;
|
|
};
|
|
|
|
const handleResize = () => {
|
|
if (open) {
|
|
// recalculate dropdown position on resize
|
|
clickDropdown();
|
|
open = true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("click", handleClickOutside);
|
|
window.addEventListener("resize", handleResize);
|
|
return () => {
|
|
window.removeEventListener("click", handleClickOutside);
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
{#if showSettingsModal}
|
|
<SettingsModal {file} onclose={() => (showSettingsModal = false)} />
|
|
{/if}
|
|
|
|
<div
|
|
class="relative w-full min-w-fit text-xl font-medium text-center"
|
|
bind:this={dropdown}
|
|
>
|
|
<button
|
|
class="relative flex items-center justify-center w-full font-display px-3 py-3.5 bg-button rounded-full overflow-hidden cursor-pointer focus:!outline-none
|
|
{disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
|
onclick={() => clickDropdown()}
|
|
{disabled}
|
|
>
|
|
<!-- <p>{selected}</p> -->
|
|
<div
|
|
class="grid grid-cols-1 grid-rows-1 w-fit flex-grow-0 max-h-[2.5rem] overflow-hidden"
|
|
>
|
|
{#key selected}
|
|
<p
|
|
in:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
out:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
class="col-start-1 row-start-1 text-center font-body font-medium truncate max-w-[4rem]"
|
|
>
|
|
{selected || "N/A"}
|
|
</p>
|
|
{/key}
|
|
{#if currentCategory}
|
|
{#each categories[currentCategory].formats as option}
|
|
<p
|
|
class="col-start-1 row-start-1 invisible pointer-events-none truncate max-w-[2.5rem]"
|
|
>
|
|
{option}
|
|
</p>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<ChevronDown
|
|
class="w-4 h-4 ml-3 mt-0.5 flex-shrink-0"
|
|
style="transform: rotate({open
|
|
? 180
|
|
: 0}deg); transition: transform {duration}ms {transition};"
|
|
/>
|
|
</button>
|
|
{#if open}
|
|
<div
|
|
bind:this={dropdownMenu}
|
|
transition:fade={{
|
|
duration,
|
|
easing: quintOut,
|
|
}}
|
|
class={clsx(
|
|
$isMobile
|
|
? "fixed inset-x-0 bottom-0 w-full z-[200] shadow-xl bg-panel-alt shadow-black/25 rounded-t-2xl overflow-hidden"
|
|
: "min-w-full shadow-xl bg-panel-alt shadow-black/25 absolute top-full mt-2 z-50 rounded-2xl overflow-hidden",
|
|
!$isMobile && {
|
|
"w-[320%]": dropdownSize === "large",
|
|
"w-[250%]": dropdownSize === "default",
|
|
"w-[150%]": dropdownSize === "small",
|
|
},
|
|
!$isMobile && {
|
|
"-translate-x-1/2 left-1/2": dropdownPosition === "center",
|
|
"left-0": dropdownPosition === "left",
|
|
"right-0": dropdownPosition === "right",
|
|
},
|
|
)}
|
|
>
|
|
<!-- search box -->
|
|
<div class="p-3 w-full">
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
placeholder={m["convert.dropdown.placeholder"]()}
|
|
class="flex-grow w-full !pl-11 !pr-3 rounded-lg bg-panel text-foreground {filteredData.isFallback
|
|
? 'opacity-50 cursor-not-allowed'
|
|
: ''}"
|
|
bind:value={searchQuery}
|
|
oninput={handleSearch}
|
|
onkeydown={onEnter}
|
|
onfocus={() => {}}
|
|
id="format-search"
|
|
autocomplete="off"
|
|
disabled={filteredData.isFallback}
|
|
/>
|
|
<span
|
|
class="absolute left-4 top-1/2 -translate-y-1/2 flex items-center {filteredData.isFallback
|
|
? 'opacity-50'
|
|
: ''}"
|
|
>
|
|
<SearchIcon class="w-4 h-4" />
|
|
</span>
|
|
{#if searchQuery}
|
|
<span
|
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted"
|
|
style="font-size: 0.7rem;"
|
|
>
|
|
{filteredData.formats.length}
|
|
{filteredData.formats.length === 1
|
|
? "result"
|
|
: "results"}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<!-- fallback message -->
|
|
{#if filteredData.isFallback}
|
|
<div class="pb-4 text-center text-muted text-base">
|
|
{m["convert.dropdown.fallback"]()}
|
|
</div>
|
|
{/if}
|
|
<!-- available categories -->
|
|
<div class="flex items-center justify-between">
|
|
{#each filteredData.categories as category}
|
|
<button
|
|
class="flex-grow text-lg hover:text-muted/20 border-b-[1px] pb-2 capitalize
|
|
{currentCategory === category
|
|
? 'text-accent border-b-accent'
|
|
: 'border-b-separator text-muted'}"
|
|
onclick={() => selectCategory(category)}
|
|
>
|
|
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
|
{(m as any)[`convert.dropdown.${category}`]?.()}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<!-- available formats -->
|
|
<div class="max-h-80 overflow-y-auto grid grid-cols-3 gap-2 p-2">
|
|
{#if filteredData.formats.length > 0}
|
|
{#each filteredData.formats as format}
|
|
<button
|
|
data-selected={format === selected}
|
|
class="w-full p-2 text-center rounded-xl
|
|
{format === selected
|
|
? 'bg-accent text-black'
|
|
: format === from
|
|
? 'bg-separator'
|
|
: 'hover:bg-panel'}"
|
|
onclick={() => selectOption(format)}
|
|
>
|
|
{format}
|
|
</button>
|
|
{/each}
|
|
{:else}
|
|
<div class="col-span-3 text-center p-4 text-muted">
|
|
{searchQuery
|
|
? m["convert.dropdown.no_results"]()
|
|
: m["convert.dropdown.no_formats"]()}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- format options -->
|
|
{#if file?.name.toLowerCase().endsWith(".zip")}
|
|
<div
|
|
class="flex flex-col gap-2 p-2 border-t border-separator text-base"
|
|
>
|
|
<button
|
|
class="w-full p-2 text-center rounded-lg bg-accent text-black"
|
|
onclick={() => extract()}
|
|
>
|
|
{m["convert.archive_file.extract"]()}
|
|
</button>
|
|
<!-- FIXME this is terrible -->
|
|
<div class="flex flex-col flex-wrap gap-1">
|
|
<!-- first row -->
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex items-center gap-2 flex-1 min-w-0 h-full"
|
|
>
|
|
<FancyInput
|
|
type="checkbox"
|
|
class="!w-fit"
|
|
bind:checked={imageSequence}
|
|
/>
|
|
<label for="extract-sequence" class="text-sm">
|
|
{m[
|
|
"convert.image_sequence.image_sequence"
|
|
]()}
|
|
</label>
|
|
</div>
|
|
<div class="w-[80px] shrink-0">
|
|
<FancyInput
|
|
thin
|
|
inputClass="!h-9 !text-xs"
|
|
type="number"
|
|
extension="FPS"
|
|
placeholder="15"
|
|
bind:value={imageSequenceFPS}
|
|
disabled={!imageSequence}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- second row -->
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex items-center gap-2 flex-1 min-w-0 h-full"
|
|
>
|
|
<FancyInput
|
|
type="checkbox"
|
|
class="!w-fit"
|
|
bind:checked={imageSequenceTransparency}
|
|
/>
|
|
<label for="extract-sequence" class="text-sm">
|
|
{m["convert.image_sequence.transparency"]()}
|
|
</label>
|
|
</div>
|
|
<!-- second thing -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="border-t border-separator text-base p-2">
|
|
<button
|
|
class="w-full p-2 text-center rounded-lg bg-accent text-black"
|
|
onclick={() => settings()}
|
|
>
|
|
{m["convert.settings.settings"]()}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|