diff --git a/.claude/PROJECT-CONTEXT.md b/.claude/PROJECT-CONTEXT.md index c9ac02c..d98a56d 100644 --- a/.claude/PROJECT-CONTEXT.md +++ b/.claude/PROJECT-CONTEXT.md @@ -26,7 +26,7 @@ Everything below is **implemented and verified working by actually running the a - Tray icon with menu: **Open / Settings / Exit** - Popup picker: search box, thumbnail grid, drag-to-move via top handle strip, Esc / click-away to dismiss, positions near cursor clamped to the current monitor -- Add memes: `+` button (file dialog), drag-and-drop onto picker, jpg/png/gif +- Add memes: `+` button (file dialog), drag-and-drop onto picker, **Ctrl+V paste**, jpg/png/gif - Search filters by filename (case-insensitive substring) - Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified) - Multi-format clipboard write (see "Clipboard formats" below) @@ -160,6 +160,15 @@ code-behind fields. Look elements up by `Tag`/traversal instead. | `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it. **Deliberately omitted for GIFs** — see below | | `CF_HTML` | The other route web-based compose boxes check; carries a base64 `data:` URI | +**Reading the clipboard (Ctrl+V to import) uses the same knowledge in reverse.** +`ClipboardService.TryReadImage()` checks formats in descending fidelity order: FileDrop → +CF_HTML `data:` URI → `"PNG"` → CF_DIB bitmap. The order is not cosmetic: a GIF is only +still animated in the first two, so a naive implementation that reached for the bitmap +first would silently flatten every pasted GIF. Verified byte-identical (SHA-256) import for +a pasted GIF file. Ctrl+V is handled in `PickerWindow.OnPreviewKeyDown` and only swallows +the keystroke when an image is actually present — plain text still pastes into the search +box as normal. + **The `"PNG"` format is skipped for GIFs on purpose.** A PNG can only hold one static frame, and since Chromium *prefers* that format over all others, offering it for a GIF is exactly what made animated GIFs paste as a still first frame. Omitting it lets those targets fall diff --git a/src/EbbesMemeClipboard/Assets/tray-icon.ico b/src/EbbesMemeClipboard/Assets/tray-icon.ico index c62d7a7..66af16a 100644 Binary files a/src/EbbesMemeClipboard/Assets/tray-icon.ico and b/src/EbbesMemeClipboard/Assets/tray-icon.ico differ diff --git a/src/EbbesMemeClipboard/Models/ClipboardImportPayload.cs b/src/EbbesMemeClipboard/Models/ClipboardImportPayload.cs new file mode 100644 index 0000000..8f0809e --- /dev/null +++ b/src/EbbesMemeClipboard/Models/ClipboardImportPayload.cs @@ -0,0 +1,20 @@ +namespace EbbesMemeClipboard.Models; + +/// +/// An importable image found on the clipboard. Either real files (copied in Explorer) or raw +/// bytes (a screenshot, or an image copied out of a browser) - never both. +/// +public sealed class ClipboardImportPayload +{ + /// Set when the clipboard held actual files; import these directly. + public IReadOnlyList? FilePaths { get; init; } + + /// Set when the clipboard held image data rather than files. + public byte[]? Data { get; init; } + + /// Extension matching , including the dot (e.g. ".png"). + public string? Extension { get; init; } + + public bool HasFiles => FilePaths is { Count: > 0 }; + public bool HasBytes => Data is { Length: > 0 } && Extension is not null; +} diff --git a/src/EbbesMemeClipboard/Services/ClipboardService.cs b/src/EbbesMemeClipboard/Services/ClipboardService.cs index f393b44..ec0cf84 100644 --- a/src/EbbesMemeClipboard/Services/ClipboardService.cs +++ b/src/EbbesMemeClipboard/Services/ClipboardService.cs @@ -2,12 +2,14 @@ using System.Collections.Specialized; using System.IO; using System.Runtime.InteropServices; using System.Text; +using System.Text.RegularExpressions; using System.Windows; using System.Windows.Media.Imaging; +using EbbesMemeClipboard.Models; namespace EbbesMemeClipboard.Services; -public sealed class ClipboardService : IClipboardService +public sealed partial class ClipboardService : IClipboardService { public async Task CopyLocalFileAsync(string filePath) { @@ -60,6 +62,86 @@ public sealed class ClipboardService : IClipboardService _ => "application/octet-stream", }; + private static readonly string[] ImportableExtensions = { ".jpg", ".jpeg", ".png", ".gif" }; + + /// + /// Formats are checked in descending order of fidelity, which matters a lot here: a GIF is + /// only still animated in the first two. Falling straight to the bitmap (as a naive + /// implementation would) silently flattens every pasted GIF to one frame. + /// + public ClipboardImportPayload? TryReadImage() + { + // 1. Real files (copied in Explorer) - byte-for-byte exact, animation intact. + if (Clipboard.ContainsFileDropList()) + { + var paths = Clipboard.GetFileDropList() + .Cast() + .Where(p => !string.IsNullOrWhiteSpace(p) && + ImportableExtensions.Contains(Path.GetExtension(p).ToLowerInvariant())) + .ToList(); + + if (paths.Count > 0) + { + return new ClipboardImportPayload { FilePaths = paths }; + } + } + + // 2. CF_HTML with an embedded data: URI - how a real (possibly animated) GIF arrives + // when copied out of a browser or out of this app itself. + if (Clipboard.ContainsText(TextDataFormat.Html)) + { + var match = DataUriRegex().Match(Clipboard.GetText(TextDataFormat.Html)); + if (match.Success) + { + try + { + var bytes = Convert.FromBase64String(match.Groups["data"].Value); + var ext = match.Groups["mime"].Value.ToLowerInvariant() switch + { + "gif" => ".gif", + "jpeg" or "jpg" => ".jpg", + _ => ".png", + }; + return new ClipboardImportPayload { Data = bytes, Extension = ext }; + } + catch (FormatException) + { + // Malformed base64 - fall through to the bitmap formats below. + } + } + } + + // 3. The registered "PNG" format - lossless and keeps transparency, unlike CF_DIB. + if (Clipboard.ContainsData("PNG") && Clipboard.GetData("PNG") is { } pngData) + { + byte[]? bytes = pngData switch + { + MemoryStream ms => ms.ToArray(), + byte[] raw => raw, + _ => null, + }; + if (bytes is { Length: > 0 }) + { + return new ClipboardImportPayload { Data = bytes, Extension = ".png" }; + } + } + + // 4. Plain bitmap (Snipping Tool, Paint) - always static, so it's the last resort. + if (Clipboard.ContainsImage() && Clipboard.GetImage() is { } bitmap) + { + using var stream = new MemoryStream(); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + encoder.Save(stream); + return new ClipboardImportPayload { Data = stream.ToArray(), Extension = ".png" }; + } + + return null; + } + + [GeneratedRegex(@"data:image/(?gif|png|jpe?g);base64,(?[A-Za-z0-9+/=]+)", RegexOptions.IgnoreCase)] + private static partial Regex DataUriRegex(); + private static async Task SetClipboardWithRetryAsync(DataObject data) { const int maxAttempts = 5; diff --git a/src/EbbesMemeClipboard/Services/IClipboardService.cs b/src/EbbesMemeClipboard/Services/IClipboardService.cs index 482b220..befe85d 100644 --- a/src/EbbesMemeClipboard/Services/IClipboardService.cs +++ b/src/EbbesMemeClipboard/Services/IClipboardService.cs @@ -1,3 +1,5 @@ +using EbbesMemeClipboard.Models; + namespace EbbesMemeClipboard.Services; public interface IClipboardService @@ -8,4 +10,10 @@ public interface IClipboardService /// useful, and animated GIFs survive as animations wherever possible. /// Task CopyLocalFileAsync(string filePath); + + /// + /// Reads an importable image off the clipboard, preferring the highest-fidelity format + /// available. Returns null when the clipboard holds no image (e.g. plain text). + /// + ClipboardImportPayload? TryReadImage(); } diff --git a/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs b/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs index 995f272..a585aa7 100644 --- a/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs +++ b/src/EbbesMemeClipboard/Services/ILocalMemeLibraryService.cs @@ -8,5 +8,9 @@ public interface ILocalMemeLibraryService IReadOnlyList Search(string query); string GetFullPath(LocalMemeRecord record); Task> ImportAsync(IEnumerable sourceFilePaths); + + /// Imports image data that has no source file (e.g. a pasted screenshot). + Task ImportBytesAsync(byte[] data, string extension, string originalFileName); + Task RemoveAsync(LocalMemeRecord record); } diff --git a/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs b/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs index 8d76246..ab56a13 100644 --- a/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs +++ b/src/EbbesMemeClipboard/Services/LocalMemeLibraryService.cs @@ -75,6 +75,27 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService return imported; } + public async Task ImportBytesAsync(byte[] data, string extension, string originalFileName) + { + if (!AllowedExtensions.Contains(extension) || data.Length == 0) + return null; + + var storedFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}"; + await File.WriteAllBytesAsync(Path.Combine(_libraryRoot, storedFileName), data); + + var record = new LocalMemeRecord + { + Id = Guid.NewGuid().ToString("N"), + FileName = storedFileName, + OriginalFileName = originalFileName, + DateAdded = DateTimeOffset.Now, + }; + + _records.Add(record); + await SaveIndexAsync(); + return record; + } + public async Task RemoveAsync(LocalMemeRecord record) { var path = GetFullPath(record); diff --git a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs index 9498cba..f9135a9 100644 --- a/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/MemeTileViewModel.cs @@ -11,7 +11,9 @@ namespace EbbesMemeClipboard.ViewModels; /// public partial class MemeTileViewModel : ObservableObject { - private const int ThumbnailPixelWidth = 150; + // Comfortably above the ~126px logical tile width so thumbnails stay sharp at 150-200% + // display scaling, without decoding anything near full resolution. + private const int ThumbnailPixelWidth = 240; private readonly IGifCacheService? _cache; private string? _localPath; diff --git a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs index f63a86b..9ae7f35 100644 --- a/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs +++ b/src/EbbesMemeClipboard/ViewModels/PickerViewModel.cs @@ -188,6 +188,54 @@ public partial class PickerViewModel : ObservableObject } } + /// + /// Synchronous peek so the Ctrl+V key handler can decide immediately whether to swallow the + /// keystroke (image on the clipboard) or let it through to the search box (plain text). + /// + public ClipboardImportPayload? ReadClipboardImage() + { + try + { + return _clipboard.TryReadImage(); + } + catch (Exception) + { + // Another process holding the clipboard shouldn't break the keystroke entirely. + return null; + } + } + + public async Task ImportClipboardAsync(ClipboardImportPayload payload) + { + try + { + if (payload.HasFiles) + { + await ImportPathsAsync(payload.FilePaths!); + return; + } + + if (!payload.HasBytes) return; + + var name = $"pasted-{DateTime.Now:yyyy-MM-dd-HHmmss}{payload.Extension}"; + var record = await _library.ImportBytesAsync(payload.Data!, payload.Extension!, name); + + if (record is null) + { + StatusMessage = "Couldn't add that image."; + return; + } + + ActiveSource = MemeSource.Local; + RefreshLocalItems(); + StatusMessage = $"Pasted {name}"; + } + catch (Exception ex) + { + StatusMessage = $"Couldn't paste image: {ex.Message}"; + } + } + public async Task ImportPathsAsync(IEnumerable paths) { var candidates = paths diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml b/src/EbbesMemeClipboard/Views/PickerWindow.xaml index d6dc95a..dee6adb 100644 --- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml +++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml @@ -75,9 +75,11 @@ - + - + @@ -174,7 +176,7 @@ ItemTemplate="{StaticResource MemeTileTemplate}"> - + @@ -182,7 +184,7 @@ - - + diff --git a/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs b/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs index 09060d7..a674eb3 100644 --- a/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs +++ b/src/EbbesMemeClipboard/Views/PickerWindow.xaml.cs @@ -108,6 +108,27 @@ public partial class PickerWindow : Window if (e.Key == Key.Escape) Hide(); } + /// + /// Ctrl+V adds whatever image is on the clipboard to the local library. The clipboard is + /// inspected synchronously so the decision to swallow the keystroke can be made before the + /// event reaches the search box - when the clipboard holds plain text instead, the paste is + /// deliberately left alone so it still lands in the search field as normal. + /// + protected override void OnPreviewKeyDown(KeyEventArgs e) + { + if (e.Key == Key.V && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control) + { + var payload = _viewModel.ReadClipboardImage(); + if (payload is not null) + { + e.Handled = true; + _ = _viewModel.ImportClipboardAsync(payload); + } + } + + base.OnPreviewKeyDown(e); + } + private void PickerWindow_OnDragOver(object sender, DragEventArgs e) { e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;