Initial first version.
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user