Added pasting and sending features

This commit is contained in:
Ebbe Baß
2026-08-13 15:43:34 +02:00
parent 940e80b630
commit ceadaff995
22 changed files with 771 additions and 55 deletions
+22 -4
View File
@@ -16,7 +16,9 @@
Deactivated="PickerWindow_OnDeactivated"
KeyDown="PickerWindow_OnKeyDown"
Drop="PickerWindow_OnDrop"
DragOver="PickerWindow_OnDragOver">
DragOver="PickerWindow_OnDragOver"
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
<Window.Resources>
<Style x:Key="FlatButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#2B2D31"/>
@@ -46,12 +48,20 @@
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="16"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</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>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -69,7 +79,7 @@
ToolTip="Add memes"/>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<ScrollViewer Grid.Row="2" VerticalScrollBarVisibility="Auto">
<Grid>
<TextBlock Text="No memes yet. Click + or drag files in here."
Foreground="#7B7D85" FontSize="13"
@@ -99,7 +109,15 @@
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
CommandParameter="{Binding}"
Tag="{Binding DataContext, ElementName=RootWindow}"
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"/>
</Button>
</DataTemplate>
@@ -108,7 +126,7 @@
</Grid>
</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>
</Border>
</Window>
@@ -1,7 +1,9 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using EbbesMemeClipboard.Native;
using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels;
namespace EbbesMemeClipboard.Views;
@@ -9,17 +11,22 @@ namespace EbbesMemeClipboard.Views;
public partial class PickerWindow : Window
{
private readonly PickerViewModel _viewModel;
private readonly IAutoPasteService _autoPaste;
private bool _isContextMenuOpen;
public PickerWindow(PickerViewModel viewModel)
public PickerWindow(PickerViewModel viewModel, IAutoPasteService autoPaste)
{
InitializeComponent();
_viewModel = viewModel;
_autoPaste = autoPaste;
DataContext = _viewModel;
_viewModel.RequestClose += (_, _) => Hide();
_viewModel.PropertyChanged += ViewModel_OnPropertyChanged;
}
public void ShowNearCursor()
{
_autoPaste.CaptureForegroundWindow();
PositionNearCursor();
_viewModel.OnShown();
Show();
@@ -40,20 +47,19 @@ public partial class PickerWindow : Window
}
/// <summary>
/// Positions the popup near the mouse cursor, clamped to the current monitor's working
/// area. The real Windows Emoji Panel can follow the text caret because it's a privileged
/// shell component; a third-party app can't replicate that, so cursor position is the
/// practical stand-in. Uses a single DPI scale factor for the conversion from WinForms'
/// physical pixels to WPF's device-independent units, which is exact for same-DPI
/// multi-monitor setups (the common case) and only approximate when the cursor is on a
/// secondary monitor running a different DPI scale than the one this window last rendered on.
/// Positions the popup near the mouse cursor, clamped to the current monitor's working area.
/// The real Windows Emoji Panel can follow the text caret because it's a privileged shell
/// component; a third-party app can't replicate that, so cursor position is the practical
/// stand-in. Resolves the DPI scale of whichever monitor the cursor is actually on (not just
/// whichever monitor this window last rendered on), so the popup stays fully on-screen on
/// mixed-DPI multi-monitor setups too.
/// </summary>
private void PositionNearCursor()
{
var cursor = System.Windows.Forms.Cursor.Position;
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 top = cursor.Y / scale;
@@ -66,7 +72,36 @@ public partial class PickerWindow : Window
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)
{
@@ -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);
}
}