Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0ffcccdf | ||
|
|
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**
|
- Tray icon with menu: **Open / Settings / Exit**
|
||||||
- Popup picker: search box, thumbnail grid, drag-to-move via top handle strip,
|
- 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
|
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)
|
- Search filters by filename (case-insensitive substring)
|
||||||
- Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified)
|
- Right-click a tile → **Remove** (deletes to Recycle Bin, recoverable — verified)
|
||||||
- Multi-format clipboard write (see "Clipboard formats" below)
|
- Multi-format clipboard write (see "Clipboard formats" below)
|
||||||
@@ -77,13 +77,44 @@ dotnet run --project src/EbbesMemeClipboard
|
|||||||
# build
|
# build
|
||||||
dotnet 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 \
|
dotnet publish src/EbbesMemeClipboard/EbbesMemeClipboard.csproj -c Release -r win-x64 \
|
||||||
--self-contained true -p:PublishSingleFile=true \
|
--self-contained true -p:PublishSingleFile=true \
|
||||||
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
|
-p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true \
|
||||||
-o publish
|
-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
|
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
|
icon appears (native library self-extraction to TEMP). Not a bug. Subsequent launches are
|
||||||
faster.
|
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 |
|
| `"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 |
|
| `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,
|
**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
|
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
|
what made animated GIFs paste as a still first frame. Omitting it lets those targets fall
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
*.user
|
*.user
|
||||||
publish/
|
publish/
|
||||||
|
publish-installer/
|
||||||
|
|
||||||
|
# Compiled installer output (the .iss script itself IS tracked)
|
||||||
|
installer/output/
|
||||||
|
|
||||||
# Local app data seeded during manual testing
|
# Local app data seeded during manual testing
|
||||||
diag.log
|
diag.log
|
||||||
|
|
||||||
|
/installer/output/
|
||||||
|
|||||||
@@ -1,2 +1,139 @@
|
|||||||
# ebbes-meme-clipboard
|
# Ebbe's Meme Clipboard
|
||||||
|
|
||||||
|
A meme and GIF picker for Windows, inspired by the built-in Windows Emoji Picker (<kbd>Win</kbd>+<kbd>.</kbd>).
|
||||||
|
|
||||||
|
Press a global hotkey anywhere, a small popup appears, search your memes or Giphy, click one —
|
||||||
|
and it lands straight in whatever app you were just typing in. It lives in the system tray and
|
||||||
|
stays out of the way until you need it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Global hotkey** — opens the picker over any app. Defaults to <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd>, fully remappable (the <kbd>Win</kbd> key works too, e.g. <kbd>Win</kbd>+<kbd>Y</kbd>).
|
||||||
|
- **Your own meme library** — add images by clicking **+**, dragging files onto the window, or simply pasting with <kbd>Ctrl</kbd>+<kbd>V</kbd>. Supports JPG, PNG and GIF.
|
||||||
|
- **Giphy search** — a second tab searches Giphy directly (needs a free API key, see below).
|
||||||
|
- **Favourites** — right-click any meme to pin it to a ★ Favourites row at the top of that tab. Kept separately per source.
|
||||||
|
- **Three insert modes** — copy to clipboard, paste into the active window, or paste *and* send instantly.
|
||||||
|
- **Animated GIFs stay animated** — the clipboard is written in several formats at once so GIFs paste as real animations in Discord, Slack and Teams, rather than as a flattened still frame.
|
||||||
|
- **Search as you type** — filters your library by filename; Giphy results are debounced so typing doesn't burn through the API rate limit.
|
||||||
|
- **Runs from the tray** — optional start-with-Windows, and a movable, dismiss-on-click-away popup.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Installer (recommended)** — run `EbbesMemeClipboard-Setup-<version>.exe`. It installs per-user by
|
||||||
|
default (no admin prompt) but lets you choose "just me" or "all users", and offers optional desktop
|
||||||
|
and start-with-Windows shortcuts.
|
||||||
|
|
||||||
|
**Portable** — grab `EbbesMemeClipboard.exe` and run it. No installation, no .NET runtime needed;
|
||||||
|
everything is bundled. It'll write its library and settings to `%AppData%` as usual.
|
||||||
|
|
||||||
|
> Windows may show a SmartScreen warning on first run, because the executable isn't code-signed.
|
||||||
|
> Choose *More info → Run anyway*. Signing requires a paid certificate from a certificate authority.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Action | How |
|
||||||
|
|---|---|
|
||||||
|
| Open / close the picker | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>M</kbd>, or left-click the tray icon |
|
||||||
|
| Insert a meme | Click it |
|
||||||
|
| Add memes | **+** button, drag files in, or <kbd>Ctrl</kbd>+<kbd>V</kbd> |
|
||||||
|
| Favourite / unfavourite | Right-click a meme |
|
||||||
|
| Delete a meme | Right-click → **Remove** (goes to the Recycle Bin) |
|
||||||
|
| Search | Just start typing |
|
||||||
|
| Move the window | Drag the title bar |
|
||||||
|
| Close the picker | <kbd>Esc</kbd>, or click elsewhere |
|
||||||
|
| Settings / quit | Right-click the tray icon |
|
||||||
|
|
||||||
|
Pasting plain text into the window still goes to the search box — only images get imported.
|
||||||
|
|
||||||
|
### Insert modes
|
||||||
|
|
||||||
|
Set these under **Settings → Insert Mode**:
|
||||||
|
|
||||||
|
- **Copy to clipboard** — just copies; you paste it yourself.
|
||||||
|
- **Paste into active window** *(default)* — restores focus to the app you came from and pastes for you.
|
||||||
|
- **Paste and send instantly** — the above, plus <kbd>Enter</kbd>. Handy in chat apps, but it *will* send the message immediately, so it's off by default.
|
||||||
|
|
||||||
|
### Giphy setup
|
||||||
|
|
||||||
|
The Giphy tab needs your own free API key:
|
||||||
|
|
||||||
|
1. Go to [developers.giphy.com](https://developers.giphy.com), create an account and an app, and choose **API Key**.
|
||||||
|
2. Paste the key into **Settings → Giphy**.
|
||||||
|
|
||||||
|
The free tier allows 100 requests per hour, which is plenty for personal use. Giphy's terms
|
||||||
|
require the "Powered by GIPHY" attribution shown in the app whenever their results are displayed.
|
||||||
|
|
||||||
|
## Where your data lives
|
||||||
|
|
||||||
|
```
|
||||||
|
%AppData%\EbbesMemeClipboard\
|
||||||
|
settings.json hotkey, insert mode, Giphy API key
|
||||||
|
favorites.json favourites, per source
|
||||||
|
Library\ your imported memes + an index
|
||||||
|
|
||||||
|
%LocalAppData%\EbbesMemeClipboard\
|
||||||
|
GifCache\ downloaded Giphy GIFs (re-downloadable; safe to delete)
|
||||||
|
```
|
||||||
|
|
||||||
|
Uninstalling deliberately leaves your memes, favourites and settings in place — only the
|
||||||
|
re-downloadable cache is cleared.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- **The popup appears at the mouse cursor, not the text caret.** The real Emoji Panel can follow
|
||||||
|
the caret because it's a privileged part of the Windows shell; third-party apps have no
|
||||||
|
equivalent access.
|
||||||
|
- **Auto-paste doesn't work into apps running as administrator.** Windows blocks a normal program
|
||||||
|
from sending input to an elevated window (UIPI). The meme is still copied — just press
|
||||||
|
<kbd>Ctrl</kbd>+<kbd>V</kbd> yourself.
|
||||||
|
- **No single-instance guard yet.** If two copies run at once they'll compete for the global
|
||||||
|
hotkey. Worth checking you don't have both a Startup shortcut *and* the autostart setting enabled.
|
||||||
|
- **Windows only.** See below.
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# run it
|
||||||
|
dotnet run --project src/EbbesMemeClipboard
|
||||||
|
|
||||||
|
# portable single exe -> publish/
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
To build the installer you'll also need [Inno Setup 6](https://jrsoftware.org/isinfo.php). Note the
|
||||||
|
publish step deliberately disables .NET's own compression, so Inno's LZMA2 can compress the raw
|
||||||
|
bytes instead — that yields a noticeably smaller setup and a faster-starting app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
"%LocalAppData%\Programs\Inno Setup 6\ISCC.exe" installer\EbbesMemeClipboard.iss
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech stack
|
||||||
|
|
||||||
|
.NET 10 · WPF · MVVM ([CommunityToolkit.Mvvm](https://github.com/CommunityToolkit/dotnet)) ·
|
||||||
|
[H.NotifyIcon](https://github.com/HavenDV/H.NotifyIcon) for the tray icon ·
|
||||||
|
`Microsoft.Extensions.DependencyInjection`
|
||||||
|
|
||||||
|
### Platform support
|
||||||
|
|
||||||
|
Windows only, and not portable without a rewrite. WPF doesn't exist on macOS or Linux, and the
|
||||||
|
features that make the app work — global hotkeys, synthetic paste, tray icon, clipboard formats,
|
||||||
|
autostart — are all built directly on Win32. The data and business-logic layer would carry over
|
||||||
|
to a cross-platform UI framework such as [Avalonia](https://avaloniaui.net), but every
|
||||||
|
platform-integration service and the entire UI would need reimplementing.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
|
|||||||
@@ -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.IO;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.Services;
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
public sealed class ClipboardService : IClipboardService
|
public sealed partial class ClipboardService : IClipboardService
|
||||||
{
|
{
|
||||||
public async Task CopyLocalFileAsync(string filePath)
|
public async Task CopyLocalFileAsync(string filePath)
|
||||||
{
|
{
|
||||||
@@ -60,6 +62,86 @@ public sealed class ClipboardService : IClipboardService
|
|||||||
_ => "application/octet-stream",
|
_ => "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)
|
private static async Task SetClipboardWithRetryAsync(DataObject data)
|
||||||
{
|
{
|
||||||
const int maxAttempts = 5;
|
const int maxAttempts = 5;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.Services;
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
public interface IClipboardService
|
public interface IClipboardService
|
||||||
@@ -8,4 +10,10 @@ public interface IClipboardService
|
|||||||
/// useful, and animated GIFs survive as animations wherever possible.
|
/// useful, and animated GIFs survive as animations wherever possible.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task CopyLocalFileAsync(string filePath);
|
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);
|
IReadOnlyList<LocalMemeRecord> Search(string query);
|
||||||
string GetFullPath(LocalMemeRecord record);
|
string GetFullPath(LocalMemeRecord record);
|
||||||
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
|
Task<IReadOnlyList<LocalMemeRecord>> ImportAsync(IEnumerable<string> sourceFilePaths);
|
||||||
|
|
||||||
|
/// <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);
|
Task RemoveAsync(LocalMemeRecord record);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,27 @@ public sealed class LocalMemeLibraryService : ILocalMemeLibraryService
|
|||||||
return imported;
|
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)
|
public async Task RemoveAsync(LocalMemeRecord record)
|
||||||
{
|
{
|
||||||
var path = GetFullPath(record);
|
var path = GetFullPath(record);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ namespace EbbesMemeClipboard.ViewModels;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MemeTileViewModel : ObservableObject
|
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 readonly IGifCacheService? _cache;
|
||||||
private string? _localPath;
|
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)
|
public async Task ImportPathsAsync(IEnumerable<string> paths)
|
||||||
{
|
{
|
||||||
var candidates = paths
|
var candidates = paths
|
||||||
|
|||||||
@@ -75,9 +75,11 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</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}">
|
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}">
|
||||||
<Grid Width="88" Height="88" Margin="4">
|
<Grid Height="118" Margin="4">
|
||||||
<Button Padding="0"
|
<Button Padding="0"
|
||||||
Style="{StaticResource FlatButtonStyle}"
|
Style="{StaticResource FlatButtonStyle}"
|
||||||
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
||||||
@@ -96,7 +98,7 @@
|
|||||||
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Button.ContextMenu>
|
</Button.ContextMenu>
|
||||||
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Width="80" Height="80"/>
|
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="5"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
|
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
ItemTemplate="{StaticResource MemeTileTemplate}">
|
ItemTemplate="{StaticResource MemeTileTemplate}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<WrapPanel Orientation="Horizontal"/>
|
<UniformGrid Columns="3"/>
|
||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
@@ -182,7 +184,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid>
|
<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"
|
Foreground="#7B7D85" FontSize="13"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
TextWrapping="Wrap" TextAlignment="Center" Width="260"
|
TextWrapping="Wrap" TextAlignment="Center" Width="260"
|
||||||
@@ -207,7 +209,7 @@
|
|||||||
ItemTemplate="{StaticResource MemeTileTemplate}">
|
ItemTemplate="{StaticResource MemeTileTemplate}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<WrapPanel Orientation="Horizontal"/>
|
<UniformGrid Columns="3"/>
|
||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|||||||
@@ -108,6 +108,27 @@ public partial class PickerWindow : Window
|
|||||||
if (e.Key == Key.Escape) Hide();
|
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)
|
private void PickerWindow_OnDragOver(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||||
|
|||||||
Reference in New Issue
Block a user