Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52ca49ac85 | ||
|
|
62a257b538 | ||
|
|
b1ffb3924a | ||
|
|
9b0ffcccdf | ||
|
|
b10370f8bc | ||
|
|
6409132659 | ||
|
|
b6644ebecf | ||
|
|
4c755919d4 |
+132
-69
@@ -26,15 +26,24 @@ 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)
|
||||||
- Three insert modes, chosen in Settings: **Copy only / Paste into active window /
|
- Three insert modes, chosen in Settings: **Copy only / Paste into active window /
|
||||||
Paste and send instantly**
|
Paste and send instantly**
|
||||||
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons
|
- Settings window: hotkey capture (incl. Windows key), insert mode radio buttons,
|
||||||
- Consistent dark styling across all menus (tray + tile right-click)
|
**autostart toggle**, **Giphy API key field**
|
||||||
- Persistence: memes and settings survive restarts
|
- **Source tabs in the picker: Local / Giphy**, with debounced Giphy search, remote
|
||||||
|
thumbnails, download-on-select caching, and the required "Powered by GIPHY" badge
|
||||||
|
- **Windows autostart** via the HKCU Run key, toggled in Settings (verified: checkbox and
|
||||||
|
registry track each other in both directions)
|
||||||
|
- **Per-source favourites**: right-click any tile → "Add to favourites", shown in a
|
||||||
|
★ Favourites section at the top of that tab, with a star overlay on favourited tiles.
|
||||||
|
Scoped per source (Local favourites don't appear on the Giphy tab) and persisted.
|
||||||
|
- App title shown top-left of the picker; the title row doubles as the drag surface
|
||||||
|
- Consistent dark styling across all menus (tray + tile right-click) **and scrollbars**
|
||||||
|
- Persistence: memes, favourites and settings survive restarts
|
||||||
|
|
||||||
**Build is clean** (0 warnings, 0 errors). Working tree was clean at last check;
|
**Build is clean** (0 warnings, 0 errors). Working tree was clean at last check;
|
||||||
`publish/` is gitignored.
|
`publish/` is gitignored.
|
||||||
@@ -43,10 +52,18 @@ Everything below is **implemented and verified working by actually running the a
|
|||||||
|
|
||||||
```
|
```
|
||||||
%AppData%\EbbesMemeClipboard\
|
%AppData%\EbbesMemeClipboard\
|
||||||
settings.json InsertMode, HotkeyModifiers, HotkeyKey
|
settings.json InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
|
||||||
|
favorites.json list of FavoriteRecord (Source, Key, Title, Preview/FullUrl, DateAdded)
|
||||||
Library\
|
Library\
|
||||||
index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded)
|
index.json list of LocalMemeRecord (Id, FileName, OriginalFileName, DateAdded)
|
||||||
<guid>.png/.jpg/.gif imported files, stored under a GUID name
|
<guid>.png/.jpg/.gif imported files, stored under a GUID name
|
||||||
|
|
||||||
|
%LocalAppData%\EbbesMemeClipboard\
|
||||||
|
GifCache\ downloaded Giphy GIFs (re-downloadable cache, so Local not Roaming)
|
||||||
|
|
||||||
|
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\EbbesMemeClipboard
|
||||||
|
autostart entry; the registry is the single source of truth for
|
||||||
|
this (deliberately NOT mirrored into settings.json, to avoid drift)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -60,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.
|
||||||
@@ -79,17 +127,25 @@ faster.
|
|||||||
src/EbbesMemeClipboard/
|
src/EbbesMemeClipboard/
|
||||||
App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration
|
App.xaml / App.xaml.cs composition root: DI container, tray icon, hotkey registration
|
||||||
Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide)
|
Styles/DarkMenuStyles.xaml implicit ContextMenu/MenuItem/Separator styles (app-wide)
|
||||||
|
Styles/DarkScrollBarStyles.xaml implicit slim dark ScrollBar style (app-wide)
|
||||||
Models/
|
Models/
|
||||||
LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded
|
LocalMemeRecord.cs Id, FileName, OriginalFileName, DateAdded
|
||||||
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey
|
AppSettings.cs InsertMode, HotkeyModifiers, HotkeyKey, GiphyApiKey
|
||||||
InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend
|
InsertMode.cs CopyOnly | PasteIntoActiveWindow | PasteAndSend
|
||||||
|
MemeSource.cs Local | Giphy
|
||||||
|
GifSearchResult.cs provider-agnostic remote hit
|
||||||
|
FavoriteRecord.cs per-source favourite (remote ones carry their own URLs)
|
||||||
Services/
|
Services/
|
||||||
ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json
|
ILocalMemeLibraryService / LocalMemeLibraryService import/search/remove, index.json
|
||||||
IClipboardService / ClipboardService multi-format clipboard write
|
IClipboardService / ClipboardService multi-format clipboard write
|
||||||
IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource
|
IGlobalHotkeyService / GlobalHotkeyService RegisterHotKey + message-only HwndSource
|
||||||
ISettingsService / SettingsService System.Text.Json persistence
|
ISettingsService / SettingsService System.Text.Json persistence
|
||||||
IAutoPasteService / AutoPasteService foreground restore + SendInput paste
|
IAutoPasteService / AutoPasteService foreground restore + SendInput paste
|
||||||
ImageDecoding.cs first-frame decode helper
|
IAutostartService / AutostartService HKCU Run key
|
||||||
|
IGifProvider / GiphyGifProvider remote search behind a provider interface
|
||||||
|
IGifCacheService / GifCacheService download + cache remote GIFs
|
||||||
|
IFavoritesService / FavoritesService per-source favourites, favorites.json
|
||||||
|
ImageDecoding.cs first-frame decode helper (file + bytes)
|
||||||
HotkeyFormatter.cs "Ctrl + Alt + M" display strings
|
HotkeyFormatter.cs "Ctrl + Alt + M" display strings
|
||||||
Native/
|
Native/
|
||||||
NativeMethods.cs P/Invoke declarations
|
NativeMethods.cs P/Invoke declarations
|
||||||
@@ -132,9 +188,24 @@ code-behind fields. Look elements up by `Tag`/traversal instead.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `CF_HDROP` (file drop list) | Discord/Slack/Teams/Explorer paste the real file — preserves GIF animation |
|
| `CF_HDROP` (file drop list) | Discord/Slack/Teams/Explorer paste the real file — preserves GIF animation |
|
||||||
| `CF_DIB` (`SetImage`) | Bitmap-only apps like Paint |
|
| `CF_DIB` (`SetImage`) | Bitmap-only apps like Paint |
|
||||||
| `"PNG"` (registered format) | **Chromium-based apps (Teams, Slack, Discord, browsers) prefer this over CF_DIB** and may paste nothing at all without it |
|
| `"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,
|
||||||
|
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
|
||||||
|
through to `CF_HTML`, which carries the full animated data URI. Verified: a GIF's clipboard
|
||||||
|
formats are FileDrop/Bitmap/HTML with no PNG, while a static image still gets PNG.
|
||||||
|
|
||||||
Two traps here:
|
Two traps here:
|
||||||
- WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/
|
- WPF does **NOT** auto-wrap `DataFormats.Html`. The `Version/StartHTML/EndHTML/
|
||||||
StartFragment/EndFragment` header with exact **byte** offsets must be built by hand
|
StartFragment/EndFragment` header with exact **byte** offsets must be built by hand
|
||||||
@@ -206,79 +277,68 @@ Automated UI testing of this app is unusually fiddly. What works:
|
|||||||
for a name containing "Meme Clipboard". This finds it even when tucked in the overflow
|
for a name containing "Meme Clipboard". This finds it even when tucked in the overflow
|
||||||
area, unlike screenshots.
|
area, unlike screenshots.
|
||||||
|
|
||||||
Clean up after testing: kill `EbbesMemeClipboard` processes and remove
|
Clean up after testing: kill `EbbesMemeClipboard` processes, remove
|
||||||
`%AppData%\EbbesMemeClipboard` so seeded test data doesn't leak into real use.
|
`%AppData%\EbbesMemeClipboard` and `%LocalAppData%\EbbesMemeClipboard`, and **check the
|
||||||
|
autostart Run key isn't left enabled** if autostart was exercised — that one writes to the
|
||||||
|
user's real machine state, not just app-local files.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pending requests (not yet implemented)
|
## Giphy integration notes
|
||||||
|
|
||||||
From the user's most recent batch. **None of these are started.**
|
**Tenor is dead.** The user originally asked for Tenor; research confirmed Google **shut
|
||||||
|
down the public Tenor API on 2026-06-30** (corroborated by 9to5Google, Shacknews,
|
||||||
|
Slashdot). Not buildable. **Giphy** was chosen instead and is what's implemented.
|
||||||
|
|
||||||
### 1. Windows auto-start, with a toggle in Settings
|
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={q}&limit={n}&rating=pg-13`
|
||||||
Straightforward. Write `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
|
- Empty query falls back to `/trending` with the same parameters.
|
||||||
**Important**: use `Environment.ProcessPath`, **not** `Assembly.Location` — the latter
|
- Response: `data[].images.{fixed_width,original}.url` — `fixed_width` for grid
|
||||||
returns an empty string in single-file published mode. Add an `IAutostartService` +
|
thumbnails, `original` for the full-quality animated file that gets pasted.
|
||||||
a checkbox in `SettingsWindow`.
|
- **The user supplies their own key** in Settings. Free tier is 100 req/hr; the ~$9k/yr
|
||||||
|
production tier is irrelevant for personal use. Without a key the tab shows guidance
|
||||||
### 2. Tab/dropdown for meme sources (Local, Giphy)
|
instead of failing at request time (`IGifProvider.IsConfigured`).
|
||||||
UI shell for multiple sources, plus the Giphy integration behind it.
|
- Searches are **debounced 350ms** and cancel the previous in-flight request — without
|
||||||
|
that, every keystroke burns a request against the free rate limit.
|
||||||
**Critical context — Tenor is dead.** The user originally asked for Tenor; research during
|
- Giphy's ToS **requires the visible "Powered by GIPHY" badge**, rendered in the picker
|
||||||
planning confirmed Google **shut down the public Tenor API on 2026-06-30** (corroborated by
|
footer whenever the Giphy tab is active. Don't remove it.
|
||||||
9to5Google, Shacknews, Slashdot). It is not buildable. The user chose **Giphy** as the
|
|
||||||
replacement. Notes:
|
|
||||||
- `GET https://api.giphy.com/v1/gifs/search?api_key={key}&q={query}&limit={n}&rating=pg-13`
|
|
||||||
- Response: `data[].images.{fixed_width,downsized,original}.url`
|
|
||||||
- Free "beta" key is 100 req/hr — plenty for personal use. Production tier is ~$9k/yr and
|
|
||||||
irrelevant here. **The user needs to supply their own key** (add a field in Settings).
|
|
||||||
- Giphy's ToS **requires a visible "Powered by GIPHY" badge** wherever results appear.
|
|
||||||
This is a real UI requirement, not optional attribution.
|
|
||||||
- Build behind an `IGifProvider` interface so another provider can be swapped in later.
|
|
||||||
- Remote GIFs must be **downloaded to a local cache file before** they can go on the
|
|
||||||
clipboard as CF_HDROP.
|
|
||||||
- Klipy (`api.klipy.com`) is a viable alternative if Giphy becomes a problem — founded by
|
- Klipy (`api.klipy.com`) is a viable alternative if Giphy becomes a problem — founded by
|
||||||
ex-Tenor people, deliberately mirrors Tenor's API shape, free.
|
ex-Tenor people, deliberately mirrors Tenor's API shape, free. Add it as a second
|
||||||
|
`IGifProvider`; the tab strip and view models are already provider-agnostic.
|
||||||
|
|
||||||
### 3. "Central store specific to the application for all local memes"
|
**Untested by the assistant**: actual Giphy search results, remote thumbnail loading, and
|
||||||
**⚠️ STILL AMBIGUOUS — ASK THE USER BEFORE BUILDING.**
|
download-on-select were never exercised against the live API, because no API key was
|
||||||
The app *already* stores memes centrally in `%AppData%\EbbesMemeClipboard\Library`. When
|
available. The code paths are written but unverified end-to-end — if something misbehaves
|
||||||
asked to clarify, the user selected "Something else" but the conversation moved on before
|
once a key is entered, start with `GiphyGifProvider.SearchAsync` (JSON field names) and
|
||||||
they explained. Options offered (and rejected) were: (a) save Giphy results into the local
|
`GifCacheService.GetOrDownloadAsync`.
|
||||||
library, (b) configurable library folder location. So it's something other than those two —
|
|
||||||
**get a concrete description first.**
|
|
||||||
|
|
||||||
### 4. Fix GIFs — "clicking a GIF doesn't copy/paste it correctly"
|
---
|
||||||
User confirmed this specific symptom (not thumbnails, not crashes, not lag).
|
|
||||||
|
|
||||||
**Strong root-cause hypothesis (not yet verified — verify before/while fixing):**
|
## Pending requests
|
||||||
`ClipboardService` sets the `"PNG"` clipboard format from `firstFrame` — i.e. **frame 0 of
|
|
||||||
the GIF re-encoded as a static PNG**. Chromium-based apps *prefer* the `"PNG"` format over
|
|
||||||
everything else (that preference is exactly why it was added, to fix static-image pasting
|
|
||||||
into Teams). So for a GIF, Teams/Discord grab the static first frame instead of the animated
|
|
||||||
GIF → the GIF pastes as a still image.
|
|
||||||
|
|
||||||
**Suggested fix**: skip the `"PNG"` format when the file is a GIF, so Chromium falls back to
|
### ⚠️ 1. "Central store specific to the application for all local memes" — STILL AMBIGUOUS
|
||||||
`CF_HTML` (which already carries the full animated `data:image/gif;base64,...` URI) or
|
**Ask the user before building.** The app *already* stores memes centrally in
|
||||||
`CF_HDROP`. `ImageDecoding.IsGif()` already exists for the check. Keep `CF_DIB` so Paint
|
`%AppData%\EbbesMemeClipboard\Library`. When asked to clarify, the user chose "Something
|
||||||
still gets something. Test in both Teams and Paint, since these two want opposite things.
|
else" but the conversation moved on before they explained. Options offered *and rejected*
|
||||||
|
were: (a) save Giphy results into the local library, (b) configurable library folder
|
||||||
|
location. So it's something other than those two — get a concrete description first.
|
||||||
|
|
||||||
**Also worth fixing while in there** (separate, minor, pre-existing):
|
### 2. Paste-send reliability ("Teams is sometimes too slow")
|
||||||
`ImageDecoding.DecodeFirstFrame` **ignores `decodePixelWidth` for GIFs** — it returns the
|
Not yet addressed. Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before
|
||||||
`GifBitmapDecoder` frame before reaching the `DecodePixelWidth` logic. So GIF thumbnails
|
Ctrl+V), `PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps, so a slow app will
|
||||||
decode at full resolution while static images are capped at 150px. A library with many
|
still occasionally miss.
|
||||||
large GIFs will use more memory than it should.
|
|
||||||
|
|
||||||
### 5. Paste-send reliability ("Teams is sometimes too slow")
|
Bumping the constants is the cheap fix but slows every paste for everyone. Better: **poll
|
||||||
Current delays in `AutoPasteService`: `FocusSettleDelay` 200ms (before Ctrl+V),
|
for readiness instead of guessing** — verify the target window is genuinely foreground
|
||||||
`PasteSettleDelay` 400ms (before Enter). Both are fixed sleeps — a slow app will still miss.
|
before sending Ctrl+V, and confirm rather than firing Enter blind. Note the failure mode
|
||||||
|
already observed: if Enter fires when the paste didn't land, **Teams sends an empty
|
||||||
|
message**, so the Enter step should be conservative — a wrong guess posts to a real chat.
|
||||||
|
|
||||||
Bumping the constants is the cheap fix, but it makes every paste slower for everyone. A
|
### 3. Possible follow-ups (not requested, just noted)
|
||||||
better approach is to **poll for readiness instead of guessing**: e.g. verify the target
|
- **Single-instance protection** (named Mutex + named pipe). Currently two instances fight
|
||||||
window is actually foreground before sending Ctrl+V, and for the send step consider
|
over the global hotkey; this caused a confusing phantom "regression" during development.
|
||||||
retrying/confirming rather than firing Enter blind. Note the failure mode already seen:
|
- **`WPF-UI` is referenced but unused** — intended for Mica/Fluent polish. Use it or drop it.
|
||||||
if Enter fires when the paste didn't land, **Teams sends an empty message** — so the Enter
|
- **Animated GIF previews on hover** (currently static first frame only) — `GifFramePlayer`
|
||||||
step should be conservative, since a wrong guess posts to a real chat.
|
control was designed for this in the original plan but never built.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -294,6 +354,9 @@ step should be conservative, since a wrong guess posts to a real chat.
|
|||||||
chat apps. Default insert mode is `PasteIntoActiveWindow`.
|
chat apps. Default insert mode is `PasteIntoActiveWindow`.
|
||||||
- No Settings-window dark title bar (content is dark, title bar is standard OS chrome).
|
- No Settings-window dark title bar (content is dark, title bar is standard OS chrome).
|
||||||
User was offered a fix and it wasn't prioritized.
|
User was offered a fix and it wasn't prioritized.
|
||||||
|
- **Autostart state lives only in the registry**, not in `settings.json`. Mirroring it would
|
||||||
|
let the two drift apart if the entry is removed via Task Manager or another tool; the
|
||||||
|
Settings checkbox reads live registry state each time the window opens.
|
||||||
|
|
||||||
## Known limitations (inherent, not bugs)
|
## Known limitations (inherent, not bugs)
|
||||||
|
|
||||||
|
|||||||
@@ -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,143 @@
|
|||||||
# 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). Scroll and it keeps loading more results. You can also paste a Giphy link straight into the search box to jump to that one GIF.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
You can also paste a Giphy link into the search box — share links
|
||||||
|
(`giphy.com/gifs/funny-cat-<id>`), direct media links (`media.giphy.com/media/<id>/giphy.gif`)
|
||||||
|
and `i.giphy.com` image links all work, and resolve to that single GIF.
|
||||||
|
|
||||||
|
## 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.5"
|
||||||
|
#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;
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
|
<ResourceDictionary Source="Styles/DarkMenuStyles.xaml"/>
|
||||||
|
<ResourceDictionary Source="Styles/DarkScrollBarStyles.xaml"/>
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
<tb:TaskbarIcon x:Key="TrayIcon"
|
<tb:TaskbarIcon x:Key="TrayIcon"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Net.Http;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using EbbesMemeClipboard.Services;
|
using EbbesMemeClipboard.Services;
|
||||||
using EbbesMemeClipboard.ViewModels;
|
using EbbesMemeClipboard.ViewModels;
|
||||||
@@ -27,6 +28,13 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
|
services.AddSingleton<IGlobalHotkeyService, GlobalHotkeyService>();
|
||||||
services.AddSingleton<ISettingsService, SettingsService>();
|
services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
services.AddSingleton<IAutoPasteService, AutoPasteService>();
|
services.AddSingleton<IAutoPasteService, AutoPasteService>();
|
||||||
|
services.AddSingleton<IAutostartService, AutostartService>();
|
||||||
|
// One long-lived HttpClient: the socket-exhaustion problem IHttpClientFactory solves
|
||||||
|
// comes from repeatedly constructing clients, which a singleton avoids anyway.
|
||||||
|
services.AddSingleton<HttpClient>();
|
||||||
|
services.AddSingleton<IGifCacheService, GifCacheService>();
|
||||||
|
services.AddSingleton<IFavoritesService, FavoritesService>();
|
||||||
|
services.AddSingleton<IGifProvider, GiphyGifProvider>();
|
||||||
services.AddSingleton<PickerViewModel>();
|
services.AddSingleton<PickerViewModel>();
|
||||||
services.AddSingleton<PickerWindow>();
|
services.AddSingleton<PickerWindow>();
|
||||||
services.AddSingleton<SettingsViewModel>();
|
services.AddSingleton<SettingsViewModel>();
|
||||||
@@ -72,11 +80,7 @@ public partial class App : Application
|
|||||||
|
|
||||||
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
|
private void TrayOpenPicker_Click(object sender, RoutedEventArgs e) => _pickerWindow.ShowNearCursor();
|
||||||
|
|
||||||
private void TraySettings_Click(object sender, RoutedEventArgs e)
|
private void TraySettings_Click(object sender, RoutedEventArgs e) => _settingsWindow.ShowSettings();
|
||||||
{
|
|
||||||
_settingsWindow.Show();
|
|
||||||
_settingsWindow.Activate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
|
private void TrayExit_Click(object sender, RoutedEventArgs e) => Shutdown();
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 766 B After Width: | Height: | Size: 18 KiB |
@@ -22,6 +22,7 @@
|
|||||||
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
<PackageReference Include="XamlAnimatedGif" Version="2.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -7,4 +7,26 @@ public sealed class AppSettings
|
|||||||
public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow;
|
public InsertMode InsertMode { get; set; } = InsertMode.PasteIntoActiveWindow;
|
||||||
public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt;
|
public ModifierKeys HotkeyModifiers { get; set; } = ModifierKeys.Control | ModifierKeys.Alt;
|
||||||
public Key HotkeyKey { get; set; } = Key.M;
|
public Key HotkeyKey { get; set; } = Key.M;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User-supplied Giphy API key. Null/empty disables the Giphy tab's searching rather than
|
||||||
|
/// failing at request time. Autostart is deliberately NOT stored here - the registry Run
|
||||||
|
/// key is its own source of truth, so caching it would risk the two drifting apart.
|
||||||
|
/// </summary>
|
||||||
|
public string? GiphyApiKey { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Animate GIF thumbnails in the grid. Off by default: playing a screenful of GIFs at once
|
||||||
|
/// costs noticeably more CPU and memory than showing static first frames.
|
||||||
|
/// </summary>
|
||||||
|
public bool PlayGifPreviews { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which tab the picker reopens on. Persisted so the choice survives an app restart, not
|
||||||
|
/// just a hide/show. Defaults to the favourites view, since that is where the memes you
|
||||||
|
/// actually reach for most often live.
|
||||||
|
/// </summary>
|
||||||
|
public MemeSource LastSource { get; set; } = MemeSource.Local;
|
||||||
|
|
||||||
|
public bool LastShowingFavorites { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A favourited item, scoped to the source it came from. Remote favourites carry their own
|
||||||
|
/// URLs so the favourites row can be rebuilt without re-hitting the provider's API (which
|
||||||
|
/// would otherwise cost a request just to show what the user already saved).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FavoriteRecord
|
||||||
|
{
|
||||||
|
public required MemeSource Source { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Local: the LocalMemeRecord Id. Remote: the provider's own item Id.</summary>
|
||||||
|
public required string Key { get; init; }
|
||||||
|
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Remote only.</summary>
|
||||||
|
public string? PreviewUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Remote only.</summary>
|
||||||
|
public string? FullUrl { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset DateAdded { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A provider-agnostic search hit. Deliberately not Giphy-shaped so a second provider can be
|
||||||
|
/// added without touching the view models.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GifSearchResult
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Smaller rendition used for the grid thumbnail.</summary>
|
||||||
|
public required string PreviewUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Full-quality animated rendition, downloaded when the user picks the tile.</summary>
|
||||||
|
public required string FullUrl { get; init; }
|
||||||
|
|
||||||
|
public required MemeSource Source { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
public enum MemeSource
|
||||||
|
{
|
||||||
|
/// <summary>Memes the user added themselves, stored under %AppData%.</summary>
|
||||||
|
Local,
|
||||||
|
|
||||||
|
/// <summary>Search results from the Giphy API.</summary>
|
||||||
|
Giphy,
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public sealed class AutostartService : IAutostartService
|
||||||
|
{
|
||||||
|
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
|
||||||
|
private const string ValueName = "EbbesMemeClipboard";
|
||||||
|
|
||||||
|
public bool IsEnabled
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
|
||||||
|
return key?.GetValue(ValueName) is string existing && existing.Contains(ExecutablePath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true)
|
||||||
|
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
// Quoted because the path can contain spaces, which Windows would otherwise treat
|
||||||
|
// as an argument boundary and fail to launch.
|
||||||
|
key.SetValue(ValueName, $"\"{ExecutablePath}\"");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Environment.ProcessPath, not Assembly.Location: in a single-file published build the
|
||||||
|
/// managed assemblies are never extracted to disk, so Assembly.Location returns an empty
|
||||||
|
/// string and the registry entry would point at nothing.
|
||||||
|
/// </summary>
|
||||||
|
private static string ExecutablePath => Environment.ProcessPath ?? string.Empty;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
@@ -27,12 +29,20 @@ public sealed class ClipboardService : IClipboardService
|
|||||||
// reading a pasted image, because those older formats are lossy for transparency. This
|
// reading a pasted image, because those older formats are lossy for transparency. This
|
||||||
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
|
// covers Teams, Slack, Discord's web layer, and browsers - without it, paste into any of
|
||||||
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
|
// them can silently produce nothing even though CF_DIB is present and perfectly valid.
|
||||||
|
//
|
||||||
|
// Deliberately skipped for GIFs: a PNG can only ever hold one static frame, and since
|
||||||
|
// Chromium PREFERS this format over all others, offering it for a GIF is what makes
|
||||||
|
// animated GIFs paste as a still first frame. Omitting it lets those targets fall
|
||||||
|
// through to CF_HTML below, which carries the full animated data URI.
|
||||||
|
if (!ImageDecoding.IsGif(filePath))
|
||||||
|
{
|
||||||
using var pngStream = new MemoryStream();
|
using var pngStream = new MemoryStream();
|
||||||
var pngEncoder = new PngBitmapEncoder();
|
var pngEncoder = new PngBitmapEncoder();
|
||||||
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
|
pngEncoder.Frames.Add(BitmapFrame.Create(firstFrame));
|
||||||
pngEncoder.Save(pngStream);
|
pngEncoder.Save(pngStream);
|
||||||
pngStream.Position = 0;
|
pngStream.Position = 0;
|
||||||
data.SetData("PNG", pngStream);
|
data.SetData("PNG", pngStream);
|
||||||
|
}
|
||||||
|
|
||||||
// CF_HTML: the other route those same web-based paste targets check, and the only one of
|
// CF_HTML: the other route those same web-based paste targets check, and the only one of
|
||||||
// these formats that preserves animation for them, for GIFs.
|
// these formats that preserves animation for them, for GIFs.
|
||||||
@@ -52,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;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public sealed class FavoritesService : IFavoritesService
|
||||||
|
{
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly List<FavoriteRecord> _records;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
public FavoritesService()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
var dir = Path.Combine(appData, "EbbesMemeClipboard");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
_path = Path.Combine(dir, "favorites.json");
|
||||||
|
_records = Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<FavoriteRecord> GetForSource(MemeSource source) =>
|
||||||
|
_records.Where(r => r.Source == source)
|
||||||
|
.OrderByDescending(r => r.DateAdded)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public bool IsFavorite(MemeSource source, string key) =>
|
||||||
|
_records.Any(r => r.Source == source && r.Key == key);
|
||||||
|
|
||||||
|
public async Task AddAsync(FavoriteRecord record)
|
||||||
|
{
|
||||||
|
if (IsFavorite(record.Source, record.Key)) return;
|
||||||
|
|
||||||
|
_records.Add(record);
|
||||||
|
await SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAsync(MemeSource source, string key)
|
||||||
|
{
|
||||||
|
int removed = _records.RemoveAll(r => r.Source == source && r.Key == key);
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
await SaveAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FavoriteRecord> Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_path)) return new List<FavoriteRecord>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(_path);
|
||||||
|
return JsonSerializer.Deserialize<List<FavoriteRecord>>(json, _jsonOptions) ?? new List<FavoriteRecord>();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Corrupt file: start clean rather than blocking the whole picker from opening.
|
||||||
|
return new List<FavoriteRecord>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(_records, _jsonOptions);
|
||||||
|
await File.WriteAllTextAsync(_path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public sealed class GifCacheService : IGifCacheService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _cacheRoot;
|
||||||
|
|
||||||
|
public GifCacheService(HttpClient http)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
// LocalApplicationData rather than the roaming AppData used by the library: this is
|
||||||
|
// re-downloadable cache, not user content, so it shouldn't follow a roaming profile.
|
||||||
|
_cacheRoot = Path.Combine(localAppData, "EbbesMemeClipboard", "GifCache");
|
||||||
|
Directory.CreateDirectory(_cacheRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetOrDownloadAsync(GifSearchResult result, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var safeId = string.Concat(result.Id.Where(char.IsLetterOrDigit));
|
||||||
|
var path = Path.Combine(_cacheRoot, $"{result.Source}_{safeId}.gif");
|
||||||
|
|
||||||
|
if (File.Exists(path) && new FileInfo(path).Length > 0)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await _http.GetByteArrayAsync(result.FullUrl, ct);
|
||||||
|
|
||||||
|
// Write to a temp name then move, so an interrupted download can't leave a truncated
|
||||||
|
// file behind that later runs would treat as a valid cache hit.
|
||||||
|
var tempPath = path + ".partial";
|
||||||
|
await File.WriteAllBytesAsync(tempPath, bytes, ct);
|
||||||
|
File.Move(tempPath, path, overwrite: true);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> DownloadPreviewAsync(GifSearchResult result, CancellationToken ct) =>
|
||||||
|
_http.GetByteArrayAsync(result.PreviewUrl, ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public sealed partial class GiphyGifProvider : IGifProvider
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://api.giphy.com/v1/gifs";
|
||||||
|
private const string Rating = "pg-13";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
|
||||||
|
public MemeSource Source => MemeSource.Giphy;
|
||||||
|
|
||||||
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.Current.GiphyApiKey);
|
||||||
|
|
||||||
|
public string NotConfiguredMessage =>
|
||||||
|
"No Giphy API key set. Add one in Settings - a free key is available from developers.giphy.com.";
|
||||||
|
|
||||||
|
public string SearchPlaceholder => "Search Giphy, or paste a GIF link...";
|
||||||
|
|
||||||
|
public GiphyGifProvider(HttpClient http, ISettingsService settings)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, int offset, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!IsConfigured) return Array.Empty<GifSearchResult>();
|
||||||
|
|
||||||
|
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
|
||||||
|
var url = string.IsNullOrWhiteSpace(query)
|
||||||
|
? $"{BaseUrl}/trending?api_key={apiKey}&limit={limit}&offset={offset}&rating={Rating}"
|
||||||
|
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&offset={offset}&rating={Rating}";
|
||||||
|
|
||||||
|
var response = await _http.GetAsync(url, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<GiphyListResponse>(cancellationToken: ct);
|
||||||
|
if (payload?.Data is null) return Array.Empty<GifSearchResult>();
|
||||||
|
|
||||||
|
return payload.Data.Select(ToResult).OfType<GifSearchResult>().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanResolveLink(string text) => TryExtractId(text, out _);
|
||||||
|
|
||||||
|
public async Task<GifSearchResult?> ResolveLinkAsync(string text, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!IsConfigured || !TryExtractId(text, out var id)) return null;
|
||||||
|
|
||||||
|
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
|
||||||
|
var response = await _http.GetAsync($"{BaseUrl}/{Uri.EscapeDataString(id)}?api_key={apiKey}", ct);
|
||||||
|
|
||||||
|
// A link can easily point at something deleted or region-blocked; treat that as "no
|
||||||
|
// result" rather than surfacing a raw HTTP error to the user.
|
||||||
|
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.BadRequest) return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<GiphySingleResponse>(cancellationToken: ct);
|
||||||
|
return payload?.Data is null ? null : ToResult(payload.Data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GifSearchResult? ToResult(GiphyItem item)
|
||||||
|
{
|
||||||
|
var preview = item.Images?.FixedWidth?.Url ?? item.Images?.Original?.Url;
|
||||||
|
var full = item.Images?.Original?.Url ?? item.Images?.FixedWidth?.Url;
|
||||||
|
if (preview is null || full is null || item.Id is null) return null;
|
||||||
|
|
||||||
|
return new GifSearchResult
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title,
|
||||||
|
PreviewUrl = preview,
|
||||||
|
FullUrl = full,
|
||||||
|
Source = MemeSource.Giphy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls the GIF id out of the various shapes a Giphy link can take - the share URL
|
||||||
|
/// (giphy.com/gifs/some-slug-ID), a direct media URL, or an i.giphy.com image URL.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryExtractId(string text, out string id)
|
||||||
|
{
|
||||||
|
id = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(text) || !text.Contains("giphy.com", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var trimmed = text.Trim();
|
||||||
|
foreach (var regex in new[] { MediaUrlRegex(), DirectImageUrlRegex(), ShareUrlRegex() })
|
||||||
|
{
|
||||||
|
var match = regex.Match(trimmed);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
id = match.Groups["id"].Value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// media.giphy.com/media/<optional rendition token>/<id>/giphy.gif
|
||||||
|
[GeneratedRegex(@"media\d*\.giphy\.com/media/(?:.+/)?(?<id>[A-Za-z0-9]{6,})/[^/]*\.(?:gif|webp|mp4)", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex MediaUrlRegex();
|
||||||
|
|
||||||
|
// i.giphy.com/<id>.gif
|
||||||
|
[GeneratedRegex(@"i\.giphy\.com/(?<id>[A-Za-z0-9]{6,})\.", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex DirectImageUrlRegex();
|
||||||
|
|
||||||
|
// giphy.com/gifs/funny-cat-<id> (also /clips/, /stickers/, /embed/)
|
||||||
|
[GeneratedRegex(@"giphy\.com/(?:gifs|clips|stickers|embed)/(?:[^/?#]*-)?(?<id>[A-Za-z0-9]{6,})", RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex ShareUrlRegex();
|
||||||
|
|
||||||
|
private sealed class GiphyListResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")] public List<GiphyItem>? Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GiphySingleResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")] public GiphyItem? Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GiphyItem
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||||
|
[JsonPropertyName("title")] public string? Title { get; set; }
|
||||||
|
[JsonPropertyName("images")] public GiphyImages? Images { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GiphyImages
|
||||||
|
{
|
||||||
|
[JsonPropertyName("fixed_width")] public GiphyRendition? FixedWidth { get; set; }
|
||||||
|
[JsonPropertyName("original")] public GiphyRendition? Original { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GiphyRendition
|
||||||
|
{
|
||||||
|
[JsonPropertyName("url")] public string? Url { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public interface IAutostartService
|
||||||
|
{
|
||||||
|
/// <summary>Reads the live registry state rather than a cached setting, so it stays truthful
|
||||||
|
/// even if the user removes the entry through Task Manager or another tool.</summary>
|
||||||
|
bool IsEnabled { get; }
|
||||||
|
|
||||||
|
void SetEnabled(bool enabled);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public interface IFavoritesService
|
||||||
|
{
|
||||||
|
IReadOnlyList<FavoriteRecord> GetForSource(MemeSource source);
|
||||||
|
bool IsFavorite(MemeSource source, string key);
|
||||||
|
Task AddAsync(FavoriteRecord record);
|
||||||
|
Task RemoveAsync(MemeSource source, string key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public interface IGifCacheService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures the result's full-quality GIF exists as a real file on disk and returns its path.
|
||||||
|
/// Required before a remote result can go on the clipboard at all, since the file-drop
|
||||||
|
/// format (the one that preserves animation for chat apps) needs an actual local file.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> GetOrDownloadAsync(GifSearchResult result, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Downloads a preview rendition into memory for grid thumbnails.</summary>
|
||||||
|
Task<byte[]> DownloadPreviewAsync(GifSearchResult result, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
|
namespace EbbesMemeClipboard.Services;
|
||||||
|
|
||||||
|
public interface IGifProvider
|
||||||
|
{
|
||||||
|
MemeSource Source { get; }
|
||||||
|
|
||||||
|
/// <summary>False when the provider isn't usable yet (e.g. no API key configured).</summary>
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
|
||||||
|
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
|
||||||
|
string NotConfiguredMessage { get; }
|
||||||
|
|
||||||
|
/// <summary>Placeholder shown in the search box while it's empty.</summary>
|
||||||
|
string SearchPlaceholder { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An empty query returns whatever the provider considers trending. <paramref name="offset"/>
|
||||||
|
/// pages through results for infinite scrolling.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, int offset, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>True when the text looks like a link to a single item on this provider.</summary>
|
||||||
|
bool CanResolveLink(string text);
|
||||||
|
|
||||||
|
/// <summary>Resolves a link from <see cref="CanResolveLink"/> to one result, or null if it can't be found.</summary>
|
||||||
|
Task<GifSearchResult?> ResolveLinkAsync(string text, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Windows.Media;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.Services;
|
namespace EbbesMemeClipboard.Services;
|
||||||
@@ -9,7 +10,7 @@ internal static class ImageDecoding
|
|||||||
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
|
Path.GetExtension(path).Equals(".gif", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decodes just the first frame of an image (for GIFs, frame 0 only - the rest of the
|
/// Decodes just the first frame of an image file (for GIFs, frame 0 only - the rest of the
|
||||||
/// animation is never touched). The result is frozen so it can be handed back across
|
/// animation is never touched). The result is frozen so it can be handed back across
|
||||||
/// threads safely when called from a background thread.
|
/// threads safely when called from a background thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -18,9 +19,7 @@ internal static class ImageDecoding
|
|||||||
if (IsGif(path))
|
if (IsGif(path))
|
||||||
{
|
{
|
||||||
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
var decoder = new GifBitmapDecoder(new Uri(path), BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||||
var frame = decoder.Frames[0];
|
return Finalize(decoder.Frames[0], decodePixelWidth);
|
||||||
frame.Freeze();
|
|
||||||
return frame;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var bitmap = new BitmapImage();
|
var bitmap = new BitmapImage();
|
||||||
@@ -35,4 +34,31 @@ internal static class ImageDecoding
|
|||||||
bitmap.Freeze();
|
bitmap.Freeze();
|
||||||
return bitmap;
|
return bitmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Same as DecodeFirstFrame but for bytes already in memory (remote previews).</summary>
|
||||||
|
internal static BitmapSource DecodeFirstFrameFromBytes(byte[] data, int? decodePixelWidth = null)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(data);
|
||||||
|
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
|
||||||
|
return Finalize(decoder.Frames[0], decodePixelWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scales a decoded frame down to the requested width and freezes it. GIF frames can't use
|
||||||
|
/// BitmapImage.DecodePixelWidth (that only applies when decoding from a URI), so without
|
||||||
|
/// this every GIF thumbnail would sit in memory at full resolution.
|
||||||
|
/// </summary>
|
||||||
|
private static BitmapSource Finalize(BitmapSource frame, int? decodePixelWidth)
|
||||||
|
{
|
||||||
|
if (decodePixelWidth is not int width || frame.PixelWidth <= width)
|
||||||
|
{
|
||||||
|
frame.Freeze();
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
double scale = width / (double)frame.PixelWidth;
|
||||||
|
var scaled = new TransformedBitmap(frame, new ScaleTransform(scale, scale));
|
||||||
|
scaled.Freeze();
|
||||||
|
return scaled;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<!-- Slim, rounded, dark scrollbar matching the rest of the app. Implicit (no x:Key) so
|
||||||
|
merging this dictionary restyles every ScrollViewer without per-control wiring.
|
||||||
|
The default WPF scrollbar is light grey with stepper arrows, which looks foreign
|
||||||
|
against the dark popup. -->
|
||||||
|
|
||||||
|
<Style x:Key="ScrollBarThumbStyle" TargetType="Thumb">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<Border x:Name="Bd" Background="#4A4C52" CornerRadius="3"
|
||||||
|
SnapsToDevicePixels="True"/>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#63666E"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsDragging" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#7B7F88"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Track background must stay hit-testable but invisible, so click-to-page still works
|
||||||
|
without drawing a visible gutter. -->
|
||||||
|
<Style x:Key="ScrollBarPageButtonStyle" TargetType="RepeatButton">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Border Background="Transparent"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ScrollBar">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Width" Value="10"/>
|
||||||
|
<Setter Property="MinWidth" Value="10"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ScrollBar">
|
||||||
|
<Border Background="{TemplateBinding Background}" Padding="2,4">
|
||||||
|
<!-- No RepeatButtons for the stepper arrows: this is a modern thin
|
||||||
|
scrollbar, so the arrows are deliberately omitted. -->
|
||||||
|
<Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageUpCommand"
|
||||||
|
Style="{StaticResource ScrollBarPageButtonStyle}"/>
|
||||||
|
</Track.DecreaseRepeatButton>
|
||||||
|
<Track.Thumb>
|
||||||
|
<Thumb Style="{StaticResource ScrollBarThumbStyle}"/>
|
||||||
|
</Track.Thumb>
|
||||||
|
<Track.IncreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageDownCommand"
|
||||||
|
Style="{StaticResource ScrollBarPageButtonStyle}"/>
|
||||||
|
</Track.IncreaseRepeatButton>
|
||||||
|
</Track>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="Orientation" Value="Horizontal">
|
||||||
|
<Setter Property="Width" Value="Auto"/>
|
||||||
|
<Setter Property="MinWidth" Value="0"/>
|
||||||
|
<Setter Property="Height" Value="10"/>
|
||||||
|
<Setter Property="MinHeight" Value="10"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ScrollBar">
|
||||||
|
<Border Background="{TemplateBinding Background}" Padding="4,2">
|
||||||
|
<Track x:Name="PART_Track" IsDirectionReversed="False">
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageLeftCommand"
|
||||||
|
Style="{StaticResource ScrollBarPageButtonStyle}"/>
|
||||||
|
</Track.DecreaseRepeatButton>
|
||||||
|
<Track.Thumb>
|
||||||
|
<Thumb Style="{StaticResource ScrollBarThumbStyle}"/>
|
||||||
|
</Track.Thumb>
|
||||||
|
<Track.IncreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageRightCommand"
|
||||||
|
Style="{StaticResource ScrollBarPageButtonStyle}"/>
|
||||||
|
</Track.IncreaseRepeatButton>
|
||||||
|
</Track>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.IO;
|
||||||
using System.Windows.Media.Imaging;
|
using System.Windows.Media.Imaging;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using EbbesMemeClipboard.Models;
|
using EbbesMemeClipboard.Models;
|
||||||
@@ -5,26 +6,114 @@ using EbbesMemeClipboard.Services;
|
|||||||
|
|
||||||
namespace EbbesMemeClipboard.ViewModels;
|
namespace EbbesMemeClipboard.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One grid tile. Covers both a local library file and a remote provider result so the picker
|
||||||
|
/// can use a single DataTemplate for every source.
|
||||||
|
/// </summary>
|
||||||
public partial class MemeTileViewModel : ObservableObject
|
public partial class MemeTileViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private const int ThumbnailPixelWidth = 150;
|
private const int ThumbnailPixelWidth = 240;
|
||||||
|
|
||||||
public LocalMemeRecord Record { get; }
|
private readonly IGifCacheService? _cache;
|
||||||
public string FullPath { get; }
|
private readonly bool _animatePreviews;
|
||||||
public string DisplayName => Record.OriginalFileName;
|
private string? _localPath;
|
||||||
|
|
||||||
|
public LocalMemeRecord? Record { get; }
|
||||||
|
public GifSearchResult? RemoteResult { get; }
|
||||||
|
|
||||||
|
public bool IsLocal => Record is not null;
|
||||||
|
public string DisplayName => Record?.OriginalFileName ?? RemoteResult?.Title ?? "Meme";
|
||||||
|
|
||||||
|
public MemeSource Source => Record is not null ? MemeSource.Local : RemoteResult!.Source;
|
||||||
|
|
||||||
|
/// <summary>Stable identity for favouriting: the library Id locally, the provider Id remotely.</summary>
|
||||||
|
public string FavoriteKey => Record?.Id ?? RemoteResult!.Id;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private BitmapSource? _thumbnail;
|
private BitmapSource? _thumbnail;
|
||||||
|
|
||||||
public MemeTileViewModel(LocalMemeRecord record, string fullPath)
|
[ObservableProperty]
|
||||||
|
private bool _isFavorite;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Animation sources for XamlAnimatedGif. Only one is ever set, and only when GIF preview
|
||||||
|
/// playback is switched on - otherwise both stay null and the static Thumbnail shows.
|
||||||
|
/// A local file animates straight from disk; a remote one needs its bytes kept in memory.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private Uri? _animationUri;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private Stream? _animationStream;
|
||||||
|
|
||||||
|
public string FavoriteActionLabel => IsFavorite ? "Remove from favourites" : "Add to favourites";
|
||||||
|
|
||||||
|
partial void OnIsFavoriteChanged(bool value) => OnPropertyChanged(nameof(FavoriteActionLabel));
|
||||||
|
|
||||||
|
public MemeTileViewModel(LocalMemeRecord record, string fullPath, bool animatePreviews = false)
|
||||||
{
|
{
|
||||||
Record = record;
|
Record = record;
|
||||||
FullPath = fullPath;
|
_localPath = fullPath;
|
||||||
|
_animatePreviews = animatePreviews;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadThumbnailAsync()
|
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
|
||||||
{
|
{
|
||||||
var path = FullPath;
|
RemoteResult = remoteResult;
|
||||||
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth));
|
_cache = cache;
|
||||||
|
_animatePreviews = animatePreviews;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a real file on disk, downloading and caching first for remote results. The
|
||||||
|
/// clipboard's file-drop format needs an actual path, so this must complete before copying.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> EnsureLocalPathAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_localPath is not null) return _localPath;
|
||||||
|
|
||||||
|
_localPath = await _cache!.GetOrDownloadAsync(RemoteResult!, ct);
|
||||||
|
return _localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadThumbnailAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (IsLocal)
|
||||||
|
{
|
||||||
|
var path = _localPath!;
|
||||||
|
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrame(path, ThumbnailPixelWidth), ct);
|
||||||
|
|
||||||
|
if (_animatePreviews && ImageDecoding.IsGif(path))
|
||||||
|
{
|
||||||
|
AnimationUri = new Uri(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var bytes = await _cache!.DownloadPreviewAsync(RemoteResult!, ct);
|
||||||
|
Thumbnail = await Task.Run(() => ImageDecoding.DecodeFirstFrameFromBytes(bytes, ThumbnailPixelWidth), ct);
|
||||||
|
|
||||||
|
// Remote previews have no file on disk, so animation has to run off the bytes
|
||||||
|
// we already fetched for the thumbnail.
|
||||||
|
if (_animatePreviews && LooksLikeGif(RemoteResult!.PreviewUrl))
|
||||||
|
{
|
||||||
|
AnimationStream = new MemoryStream(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Superseded by a newer search - drop it silently, the tile is already discarded.
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// A single unreadable/failed thumbnail shouldn't blank the whole grid; the tile just
|
||||||
|
// renders empty and stays clickable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool LooksLikeGif(string url) =>
|
||||||
|
url.Contains(".gif", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
@@ -9,10 +10,28 @@ namespace EbbesMemeClipboard.ViewModels;
|
|||||||
|
|
||||||
public partial class PickerViewModel : ObservableObject
|
public partial class PickerViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
|
// 50 is the Giphy per-request maximum; more results come from paging via offset as the user
|
||||||
|
// scrolls rather than from a single bigger call.
|
||||||
|
private const int RemoteResultLimit = 50;
|
||||||
|
private static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
|
||||||
|
|
||||||
private readonly ILocalMemeLibraryService _library;
|
private readonly ILocalMemeLibraryService _library;
|
||||||
private readonly IClipboardService _clipboard;
|
private readonly IClipboardService _clipboard;
|
||||||
private readonly ISettingsService _settings;
|
private readonly ISettingsService _settings;
|
||||||
private readonly IAutoPasteService _autoPaste;
|
private readonly IAutoPasteService _autoPaste;
|
||||||
|
private readonly IGifCacheService _gifCache;
|
||||||
|
private readonly IFavoritesService _favorites;
|
||||||
|
private readonly IReadOnlyList<IGifProvider> _providers;
|
||||||
|
|
||||||
|
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
|
||||||
|
private CancellationTokenSource? _searchCts;
|
||||||
|
|
||||||
|
private int _remoteOffset;
|
||||||
|
private bool _remoteHasMore;
|
||||||
|
private bool _isLoadingMore;
|
||||||
|
|
||||||
|
/// <summary>True when the grid is showing a single item resolved from a pasted link.</summary>
|
||||||
|
private bool _showingResolvedLink;
|
||||||
|
|
||||||
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
|
||||||
|
|
||||||
@@ -22,30 +41,120 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _statusMessage = string.Empty;
|
private string _statusMessage = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
[ObservableProperty]
|
||||||
/// True while a modal dialog (currently: the "add memes" file picker) opened from within the
|
private bool _isBusy;
|
||||||
/// picker is showing. The window's Deactivated handler checks this so opening that dialog
|
|
||||||
/// doesn't get treated as "user clicked away" and hide the picker out from under it.
|
|
||||||
/// </summary>
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isDialogOpen;
|
private bool _isDialogOpen;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Favourites act as a filtered view of the active source rather than an inline row, so they
|
||||||
|
/// get the full grid and the search box narrows within them.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _showingFavorites;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private MemeSource _activeSource = MemeSource.Local;
|
||||||
|
|
||||||
|
public bool IsLocalSource => ActiveSource == MemeSource.Local;
|
||||||
|
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
|
||||||
|
|
||||||
|
/// <summary>Importing only applies to the local library, and not while browsing favourites.</summary>
|
||||||
|
public bool CanImport => IsLocalSource && !ShowingFavorites;
|
||||||
|
|
||||||
|
public string SearchPlaceholder => ShowingFavorites
|
||||||
|
? "Search favourites..."
|
||||||
|
: IsLocalSource
|
||||||
|
? "Search your memes..."
|
||||||
|
: ActiveProvider?.SearchPlaceholder ?? "Search...";
|
||||||
|
|
||||||
|
public bool ShowEmptyState => Items.Count == 0 && !IsBusy && (ShowingFavorites || IsLocalSource);
|
||||||
|
|
||||||
|
public string EmptyStateText => ShowingFavorites
|
||||||
|
? "No favourites here yet. Right-click any meme to add one."
|
||||||
|
: "No memes yet. Click +, drag files in, or paste with Ctrl+V.";
|
||||||
|
|
||||||
|
private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
|
||||||
|
|
||||||
|
private bool AnimatePreviews => _settings.Current.PlayGifPreviews;
|
||||||
|
|
||||||
public event EventHandler? RequestClose;
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
public PickerViewModel(
|
public PickerViewModel(
|
||||||
ILocalMemeLibraryService library,
|
ILocalMemeLibraryService library,
|
||||||
IClipboardService clipboard,
|
IClipboardService clipboard,
|
||||||
ISettingsService settings,
|
ISettingsService settings,
|
||||||
IAutoPasteService autoPaste)
|
IAutoPasteService autoPaste,
|
||||||
|
IGifCacheService gifCache,
|
||||||
|
IFavoritesService favorites,
|
||||||
|
IEnumerable<IGifProvider> providers)
|
||||||
{
|
{
|
||||||
_library = library;
|
_library = library;
|
||||||
_clipboard = clipboard;
|
_clipboard = clipboard;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_autoPaste = autoPaste;
|
_autoPaste = autoPaste;
|
||||||
RefreshItems();
|
_gifCache = gifCache;
|
||||||
|
_favorites = favorites;
|
||||||
|
_providers = providers.ToList();
|
||||||
|
|
||||||
|
Items.CollectionChanged += OnItemsChanged;
|
||||||
|
|
||||||
|
// Restore the tab the picker was last left on. Assigned to the backing fields directly
|
||||||
|
// so restoring doesn't count as a user change and immediately re-save.
|
||||||
|
_activeSource = _settings.Current.LastSource;
|
||||||
|
_showingFavorites = _settings.Current.LastShowingFavorites;
|
||||||
|
|
||||||
|
// Seed the grid for the restored view. Remote sources are skipped here because that
|
||||||
|
// needs an async call - OnShown covers it when the window is actually opened.
|
||||||
|
if (_showingFavorites) RefreshFavoriteItems();
|
||||||
|
else if (IsLocalSource) RefreshLocalItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSearchTextChanged(string value) => RefreshItems();
|
private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||||
|
OnPropertyChanged(nameof(ShowEmptyState));
|
||||||
|
|
||||||
|
partial void OnSearchTextChanged(string value) => _ = RefreshAsync();
|
||||||
|
|
||||||
|
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(ShowEmptyState));
|
||||||
|
|
||||||
|
partial void OnShowingFavoritesChanged(bool value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SearchPlaceholder));
|
||||||
|
OnPropertyChanged(nameof(CanImport));
|
||||||
|
OnPropertyChanged(nameof(EmptyStateText));
|
||||||
|
OnPropertyChanged(nameof(ShowEmptyState));
|
||||||
|
SearchText = string.Empty;
|
||||||
|
PersistViewState();
|
||||||
|
_ = RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnActiveSourceChanged(MemeSource value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsLocalSource));
|
||||||
|
OnPropertyChanged(nameof(IsGiphySource));
|
||||||
|
OnPropertyChanged(nameof(SearchPlaceholder));
|
||||||
|
OnPropertyChanged(nameof(CanImport));
|
||||||
|
OnPropertyChanged(nameof(ShowEmptyState));
|
||||||
|
SearchText = string.Empty;
|
||||||
|
PersistViewState();
|
||||||
|
// Setting SearchText above only triggers a refresh if the value actually changed, so
|
||||||
|
// refresh explicitly here to cover switching tabs with an already-empty box.
|
||||||
|
_ = RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistViewState()
|
||||||
|
{
|
||||||
|
_settings.Current.LastSource = ActiveSource;
|
||||||
|
_settings.Current.LastShowingFavorites = ShowingFavorites;
|
||||||
|
_ = _settings.SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SelectSource(MemeSource source) => ActiveSource = source;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleFavoritesView() => ShowingFavorites = !ShowingFavorites;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SelectItemAsync(MemeTileViewModel? tile)
|
private async Task SelectItemAsync(MemeTileViewModel? tile)
|
||||||
@@ -54,7 +163,12 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _clipboard.CopyLocalFileAsync(tile.FullPath);
|
IsBusy = true;
|
||||||
|
// For remote results this downloads and caches the full-quality GIF first; the
|
||||||
|
// clipboard's file-drop format needs a real file on disk.
|
||||||
|
var path = await tile.EnsureLocalPathAsync();
|
||||||
|
|
||||||
|
await _clipboard.CopyLocalFileAsync(path);
|
||||||
StatusMessage = $"Copied {tile.DisplayName}";
|
StatusMessage = $"Copied {tile.DisplayName}";
|
||||||
RequestClose?.Invoke(this, EventArgs.Empty);
|
RequestClose?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
@@ -66,21 +180,58 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Deliberately broad: CommunityToolkit's AsyncRelayCommand otherwise swallows any
|
// Deliberately broad: AsyncRelayCommand otherwise swallows exceptions silently, so
|
||||||
// exception here silently (no crash, no message, the click just appears to do
|
// a clipboard/download failure would look like the click simply did nothing.
|
||||||
// nothing), which makes clipboard/paste failures impossible to diagnose from the UI.
|
|
||||||
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
|
StatusMessage = $"Couldn't insert {tile.DisplayName}: {ex.Message}";
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ToggleFavoriteAsync(MemeTileViewModel? tile)
|
||||||
|
{
|
||||||
|
if (tile is null) return;
|
||||||
|
|
||||||
|
if (_favorites.IsFavorite(tile.Source, tile.FavoriteKey))
|
||||||
|
{
|
||||||
|
await _favorites.RemoveAsync(tile.Source, tile.FavoriteKey);
|
||||||
|
tile.IsFavorite = false;
|
||||||
|
StatusMessage = $"Removed {tile.DisplayName} from favourites";
|
||||||
|
|
||||||
|
// In the favourites view an unfavourited tile no longer belongs on screen.
|
||||||
|
if (ShowingFavorites) Items.Remove(tile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _favorites.AddAsync(new FavoriteRecord
|
||||||
|
{
|
||||||
|
Source = tile.Source,
|
||||||
|
Key = tile.FavoriteKey,
|
||||||
|
Title = tile.DisplayName,
|
||||||
|
PreviewUrl = tile.RemoteResult?.PreviewUrl,
|
||||||
|
FullUrl = tile.RemoteResult?.FullUrl,
|
||||||
|
DateAdded = DateTimeOffset.Now,
|
||||||
|
});
|
||||||
|
tile.IsFavorite = true;
|
||||||
|
StatusMessage = $"Added {tile.DisplayName} to favourites";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task RemoveItemAsync(MemeTileViewModel? tile)
|
private async Task RemoveItemAsync(MemeTileViewModel? tile)
|
||||||
{
|
{
|
||||||
if (tile is null) return;
|
if (tile?.Record is null) return;
|
||||||
|
|
||||||
|
var name = tile.DisplayName;
|
||||||
await _library.RemoveAsync(tile.Record);
|
await _library.RemoveAsync(tile.Record);
|
||||||
RefreshItems();
|
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
|
||||||
StatusMessage = $"Removed {tile.DisplayName}";
|
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
|
||||||
|
|
||||||
|
await RefreshAsync();
|
||||||
|
StatusMessage = $"Removed {name}";
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -107,6 +258,55 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShowingFavorites = false;
|
||||||
|
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
|
||||||
@@ -120,17 +320,184 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
var imported = await _library.ImportAsync(candidates);
|
var imported = await _library.ImportAsync(candidates);
|
||||||
RefreshItems();
|
ShowingFavorites = false;
|
||||||
|
ActiveSource = MemeSource.Local;
|
||||||
|
RefreshLocalItems();
|
||||||
StatusMessage = $"Added {imported.Count} meme(s).";
|
StatusMessage = $"Added {imported.Count} meme(s).";
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnShown()
|
public void OnShown()
|
||||||
{
|
{
|
||||||
SearchText = string.Empty;
|
SearchText = string.Empty;
|
||||||
RefreshItems();
|
// Rebuild rather than just reset: picks up memes added since last time, and lets a
|
||||||
|
// changed "animate GIF previews" setting take effect.
|
||||||
|
_ = RefreshAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshItems()
|
/// <summary>
|
||||||
|
/// Appends the next page of remote results. Called as the grid is scrolled near the bottom,
|
||||||
|
/// so browsing feels continuous instead of stopping dead at the first batch.
|
||||||
|
/// </summary>
|
||||||
|
public async Task LoadMoreAsync()
|
||||||
|
{
|
||||||
|
if (IsLocalSource || ShowingFavorites || _showingResolvedLink || !_remoteHasMore || _isLoadingMore) return;
|
||||||
|
|
||||||
|
var provider = ActiveProvider;
|
||||||
|
if (provider is null || !provider.IsConfigured) return;
|
||||||
|
|
||||||
|
_isLoadingMore = true;
|
||||||
|
var queryAtStart = SearchText;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await provider.SearchAsync(queryAtStart, RemoteResultLimit, _remoteOffset, CancellationToken.None);
|
||||||
|
|
||||||
|
// The query may have changed while this page was in flight; if so the newer search
|
||||||
|
// owns the grid and these results are stale.
|
||||||
|
if (!string.Equals(queryAtStart, SearchText, StringComparison.Ordinal)) return;
|
||||||
|
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
AddRemoteTile(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
_remoteOffset += results.Count;
|
||||||
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
||||||
|
StatusMessage = $"{Items.Count} result(s).";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Couldn't load more: {ex.Message}";
|
||||||
|
_remoteHasMore = false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshAsync()
|
||||||
|
{
|
||||||
|
// Any pending remote search is stale the moment the query, tab or view changes.
|
||||||
|
_searchCts?.Cancel();
|
||||||
|
_searchCts?.Dispose();
|
||||||
|
_searchCts = null;
|
||||||
|
|
||||||
|
_remoteOffset = 0;
|
||||||
|
_remoteHasMore = false;
|
||||||
|
_showingResolvedLink = false;
|
||||||
|
|
||||||
|
if (ShowingFavorites)
|
||||||
|
{
|
||||||
|
RefreshFavoriteItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsLocalSource)
|
||||||
|
{
|
||||||
|
RefreshLocalItems();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = ActiveProvider;
|
||||||
|
if (provider is null) return;
|
||||||
|
|
||||||
|
if (!provider.IsConfigured)
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
StatusMessage = provider.NotConfiguredMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_searchCts = cts;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Debounce: without this every keystroke fires an API call, which burns through a
|
||||||
|
// free-tier rate limit almost immediately.
|
||||||
|
await Task.Delay(SearchDebounce, cts.Token);
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
if (provider.CanResolveLink(SearchText))
|
||||||
|
{
|
||||||
|
await ResolveLinkAsync(provider, cts.Token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusMessage = "Searching...";
|
||||||
|
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, 0, cts.Token);
|
||||||
|
cts.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
Items.Clear();
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
AddRemoteTile(result, cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
_remoteOffset = results.Count;
|
||||||
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
||||||
|
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Superseded by a newer query - the newer one owns the UI state now.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Search failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_searchCts == cts)
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResolveLinkAsync(IGifProvider provider, CancellationToken ct)
|
||||||
|
{
|
||||||
|
StatusMessage = "Opening link...";
|
||||||
|
var result = await provider.ResolveLinkAsync(SearchText, ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
Items.Clear();
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
StatusMessage = "Couldn't find that GIF - check the link.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddRemoteTile(result, ct);
|
||||||
|
|
||||||
|
// A single resolved item has nothing to page through.
|
||||||
|
_showingResolvedLink = true;
|
||||||
|
StatusMessage = "Found 1 GIF from link.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRemoteTile(GifSearchResult result, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var tile = new MemeTileViewModel(result, _gifCache, AnimatePreviews)
|
||||||
|
{
|
||||||
|
IsFavorite = _favorites.IsFavorite(result.Source, result.Id),
|
||||||
|
};
|
||||||
|
Items.Add(tile);
|
||||||
|
_ = tile.LoadThumbnailAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddLocalTile(LocalMemeRecord record)
|
||||||
|
{
|
||||||
|
var tile = new MemeTileViewModel(record, _library.GetFullPath(record), AnimatePreviews)
|
||||||
|
{
|
||||||
|
IsFavorite = _favorites.IsFavorite(MemeSource.Local, record.Id),
|
||||||
|
};
|
||||||
|
Items.Add(tile);
|
||||||
|
_ = tile.LoadThumbnailAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshLocalItems()
|
||||||
{
|
{
|
||||||
var records = string.IsNullOrWhiteSpace(SearchText)
|
var records = string.IsNullOrWhiteSpace(SearchText)
|
||||||
? _library.GetAll()
|
? _library.GetAll()
|
||||||
@@ -139,9 +506,48 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
Items.Clear();
|
Items.Clear();
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
|
AddLocalTile(record);
|
||||||
Items.Add(tile);
|
}
|
||||||
_ = tile.LoadThumbnailAsync();
|
|
||||||
|
StatusMessage = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Favourites for the active source, narrowed by the search box like any other view.</summary>
|
||||||
|
private void RefreshFavoriteItems()
|
||||||
|
{
|
||||||
|
Items.Clear();
|
||||||
|
|
||||||
|
var query = SearchText;
|
||||||
|
foreach (var fav in _favorites.GetForSource(ActiveSource))
|
||||||
|
{
|
||||||
|
if (fav.Source == MemeSource.Local)
|
||||||
|
{
|
||||||
|
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
|
||||||
|
// Favourite pointing at a meme that has since been deleted - skip it rather
|
||||||
|
// than rendering a broken tile.
|
||||||
|
if (record is null) continue;
|
||||||
|
if (!Matches(record.OriginalFileName, query)) continue;
|
||||||
|
AddLocalTile(record);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
|
||||||
|
if (!Matches(fav.Title ?? string.Empty, query)) continue;
|
||||||
|
|
||||||
|
AddRemoteTile(new GifSearchResult
|
||||||
|
{
|
||||||
|
Id = fav.Key,
|
||||||
|
Title = fav.Title ?? "Favourite",
|
||||||
|
PreviewUrl = fav.PreviewUrl,
|
||||||
|
FullUrl = fav.FullUrl,
|
||||||
|
Source = fav.Source,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(string text, string query) =>
|
||||||
|
string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly ISettingsService _settings;
|
private readonly ISettingsService _settings;
|
||||||
private readonly IGlobalHotkeyService _hotkeyService;
|
private readonly IGlobalHotkeyService _hotkeyService;
|
||||||
|
private readonly IAutostartService _autostart;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _hotkeyDisplay = string.Empty;
|
private string _hotkeyDisplay = string.Empty;
|
||||||
@@ -19,6 +20,15 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private InsertMode _insertMode;
|
private InsertMode _insertMode;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _autostartEnabled;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string _giphyApiKey = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _playGifPreviews;
|
||||||
|
|
||||||
public bool IsCopyOnly
|
public bool IsCopyOnly
|
||||||
{
|
{
|
||||||
get => InsertMode == InsertMode.CopyOnly;
|
get => InsertMode == InsertMode.CopyOnly;
|
||||||
@@ -37,12 +47,19 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
set { if (value) InsertMode = InsertMode.PasteAndSend; }
|
set { if (value) InsertMode = InsertMode.PasteAndSend; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService)
|
public SettingsViewModel(ISettingsService settings, IGlobalHotkeyService hotkeyService, IAutostartService autostart)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_hotkeyService = hotkeyService;
|
_hotkeyService = hotkeyService;
|
||||||
|
_autostart = autostart;
|
||||||
|
|
||||||
_insertMode = _settings.Current.InsertMode;
|
_insertMode = _settings.Current.InsertMode;
|
||||||
|
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
|
||||||
|
_playGifPreviews = _settings.Current.PlayGifPreviews;
|
||||||
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
|
_hotkeyDisplay = HotkeyFormatter.Format(_settings.Current.HotkeyModifiers, _settings.Current.HotkeyKey);
|
||||||
|
// Read from the registry rather than a stored setting, so the checkbox reflects reality
|
||||||
|
// even if the entry was removed outside the app.
|
||||||
|
_autostartEnabled = _autostart.IsEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnInsertModeChanged(InsertMode value)
|
partial void OnInsertModeChanged(InsertMode value)
|
||||||
@@ -54,6 +71,23 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(IsPasteAndSend));
|
OnPropertyChanged(nameof(IsPasteAndSend));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnAutostartEnabledChanged(bool value) => _autostart.SetEnabled(value);
|
||||||
|
|
||||||
|
partial void OnPlayGifPreviewsChanged(bool value)
|
||||||
|
{
|
||||||
|
_settings.Current.PlayGifPreviews = value;
|
||||||
|
_ = _settings.SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnGiphyApiKeyChanged(string value)
|
||||||
|
{
|
||||||
|
_settings.Current.GiphyApiKey = string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
_ = _settings.SaveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Called when the settings window is shown, to re-sync externally-changed state.</summary>
|
||||||
|
public void Refresh() => AutostartEnabled = _autostart.IsEnabled;
|
||||||
|
|
||||||
public void TrySetHotkey(ModifierKeys modifiers, Key key)
|
public void TrySetHotkey(ModifierKeys modifiers, Key key)
|
||||||
{
|
{
|
||||||
if (_hotkeyService.Register(modifiers, key))
|
if (_hotkeyService.Register(modifiers, key))
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
|
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
|
||||||
|
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
|
||||||
|
xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif"
|
||||||
Title="Meme Clipboard"
|
Title="Meme Clipboard"
|
||||||
Width="420" Height="520"
|
Width="420" Height="520"
|
||||||
WindowStyle="None"
|
WindowStyle="None"
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
|
ContextMenuOpening="PickerWindow_OnContextMenuOpening"
|
||||||
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
|
ContextMenuClosing="PickerWindow_OnContextMenuClosing">
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
||||||
|
|
||||||
<Style x:Key="FlatButtonStyle" TargetType="Button">
|
<Style x:Key="FlatButtonStyle" TargetType="Button">
|
||||||
<Setter Property="Background" Value="#2B2D31"/>
|
<Setter Property="Background" Value="#2B2D31"/>
|
||||||
<Setter Property="Foreground" Value="White"/>
|
<Setter Property="Foreground" Value="White"/>
|
||||||
@@ -43,69 +47,41 @@
|
|||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
</Window.Resources>
|
|
||||||
|
|
||||||
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
|
<!-- Tag carries "is this tab active", so one style can drive both tab buttons. -->
|
||||||
<Grid Margin="12">
|
<Style x:Key="TabButtonStyle" TargetType="Button">
|
||||||
<Grid.RowDefinitions>
|
<Setter Property="Foreground" Value="#9A9CA3"/>
|
||||||
<RowDefinition Height="16"/>
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
<RowDefinition Height="*"/>
|
<Setter Property="Padding" Value="14,6"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<Setter Property="FontSize" Value="13"/>
|
||||||
</Grid.RowDefinitions>
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll"
|
<ControlTemplate TargetType="Button">
|
||||||
MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="6" SnapsToDevicePixels="True">
|
||||||
ToolTip="Drag to move">
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
|
||||||
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
|
||||||
</Border>
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
<Grid Grid.Row="1" Margin="0,4,0,10">
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
<Grid.ColumnDefinitions>
|
<Setter TargetName="Bd" Property="Background" Value="#2B2D31"/>
|
||||||
<ColumnDefinition Width="*"/>
|
</Trigger>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<DataTrigger Binding="{Binding Tag, RelativeSource={RelativeSource Self}}" Value="True">
|
||||||
</Grid.ColumnDefinitions>
|
<Setter TargetName="Bd" Property="Background" Value="#3A3D42"/>
|
||||||
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
|
<Setter Property="Foreground" Value="White"/>
|
||||||
<TextBox x:Name="SearchBox"
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
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="2" 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>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
</TextBlock.Style>
|
|
||||||
</TextBlock>
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Items}">
|
<!-- Shared by the favourites row and the main grid so both behave identically.
|
||||||
<ItemsControl.ItemsPanel>
|
No fixed Width: the tile stretches to fill its UniformGrid cell, so a row always
|
||||||
<ItemsPanelTemplate>
|
spans the full panel width instead of leaving a ragged gap on the right. -->
|
||||||
<WrapPanel Orientation="Horizontal"/>
|
<DataTemplate x:Key="MemeTileTemplate" DataType="{x:Type vm:MemeTileViewModel}">
|
||||||
</ItemsPanelTemplate>
|
<Grid Height="118" Margin="4">
|
||||||
</ItemsControl.ItemsPanel>
|
<Button Padding="0"
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate DataType="{x:Type vm:MemeTileViewModel}">
|
|
||||||
<Button Width="88" Height="88" Margin="4" Padding="0"
|
|
||||||
Style="{StaticResource FlatButtonStyle}"
|
Style="{StaticResource FlatButtonStyle}"
|
||||||
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
Command="{Binding DataContext.SelectItemCommand, ElementName=RootWindow}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
@@ -113,20 +89,154 @@
|
|||||||
ToolTip="{Binding DisplayName}">
|
ToolTip="{Binding DisplayName}">
|
||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
|
<MenuItem Header="{Binding PlacementTarget.DataContext.FavoriteActionLabel, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ToggleFavoriteCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
|
||||||
|
<Separator/>
|
||||||
<MenuItem Header="Remove"
|
<MenuItem Header="Remove"
|
||||||
|
IsEnabled="{Binding PlacementTarget.DataContext.IsLocal, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.RemoveItemCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
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"/>
|
<!-- Source is the static first frame; the gif attached properties take over only when
|
||||||
|
preview playback is enabled (both stay null otherwise). -->
|
||||||
|
<Image Stretch="Uniform" Margin="5"
|
||||||
|
Source="{Binding Thumbnail}"
|
||||||
|
gif:AnimationBehavior.SourceUri="{Binding AnimationUri}"
|
||||||
|
gif:AnimationBehavior.SourceStream="{Binding AnimationStream}"
|
||||||
|
gif:AnimationBehavior.RepeatBehavior="Forever"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
|
||||||
|
<TextBlock Text="★" FontSize="13" Foreground="#FFC72C"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Top"
|
||||||
|
Margin="0,4,6,0" IsHitTestVisible="False"
|
||||||
|
Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVisibility}}"/>
|
||||||
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</Window.Resources>
|
||||||
|
|
||||||
|
<Border Background="#F0202225" CornerRadius="10" BorderBrush="#3A3B3E" BorderThickness="1">
|
||||||
|
<Grid Margin="12,8,12,12">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Title bar: doubles as the drag surface. -->
|
||||||
|
<Border Grid.Row="0" Background="Transparent" Cursor="SizeAll" Height="26"
|
||||||
|
MouseLeftButtonDown="DragHandle_OnMouseLeftButtonDown"
|
||||||
|
ToolTip="Drag to move">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Text="Meme Clipboard" Foreground="White" FontSize="13"
|
||||||
|
FontWeight="SemiBold" VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
<Border Width="36" Height="4" CornerRadius="2" Background="#4A4C52"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="0,4,0,8">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
|
||||||
|
<Button Content="Local"
|
||||||
|
Style="{StaticResource TabButtonStyle}"
|
||||||
|
Tag="{Binding IsLocalSource}"
|
||||||
|
Command="{Binding SelectSourceCommand}"
|
||||||
|
CommandParameter="{x:Static models:MemeSource.Local}"/>
|
||||||
|
<Button Content="Giphy" Margin="6,0,0,0"
|
||||||
|
Style="{StaticResource TabButtonStyle}"
|
||||||
|
Tag="{Binding IsGiphySource}"
|
||||||
|
Command="{Binding SelectSourceCommand}"
|
||||||
|
CommandParameter="{x:Static models:MemeSource.Giphy}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Right-aligned and separated from the source tabs on purpose: it filters
|
||||||
|
the active source rather than being a source of its own. -->
|
||||||
|
<Button Content="★ Favourites" HorizontalAlignment="Right"
|
||||||
|
Style="{StaticResource TabButtonStyle}"
|
||||||
|
Tag="{Binding ShowingFavorites}"
|
||||||
|
Command="{Binding ToggleFavoritesViewCommand}"
|
||||||
|
ToolTip="Show only your favourites from this source"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
|
||||||
|
<Grid>
|
||||||
|
<TextBox x:Name="SearchBox"
|
||||||
|
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
|
Padding="10,7" FontSize="14"
|
||||||
|
CaretBrush="White"/>
|
||||||
|
<!-- WPF has no native placeholder, so this sits behind the caret and
|
||||||
|
hides as soon as anything is typed. Not hit-testable, so clicking
|
||||||
|
it still focuses the box underneath. -->
|
||||||
|
<TextBlock Text="{Binding SearchPlaceholder}"
|
||||||
|
Foreground="#6E7078" FontSize="14"
|
||||||
|
Margin="11,0,10,0" VerticalAlignment="Center"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
TextTrimming="CharacterEllipsis">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding SearchText}" Value="">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
|
||||||
|
FontSize="18" Style="{StaticResource FlatButtonStyle}"
|
||||||
|
Command="{Binding ImportFilesCommand}"
|
||||||
|
Visibility="{Binding CanImport, Converter={StaticResource BoolToVisibility}}"
|
||||||
|
ToolTip="Add memes"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ScrollViewer x:Name="ResultsScrollViewer"
|
||||||
|
Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
|
||||||
|
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Text="{Binding EmptyStateText}"
|
||||||
|
Foreground="#7B7D85" FontSize="13"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap" TextAlignment="Center" Width="260"
|
||||||
|
Margin="0,40,0,0"
|
||||||
|
Visibility="{Binding ShowEmptyState, Converter={StaticResource BoolToVisibility}}"/>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Items}"
|
||||||
|
ItemTemplate="{StaticResource MemeTileTemplate}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<UniformGrid Columns="3"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="#9A9CA3" FontSize="11" Margin="2,8,0,0"/>
|
<Grid Grid.Row="4" Margin="2,8,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="#9A9CA3"
|
||||||
|
FontSize="11" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
<!-- Required by Giphy's API terms wherever their results are displayed. -->
|
||||||
|
<TextBlock Grid.Column="1" Text="Powered by GIPHY" Foreground="#7B7D85"
|
||||||
|
FontSize="10" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Visibility="{Binding IsGiphySource, Converter={StaticResource BoolToVisibility}}"/>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public partial class PickerWindow : Window
|
|||||||
_autoPaste.CaptureForegroundWindow();
|
_autoPaste.CaptureForegroundWindow();
|
||||||
PositionNearCursor();
|
PositionNearCursor();
|
||||||
_viewModel.OnShown();
|
_viewModel.OnShown();
|
||||||
|
// Always reopen at the top rather than wherever the last session was left scrolled to.
|
||||||
|
ResultsScrollViewer.ScrollToTop();
|
||||||
Show();
|
Show();
|
||||||
Activate();
|
Activate();
|
||||||
SearchBox.Focus();
|
SearchBox.Focus();
|
||||||
@@ -88,6 +90,15 @@ public partial class PickerWindow : Window
|
|||||||
{
|
{
|
||||||
Activate();
|
Activate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switching tab/view or typing replaces the whole grid, so a retained scroll offset
|
||||||
|
// would leave the user part-way down a completely different set of results.
|
||||||
|
if (e.PropertyName is nameof(PickerViewModel.ActiveSource)
|
||||||
|
or nameof(PickerViewModel.ShowingFavorites)
|
||||||
|
or nameof(PickerViewModel.SearchText))
|
||||||
|
{
|
||||||
|
ResultsScrollViewer.ScrollToTop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void PickerWindow_OnDeactivated(object? sender, EventArgs e)
|
private void PickerWindow_OnDeactivated(object? sender, EventArgs e)
|
||||||
@@ -99,6 +110,22 @@ public partial class PickerWindow : Window
|
|||||||
Hide();
|
Hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the next page of remote results once the user scrolls near the bottom, so Giphy
|
||||||
|
/// browsing continues instead of stopping at the first batch. The view model guards against
|
||||||
|
/// overlapping or unnecessary calls, so firing this on every scroll tick is safe.
|
||||||
|
/// </summary>
|
||||||
|
private void ResultsScrollViewer_OnScrollChanged(object sender, ScrollChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not ScrollViewer viewer || viewer.ScrollableHeight <= 0) return;
|
||||||
|
|
||||||
|
const double triggerDistanceFromBottom = 250;
|
||||||
|
if (viewer.VerticalOffset >= viewer.ScrollableHeight - triggerDistanceFromBottom)
|
||||||
|
{
|
||||||
|
_ = _viewModel.LoadMoreAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void PickerWindow_OnContextMenuOpening(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = true;
|
private void PickerWindow_OnContextMenuOpening(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = true;
|
||||||
|
|
||||||
private void PickerWindow_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
|
private void PickerWindow_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
|
||||||
@@ -108,6 +135,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;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Settings - Ebbe's Meme Clipboard"
|
Title="Settings - Ebbe's Meme Clipboard"
|
||||||
Width="380" Height="380"
|
Width="400"
|
||||||
Background="#F0202225"
|
Background="#F0202225"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
@@ -32,5 +32,23 @@
|
|||||||
IsChecked="{Binding IsPasteAndSend}"/>
|
IsChecked="{Binding IsPasteAndSend}"/>
|
||||||
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps."
|
<TextBlock Text="Presses Enter right after pasting to submit it - use with care in chat apps."
|
||||||
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
|
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Startup" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
||||||
|
<CheckBox Content="Start automatically when Windows starts" Foreground="White"
|
||||||
|
IsChecked="{Binding AutostartEnabled}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Previews" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
||||||
|
<CheckBox Content="Play GIF previews in the picker" Foreground="White"
|
||||||
|
IsChecked="{Binding PlayGifPreviews}"/>
|
||||||
|
<TextBlock Text="Animates GIF thumbnails instead of showing a still frame. Uses more CPU and memory, especially with lots of Giphy results."
|
||||||
|
Foreground="#7B7D85" FontSize="10" TextWrapping="Wrap" Margin="20,4,0,0"/>
|
||||||
|
<TextBlock Text="Giphy" Foreground="White" FontSize="14" FontWeight="Bold" Margin="0,24,0,8"/>
|
||||||
|
<Border Background="#2B2D31" CornerRadius="6">
|
||||||
|
<TextBox Text="{Binding GiphyApiKey, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
|
Padding="10,8" FontSize="13" CaretBrush="White"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock TextWrapping="Wrap" Foreground="#7B7D85" FontSize="11" Margin="0,4,0,0"
|
||||||
|
Text="API key for the Giphy tab. Get a free one at developers.giphy.com (create an app, choose an API key). Leave empty to disable Giphy search."/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ public partial class SettingsWindow : Window
|
|||||||
DataContext = _viewModel;
|
DataContext = _viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-reads live state (e.g. the autostart registry entry) each time it's opened.</summary>
|
||||||
|
public void ShowSettings()
|
||||||
|
{
|
||||||
|
_viewModel.Refresh();
|
||||||
|
Show();
|
||||||
|
Activate();
|
||||||
|
}
|
||||||
|
|
||||||
private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
|
private void HotkeyBox_OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user