feat(ui): add drop and paste image uploads
This commit is contained in:
@@ -1,26 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import ImageUploadInput from '$lib/components/ImageUploadInput.svelte';
|
||||
let {
|
||||
currentUrl = '',
|
||||
label = 'ICON'
|
||||
}: { currentUrl?: string | null; label?: string } = $props();
|
||||
let previewUrl = $state('');
|
||||
let fileName = $state('');
|
||||
const choose = (event: Event) => {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = file && file.type !== 'image/svg+xml' ? URL.createObjectURL(file) : '';
|
||||
fileName = file?.name ?? '';
|
||||
};
|
||||
onDestroy(() => { if (previewUrl) URL.revokeObjectURL(previewUrl); });
|
||||
</script>
|
||||
|
||||
<fieldset class="icon-upload-field">
|
||||
<legend>{label}</legend>
|
||||
<div class="icon-upload-preview">
|
||||
{#if previewUrl || currentUrl}<img src={previewUrl || currentUrl || ''} alt="Current icon preview"/>{:else}<span>NO ICON</span>{/if}
|
||||
</div>
|
||||
<label><input name="icon" type="file" accept="image/svg+xml,image/png,image/webp,image/avif" onchange={choose}/><span>{fileName || (currentUrl ? 'REPLACE ICON' : 'UPLOAD ICON')}</span></label>
|
||||
<ImageUploadInput name="icon" accept="image/svg+xml,image/png,image/webp,image/avif" {currentUrl} emptyLabel="NO ICON" browseLabel={currentUrl ? 'REPLACE ICON' : 'CHOOSE ICON'}/>
|
||||
{#if currentUrl}<label class="icon-remove"><input name="removeIcon" type="checkbox" value="1"/><span>REMOVE CURRENT ICON</span></label>{/if}
|
||||
<small>TRANSPARENT SVG, PNG, WEBP, OR AVIF · 2 MiB MAX</small>
|
||||
</fieldset>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
accept,
|
||||
required = false,
|
||||
currentUrl = '',
|
||||
emptyLabel = 'NO IMAGE',
|
||||
browseLabel = 'CHOOSE FILE'
|
||||
}: {
|
||||
name: string;
|
||||
accept: string;
|
||||
required?: boolean;
|
||||
currentUrl?: string | null;
|
||||
emptyLabel?: string;
|
||||
browseLabel?: string;
|
||||
} = $props();
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let previewUrl = $state('');
|
||||
let fileName = $state('');
|
||||
let dragging = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
function setFile(file: File | undefined) {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error = 'Clipboard or dropped content must be an image.';
|
||||
return;
|
||||
}
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(file);
|
||||
input.files = transfer.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function choose(event: Event) {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = file ? URL.createObjectURL(file) : '';
|
||||
fileName = file?.name ?? '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
setFile(Array.from(event.dataTransfer?.files ?? []).find((file) => file.type.startsWith('image/')));
|
||||
}
|
||||
|
||||
function paste(event: ClipboardEvent) {
|
||||
const file = Array.from(event.clipboardData?.files ?? []).find((item) => item.type.startsWith('image/'));
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
setFile(file);
|
||||
} else {
|
||||
error = 'No image was found on the clipboard.';
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteFromClipboard() {
|
||||
error = '';
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
const type = item.types.find((candidate) => candidate.startsWith('image/'));
|
||||
if (type) {
|
||||
const blob = await item.getType(type);
|
||||
setFile(new File([blob], `pasted-image.${type.split('/')[1] || 'png'}`, { type }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
error = 'No image was found on the clipboard.';
|
||||
} catch {
|
||||
error = 'Clipboard access was blocked. Focus the drop zone and press Ctrl+V instead.';
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="image-drop-input"
|
||||
class:dragging
|
||||
role="group"
|
||||
aria-label="Drop an image here or paste one from the clipboard"
|
||||
ondragenter={(event) => { event.preventDefault(); dragging = true; }}
|
||||
ondragover={(event) => event.preventDefault()}
|
||||
ondragleave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) dragging = false;
|
||||
}}
|
||||
ondrop={drop}
|
||||
onpaste={paste}
|
||||
>
|
||||
<input bind:this={input} class="image-file-input" {name} type="file" {accept} {required} onchange={choose}/>
|
||||
<div class="image-upload-preview">
|
||||
{#if previewUrl || currentUrl}
|
||||
<img src={previewUrl || currentUrl || ''} alt="Selected upload preview"/>
|
||||
{:else}
|
||||
<span>{emptyLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="image-drop-copy">
|
||||
<b>{dragging ? 'RELEASE TO SELECT' : 'DROP IMAGE HERE'}</b>
|
||||
<span>{fileName || 'OR USE AN UPLOAD METHOD'}</span>
|
||||
</div>
|
||||
<div class="image-upload-actions">
|
||||
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); input.click(); }}>{browseLabel}</button>
|
||||
<button type="button" class="secondary" onclick={(event) => { event.stopPropagation(); pasteFromClipboard(); }}>PASTE IMAGE</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if error}<small class="image-upload-error" role="alert">{error}</small>{/if}
|
||||
Reference in New Issue
Block a user