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