Added pasting new memes, fixed offsets and changed out tray icon.

This commit is contained in:
Ebbe Baß
2026-08-19 10:15:16 +02:00
parent 4c755919d4
commit b6644ebecf
11 changed files with 226 additions and 9 deletions
+10 -1
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,20 @@
namespace EbbesMemeClipboard.Models;
/// <summary>
/// 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.
/// </summary>
public sealed class ClipboardImportPayload
{
/// <summary>Set when the clipboard held actual files; import these directly.</summary>
public IReadOnlyList<string>? FilePaths { get; init; }
/// <summary>Set when the clipboard held image data rather than files.</summary>
public byte[]? Data { get; init; }
/// <summary>Extension matching <see cref="Data"/>, including the dot (e.g. ".png").</summary>
public string? Extension { get; init; }
public bool HasFiles => FilePaths is { Count: > 0 };
public bool HasBytes => Data is { Length: > 0 } && Extension is not null;
}
@@ -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" };
/// <summary>
/// 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.
/// </summary>
public ClipboardImportPayload? TryReadImage()
{
// 1. Real files (copied in Explorer) - byte-for-byte exact, animation intact.
if (Clipboard.ContainsFileDropList())
{
var paths = Clipboard.GetFileDropList()
.Cast<string>()
.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/(?<mime>gif|png|jpe?g);base64,(?<data>[A-Za-z0-9+/=]+)", RegexOptions.IgnoreCase)]
private static partial Regex DataUriRegex();
private static async Task SetClipboardWithRetryAsync(DataObject data)
{
const int maxAttempts = 5;
@@ -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.
/// </summary>
Task CopyLocalFileAsync(string filePath);
/// <summary>
/// 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).
/// </summary>
ClipboardImportPayload? TryReadImage();
}
@@ -8,5 +8,9 @@ public interface ILocalMemeLibraryService
IReadOnlyList<LocalMemeRecord> Search(string query);
string GetFullPath(LocalMemeRecord record);
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
/// <summary>Imports image data that has no source file (e.g. a pasted screenshot).</summary>
Task<LocalMemeRecord?> ImportBytesAsync(byte[] data, string extension, string originalFileName);
Task RemoveAsync(LocalMemeRecord record);
}
@@ -75,6 +75,27 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
return imported;
}
public async Task<LocalMemeRecord?> 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);
@@ -11,7 +11,9 @@ namespace EbbesMemeClipboard.ViewModels;
/// </summary>
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;
@@ -188,6 +188,54 @@ public partial class PickerViewModel : ObservableObject
}
}
/// <summary>
/// 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).
/// </summary>
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<string> paths)
{
var candidates = paths
@@ -75,9 +75,11 @@
</Setter>
</Style>
<!-- Shared by the favourites row and the main grid so both behave identically. -->
<!-- Shared by the favourites row and the main grid so both behave identically.
No fixed Width: the tile stretches to fill its UniformGrid cell, so a row always
spans the full panel width instead of leaving a ragged gap on the right. -->
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}">
<Grid Width="88" Height="88" Margin="4">
<Grid Height="118" Margin="4">
<Button Padding="0"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
@@ -96,7 +98,7 @@
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="5"/>
</Button>
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
@@ -174,7 +176,7 @@
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
@@ -182,7 +184,7 @@
</StackPanel>
<Grid>
<TextBlock Text="No memes yet. Click + or drag files in here."
<TextBlock Text="No memes yet. Click +, drag files in, or paste with Ctrl+V."
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260"
@@ -207,7 +209,7 @@
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
@@ -108,6 +108,27 @@ public partial class PickerWindow : Window
if (e.Key == Key.Escape) Hide();
}
/// <summary>
/// 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.
/// </summary>
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;