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;