Added pasting new memes, fixed offsets and changed out tray icon.
This commit is contained in:
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;
|
||||
|
||||
Reference in New Issue
Block a user