From b6644ebecf5aab24d2a3d9257131a8ca85a8b817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ebbe=20Ba=C3=9F?= Date: Wed, 19 Aug 2026 10:15:16 +0200 Subject: [PATCH] Added pasting new memes, fixed offsets and changed out tray icon. --- .claude/PROJECT-CONTEXT.md | 11 ++- src/EbbesMemeClipboard/Assets/tray-icon.ico | Bin 766 -> 18201 bytes .../Models/ClipboardImportPayload.cs | 20 +++++ .../Services/ClipboardService.cs | 84 +++++++++++++++++- .../Services/IClipboardService.cs | 8 ++ .../Services/ILocalMemeLibraryService.cs | 4 + .../Services/LocalMemeLibraryService.cs | 21 +++++ .../ViewModels/MemeTileViewModel.cs | 4 +- .../ViewModels/PickerViewModel.cs | 48 ++++++++++ .../Views/PickerWindow.xaml | 14 +-- .../Views/PickerWindow.xaml.cs | 21 +++++ 11 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 src/EbbesMemeClipboard/Models/ClipboardImportPayload.cs 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 c62d7a7d49959909e0fd24684af53cf31327437c..66af16a3f7672995bea37c2b94270b431cdf48ae 100644 GIT binary patch literal 18201 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 QVgLXD07*qoM6N<$f++V8u>b%7 literal 766 zcmZuvyH3O~5S$RCD=6-ylV~WClF}!aDULtG|CG{AKIKYE$`t6#%&r|GV3S?%>?`Bg zK*whsPmp{(0MA@ssU!7`+ToEFNX-2&iAiQs@`aWzHDOs6CW^f%MIM!pLNX@j7-OhD zmpJ8|=SvGjA_Zzi?~nnfJT;uHV@uZprDx7p6(=Zdlg@G+2gG>6WY{cC4yp`_n#WqX zoeY%{p4gzG#&4=P&-AJ>Hm4V2GghCCgflTZa_1lXJD-F4%@~P16S^_hLRE}!16+em z-2&S0v>|;I+SMld!^YSnO79E&0JaI9=5f_(PqV|$Z-1aY|G&-pW6FIfWj&O#bh-`i wci{R33@dQ_1P;<0Ni|(~9e0YI-?bX{^y +/// 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;