Add drag & drop, smart presets, and keyboard shortcuts

This commit is contained in:
2026-03-08 17:06:58 -05:00
parent 4f694b1024
commit e6a99a4141

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { transformImage } from "./lib/api"; import { transformImage } from "./lib/api";
import { import {
generateClientPreview, generateClientPreview,
@@ -9,6 +10,7 @@
type TransformOptions type TransformOptions
} from "./lib/preview"; } from "./lib/preview";
import { theme } from "./lib/theme"; import { theme } from "./lib/theme";
import { PRESETS, applyPreset, type Preset } from "./lib/presets";
let file: File | null = null; let file: File | null = null;
let filePreviewUrl: string | null = null; let filePreviewUrl: string | null = null;
@@ -22,6 +24,10 @@
let processing = false; let processing = false;
let error: string | null = null; let error: string | null = null;
// Drag & drop state
let isDragging = false;
let showShortcuts = false;
// Preview state // Preview state
let previewUrl: string | null = null; let previewUrl: string | null = null;
let previewSize = 0; let previewSize = 0;
@@ -93,13 +99,93 @@
} }
} }
function handleFile(selectedFile: File) {
if (!selectedFile.type.startsWith('image/')) {
error = 'Please select an image file';
return;
}
file = selectedFile;
filePreviewUrl = URL.createObjectURL(selectedFile);
error = null;
}
function onFileChange(e: Event) { function onFileChange(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
file = target.files?.[0] || null; const selectedFile = target.files?.[0];
if (file) { if (selectedFile) {
filePreviewUrl = URL.createObjectURL(file); handleFile(selectedFile);
} else { }
filePreviewUrl = null; }
// Drag & Drop handlers
function onDragOver(e: DragEvent) {
e.preventDefault();
isDragging = true;
}
function onDragLeave(e: DragEvent) {
e.preventDefault();
isDragging = false;
}
function onDrop(e: DragEvent) {
e.preventDefault();
isDragging = false;
const droppedFile = e.dataTransfer?.files?.[0];
if (droppedFile) {
handleFile(droppedFile);
}
}
// Paste handler
function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const pastedFile = items[i].getAsFile();
if (pastedFile) {
handleFile(pastedFile);
}
break;
}
}
}
// Keyboard shortcuts
function onKeyDown(e: KeyboardEvent) {
// Show shortcuts help
if (e.key === '?') {
e.preventDefault();
showShortcuts = !showShortcuts;
return;
}
// Close shortcuts dialog
if (e.key === 'Escape' && showShortcuts) {
showShortcuts = false;
return;
}
// Ctrl/Cmd + V - Paste (handled by paste event)
// Ctrl/Cmd + Enter - Transform & Download
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && file && !processing) {
e.preventDefault();
onSubmit();
return;
}
// Enter alone - Transform & Download (if input not focused)
const activeElement = document.activeElement;
const isInputFocused = activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'SELECT' ||
activeElement?.tagName === 'TEXTAREA';
if (e.key === 'Enter' && !isInputFocused && file && !processing) {
e.preventDefault();
onSubmit();
} }
} }
@@ -112,6 +198,27 @@
previewSize = 0; previewSize = 0;
} }
// Apply preset
function selectPreset(preset: Preset) {
const settings = applyPreset(preset, width, height);
width = settings.width;
height = settings.height;
quality = settings.quality;
format = settings.format;
fit = settings.fit;
}
// Setup event listeners
onMount(() => {
document.addEventListener('paste', onPaste);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('paste', onPaste);
document.removeEventListener('keydown', onKeyDown);
};
});
const savings = showPreview ? calculateSavings(originalSize, previewSize) : null; const savings = showPreview ? calculateSavings(originalSize, previewSize) : null;
</script> </script>
@@ -122,6 +229,10 @@
<h1 class="mb-0">PNGer</h1> <h1 class="mb-0">PNGer</h1>
<p class="text-sm mb-0">Modern PNG Editor & Resizer</p> <p class="text-sm mb-0">Modern PNG Editor & Resizer</p>
</div> </div>
<div class="flex gap-sm">
<button class="btn-outline" on:click={() => showShortcuts = !showShortcuts} title="Keyboard shortcuts (?)">
⌨️
</button>
<button class="btn-outline" on:click={() => theme.toggle()}> <button class="btn-outline" on:click={() => theme.toggle()}>
{#if $theme === 'dark'} {#if $theme === 'dark'}
☀️ Light ☀️ Light
@@ -129,8 +240,43 @@
🌙 Dark 🌙 Dark
{/if} {/if}
</button> </button>
</div>
</header> </header>
<!-- Shortcuts Modal -->
{#if showShortcuts}
<div class="modal-overlay" on:click={() => showShortcuts = false}>
<div class="modal-content" on:click|stopPropagation>
<h2>Keyboard Shortcuts</h2>
<div class="shortcuts-list">
<div class="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>V</kbd>
<span>Paste image from clipboard</span>
</div>
<div class="shortcut-item">
<kbd>Enter</kbd>
<span>Transform & Download</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>Enter</kbd>
<span>Transform & Download (anywhere)</span>
</div>
<div class="shortcut-item">
<kbd>?</kbd>
<span>Show/hide this help</span>
</div>
<div class="shortcut-item">
<kbd>Esc</kbd>
<span>Close this dialog</span>
</div>
</div>
<button style="margin-top: var(--space-lg);" on:click={() => showShortcuts = false}>
Close
</button>
</div>
</div>
{/if}
<!-- Controls Section --> <!-- Controls Section -->
<div class="card fade-in" style="margin-bottom: var(--space-xl);"> <div class="card fade-in" style="margin-bottom: var(--space-xl);">
<div class="grid grid-cols-2 gap-lg"> <div class="grid grid-cols-2 gap-lg">
@@ -138,30 +284,74 @@
<div> <div>
<h2>Upload & Settings</h2> <h2>Upload & Settings</h2>
<!-- File Upload --> <!-- Drag & Drop / File Upload -->
<div style="margin-bottom: var(--space-lg);"> <div style="margin-bottom: var(--space-lg);">
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;"> <label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
Select Image Select or Drop Image
</label> </label>
<!-- Drop Zone -->
<div
class="drop-zone {isDragging ? 'dragging' : ''}"
on:dragover={onDragOver}
on:dragleave={onDragLeave}
on:drop={onDrop}
>
<input <input
type="file" type="file"
accept="image/*" accept="image/*"
on:change={onFileChange} on:change={onFileChange}
style="margin-bottom: var(--space-sm);" id="file-input"
style="display: none;"
/> />
<label for="file-input" class="drop-zone-label">
{#if file} {#if file}
<div class="flex gap-sm items-center" style="margin-top: var(--space-sm);"> <div class="flex gap-sm items-center" style="flex-direction: column;">
<span style="font-size: 2rem;"></span>
<span class="text-sm">{file.name}</span> <span class="text-sm">{file.name}</span>
<span class="text-xs" style="color: var(--color-text-secondary);"> <span class="text-xs" style="color: var(--color-text-secondary);">
({formatFileSize(file.size)}) ({formatFileSize(file.size)})
</span> </span>
<button class="btn-secondary" style="padding: var(--space-xs) var(--space-sm); font-size: 0.875rem;" on:click={clearFile}> </div>
Clear {:else}
</button> <div style="text-align: center;">
<p style="font-size: 3rem; margin-bottom: var(--space-sm);">🖼️</p>
<p style="margin-bottom: var(--space-xs);">Drag & drop image here</p>
<p class="text-sm" style="color: var(--color-text-secondary); margin-bottom: var(--space-sm);">or click to browse</p>
<p class="text-xs" style="color: var(--color-text-secondary);">Paste with Ctrl+V</p>
</div> </div>
{/if} {/if}
</label>
</div> </div>
{#if file}
<button class="btn-secondary" style="width: 100%; margin-top: var(--space-sm);" on:click={clearFile}>
Clear File
</button>
{/if}
</div>
<!-- Smart Presets -->
{#if file}
<div style="margin-bottom: var(--space-lg);" class="fade-in">
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
Quick Presets
</label>
<div class="presets-grid">
{#each PRESETS as preset}
<button
class="preset-btn"
on:click={() => selectPreset(preset)}
title={preset.description}
>
<span class="preset-icon">{preset.icon}</span>
<span class="preset-name">{preset.name}</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Dimensions --> <!-- Dimensions -->
<div style="margin-bottom: var(--space-lg);"> <div style="margin-bottom: var(--space-lg);">
<h3>Dimensions</h3> <h3>Dimensions</h3>
@@ -258,6 +448,12 @@
⬇️ Transform & Download ⬇️ Transform & Download
{/if} {/if}
</button> </button>
{#if file}
<p class="text-xs" style="color: var(--color-text-secondary); text-align: center; margin-top: var(--space-sm); margin-bottom: 0;">
Press <kbd style="font-size: 0.75rem; padding: 2px 4px;">Enter</kbd> to download
</p>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -271,6 +467,7 @@
<div> <div>
<p style="font-size: 3rem; margin-bottom: var(--space-md)">🖼️</p> <p style="font-size: 3rem; margin-bottom: var(--space-md)">🖼️</p>
<p class="mb-0">Upload an image to see live preview</p> <p class="mb-0">Upload an image to see live preview</p>
<p class="text-sm" style="color: var(--color-text-secondary); margin-top: var(--space-sm);">Drag & drop, click to browse, or paste with Ctrl+V</p>
</div> </div>
</div> </div>
{:else if showPreview} {:else if showPreview}
@@ -337,3 +534,130 @@
{/if} {/if}
</div> </div>
</div> </div>
<style>
/* Drop zone styles */
.drop-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-xl);
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-bg-secondary);
}
.drop-zone:hover {
border-color: var(--color-accent);
background: var(--color-bg-tertiary);
}
.drop-zone.dragging {
border-color: var(--color-accent);
background: var(--color-accent)15;
border-style: solid;
}
.drop-zone-label {
display: block;
cursor: pointer;
}
/* Presets grid */
.presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-sm);
}
.preset-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.75rem;
}
.preset-btn:hover {
border-color: var(--color-accent);
background: var(--color-bg-tertiary);
transform: translateY(-2px);
}
.preset-icon {
font-size: 1.5rem;
}
.preset-name {
font-size: 0.7rem;
text-align: center;
line-height: 1.2;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal-content {
background: var(--color-bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-2xl);
max-width: 500px;
width: 90%;
box-shadow: var(--shadow-2xl);
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-top: var(--space-lg);
}
.shortcut-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm);
background: var(--color-bg-secondary);
border-radius: var(--radius-sm);
}
.shortcut-item span {
flex: 1;
color: var(--color-text-secondary);
}
kbd {
display: inline-block;
padding: 4px 8px;
font-size: 0.875rem;
font-family: monospace;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 2px 0 var(--color-border);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>