Add drag & drop, smart presets, and keyboard shortcuts
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { transformImage } from "./lib/api";
|
||||
import {
|
||||
generateClientPreview,
|
||||
@@ -9,6 +10,7 @@
|
||||
type TransformOptions
|
||||
} from "./lib/preview";
|
||||
import { theme } from "./lib/theme";
|
||||
import { PRESETS, applyPreset, type Preset } from "./lib/presets";
|
||||
|
||||
let file: File | null = null;
|
||||
let filePreviewUrl: string | null = null;
|
||||
@@ -22,6 +24,10 @@
|
||||
let processing = false;
|
||||
let error: string | null = null;
|
||||
|
||||
// Drag & drop state
|
||||
let isDragging = false;
|
||||
let showShortcuts = false;
|
||||
|
||||
// Preview state
|
||||
let previewUrl: string | null = null;
|
||||
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) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
file = target.files?.[0] || null;
|
||||
if (file) {
|
||||
filePreviewUrl = URL.createObjectURL(file);
|
||||
} else {
|
||||
filePreviewUrl = null;
|
||||
const selectedFile = target.files?.[0];
|
||||
if (selectedFile) {
|
||||
handleFile(selectedFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
</script>
|
||||
|
||||
@@ -122,6 +229,10 @@
|
||||
<h1 class="mb-0">PNGer</h1>
|
||||
<p class="text-sm mb-0">Modern PNG Editor & Resizer</p>
|
||||
</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()}>
|
||||
{#if $theme === 'dark'}
|
||||
☀️ Light
|
||||
@@ -129,8 +240,43 @@
|
||||
🌙 Dark
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</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 -->
|
||||
<div class="card fade-in" style="margin-bottom: var(--space-xl);">
|
||||
<div class="grid grid-cols-2 gap-lg">
|
||||
@@ -138,30 +284,74 @@
|
||||
<div>
|
||||
<h2>Upload & Settings</h2>
|
||||
|
||||
<!-- File Upload -->
|
||||
<!-- Drag & Drop / File Upload -->
|
||||
<div style="margin-bottom: var(--space-lg);">
|
||||
<label style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">
|
||||
Select Image
|
||||
Select or Drop Image
|
||||
</label>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone {isDragging ? 'dragging' : ''}"
|
||||
on:dragover={onDragOver}
|
||||
on:dragleave={onDragLeave}
|
||||
on:drop={onDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
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}
|
||||
<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-xs" style="color: var(--color-text-secondary);">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
<button class="btn-secondary" style="padding: var(--space-xs) var(--space-sm); font-size: 0.875rem;" on:click={clearFile}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
</label>
|
||||
</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 -->
|
||||
<div style="margin-bottom: var(--space-lg);">
|
||||
<h3>Dimensions</h3>
|
||||
@@ -258,6 +448,12 @@
|
||||
⬇️ Transform & Download
|
||||
{/if}
|
||||
</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>
|
||||
@@ -271,6 +467,7 @@
|
||||
<div>
|
||||
<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="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>
|
||||
{:else if showPreview}
|
||||
@@ -337,3 +534,130 @@
|
||||
{/if}
|
||||
</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>
|
||||
Reference in New Issue
Block a user