Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10370f8bc | ||
|
|
6409132659 | ||
|
|
b6644ebecf |
@@ -26,7 +26,7 @@ Everything below is **implemented and verified working by actually running the a
|
||||
- Tray icon with menu: **Open / Settings / Exit**
|
||||
- Popup picker: search box, thumbnail grid, drag-to-move via top handle strip,
|
||||
Esc / click-away to dismiss, positions near cursor clamped to the current monitor
|
||||
- Add memes: `+` button (file dialog), drag-and-drop onto picker, jpg/png/gif
|
||||
- Add memes: `+` button (file dialog), drag-and-drop onto picker, **Ctrl+V paste**, jpg/png/gif
|
||||
- Search filters by filename (case-insensitive substring)
|
||||
- Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified)
|
||||
- Multi-format clipboard write (see "Clipboard formats" below)
|
||||
@@ -77,13 +77,44 @@ dotnet run --project src/EbbesMemeClipboard
|
||||
# build
|
||||
dotnet build
|
||||
|
||||
# publish self-contained single-file binary -> publish/EbbesMemeClipboard.exe (~78 MB)
|
||||
# portable single exe -> publish/EbbesMemeClipboard.exe (~75 MB, compressed)
|
||||
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
|
||||
-o publish
|
||||
|
||||
# installer build: NOTE compression deliberately OFF (see below) -> publish-installer/ (~172 MB)
|
||||
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
|
||||
--self-contained true -p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=false \
|
||||
-o publish-installer
|
||||
|
||||
# then compile the wizard -> installer/output/EbbesMemeClipboard-Setup-<version>.exe (~51 MB)
|
||||
"$LOCALAPPDATA/Programs/Inno Setup 6/ISCC.exe" installer/EbbesMemeClipboard.iss
|
||||
```
|
||||
|
||||
### Installer (Inno Setup)
|
||||
|
||||
Script: `installer/EbbesMemeClipboard.iss` (tracked; `installer/output/` is gitignored).
|
||||
Inno Setup 6 installs **per-user** to `%LocalAppData%\Programs\Inno Setup 6\`, not Program
|
||||
Files — that tripped up the first attempt to locate `ISCC.exe`.
|
||||
|
||||
- Per-user install by default (no UAC), with a wizard page letting the user pick
|
||||
"just me" vs "all users" (`PrivilegesRequired=lowest` +
|
||||
`PrivilegesRequiredOverridesAllowed=dialog`).
|
||||
- Optional unchecked tasks: desktop icon, start-with-Windows.
|
||||
- **Don't double-compress.** Publishing with `EnableCompressionInSingleFile=false` and
|
||||
letting Inno's LZMA2 do the work gives a ~51 MB setup vs ~70 MB, and the installed app
|
||||
starts faster because there's no decompression at launch.
|
||||
- **Uninstall only removes the autostart entry if it points at the installed copy.** The
|
||||
app's own Settings toggle writes the same `Run` value name, so an unconditional delete
|
||||
would wipe out a portable build's autostart. This was a real bug caught in testing.
|
||||
- User data under `%AppData%\EbbesMemeClipboard` is deliberately preserved on uninstall;
|
||||
only the re-downloadable `GifCache` is removed.
|
||||
- Verified end-to-end: silent install → files/shortcut/uninstall-entry present → app runs →
|
||||
silent uninstall → everything removed, user data and unrelated autostart intact.
|
||||
- An installer does **not** fix SmartScreen warnings; only code signing does.
|
||||
|
||||
Note: the published single-file exe takes **~10 seconds on first launch** before the tray
|
||||
icon appears (native library self-extraction to TEMP). Not a bug. Subsequent launches are
|
||||
faster.
|
||||
@@ -160,6 +191,15 @@ code-behind fields. Look elements up by `Tag`/traversal instead.
|
||||
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it. **Deliberately omitted for GIFs** — see below |
|
||||
| `CF_HTML` | The other route web-based compose boxes check; carries a base64 `data:` URI |
|
||||
|
||||
**Reading the clipboard (Ctrl+V to import) uses the same knowledge in reverse.**
|
||||
`ClipboardService.TryReadImage()` checks formats in descending fidelity order: FileDrop →
|
||||
CF_HTML `data:` URI → `"PNG"` → CF_DIB bitmap. The order is not cosmetic: a GIF is only
|
||||
still animated in the first two, so a naive implementation that reached for the bitmap
|
||||
first would silently flatten every pasted GIF. Verified byte-identical (SHA-256) import for
|
||||
a pasted GIF file. Ctrl+V is handled in `PickerWindow.OnPreviewKeyDown` and only swallows
|
||||
the keystroke when an image is actually present — plain text still pastes into the search
|
||||
box as normal.
|
||||
|
||||
**The `"PNG"` format is skipped for GIFs on purpose.** A PNG can only hold one static frame,
|
||||
and since Chromium *prefers* that format over all others, offering it for a GIF is exactly
|
||||
what made animated GIFs paste as a still first frame. Omitting it lets those targets fall
|
||||
|
||||
@@ -17,7 +17,12 @@ bin/
|
||||
obj/
|
||||
*.user
|
||||
publish/
|
||||
publish-installer/
|
||||
|
||||
# Compiled installer output (the .iss script itself IS tracked)
|
||||
installer/output/
|
||||
|
||||
# Local app data seeded during manual testing
|
||||
diag.log
|
||||
|
||||
/installer/output/
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
; Inno Setup script for Ebbe's Meme Clipboard.
|
||||
;
|
||||
; Build with:
|
||||
; "%LocalAppData%\Programs\Inno Setup 6\ISCC.exe" installer\EbbesMemeClipboard.iss
|
||||
;
|
||||
; Expects an installer-flavoured publish first (note: compression DISABLED on purpose):
|
||||
; dotnet publish src\EbbesMemeClipboard\EbbesMemeClipboard.csproj -c Release -r win-x64 ^
|
||||
; --self-contained true -p:PublishSingleFile=true ^
|
||||
; -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=false ^
|
||||
; -o publish-installer
|
||||
;
|
||||
; Leaving .NET's own single-file compression off lets Inno's LZMA2 compress the raw bytes
|
||||
; instead, which it does far better: ~51 MB setup vs ~70 MB when double-compressing. The
|
||||
; installed exe is larger on disk (172 MB vs 75 MB) but starts faster, since there's no
|
||||
; decompression on launch. The separate publish\ folder keeps the compressed build for
|
||||
; anyone who just wants the portable single exe.
|
||||
|
||||
#define AppName "Ebbe's Meme Clipboard"
|
||||
#define AppVersion "1.1.0"
|
||||
#define AppPublisher "Ebbe Baß"
|
||||
#define AppExeName "EbbesMemeClipboard.exe"
|
||||
#define SourceExe "..\publish-installer\EbbesMemeClipboard.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{8E4C1F02-6B3A-4D77-9C21-5A0E7F3B9D64}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName={#AppName} {#AppVersion}
|
||||
AppPublisher={#AppPublisher}
|
||||
DefaultDirName={autopf}\Ebbes Meme Clipboard
|
||||
DefaultGroupName={#AppName}
|
||||
UninstallDisplayName={#AppName}
|
||||
UninstallDisplayIcon={app}\{#AppExeName}
|
||||
OutputDir=.\output
|
||||
OutputBaseFilename=EbbesMemeClipboard-Setup-{#AppVersion}
|
||||
SetupIconFile=..\src\EbbesMemeClipboard\Assets\tray-icon.ico
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
DisableProgramGroupPage=yes
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
|
||||
; Default to a per-user install so no UAC prompt is needed, but show a dialog letting the
|
||||
; user pick "just me" or "all users". {autopf} then resolves to LocalAppData\Programs or
|
||||
; Program Files to match whichever they chose.
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
|
||||
; The app has no single-instance mutex, so a running copy would hold a lock on the exe and
|
||||
; break an upgrade. Restart Manager closes it first and reopens it afterwards.
|
||||
CloseApplications=yes
|
||||
RestartApplications=yes
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
Name: "autostart"; Description: "Start {#AppName} when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceExe}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
|
||||
Name: "{group}\{cm:UninstallProgram,{#AppName}}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Registry]
|
||||
; Same value name the app's own Settings toggle manages, so the installer checkbox and the
|
||||
; in-app checkbox stay in agreement instead of fighting over two separate entries.
|
||||
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||
ValueName: "EbbesMemeClipboard"; ValueData: """{app}\{#AppExeName}"""; \
|
||||
Flags: uninsdeletevalue; Tasks: autostart
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#AppName}}"; \
|
||||
Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallDelete]
|
||||
; The app writes its library/settings under %AppData% and a re-downloadable cache under
|
||||
; %LocalAppData%. Only the cache is removed automatically - the user's own memes, favourites
|
||||
; and settings are deliberately left behind so an uninstall/reinstall doesn't destroy them.
|
||||
Type: filesandordirs; Name: "{localappdata}\EbbesMemeClipboard\GifCache"
|
||||
|
||||
[Code]
|
||||
// Clean up the autostart entry on uninstall, but ONLY when it actually points at the copy
|
||||
// being removed. The app's own Settings toggle writes the same value name, so a user running
|
||||
// a portable build alongside this one would otherwise have their autostart silently deleted
|
||||
// by an uninstall that had nothing to do with it.
|
||||
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||
var
|
||||
Existing: String;
|
||||
AppPath: String;
|
||||
begin
|
||||
if CurUninstallStep <> usPostUninstall then
|
||||
Exit;
|
||||
|
||||
if not RegQueryStringValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run',
|
||||
'EbbesMemeClipboard', Existing) then
|
||||
Exit;
|
||||
|
||||
AppPath := LowerCase(ExpandConstant('{app}'));
|
||||
if Pos(AppPath, LowerCase(Existing)) > 0 then
|
||||
RegDeleteValue(HKCU, 'Software\Microsoft\Windows\CurrentVersion\Run', 'EbbesMemeClipboard');
|
||||
end;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 766 B After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,20 @@
|
||||
namespace EbbesMemeClipboard.Models;
|
||||
|
||||
/// <summary>
|
||||
/// An importable image found on the clipboard. Either real files (copied in Explorer) or raw
|
||||
/// bytes (a screenshot, or an image copied out of a browser) - never both.
|
||||
/// </summary>
|
||||
public sealed class ClipboardImportPayload
|
||||
{
|
||||
/// <summary>Set when the clipboard held actual files; import these directly.</summary>
|
||||
public IReadOnlyList<string>? FilePaths { get; init; }
|
||||
|
||||
/// <summary>Set when the clipboard held image data rather than files.</summary>
|
||||
public byte[]? Data { get; init; }
|
||||
|
||||
/// <summary>Extension matching <see cref="Data"/>, including the dot (e.g. ".png").</summary>
|
||||
public string? Extension { get; init; }
|
||||
|
||||
public bool HasFiles => FilePaths is { Count: > 0 };
|
||||
public bool HasBytes => Data is { Length: > 0 } && Extension is not null;
|
||||
}
|
||||
@@ -2,12 +2,14 @@ using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Imaging;
|
||||
using EbbesMemeClipboard.Models;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public sealed class ClipboardService : IClipboardService
|
||||
public sealed partial class ClipboardService : IClipboardService
|
||||
{
|
||||
public async Task CopyLocalFileAsync(string filePath)
|
||||
{
|
||||
@@ -60,6 +62,86 @@ public sealed class ClipboardService : IClipboardService
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
private static readonly string[] ImportableExtensions = { ".jpg", ".jpeg", ".png", ".gif" };
|
||||
|
||||
/// <summary>
|
||||
/// Formats are checked in descending order of fidelity, which matters a lot here: a GIF is
|
||||
/// only still animated in the first two. Falling straight to the bitmap (as a naive
|
||||
/// implementation would) silently flattens every pasted GIF to one frame.
|
||||
/// </summary>
|
||||
public ClipboardImportPayload? TryReadImage()
|
||||
{
|
||||
// 1. Real files (copied in Explorer) - byte-for-byte exact, animation intact.
|
||||
if (Clipboard.ContainsFileDropList())
|
||||
{
|
||||
var paths = Clipboard.GetFileDropList()
|
||||
.Cast<string>()
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p) &&
|
||||
ImportableExtensions.Contains(Path.GetExtension(p).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
if (paths.Count > 0)
|
||||
{
|
||||
return new ClipboardImportPayload { FilePaths = paths };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CF_HTML with an embedded data: URI - how a real (possibly animated) GIF arrives
|
||||
// when copied out of a browser or out of this app itself.
|
||||
if (Clipboard.ContainsText(TextDataFormat.Html))
|
||||
{
|
||||
var match = DataUriRegex().Match(Clipboard.GetText(TextDataFormat.Html));
|
||||
if (match.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(match.Groups["data"].Value);
|
||||
var ext = match.Groups["mime"].Value.ToLowerInvariant() switch
|
||||
{
|
||||
"gif" => ".gif",
|
||||
"jpeg" or "jpg" => ".jpg",
|
||||
_ => ".png",
|
||||
};
|
||||
return new ClipboardImportPayload { Data = bytes, Extension = ext };
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Malformed base64 - fall through to the bitmap formats below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The registered "PNG" format - lossless and keeps transparency, unlike CF_DIB.
|
||||
if (Clipboard.ContainsData("PNG") && Clipboard.GetData("PNG") is { } pngData)
|
||||
{
|
||||
byte[]? bytes = pngData switch
|
||||
{
|
||||
MemoryStream ms => ms.ToArray(),
|
||||
byte[] raw => raw,
|
||||
_ => null,
|
||||
};
|
||||
if (bytes is { Length: > 0 })
|
||||
{
|
||||
return new ClipboardImportPayload { Data = bytes, Extension = ".png" };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Plain bitmap (Snipping Tool, Paint) - always static, so it's the last resort.
|
||||
if (Clipboard.ContainsImage() && Clipboard.GetImage() is { } bitmap)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
encoder.Save(stream);
|
||||
return new ClipboardImportPayload { Data = stream.ToArray(), Extension = ".png" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"data:image/(?<mime>gif|png|jpe?g);base64,(?<data>[A-Za-z0-9+/=]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DataUriRegex();
|
||||
|
||||
private static async Task SetClipboardWithRetryAsync(DataObject data)
|
||||
{
|
||||
const int maxAttempts = 5;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using EbbesMemeClipboard.Models;
|
||||
|
||||
namespace EbbesMemeClipboard.Services;
|
||||
|
||||
public interface IClipboardService
|
||||
@@ -8,4 +10,10 @@ public interface IClipboardService
|
||||
/// useful, and animated GIFs survive as animations wherever possible.
|
||||
/// </summary>
|
||||
Task CopyLocalFileAsync(string filePath);
|
||||
|
||||
/// <summary>
|
||||
/// Reads an importable image off the clipboard, preferring the highest-fidelity format
|
||||
/// available. Returns null when the clipboard holds no image (e.g. plain text).
|
||||
/// </summary>
|
||||
ClipboardImportPayload? TryReadImage();
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@ public interface ILocalMemeLibraryService
|
||||
IReadOnlyList<LocalMemeRecord> Search(string query);
|
||||
string GetFullPath(LocalMemeRecord record);
|
||||
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
|
||||
|
||||
/// <summary>Imports image data that has no source file (e.g. a pasted screenshot).</summary>
|
||||
Task<LocalMemeRecord?> ImportBytesAsync(byte[] data, string extension, string originalFileName);
|
||||
|
||||
Task RemoveAsync(LocalMemeRecord record);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,27 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
|
||||
return imported;
|
||||
}
|
||||
|
||||
public async Task<LocalMemeRecord?> ImportBytesAsync(byte[] data, string extension, string originalFileName)
|
||||
{
|
||||
if (!AllowedExtensions.Contains(extension) || data.Length == 0)
|
||||
return null;
|
||||
|
||||
var storedFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||
await File.WriteAllBytesAsync(Path.Combine(_libraryRoot, storedFileName), data);
|
||||
|
||||
var record = new LocalMemeRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
FileName = storedFileName,
|
||||
OriginalFileName = originalFileName,
|
||||
DateAdded = DateTimeOffset.Now,
|
||||
};
|
||||
|
||||
_records.Add(record);
|
||||
await SaveIndexAsync();
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(LocalMemeRecord record)
|
||||
{
|
||||
var path = GetFullPath(record);
|
||||
|
||||
@@ -11,7 +11,9 @@ namespace EbbesMemeClipboard.ViewModels;
|
||||
/// </summary>
|
||||
public partial class MemeTileViewModel : ObservableObject
|
||||
{
|
||||
private const int ThumbnailPixelWidth = 150;
|
||||
// Comfortably above the ~126px logical tile width so thumbnails stay sharp at 150-200%
|
||||
// display scaling, without decoding anything near full resolution.
|
||||
private const int ThumbnailPixelWidth = 240;
|
||||
|
||||
private readonly IGifCacheService? _cache;
|
||||
private string? _localPath;
|
||||
|
||||
@@ -188,6 +188,54 @@ public partial class PickerViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous peek so the Ctrl+V key handler can decide immediately whether to swallow the
|
||||
/// keystroke (image on the clipboard) or let it through to the search box (plain text).
|
||||
/// </summary>
|
||||
public ClipboardImportPayload? ReadClipboardImage()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _clipboard.TryReadImage();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Another process holding the clipboard shouldn't break the keystroke entirely.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ImportClipboardAsync(ClipboardImportPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (payload.HasFiles)
|
||||
{
|
||||
await ImportPathsAsync(payload.FilePaths!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.HasBytes) return;
|
||||
|
||||
var name = $"pasted-{DateTime.Now:yyyy-MM-dd-HHmmss}{payload.Extension}";
|
||||
var record = await _library.ImportBytesAsync(payload.Data!, payload.Extension!, name);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
StatusMessage = "Couldn't add that image.";
|
||||
return;
|
||||
}
|
||||
|
||||
ActiveSource = MemeSource.Local;
|
||||
RefreshLocalItems();
|
||||
StatusMessage = $"Pasted {name}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Couldn't paste image: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ImportPathsAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var candidates = paths
|
||||
|
||||
@@ -75,9 +75,11 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Shared by the favourites row and the main grid so both behave identically. -->
|
||||
<!-- Shared by the favourites row and the main grid so both behave identically.
|
||||
No fixed Width: the tile stretches to fill its UniformGrid cell, so a row always
|
||||
spans the full panel width instead of leaving a ragged gap on the right. -->
|
||||
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}">
|
||||
<Grid Width="88" Height="88" Margin="4">
|
||||
<Grid Height="118" Margin="4">
|
||||
<Button Padding="0"
|
||||
Style="{StaticResource FlatButtonStyle}"
|
||||
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
||||
@@ -96,7 +98,7 @@
|
||||
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" Margin="5"/>
|
||||
</Button>
|
||||
|
||||
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
|
||||
@@ -174,7 +176,7 @@
|
||||
ItemTemplate="{StaticResource MemeTileTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
<UniformGrid Columns="3"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
@@ -182,7 +184,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<TextBlock Text="No memes yet. Click + or drag files in here."
|
||||
<TextBlock Text="No memes yet. Click +, drag files in, or paste with Ctrl+V."
|
||||
Foreground="#7B7D85" FontSize="13"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center" Width="260"
|
||||
@@ -207,7 +209,7 @@
|
||||
ItemTemplate="{StaticResource MemeTileTemplate}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"/>
|
||||
<UniformGrid Columns="3"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
|
||||
@@ -108,6 +108,27 @@ public partial class PickerWindow : Window
|
||||
if (e.Key == Key.Escape) Hide();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ctrl+V adds whatever image is on the clipboard to the local library. The clipboard is
|
||||
/// inspected synchronously so the decision to swallow the keystroke can be made before the
|
||||
/// event reaches the search box - when the clipboard holds plain text instead, the paste is
|
||||
/// deliberately left alone so it still lands in the search field as normal.
|
||||
/// </summary>
|
||||
protected override void OnPreviewKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.V && (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
|
||||
{
|
||||
var payload = _viewModel.ReadClipboardImage();
|
||||
if (payload is not null)
|
||||
{
|
||||
e.Handled = true;
|
||||
_ = _viewModel.ImportClipboardAsync(payload);
|
||||
}
|
||||
}
|
||||
|
||||
base.OnPreviewKeyDown(e);
|
||||
}
|
||||
|
||||
private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
Reference in New Issue
Block a user