Added pasting and sending features
This commit is contained in:
@@ -3,18 +3,25 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
|
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
<tb:TaskbarIcon x:Key="TrayIcon"
|
<tb:TaskbarIcon x:Key="TrayIcon"
|
||||||
IconSource="/Assets/tray-icon.ico"
|
IconSource="/Assets/tray-icon.ico"
|
||||||
ToolTipText="Ebbe's Meme Clipboard"
|
ToolTipText="Ebbe's Meme Clipboard"
|
||||||
|
Visibility="Visible"
|
||||||
MenuActivation="RightClick">
|
MenuActivation="RightClick">
|
||||||
<tb:TaskbarIcon.ContextMenu>
|
<tb:TaskbarIcon.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="Open Picker" Click="TrayOpenPicker_Click"/>
|
<MenuItem Header="Open" Click="TrayOpenPicker_Click"/>
|
||||||
<MenuItem Header="Add Memes..." Click="TrayAddMemes_Click"/>
|
<MenuItem Header="Settings" Click="TraySettings_Click"/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Exit" Click="TrayExit_Click"/>
|
<MenuItem Header="Exit" Click="TrayExit_Click"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</tb:TaskbarIcon.ContextMenu>
|
</tb:TaskbarIcon.ContextMenu>
|
||||||
</tb:TaskbarIcon>
|
</tb:TaskbarIcon>
|
||||||
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Input;
|
|
||||||
using EbbesMemeClipboard.Services;
|
using EbbesMemeClipboard.Services;
|
||||||
using EbbesMemeClipboard.ViewModels;
|
using EbbesMemeClipboard.ViewModels;
|
||||||
using EbbesMemeClipboard.Views;
|
using EbbesMemeClipboard.Views;
|
||||||
@@ -10,13 +9,11 @@ namespace EbbesMemeClipboard;
|
|||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private static readonly ModifierKeys DefaultHotkeyModifiers = ModifierKeys.Control | ModifierKeys.Alt;
|
|
||||||
private const Key DefaultHotkeyKey = Key.M;
|
|
||||||
|
|
||||||
private IServiceProvider _services = null!;
|
private IServiceProvider _services = null!;
|
||||||
private IGlobalHotkeyService _hotkeyService = null!;
|
private IGlobalHotkeyService _hotkeyService = null!;
|
||||||
|
private ISettingsService _settingsService = null!;
|
||||||
private PickerWindow _pickerWindow = null!;
|
private PickerWindow _pickerWindow = null!;
|
||||||
private PickerViewModel _pickerViewModel = null!;
|
private SettingsWindow _settingsWindow = null!;
|
||||||
private TaskbarIcon? _trayIcon;
|
private TaskbarIcon? _trayIcon;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
@@ -28,26 +25,45 @@ public partial class App : Application
|
|||||||
services.AddSingleton<ILocalMemeLibraryService, LocalMemeLibraryService>();
|
services.AddSingleton<ILocalMemeLibraryService, LocalMemeLibraryService>();
|
||||||
services.AddSingleton<IClipboardService, ClipboardService>();
|
services.AddSingleton<IClipboardService, ClipboardService>();
|
||||||
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
|
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
|
||||||
|
services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
|
services.AddSingleton<IAutoPasteService, AutoPasteService>();
|
||||||
services.AddSingleton<PickerViewModel>();
|
services.AddSingleton<PickerViewModel>();
|
||||||
services.AddSingleton<PickerWindow>();
|
services.AddSingleton<PickerWindow>();
|
||||||
|
services.AddSingleton<SettingsViewModel>();
|
||||||
|
services.AddSingleton<SettingsWindow>();
|
||||||
_services = services.BuildServiceProvider();
|
_services = services.BuildServiceProvider();
|
||||||
|
|
||||||
_pickerViewModel = _services.GetRequiredService<PickerViewModel>();
|
_settingsService = _services.GetRequiredService<ISettingsService>();
|
||||||
_pickerWindow = _services.GetRequiredService<PickerWindow>();
|
_pickerWindow = _services.GetRequiredService<PickerWindow>();
|
||||||
|
_settingsWindow = _services.GetRequiredService<SettingsWindow>();
|
||||||
|
|
||||||
|
// Shell_NotifyIcon registration silently no-ops if no window in the process has a real
|
||||||
|
// Win32 handle yet, which is otherwise the case here until the picker is first shown -
|
||||||
|
// force the (still invisible) picker window's handle to exist before creating the tray
|
||||||
|
// icon. Found by comparing tray state via UI Automation with/without this line; there's
|
||||||
|
// no exception either way; the icon just never reaches the shell without it.
|
||||||
|
new System.Windows.Interop.WindowInteropHelper(_pickerWindow).EnsureHandle();
|
||||||
|
|
||||||
_trayIcon = (TaskbarIcon)Resources["TrayIcon"];
|
_trayIcon = (TaskbarIcon)Resources["TrayIcon"];
|
||||||
|
// TaskbarIcon normally creates its Win32 icon in response to its own Loaded event, which
|
||||||
|
// only fires for elements that are part of a visual tree. Declared here as an
|
||||||
|
// Application.Resources entry (so it lives for the whole app, with no host window
|
||||||
|
// needed), it never gets a Loaded event, so the icon has to be created explicitly.
|
||||||
|
_trayIcon.ForceCreate(enablesEfficiencyMode: false);
|
||||||
_trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
|
_trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
|
||||||
|
|
||||||
_hotkeyService = _services.GetRequiredService<IGlobalHotkeyService>();
|
_hotkeyService = _services.GetRequiredService<IGlobalHotkeyService>();
|
||||||
if (_hotkeyService.Register(DefaultHotkeyModifiers, DefaultHotkeyKey))
|
var hotkey = _settingsService.Current;
|
||||||
|
if (_hotkeyService.Register(hotkey.HotkeyModifiers, hotkey.HotkeyKey))
|
||||||
{
|
{
|
||||||
_hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility());
|
_hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
"Couldn't register the global hotkey (Ctrl+Alt+M) - it may already be in use by another application. " +
|
$"Couldn't register the global hotkey ({HotkeyFormatter.Format(hotkey.HotkeyModifiers, hotkey.HotkeyKey)}) - " +
|
||||||
"You can still open the picker from the tray icon.",
|
"it may already be in use by another application. You can still open the picker from the tray icon, " +
|
||||||
|
"or pick a different shortcut in Settings.",
|
||||||
"Ebbe's Meme Clipboard",
|
"Ebbe's Meme Clipboard",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Warning);
|
MessageBoxImage.Warning);
|
||||||
@@ -56,10 +72,10 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
|
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
|
||||||
|
|
||||||
private async void TrayAddMemes_Click(object sender, RoutedEventArgs e)
|
private void TraySettings_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_pickerWindow.ShowNearCursor();
|
_settingsWindow.Show();
|
||||||
await _pickerViewModel.ImportFilesCommand.ExecuteAsync(null);
|
_settingsWindow.Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
|
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
public sealed class AppSettings
|
||||||
|
{
|
||||||
|
public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow;
|
||||||
|
public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt;
|
||||||
|
public Key HotkeyKey { get; set; } = Key.M;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
public enum InsertMode
|
||||||
|
{
|
||||||
|
/// <summary>Just copy the meme to the clipboard; the user pastes it manually.</summary>
|
||||||
|
CopyOnly,
|
||||||
|
|
||||||
|
/// <summary>Copy to the clipboard, then paste it into whatever app was focused before the picker opened.</summary>
|
||||||
|
PasteIntoActiveWindow,
|
||||||
|
|
||||||
|
/// <summary>Same as PasteIntoActiveWindow, then also press Enter to submit/send it.</summary>
|
||||||
|
PasteAndSend,
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Native;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct KEYBDINPUT
|
||||||
|
{
|
||||||
|
internal ushort wVk;
|
||||||
|
internal ushort wScan;
|
||||||
|
internal uint dwFlags;
|
||||||
|
internal uint time;
|
||||||
|
internal IntPtr dwExtraInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real Win32 INPUT union also has MOUSEINPUT/HARDWAREINPUT members, which are what actually
|
||||||
|
// determine its size (32 bytes on x64). SendInput validates cbSize against that real size, so the
|
||||||
|
// union must be padded to 32 bytes even though only the keyboard member is used here - a smaller
|
||||||
|
// struct silently fails every call with ERROR_INVALID_PARAMETER.
|
||||||
|
[StructLayout(LayoutKind.Explicit, Size = 32)]
|
||||||
|
internal struct InputUnion
|
||||||
|
{
|
||||||
|
[FieldOffset(0)] internal KEYBDINPUT ki;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct INPUT
|
||||||
|
{
|
||||||
|
internal uint type;
|
||||||
|
internal InputUnion U;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class InputSimulation
|
||||||
|
{
|
||||||
|
internal const ushort VK_CONTROL = 0x11;
|
||||||
|
internal const ushort VK_MENU = 0x12;
|
||||||
|
internal const ushort VK_RETURN = 0x0D;
|
||||||
|
internal const ushort VK_V = 0x56;
|
||||||
|
|
||||||
|
private const uint InputKeyboard = 1;
|
||||||
|
private const uint KeyEventFKeyUp = 0x0002;
|
||||||
|
|
||||||
|
internal static void SendKeyTap(ushort vk) => Send(Down(vk), Up(vk));
|
||||||
|
|
||||||
|
internal static void SendKeyCombo(ushort vk1, ushort vk2) => Send(Down(vk1), Down(vk2), Up(vk2), Up(vk1));
|
||||||
|
|
||||||
|
private static void Send(params INPUT[] inputs) =>
|
||||||
|
NativeMethods.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT)));
|
||||||
|
|
||||||
|
private static INPUT Down(ushort vk) => KeyInput(vk, up: false);
|
||||||
|
|
||||||
|
private static INPUT Up(ushort vk) => KeyInput(vk, up: true);
|
||||||
|
|
||||||
|
private static INPUT KeyInput(ushort vk, bool up)
|
||||||
|
{
|
||||||
|
var ki = new KEYBDINPUT
|
||||||
|
{
|
||||||
|
wVk = vk,
|
||||||
|
wScan = 0,
|
||||||
|
dwFlags = up ? KeyEventFKeyUp : 0,
|
||||||
|
time = 0,
|
||||||
|
dwExtraInfo = IntPtr.Zero,
|
||||||
|
};
|
||||||
|
return new INPUT { type = InputKeyboard, U = new InputUnion { ki = ki } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Native;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct POINT
|
||||||
|
{
|
||||||
|
internal int X;
|
||||||
|
internal int Y;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum MONITOR_DPI_TYPE
|
||||||
|
{
|
||||||
|
MDT_EFFECTIVE_DPI = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class MonitorInterop
|
||||||
|
{
|
||||||
|
private const uint MONITOR_DEFAULTTONEAREST = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the DPI scale (1.0 = 96 DPI / 100%) of whichever monitor a physical-pixel point
|
||||||
|
/// falls on. Needed because a window's own DPI context only reflects the monitor it's
|
||||||
|
/// currently rendered on - on a mixed-DPI multi-monitor setup, positioning a not-yet-shown
|
||||||
|
/// window using that stale/wrong scale factor can place it partially or fully off-screen.
|
||||||
|
/// </summary>
|
||||||
|
internal static double GetDpiScaleForPoint(System.Drawing.Point point)
|
||||||
|
{
|
||||||
|
var pt = new POINT { X = point.X, Y = point.Y };
|
||||||
|
var hMonitor = NativeMethods.MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
|
||||||
|
if (hMonitor != IntPtr.Zero &&
|
||||||
|
NativeMethods.GetDpiForMonitor(hMonitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0)
|
||||||
|
{
|
||||||
|
return dpiX / 96.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,28 @@ internal static class NativeMethods
|
|||||||
|
|
||||||
[DllImport("user32.dll", SetLastError = true)]
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
internal static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, [MarshalAs(UnmanagedType.Bool)] bool fAttach);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
internal static extern uint GetCurrentThreadId();
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
internal static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
||||||
|
|
||||||
|
[DllImport("shcore.dll")]
|
||||||
|
internal static extern int GetDpiForMonitor(IntPtr hmonitor, MONITOR_DPI_TYPE dpiType, out uint dpiX, out uint dpiY);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.Services;
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
@@ -10,8 +11,6 @@ public sealed class ClipboardService : IClipboardService
|
|||||||
{
|
{
|
||||||
public async Task CopyLocalFileAsync(string filePath)
|
public async Task CopyLocalFileAsync(string filePath)
|
||||||
{
|
{
|
||||||
bool isGif = ImageDecoding.IsGif(filePath);
|
|
||||||
|
|
||||||
var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
|
var firstFrame = await Task.Run(() => ImageDecoding.DecodeFirstFrame(filePath));
|
||||||
|
|
||||||
var data = new DataObject();
|
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.
|
// WPF's DataObject.SetImage auto-converts to CF_DIB - no manual header work needed here.
|
||||||
data.SetImage(firstFrame);
|
data.SetImage(firstFrame);
|
||||||
|
|
||||||
if (isGif)
|
// "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
|
||||||
// CF_HTML: the format most browser/rich-text paste targets use, and the only one of
|
// reading a pasted image, because those older formats are lossy for transparency. This
|
||||||
// the three that preserves animation for those targets (CF_DIB is a single frame).
|
// 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 bytes = await File.ReadAllBytesAsync(filePath);
|
||||||
var fragment = $"<img src=\"data:image/gif;base64,{Convert.ToBase64String(bytes)}\">";
|
var fragment = $"<img src=\"data:{GetMimeType(filePath)};base64,{Convert.ToBase64String(bytes)}\">";
|
||||||
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
|
data.SetData(DataFormats.Html, BuildCfHtml(fragment));
|
||||||
}
|
|
||||||
|
|
||||||
await SetClipboardWithRetryAsync(data);
|
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)
|
private static async Task SetClipboardWithRetryAsync(DataObject data)
|
||||||
{
|
{
|
||||||
const int maxAttempts = 5;
|
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);
|
IReadOnlyList<LocalMemeRecord> Search(string query);
|
||||||
string GetFullPath(LocalMemeRecord record);
|
string GetFullPath(LocalMemeRecord record);
|
||||||
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
|
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;
|
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()
|
private List<LocalMemeRecord> LoadIndex()
|
||||||
{
|
{
|
||||||
if (!File.Exists(_indexPath))
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- Shared dark styling for every ContextMenu/MenuItem in the app (tray icon menu, tile
|
||||||
|
right-click menu, and any added later) so they all match the app's own dark UI instead
|
||||||
|
of the default OS menu chrome. Implicit (no x:Key), so merging this dictionary into
|
||||||
|
Application.Resources is enough to apply it everywhere - no per-menu wiring needed. -->
|
||||||
|
|
||||||
|
<Style TargetType="ContextMenu">
|
||||||
|
<Setter Property="Background" Value="#F0202225"/>
|
||||||
|
<Setter Property="BorderBrush" Value="#3A3B3E"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="Padding" Value="4"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ContextMenu">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="8"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<StackPanel IsItemsHost="True"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="Padding" Value="10,7"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="MenuItem">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="4"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<Grid Margin="2,1">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<ContentPresenter Grid.Column="0" ContentSource="Header" RecognizesAccessKey="True"
|
||||||
|
Margin="{TemplateBinding Padding}" VerticalAlignment="Center"/>
|
||||||
|
<Path x:Name="SubmenuArrow" Grid.Column="1" Visibility="Collapsed"
|
||||||
|
Data="M0,0 L4,4 L0,8" Stroke="#9A9CA3" StrokeThickness="1.3"
|
||||||
|
Width="8" Height="8" Margin="0,0,10,0" VerticalAlignment="Center"/>
|
||||||
|
<Popup x:Name="PART_Popup" Placement="Right" AllowsTransparency="True"
|
||||||
|
Focusable="False" PopupAnimation="Fade"
|
||||||
|
IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}">
|
||||||
|
<Border Background="#F0202225" BorderBrush="#3A3B3E" BorderThickness="1"
|
||||||
|
CornerRadius="8" Padding="4">
|
||||||
|
<StackPanel IsItemsHost="True"/>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="Role" Value="SubmenuHeader">
|
||||||
|
<Setter TargetName="SubmenuArrow" Property="Visibility" Value="Visible"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="#5A5C63"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Separator">
|
||||||
|
<Setter Property="Background" Value="#3A3B3E"/>
|
||||||
|
<Setter Property="Margin" Value="6,4"/>
|
||||||
|
<Setter Property="Height" Value="1"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
using EbbesMemeClipboard.Services;
|
using EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.ViewModels;
|
namespace EbbesMemeClipboard.ViewModels;
|
||||||
@@ -10,6 +11,8 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly ILocalMemeLibraryService _library;
|
private readonly ILocalMemeLibraryService _library;
|
||||||
private readonly IClipboardService _clipboard;
|
private readonly IClipboardService _clipboard;
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
private readonly IAutoPasteService _autoPaste;
|
||||||
|
|
||||||
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
||||||
|
|
||||||
@@ -19,12 +22,26 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _statusMessage = string.Empty;
|
private string _statusMessage = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
|
||||||
|
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
|
||||||
|
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isDialogOpen;
|
||||||
|
|
||||||
public event EventHandler? RequestClose;
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
public PickerViewModel(ILocalMemeLibraryService library, IClipboardService clipboard)
|
public PickerViewModel(
|
||||||
|
ILocalMemeLibraryService library,
|
||||||
|
IClipboardService clipboard,
|
||||||
|
ISettingsService settings,
|
||||||
|
IAutoPasteService autoPaste)
|
||||||
{
|
{
|
||||||
_library = library;
|
_library = library;
|
||||||
_clipboard = clipboard;
|
_clipboard = clipboard;
|
||||||
|
_settings = settings;
|
||||||
|
_autoPaste = autoPaste;
|
||||||
RefreshItems();
|
RefreshItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +57,31 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
await _clipboard.CopyLocalFileAsync(tile.FullPath);
|
await _clipboard.CopyLocalFileAsync(tile.FullPath);
|
||||||
StatusMessage = $"Copied {tile.DisplayName}";
|
StatusMessage = $"Copied {tile.DisplayName}";
|
||||||
RequestClose?.Invoke(this, EventArgs.Empty);
|
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
|
||||||
catch (IOException ex)
|
var mode = _settings.Current.InsertMode;
|
||||||
|
if (mode is InsertMode.PasteIntoActiveWindow or InsertMode.PasteAndSend)
|
||||||
{
|
{
|
||||||
StatusMessage = $"Couldn't copy {tile.DisplayName}: {ex.Message}";
|
await _autoPaste.PasteAsync(alsoSend: mode == InsertMode.PasteAndSend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any
|
||||||
|
// exception here silently (no crash, no message, the click just appears to do
|
||||||
|
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
|
||||||
|
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RemoveItemAsync(MemeTileViewModel? tile)
|
||||||
|
{
|
||||||
|
if (tile is null) return;
|
||||||
|
|
||||||
|
await _library.RemoveAsync(tile.Record);
|
||||||
|
RefreshItems();
|
||||||
|
StatusMessage = $"Removed {tile.DisplayName}";
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ImportFilesAsync()
|
private async Task ImportFilesAsync()
|
||||||
@@ -57,11 +93,19 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
Title = "Add memes",
|
Title = "Add memes",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
IsDialogOpen = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
if (dialog.ShowDialog() == true)
|
if (dialog.ShowDialog() == true)
|
||||||
{
|
{
|
||||||
await ImportPathsAsync(dialog.FileNames);
|
await ImportPathsAsync(dialog.FileNames);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDialogOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ImportPathsAsync(IEnumerable<string> paths)
|
public async Task ImportPathsAsync(IEnumerable<string> paths)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
using EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
|
public partial class SettingsViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
private readonly IGlobalHotkeyService _hotkeyService;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hotkeyDisplay = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _hotkeyError = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private InsertMode _insertMode;
|
||||||
|
|
||||||
|
public bool IsCopyOnly
|
||||||
|
{
|
||||||
|
get => InsertMode == InsertMode.CopyOnly;
|
||||||
|
set { if (value) InsertMode = InsertMode.CopyOnly; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPasteIntoActiveWindow
|
||||||
|
{
|
||||||
|
get => InsertMode == InsertMode.PasteIntoActiveWindow;
|
||||||
|
set { if (value) InsertMode = InsertMode.PasteIntoActiveWindow; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsPasteAndSend
|
||||||
|
{
|
||||||
|
get => InsertMode == InsertMode.PasteAndSend;
|
||||||
|
set { if (value) InsertMode = InsertMode.PasteAndSend; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_hotkeyService = hotkeyService;
|
||||||
|
_insertMode = _settings.Current.InsertMode;
|
||||||
|
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnInsertModeChanged(InsertMode value)
|
||||||
|
{
|
||||||
|
_settings.Current.InsertMode = value;
|
||||||
|
_ = _settings.SaveAsync();
|
||||||
|
OnPropertyChanged(nameof(IsCopyOnly));
|
||||||
|
OnPropertyChanged(nameof(IsPasteIntoActiveWindow));
|
||||||
|
OnPropertyChanged(nameof(IsPasteAndSend));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrySetHotkey(ModifierKeys modifiers, Key key)
|
||||||
|
{
|
||||||
|
if (_hotkeyService.Register(modifiers, key))
|
||||||
|
{
|
||||||
|
_settings.Current.HotkeyModifiers = modifiers;
|
||||||
|
_settings.Current.HotkeyKey = key;
|
||||||
|
_ = _settings.SaveAsync();
|
||||||
|
HotkeyDisplay = HotkeyFormatter.Format(modifiers, key);
|
||||||
|
HotkeyError = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Register() unregisters the old combo before trying the new one, so on failure
|
||||||
|
// nothing is registered at all - restore the last-known-working combo rather than
|
||||||
|
// leaving the app with no working hotkey.
|
||||||
|
_hotkeyService.Register(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
|
||||||
|
HotkeyError = "That shortcut is already in use - try a different one.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@
|
|||||||
Deactivated="PickerWindow_OnDeactivated"
|
Deactivated="PickerWindow_OnDeactivated"
|
||||||
KeyDown="PickerWindow_OnKeyDown"
|
KeyDown="PickerWindow_OnKeyDown"
|
||||||
Drop="PickerWindow_OnDrop"
|
Drop="PickerWindow_OnDrop"
|
||||||
DragOver="PickerWindow_OnDragOver">
|
DragOver="PickerWindow_OnDragOver"
|
||||||
|
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
|
||||||
|
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
<Style x:Key="FlatButtonStyle" TargetType="Button">
|
<Style x:Key="FlatButtonStyle" TargetType="Button">
|
||||||
<Setter Property="Background" Value="#2B2D31"/>
|
<Setter Property="Background" Value="#2B2D31"/>
|
||||||
@@ -46,12 +48,20 @@
|
|||||||
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
|
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
|
||||||
<Grid Margin="12">
|
<Grid Margin="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="16"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<Grid Grid.Row="0" Margin="0,0,0,10">
|
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll"
|
||||||
|
MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
|
||||||
|
ToolTip="Drag to move">
|
||||||
|
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="0,4,0,10">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
@@ -69,7 +79,7 @@
|
|||||||
ToolTip="Add memes"/>
|
ToolTip="Add memes"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
|
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextBlock Text="No memes yet. Click + or drag files in here."
|
<TextBlock Text="No memes yet. Click + or drag files in here."
|
||||||
Foreground="#7B7D85" FontSize="13"
|
Foreground="#7B7D85" FontSize="13"
|
||||||
@@ -99,7 +109,15 @@
|
|||||||
Style="{StaticResource FlatButtonStyle}"
|
Style="{StaticResource FlatButtonStyle}"
|
||||||
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
|
Tag="{Binding DataContext, ElementName=RootWindow}"
|
||||||
ToolTip="{Binding DisplayName}">
|
ToolTip="{Binding DisplayName}">
|
||||||
|
<Button.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Remove"
|
||||||
|
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
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" Width="80" Height="80"/>
|
||||||
</Button>
|
</Button>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -108,7 +126,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
|
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using System.Windows.Media;
|
using EbbesMemeClipboard.Native;
|
||||||
|
using EbbesMemeClipboard.Services;
|
||||||
using EbbesMemeClipboard.ViewModels;
|
using EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.Views;
|
namespace EbbesMemeClipboard.Views;
|
||||||
@@ -9,17 +11,22 @@ namespace EbbesMemeClipboard.Views;
|
|||||||
public partial class PickerWindow : Window
|
public partial class PickerWindow : Window
|
||||||
{
|
{
|
||||||
private readonly PickerViewModel _viewModel;
|
private readonly PickerViewModel _viewModel;
|
||||||
|
private readonly IAutoPasteService _autoPaste;
|
||||||
|
private bool _isContextMenuOpen;
|
||||||
|
|
||||||
public PickerWindow(PickerViewModel viewModel)
|
public PickerWindow(PickerViewModel viewModel, IAutoPasteService autoPaste)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_viewModel = viewModel;
|
_viewModel = viewModel;
|
||||||
|
_autoPaste = autoPaste;
|
||||||
DataContext = _viewModel;
|
DataContext = _viewModel;
|
||||||
_viewModel.RequestClose += (_, _) => Hide();
|
_viewModel.RequestClose += (_, _) => Hide();
|
||||||
|
_viewModel.PropertyChanged += ViewModel_OnPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowNearCursor()
|
public void ShowNearCursor()
|
||||||
{
|
{
|
||||||
|
_autoPaste.CaptureForegroundWindow();
|
||||||
PositionNearCursor();
|
PositionNearCursor();
|
||||||
_viewModel.OnShown();
|
_viewModel.OnShown();
|
||||||
Show();
|
Show();
|
||||||
@@ -40,20 +47,19 @@ public partial class PickerWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Positions the popup near the mouse cursor, clamped to the current monitor's working
|
/// Positions the popup near the mouse cursor, clamped to the current monitor's working area.
|
||||||
/// area. The real Windows Emoji Panel can follow the text caret because it's a privileged
|
/// The real Windows Emoji Panel can follow the text caret because it's a privileged shell
|
||||||
/// shell component; a third-party app can't replicate that, so cursor position is the
|
/// component; a third-party app can't replicate that, so cursor position is the practical
|
||||||
/// practical stand-in. Uses a single DPI scale factor for the conversion from WinForms'
|
/// stand-in. Resolves the DPI scale of whichever monitor the cursor is actually on (not just
|
||||||
/// physical pixels to WPF's device-independent units, which is exact for same-DPI
|
/// whichever monitor this window last rendered on), so the popup stays fully on-screen on
|
||||||
/// multi-monitor setups (the common case) and only approximate when the cursor is on a
|
/// mixed-DPI multi-monitor setups too.
|
||||||
/// secondary monitor running a different DPI scale than the one this window last rendered on.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void PositionNearCursor()
|
private void PositionNearCursor()
|
||||||
{
|
{
|
||||||
var cursor = System.Windows.Forms.Cursor.Position;
|
var cursor = System.Windows.Forms.Cursor.Position;
|
||||||
var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea;
|
var workingArea = System.Windows.Forms.Screen.FromPoint(cursor).WorkingArea;
|
||||||
|
|
||||||
double scale = VisualTreeHelper.GetDpi(this).DpiScaleX;
|
double scale = MonitorInterop.GetDpiScaleForPoint(cursor);
|
||||||
|
|
||||||
double left = cursor.X / scale;
|
double left = cursor.X / scale;
|
||||||
double top = cursor.Y / scale;
|
double top = cursor.Y / scale;
|
||||||
@@ -66,7 +72,36 @@ public partial class PickerWindow : Window
|
|||||||
Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop));
|
Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PickerWindow_OnDeactivated(object? sender, EventArgs e) => Hide();
|
private void DragHandle_OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.LeftButton == MouseButtonState.Pressed)
|
||||||
|
{
|
||||||
|
DragMove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// Once the "add memes" dialog closes, hand keyboard focus back to the picker so the
|
||||||
|
// user can keep searching/clicking without having to click into it again.
|
||||||
|
if (e.PropertyName == nameof(PickerViewModel.IsDialogOpen) && !_viewModel.IsDialogOpen && IsVisible)
|
||||||
|
{
|
||||||
|
Activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickerWindow_OnDeactivated(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// Don't auto-hide while our own file-picker dialog or a tile's right-click context menu
|
||||||
|
// is showing - both take keyboard focus away from the window in a way that looks like
|
||||||
|
// "user clicked away" but isn't, and hiding here would close the menu/dialog too.
|
||||||
|
if (_viewModel.IsDialogOpen || _isContextMenuOpen) return;
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PickerWindow_OnContextMenuOpening(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = true;
|
||||||
|
|
||||||
|
private void PickerWindow_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
|
||||||
|
|
||||||
private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e)
|
private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<Window x:Class="EbbesMemeClipboard.Views.SettingsWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Settings - Ebbe's Meme Clipboard"
|
||||||
|
Width="380" Height="380"
|
||||||
|
Background="#F0202225"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
SizeToContent="Height">
|
||||||
|
<StackPanel Margin="20">
|
||||||
|
<TextBlock Text="Global Hotkey" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,0,0,8"/>
|
||||||
|
<Border Background="#2B2D31" CornerRadius="6">
|
||||||
|
<TextBox x:Name="HotkeyBox"
|
||||||
|
Text="{Binding HotkeyDisplay, Mode=OneWay}"
|
||||||
|
IsReadOnly="True" Focusable="True"
|
||||||
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
|
Padding="10,8" FontSize="13"
|
||||||
|
CaretBrush="Transparent"
|
||||||
|
PreviewKeyDown="HotkeyBox_OnPreviewKeyDown"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="Click the box above, then press a new key combination." TextWrapping="Wrap"
|
||||||
|
Foreground="#7B7D85" FontSize="11" Margin="0,4,0,0"/>
|
||||||
|
<TextBlock Text="{Binding HotkeyError}" TextWrapping="Wrap"
|
||||||
|
Foreground="#E06060" FontSize="11" Margin="0,4,0,0"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Insert Mode" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
||||||
|
<RadioButton GroupName="InsertMode" Content="Copy to clipboard" Foreground="White"
|
||||||
|
IsChecked="{Binding IsCopyOnly}" Margin="0,0,0,8"/>
|
||||||
|
<RadioButton GroupName="InsertMode" Content="Paste into active window" Foreground="White"
|
||||||
|
IsChecked="{Binding IsPasteIntoActiveWindow}" Margin="0,0,0,8"/>
|
||||||
|
<RadioButton GroupName="InsertMode" Content="Paste and send instantly" Foreground="White"
|
||||||
|
IsChecked="{Binding IsPasteAndSend}"/>
|
||||||
|
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps."
|
||||||
|
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Views;
|
||||||
|
|
||||||
|
public partial class SettingsWindow : Window
|
||||||
|
{
|
||||||
|
private readonly SettingsViewModel _viewModel;
|
||||||
|
|
||||||
|
public SettingsWindow(SettingsViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_viewModel = viewModel;
|
||||||
|
DataContext = _viewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
var key = e.Key == Key.System ? e.SystemKey : e.Key;
|
||||||
|
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt
|
||||||
|
or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin or Key.System)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifiers = Keyboard.Modifiers;
|
||||||
|
// Keyboard.Modifiers only tracks Ctrl/Alt/Shift - the Windows key isn't a "modifier" as
|
||||||
|
// far as WPF's own key-state tracking is concerned, so it has to be checked separately.
|
||||||
|
if (Keyboard.IsKeyDown(Key.LWin) || Keyboard.IsKeyDown(Key.RWin))
|
||||||
|
{
|
||||||
|
modifiers |= ModifierKeys.Windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifiers == ModifierKeys.None)
|
||||||
|
{
|
||||||
|
// A bare, unmodified key (e.g. plain "M") would fire on every keystroke system-wide.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_viewModel.TrySetHotkey(modifiers, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosing(CancelEventArgs e)
|
||||||
|
{
|
||||||
|
// Reused DI singleton for the app's whole lifetime - closing (title-bar X) should just
|
||||||
|
// hide it, not tear it down.
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
base.OnClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user