Initial first version.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public sealed class ClipboardService : IClipboardService
|
||||
{
|
||||
public async Task CopyLocalFileAsync(string filePath)
|
||||
{
|
||||
bool isGif = ImageDecoding.IsGif(filePath);
|
||||
|
||||
var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
|
||||
|
||||
var data = new DataObject();
|
||||
|
||||
// CF_HDROP: lets Discord/Slack/Teams/Explorer paste the real file (animated, for GIFs).
|
||||
data.SetFileDropList(new StringCollection { filePath });
|
||||
|
||||
// CF_DIB fallback: apps that only accept bitmap paste (e.g. Paint) get a static image.
|
||||
// WPF's DataObject.SetImage auto-converts to CF_DIB - no manual header work needed here.
|
||||
data.SetImage(firstFrame);
|
||||
|
||||
if (isGif)
|
||||
{
|
||||
// CF_HTML: the format most browser/rich-text paste targets use, and the only one of
|
||||
// the three that preserves animation for those targets (CF_DIB is a single frame).
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
var fragment = $"<img src=\"data:image/gif;base64,{Convert.ToBase64String(bytes)}\">";
|
||||
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
|
||||
}
|
||||
|
||||
await SetClipboardWithRetryAsync(data);
|
||||
}
|
||||
|
||||
private static async Task SetClipboardWithRetryAsync(DataObject data)
|
||||
{
|
||||
const int maxAttempts = 5;
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
Clipboard.SetDataObject(data, copy: true);
|
||||
return;
|
||||
}
|
||||
catch (COMException) when (attempt < maxAttempts)
|
||||
{
|
||||
// Another process is transiently holding the clipboard - back off and retry.
|
||||
await Task.Delay(75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CF_HTML clipboard payload. WPF's DataObject.SetData(DataFormats.Html, ...) does
|
||||
/// NOT wrap plain HTML into CF_HTML automatically - the Version/StartHTML/EndHTML/
|
||||
/// StartFragment/EndFragment header with exact byte offsets has to be built by hand, or
|
||||
/// paste targets will reject or mis-render the fragment.
|
||||
/// </summary>
|
||||
private static string BuildCfHtml(string htmlFragment)
|
||||
{
|
||||
const string prefix = "<html><body><!--StartFragment-->";
|
||||
const string suffix = "<!--EndFragment--></body></html>";
|
||||
|
||||
int headerLength = Encoding.UTF8.GetByteCount(FormatHeader(0, 0, 0, 0));
|
||||
int startHtml = headerLength;
|
||||
int startFragment = startHtml + Encoding.UTF8.GetByteCount(prefix);
|
||||
int endFragment = startFragment + Encoding.UTF8.GetByteCount(htmlFragment);
|
||||
int endHtml = endFragment + Encoding.UTF8.GetByteCount(suffix);
|
||||
|
||||
return FormatHeader(startHtml, endHtml, startFragment, endFragment) + prefix + htmlFragment + suffix;
|
||||
}
|
||||
|
||||
private static string FormatHeader(int startHtml, int endHtml, int startFragment, int endFragment) =>
|
||||
"Version:0.9\r\n" +
|
||||
$"StartHTML:{startHtml:D10}\r\n" +
|
||||
$"EndHTML:{endHtml:D10}\r\n" +
|
||||
$"StartFragment:{startFragment:D10}\r\n" +
|
||||
$"EndFragment:{endFragment:D10}\r\n";
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
using EbbesMemeClipboard.Native;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public sealed class GlobalHotkeyService : IGlobalHotkeyService
|
||||
{
|
||||
private const int HotkeyId = 0xB001;
|
||||
private const int HwndMessage = -3;
|
||||
|
||||
private readonly HwndSource _messageSource;
|
||||
private bool _registered;
|
||||
|
||||
public event EventHandler? HotkeyPressed;
|
||||
|
||||
public GlobalHotkeyService()
|
||||
{
|
||||
var parameters = new HwndSourceParameters("EbbesMemeClipboardHotkeyWindow")
|
||||
{
|
||||
WindowStyle = 0,
|
||||
ParentWindow = new IntPtr(HwndMessage),
|
||||
};
|
||||
_messageSource = new HwndSource(parameters);
|
||||
_messageSource.AddHook(WndProc);
|
||||
}
|
||||
|
||||
public bool Register(ModifierKeys modifiers, Key key)
|
||||
{
|
||||
Unregister();
|
||||
|
||||
uint nativeModifiers = ToNativeModifiers(modifiers) | HotkeyConstants.MOD_NOREPEAT;
|
||||
uint virtualKey = (uint)KeyInterop.VirtualKeyFromKey(key);
|
||||
|
||||
_registered = NativeMethods.RegisterHotKey(_messageSource.Handle, HotkeyId, nativeModifiers, virtualKey);
|
||||
return _registered;
|
||||
}
|
||||
|
||||
public void Unregister()
|
||||
{
|
||||
if (_registered)
|
||||
{
|
||||
NativeMethods.UnregisterHotKey(_messageSource.Handle, HotkeyId);
|
||||
_registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == HotkeyConstants.WM_HOTKEY && wParam.ToInt32() == HotkeyId)
|
||||
{
|
||||
HotkeyPressed?.Invoke(this, EventArgs.Empty);
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static uint ToNativeModifiers(ModifierKeys modifiers)
|
||||
{
|
||||
uint result = 0;
|
||||
if (modifiers.HasFlag(ModifierKeys.Alt)) result |= HotkeyConstants.MOD_ALT;
|
||||
if (modifiers.HasFlag(ModifierKeys.Control)) result |= HotkeyConstants.MOD_CONTROL;
|
||||
if (modifiers.HasFlag(ModifierKeys.Shift)) result |= HotkeyConstants.MOD_SHIFT;
|
||||
if (modifiers.HasFlag(ModifierKeys.Windows)) result |= HotkeyConstants.MOD_WIN;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Unregister();
|
||||
_messageSource.RemoveHook(WndProc);
|
||||
_messageSource.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public interface IClipboardService
|
||||
{
|
||||
/// <summary>
|
||||
/// Places a local file on the clipboard using multiple formats simultaneously so both
|
||||
/// file-paste apps (Discord/Slack/Teams) and bitmap-only apps (Paint) get something
|
||||
/// useful, and animated GIFs survive as animations wherever possible.
|
||||
/// </summary>
|
||||
Task CopyLocalFileAsync(string filePath);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public interface IGlobalHotkeyService : IDisposable
|
||||
{
|
||||
event EventHandler? HotkeyPressed;
|
||||
|
||||
/// <summary>Returns false if the combo is already claimed by the OS or another app.</summary>
|
||||
bool Register(ModifierKeys modifiers, Key key);
|
||||
|
||||
void Unregister();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using EbbesMemeClipboard.Models;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public interface ILocalMemeLibraryService
|
||||
{
|
||||
IReadOnlyList<LocalMemeRecord> GetAll();
|
||||
IReadOnlyList<LocalMemeRecord> Search(string query);
|
||||
string GetFullPath(LocalMemeRecord record);
|
||||
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
internal static class ImageDecoding
|
||||
{
|
||||
internal static bool IsGif(string path) =>
|
||||
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes just the first frame of an image (for GIFs, frame 0 only - the rest of the
|
||||
/// animation is never touched). The result is frozen so it can be handed back across
|
||||
/// threads safely when called from a background thread.
|
||||
/// </summary>
|
||||
internal static BitmapSource DecodeFirstFrame(string path, int? decodePixelWidth = null)
|
||||
{
|
||||
if (IsGif(path))
|
||||
{
|
||||
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||
var frame = decoder.Frames[0];
|
||||
frame.Freeze();
|
||||
return frame;
|
||||
}
|
||||
|
||||
var bitmap = new BitmapImage();
|
||||
bitmap.BeginInit();
|
||||
bitmap.UriSource = new Uri(path);
|
||||
bitmap.CacheOption = BitmapCacheOption.OnLoad;
|
||||
if (decodePixelWidth is int width)
|
||||
{
|
||||
bitmap.DecodePixelWidth = width;
|
||||
}
|
||||
bitmap.EndInit();
|
||||
bitmap.Freeze();
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using EbbesMemeClipboard.Models;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
|
||||
{
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".gif",
|
||||
};
|
||||
|
||||
private readonly string _libraryRoot;
|
||||
private readonly string _indexPath;
|
||||
private readonly List<LocalMemeRecord> _records;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public LocalMemeLibraryService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
_libraryRoot = Path.Combine(appData, "EbbesMemeClipboard", "Library");
|
||||
Directory.CreateDirectory(_libraryRoot);
|
||||
_indexPath = Path.Combine(_libraryRoot, "index.json");
|
||||
_records = LoadIndex();
|
||||
}
|
||||
|
||||
public IReadOnlyList<LocalMemeRecord> GetAll() =>
|
||||
_records.OrderByDescending(r => r.DateAdded).ToList();
|
||||
|
||||
public IReadOnlyList<LocalMemeRecord> Search(string query) =>
|
||||
_records
|
||||
.Where(r => r.OriginalFileName.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(r => r.DateAdded)
|
||||
.ToList();
|
||||
|
||||
public string GetFullPath(LocalMemeRecord record) => Path.Combine(_libraryRoot, record.FileName);
|
||||
|
||||
public async Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths)
|
||||
{
|
||||
var imported = new List<LocalMemeRecord>();
|
||||
|
||||
foreach (var sourcePath in sourceFilePaths)
|
||||
{
|
||||
var extension = Path.GetExtension(sourcePath);
|
||||
if (!AllowedExtensions.Contains(extension) || !File.Exists(sourcePath))
|
||||
continue;
|
||||
|
||||
var storedFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||
var destinationPath = Path.Combine(_libraryRoot, storedFileName);
|
||||
|
||||
using (var sourceStream = File.OpenRead(sourcePath))
|
||||
using (var destStream = File.Create(destinationPath))
|
||||
{
|
||||
await sourceStream.CopyToAsync(destStream);
|
||||
}
|
||||
|
||||
var record = new LocalMemeRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
FileName = storedFileName,
|
||||
OriginalFileName = Path.GetFileName(sourcePath),
|
||||
DateAdded = DateTimeOffset.Now,
|
||||
};
|
||||
|
||||
_records.Add(record);
|
||||
imported.Add(record);
|
||||
}
|
||||
|
||||
if (imported.Count > 0)
|
||||
{
|
||||
await SaveIndexAsync();
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
private List<LocalMemeRecord> LoadIndex()
|
||||
{
|
||||
if (!File.Exists(_indexPath))
|
||||
return new List<LocalMemeRecord>();
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_indexPath);
|
||||
return JsonSerializer.Deserialize<List<LocalMemeRecord>>(json, _jsonOptions) ?? new List<LocalMemeRecord>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Corrupt index: start fresh rather than crashing. Previously imported files stay
|
||||
// on disk but drop out of search until re-imported.
|
||||
return new List<LocalMemeRecord>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveIndexAsync()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_records, _jsonOptions);
|
||||
await File.WriteAllTextAsync(_indexPath, json);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user