Initial first version.

This commit is contained in:
Ebbe Baß
2026-08-13 10:04:59 +02:00
parent df725b43c4
commit 940e80b630
22 changed files with 890 additions and 0 deletions
+9
View File
@@ -12,3 +12,12 @@
# Built Visual Studio Code Extensions
*.vsix
# ---> dotnet / Visual Studio
bin/
obj/
*.user
publish/
# Local app data seeded during manual testing
diag.log
+5
View File
@@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/EbbesMemeClipboard/EbbesMemeClipboard.csproj" />
</Folder>
</Solution>
+20
View File
@@ -0,0 +1,20 @@
<Application x:Class="EbbesMemeClipboard.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:tb="clr-namespace:H.NotifyIcon;assembly=H.NotifyIcon.Wpf">
<Application.Resources>
<tb:TaskbarIcon x:Key="TrayIcon"
IconSource="/Assets/tray-icon.ico"
ToolTipText="Ebbe's Meme Clipboard"
MenuActivation="RightClick">
<tb:TaskbarIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="Open Picker" Click="TrayOpenPicker_Click"/>
<MenuItem Header="Add Memes..." Click="TrayAddMemes_Click"/>
<Separator/>
<MenuItem Header="Exit" Click="TrayExit_Click"/>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>
</Application.Resources>
</Application>
+73
View File
@@ -0,0 +1,73 @@
using System.Windows;
using System.Windows.Input;
using EbbesMemeClipboard.Services;
using EbbesMemeClipboard.ViewModels;
using EbbesMemeClipboard.Views;
using H.NotifyIcon;
using Microsoft.Extensions.DependencyInjection;
namespace EbbesMemeClipboard;
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 IGlobalHotkeyService _hotkeyService = null!;
private PickerWindow _pickerWindow = null!;
private PickerViewModel _pickerViewModel = null!;
private TaskbarIcon? _trayIcon;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
ShutdownMode = ShutdownMode.OnExplicitShutdown;
var services = new ServiceCollection();
services.AddSingleton<ILocalMemeLibraryService, LocalMemeLibraryService>();
services.AddSingleton<IClipboardService, ClipboardService>();
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
services.AddSingleton<PickerViewModel>();
services.AddSingleton<PickerWindow>();
_services = services.BuildServiceProvider();
_pickerViewModel = _services.GetRequiredService<PickerViewModel>();
_pickerWindow = _services.GetRequiredService<PickerWindow>();
_trayIcon = (TaskbarIcon)Resources["TrayIcon"];
_trayIcon.TrayLeftMouseUp += (_, _) => _pickerWindow.ToggleVisibility();
_hotkeyService = _services.GetRequiredService<IGlobalHotkeyService>();
if (_hotkeyService.Register(DefaultHotkeyModifiers, DefaultHotkeyKey))
{
_hotkeyService.HotkeyPressed += (_, _) => Dispatcher.Invoke(() => _pickerWindow.ToggleVisibility());
}
else
{
MessageBox.Show(
"Couldn't register the global hotkey (Ctrl+Alt+M) - it may already be in use by another application. " +
"You can still open the picker from the tray icon.",
"Ebbe's Meme Clipboard",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
private async void TrayAddMemes_Click(object sender, RoutedEventArgs e)
{
_pickerWindow.ShowNearCursor();
await _pickerViewModel.ImportFilesCommand.ExecuteAsync(null);
}
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
protected override void OnExit(ExitEventArgs e)
{
_hotkeyService?.Dispose();
_trayIcon?.Dispose();
base.OnExit(e);
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
Binary file not shown.

After

Width:  |  Height:  |  Size: 766 B

@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>EbbesMemeClipboard</AssemblyName>
<RootNamespace>EbbesMemeClipboard</RootNamespace>
<ApplicationIcon>Assets\tray-icon.ico</ApplicationIcon>
<!-- This is a WPF app; DPI awareness is correctly declared via app.manifest (the standard
WPF mechanism). WinForms is referenced only for two static helpers (Cursor/Screen),
not for any WinForms UI, so its "use SetHighDpiMode instead" suggestion doesn't apply. -->
<NoWarn>$(NoWarn);WFO0003</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.4.1" />
<PackageReference Include="WPF-UI" Version="4.3.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\tray-icon.ico" />
</ItemGroup>
<ItemGroup>
<!-- UseWPF + UseWindowsForms both contribute implicit global usings, and
System.Windows.Forms collides with System.Windows on Application/DataObject/
KeyEventArgs/DragEventArgs. WPF types should win unqualified; the few WinForms
helpers we use (Cursor, Screen) are referenced with a full type name instead. -->
<Using Remove="System.Windows.Forms" />
</ItemGroup>
</Project>
@@ -0,0 +1,9 @@
namespace EbbesMemeClipboard.Models;
public sealed class LocalMemeRecord
{
public required string Id { get; init; }
public required string FileName { get; init; }
public required string OriginalFileName { get; init; }
public required DateTimeOffset DateAdded { get; init; }
}
@@ -0,0 +1,14 @@
namespace EbbesMemeClipboard.Native;
internal static class HotkeyConstants
{
internal const int WM_HOTKEY = 0x0312;
internal const uint MOD_ALT = 0x0001;
internal const uint MOD_CONTROL = 0x0002;
internal const uint MOD_SHIFT = 0x0004;
internal const uint MOD_WIN = 0x0008;
// Prevents the shell from re-firing WM_HOTKEY on key-repeat while the combo is held down.
internal const uint MOD_NOREPEAT = 0x4000;
}
@@ -0,0 +1,12 @@
using System.Runtime.InteropServices;
namespace EbbesMemeClipboard.Native;
internal static class NativeMethods
{
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll", SetLastError = true)]
internal static extern bool UnregisterHotKey(IntPtr hWnd, int id);
}
@@ -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);
}
}
@@ -0,0 +1,30 @@
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using EbbesMemeClipboard.Models;
using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels;
public partial class MemeTileViewModel : ObservableObject
{
private const int ThumbnailPixelWidth = 150;
public LocalMemeRecord Record { get; }
public string FullPath { get; }
public string DisplayName => Record.OriginalFileName;
[ObservableProperty]
private BitmapSource? _thumbnail;
public MemeTileViewModel(LocalMemeRecord record, string fullPath)
{
Record = record;
FullPath = fullPath;
}
public async Task LoadThumbnailAsync()
{
var path = FullPath;
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
}
}
@@ -0,0 +1,103 @@
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using EbbesMemeClipboard.Services;
namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject
{
private readonly ILocalMemeLibraryService _library;
private readonly IClipboardService _clipboard;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
[ObservableProperty]
private string _searchText = string.Empty;
[ObservableProperty]
private string _statusMessage = string.Empty;
public event EventHandler? RequestClose;
public PickerViewModel(ILocalMemeLibraryService library, IClipboardService clipboard)
{
_library = library;
_clipboard = clipboard;
RefreshItems();
}
partial void OnSearchTextChanged(string value) => RefreshItems();
[RelayCommand]
private async Task SelectItemAsync(MemeTileViewModel? tile)
{
if (tile is null) return;
try
{
await _clipboard.CopyLocalFileAsync(tile.FullPath);
StatusMessage = $"Copied {tile.DisplayName}";
RequestClose?.Invoke(this, EventArgs.Empty);
}
catch (IOException ex)
{
StatusMessage = $"Couldn't copy {tile.DisplayName}: {ex.Message}";
}
}
[RelayCommand]
private async Task ImportFilesAsync()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Multiselect = true,
Filter = "Images and GIFs|*.jpg;*.jpeg;*.png;*.gif",
Title = "Add memes",
};
if (dialog.ShowDialog() == true)
{
await ImportPathsAsync(dialog.FileNames);
}
}
public async Task ImportPathsAsync(IEnumerable<string> paths)
{
var candidates = paths
.Where(p => Path.GetExtension(p).ToLowerInvariant() is ".jpg" or ".jpeg" or ".png" or ".gif")
.ToList();
if (candidates.Count == 0)
{
StatusMessage = "No supported image/GIF files in that selection.";
return;
}
var imported = await _library.ImportAsync(candidates);
RefreshItems();
StatusMessage = $"Added {imported.Count} meme(s).";
}
public void OnShown()
{
SearchText = string.Empty;
RefreshItems();
}
private void RefreshItems()
{
var records = string.IsNullOrWhiteSpace(SearchText)
? _library.GetAll()
: _library.Search(SearchText);
Items.Clear();
foreach (var record in records)
{
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
Items.Add(tile);
_ = tile.LoadThumbnailAsync();
}
}
}
@@ -0,0 +1,114 @@
<Window x:Class="EbbesMemeClipboard.Views.PickerWindow"
x:Name="RootWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
Title="Meme Clipboard"
Width="420" Height="520"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
Topmost="True"
ResizeMode="NoResize"
WindowStartupLocation="Manual"
AllowDrop="True"
Deactivated="PickerWindow_OnDeactivated"
KeyDown="PickerWindow_OnKeyDown"
Drop="PickerWindow_OnDrop"
DragOver="PickerWindow_OnDragOver">
<Window.Resources>
<Style x:Key="FlatButtonStyle" TargetType="Button">
<Setter Property="Background" Value="#2B2D31"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="8" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#454952"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
<TextBox x:Name="SearchBox"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Background="Transparent" Foreground="White" BorderThickness="0"
Padding="10,7" FontSize="14"
CaretBrush="White"/>
</Border>
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
FontSize="18" Style="{StaticResource FlatButtonStyle}"
Command="{Binding ImportFilesCommand}"
ToolTip="Add memes"/>
</Grid>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<Grid>
<TextBlock Text="No memes yet. Click + or drag files in here."
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Items.Count}" Value="0">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:MemeTileViewModel}">
<Button Width="88" Height="88" Margin="4" Padding="0"
Style="{StaticResource FlatButtonStyle}"
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
CommandParameter="{Binding}"
ToolTip="{Binding DisplayName}">
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</ScrollViewer>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
</Grid>
</Border>
</Window>
@@ -0,0 +1,98 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using EbbesMemeClipboard.ViewModels;
namespace EbbesMemeClipboard.Views;
public partial class PickerWindow : Window
{
private readonly PickerViewModel _viewModel;
public PickerWindow(PickerViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = _viewModel;
_viewModel.RequestClose += (_, _) => Hide();
}
public void ShowNearCursor()
{
PositionNearCursor();
_viewModel.OnShown();
Show();
Activate();
SearchBox.Focus();
}
public void ToggleVisibility()
{
if (IsVisible)
{
Hide();
}
else
{
ShowNearCursor();
}
}
/// <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.
/// </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 left = cursor.X / scale;
double top = cursor.Y / scale;
double maxLeft = (workingArea.Right / scale) - Width;
double maxTop = (workingArea.Bottom / scale) - Height;
double minLeft = workingArea.Left / scale;
double minTop = workingArea.Top / scale;
Left = Math.Clamp(left, minLeft, Math.Max(minLeft, maxLeft));
Top = Math.Clamp(top, minTop, Math.Max(minTop, maxTop));
}
private void PickerWindow_OnDeactivated(object? sender, EventArgs e) => Hide();
private void PickerWindow_OnKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Hide();
}
private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
private async void PickerWindow_OnDrop(object sender, DragEventArgs e)
{
if (e.Data.GetData(DataFormats.FileDrop) is string[] paths)
{
await _viewModel.ImportPathsAsync(paths);
}
}
protected override void OnClosing(CancelEventArgs e)
{
// This window is reused for the app's whole lifetime - closing it (e.g. Alt+F4) should
// just hide it. Real shutdown only happens via the tray icon's Exit command.
e.Cancel = true;
Hide();
base.OnClosing(e);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="EbbesMemeClipboard.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows Vista/7/8/8.1/10/11 -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Per-Monitor V2 DPI awareness: needed for correct popup placement across monitors with different scaling -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>