Added pasting and sending features
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using EbbesMemeClipboard.Native;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public sealed class AutoPasteService : IAutoPasteService
|
||||
{
|
||||
// Both delays exist because target apps (Discord/Slack/Teams etc. especially) need a moment
|
||||
// to actually process regaining focus, and then again to process the pasted image, before
|
||||
// they're ready for the next synthetic keystroke - firing too soon is a common cause of the
|
||||
// paste (or the follow-up Enter) silently getting dropped.
|
||||
private static readonly TimeSpan FocusSettleDelay = TimeSpan.FromMilliseconds(200);
|
||||
private static readonly TimeSpan PasteSettleDelay = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private IntPtr _capturedForeground;
|
||||
|
||||
public void CaptureForegroundWindow() => _capturedForeground = NativeMethods.GetForegroundWindow();
|
||||
|
||||
public async Task PasteAsync(bool alsoSend)
|
||||
{
|
||||
var target = _capturedForeground;
|
||||
if (target == IntPtr.Zero || !RestoreForeground(target))
|
||||
{
|
||||
// Couldn't confirm we got the right window focused - the clipboard write already
|
||||
// succeeded regardless, so bail rather than risk sending keystrokes somewhere else.
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(FocusSettleDelay);
|
||||
InputSimulation.SendKeyCombo(InputSimulation.VK_CONTROL, InputSimulation.VK_V);
|
||||
|
||||
if (alsoSend)
|
||||
{
|
||||
await Task.Delay(PasteSettleDelay);
|
||||
InputSimulation.SendKeyTap(InputSimulation.VK_RETURN);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RestoreForeground(IntPtr target)
|
||||
{
|
||||
// No synthetic Alt tap here (a common trick to "unlock" SetForegroundWindow when focus-
|
||||
// stealing prevention would otherwise block it) - it isn't needed in our case, since the
|
||||
// picker window is already the foreground process at this point (from Activate() moments
|
||||
// earlier), which is itself one of the documented conditions Windows exempts from that
|
||||
// restriction. It was also actively harmful for Electron/Chromium targets like Teams:
|
||||
// those apps often treat a lone Alt press as "toggle menu bar focus", which knocked their
|
||||
// compose box's own internal focus away right before the paste - the window still came
|
||||
// back to the foreground correctly, but Ctrl+V had nothing focused to land in.
|
||||
uint targetThread = NativeMethods.GetWindowThreadProcessId(target, out _);
|
||||
uint currentThread = NativeMethods.GetCurrentThreadId();
|
||||
|
||||
bool attached = targetThread != currentThread &&
|
||||
NativeMethods.AttachThreadInput(currentThread, targetThread, true);
|
||||
try
|
||||
{
|
||||
NativeMethods.SetForegroundWindow(target);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (attached)
|
||||
{
|
||||
NativeMethods.AttachThreadInput(currentThread, targetThread, false);
|
||||
}
|
||||
}
|
||||
|
||||
return NativeMethods.GetForegroundWindow() == target;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
@@ -10,8 +11,6 @@ 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();
|
||||
@@ -23,18 +22,36 @@ public sealed class ClipboardService : IClipboardService
|
||||
// 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));
|
||||
}
|
||||
// "PNG": a registered clipboard format (not one of the classic CF_* constants) that
|
||||
// Chromium-based apps specifically look for and prefer over CF_DIB/CF_BITMAP when
|
||||
// reading a pasted image, because those older formats are lossy for transparency. This
|
||||
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
|
||||
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
|
||||
using var pngStream = new MemoryStream();
|
||||
var pngEncoder = new PngBitmapEncoder();
|
||||
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
|
||||
pngEncoder.Save(pngStream);
|
||||
pngStream.Position = 0;
|
||||
data.SetData("PNG", pngStream);
|
||||
|
||||
// CF_HTML: the other route those same web-based paste targets check, and the only one of
|
||||
// these formats that preserves animation for them, for GIFs.
|
||||
var bytes = await File.ReadAllBytesAsync(filePath);
|
||||
var fragment = $"<img src=\"data:{GetMimeType(filePath)};base64,{Convert.ToBase64String(bytes)}\">";
|
||||
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
|
||||
|
||||
await SetClipboardWithRetryAsync(data);
|
||||
}
|
||||
|
||||
private static string GetMimeType(string filePath) =>
|
||||
Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
private static async Task SetClipboardWithRetryAsync(DataObject data)
|
||||
{
|
||||
const int maxAttempts = 5;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
internal static class HotkeyFormatter
|
||||
{
|
||||
internal static string Format(ModifierKeys modifiers, Key key)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
if (modifiers.HasFlag(ModifierKeys.Control)) sb.Append("Ctrl + ");
|
||||
if (modifiers.HasFlag(ModifierKeys.Alt)) sb.Append("Alt + ");
|
||||
if (modifiers.HasFlag(ModifierKeys.Shift)) sb.Append("Shift + ");
|
||||
if (modifiers.HasFlag(ModifierKeys.Windows)) sb.Append("Win + ");
|
||||
sb.Append(key);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public interface IAutoPasteService
|
||||
{
|
||||
/// <summary>Remembers whatever window was focused before the picker is shown, so it can be restored afterward.</summary>
|
||||
void CaptureForegroundWindow();
|
||||
|
||||
/// <summary>Restores focus to the captured window and pastes (Ctrl+V); optionally also presses Enter to send it.</summary>
|
||||
Task PasteAsync(bool alsoSend);
|
||||
}
|
||||
@@ -8,4 +8,5 @@ public interface ILocalMemeLibraryService
|
||||
IReadOnlyList<LocalMemeRecord> Search(string query);
|
||||
string GetFullPath(LocalMemeRecord record);
|
||||
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
|
||||
Task RemoveAsync(LocalMemeRecord record);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using EbbesMemeClipboard.Models;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public interface ISettingsService
|
||||
{
|
||||
AppSettings Current { get; }
|
||||
Task SaveAsync();
|
||||
}
|
||||
@@ -75,6 +75,31 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
|
||||
return imported;
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(LocalMemeRecord record)
|
||||
{
|
||||
var path = GetFullPath(record);
|
||||
|
||||
_records.RemoveAll(r => r.Id == record.Id);
|
||||
await SaveIndexAsync();
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// Recycle Bin, not a hard delete - a misclick on "Remove" shouldn't be unrecoverable.
|
||||
Microsoft.VisualBasic.FileIO.FileSystem.DeleteFile(
|
||||
path,
|
||||
Microsoft.VisualBasic.FileIO.UIOption.OnlyErrorDialogs,
|
||||
Microsoft.VisualBasic.FileIO.RecycleOption.SendToRecycleBin);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File locked or otherwise inaccessible - it's already out of the index (what the
|
||||
// picker shows), so leave the orphaned file for manual cleanup rather than failing.
|
||||
}
|
||||
}
|
||||
|
||||
private List<LocalMemeRecord> LoadIndex()
|
||||
{
|
||||
if (!File.Exists(_indexPath))
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using EbbesMemeClipboard.Models;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public sealed class SettingsService : ISettingsService
|
||||
{
|
||||
private readonly string _settingsPath;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
public AppSettings Current { get; }
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var dir = Path.Combine(appData, "EbbesMemeClipboard");
|
||||
Directory.CreateDirectory(dir);
|
||||
_settingsPath = Path.Combine(dir, "settings.json");
|
||||
Current = Load();
|
||||
}
|
||||
|
||||
private AppSettings Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
return new AppSettings();
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
return JsonSerializer.Deserialize<AppSettings>(json, _jsonOptions) ?? new AppSettings();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Current, _jsonOptions);
|
||||
await File.WriteAllTextAsync(_settingsPath, json);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user