3 Commits
12 changed files with 481 additions and 148 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ stays out of the way until you need it.
- **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).
- **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.
@@ -65,6 +65,10 @@ The Giphy tab needs your own free API key:
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
```
+1 -1
View File
@@ -16,7 +16,7 @@
; anyone who just wants the portable single exe.
#define AppName "Ebbe's Meme Clipboard"
#define AppVersion "1.1.0"
#define AppVersion "1.1.5"
#define AppPublisher "Ebbe Baß"
#define AppExeName "EbbesMemeClipboard.exe"
#define SourceExe "..\publish-installer\EbbesMemeClipboard.exe"
@@ -22,6 +22,7 @@
<PackageReference Include="WPF-UI" Version="4.3.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="XamlAnimatedGif" Version="2.3.2" />
</ItemGroup>
<ItemGroup>
@@ -14,4 +14,19 @@ public sealed class AppSettings
/// 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;
}
@@ -1,11 +1,13 @@
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 class GiphyGifProvider : IGifProvider
public sealed partial class GiphyGifProvider : IGifProvider
{
private const string BaseUrl = "https://api.giphy.com/v1/gifs";
private const string Rating = "pg-13";
@@ -20,45 +22,112 @@ public sealed class GiphyGifProvider : IGifProvider
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, CancellationToken ct)
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}&rating={Rating}"
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&rating={Rating}";
? $"{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<GiphyResponse>(cancellationToken: ct);
var payload = await response.Content.ReadFromJsonAsync<GiphyListResponse>(cancellationToken: ct);
if (payload?.Data is null) return Array.Empty<GifSearchResult>();
return payload.Data
.Where(item => item.Images?.FixedWidth?.Url is not null && item.Images.Original?.Url is not null)
.Select(item => new GifSearchResult
{
Id = item.Id ?? Guid.NewGuid().ToString("N"),
Title = string.IsNullOrWhiteSpace(item.Title) ? "Giphy GIF" : item.Title,
PreviewUrl = item.Images!.FixedWidth!.Url!,
FullUrl = item.Images.Original!.Url!,
Source = MemeSource.Giphy,
})
.ToList();
return payload.Data.Select(ToResult).OfType<GifSearchResult>().ToList();
}
private sealed class GiphyResponse
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; }
@@ -12,6 +12,18 @@ public interface IGifProvider
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
string NotConfiguredMessage { get; }
/// <summary>An empty query returns whatever the provider considers trending.</summary>
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct);
/// <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);
}
@@ -1,3 +1,4 @@
using System.IO;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using EbbesMemeClipboard.Models;
@@ -11,11 +12,10 @@ namespace EbbesMemeClipboard.ViewModels;
/// </summary>
public partial class MemeTileViewModel : ObservableObject
{
// Comfortably above the ~126px logical tile width so thumbnails stay sharp at 150-200%
// display scaling, without decoding anything near full resolution.
private const int ThumbnailPixelWidth = 240;
private readonly IGifCacheService? _cache;
private readonly bool _animatePreviews;
private string? _localPath;
public LocalMemeRecord? Record { get; }
@@ -35,20 +35,33 @@ public partial class MemeTileViewModel : ObservableObject
[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)
public MemeTileViewModel(LocalMemeRecord record, string fullPath, bool animatePreviews = false)
{
Record = record;
_localPath = fullPath;
_animatePreviews = animatePreviews;
}
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache)
public MemeTileViewModel(GifSearchResult remoteResult, IGifCacheService cache, bool animatePreviews = false)
{
RemoteResult = remoteResult;
_cache = cache;
_animatePreviews = animatePreviews;
}
/// <summary>
@@ -71,11 +84,23 @@ public partial class MemeTileViewModel : ObservableObject
{
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)
@@ -88,4 +113,7 @@ public partial class MemeTileViewModel : ObservableObject
// 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.Specialized;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -9,7 +10,9 @@ namespace EbbesMemeClipboard.ViewModels;
public partial class PickerViewModel : ObservableObject
{
private const int RemoteResultLimit = 30;
// 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;
@@ -23,10 +26,14 @@ public partial class PickerViewModel : ObservableObject
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
private CancellationTokenSource? _searchCts;
public ObservableCollection<MemeTileViewModel> Items { get; } = new();
private int _remoteOffset;
private bool _remoteHasMore;
private bool _isLoadingMore;
/// <summary>Favourites for the currently active source only.</summary>
public ObservableCollection<MemeTileViewModel> Favorites { get; } = new();
/// <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();
[ObservableProperty]
private string _searchText = string.Empty;
@@ -40,8 +47,12 @@ public partial class PickerViewModel : ObservableObject
[ObservableProperty]
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 _hasFavorites;
private bool _showingFavorites;
[ObservableProperty]
private MemeSource _activeSource = MemeSource.Local;
@@ -49,6 +60,25 @@ public partial class PickerViewModel : ObservableObject
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 PickerViewModel(
@@ -68,26 +98,64 @@ public partial class PickerViewModel : ObservableObject
_favorites = favorites;
_providers = providers.ToList();
RefreshLocalItems();
RefreshFavorites();
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();
}
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;
RefreshFavorites();
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]
private async Task SelectItemAsync(MemeTileViewModel? tile)
{
@@ -130,7 +198,11 @@ public partial class PickerViewModel : ObservableObject
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
{
@@ -143,11 +215,9 @@ public partial class PickerViewModel : ObservableObject
FullUrl = tile.RemoteResult?.FullUrl,
DateAdded = DateTimeOffset.Now,
});
tile.IsFavorite = true;
StatusMessage = $"Added {tile.DisplayName} to favourites";
}
RefreshFavorites();
SyncFavoriteFlags();
}
[RelayCommand]
@@ -155,13 +225,13 @@ public partial class PickerViewModel : ObservableObject
{
if (tile?.Record is null) return;
var name = tile.DisplayName;
await _library.RemoveAsync(tile.Record);
// Drop the matching favourite too, otherwise it lingers pointing at a deleted file.
await _favorites.RemoveAsync(MemeSource.Local, tile.Record.Id);
RefreshLocalItems();
RefreshFavorites();
StatusMessage = $"Removed {tile.DisplayName}";
await RefreshAsync();
StatusMessage = $"Removed {name}";
}
[RelayCommand]
@@ -226,6 +296,7 @@ public partial class PickerViewModel : ObservableObject
return;
}
ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Pasted {name}";
@@ -249,6 +320,7 @@ public partial class PickerViewModel : ObservableObject
}
var imported = await _library.ImportAsync(candidates);
ShowingFavorites = false;
ActiveSource = MemeSource.Local;
RefreshLocalItems();
StatusMessage = $"Added {imported.Count} meme(s).";
@@ -257,27 +329,76 @@ public partial class PickerViewModel : ObservableObject
public void OnShown()
{
SearchText = string.Empty;
if (IsLocalSource)
// Rebuild rather than just reset: picks up memes added since last time, and lets a
// changed "animate GIF previews" setting take effect.
_ = RefreshAsync();
}
/// <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()
{
RefreshLocalItems();
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;
}
RefreshFavorites();
}
private async Task RefreshAsync()
{
// Any pending remote search is stale the moment the query or tab changes.
// 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 = _providers.FirstOrDefault(p => p.Source == ActiveSource);
var provider = ActiveProvider;
if (provider is null) return;
if (!provider.IsConfigured)
@@ -297,20 +418,25 @@ public partial class PickerViewModel : ObservableObject
await Task.Delay(SearchDebounce, cts.Token);
IsBusy = true;
StatusMessage = "Searching...";
var results = await provider.SearchAsync(SearchText, RemoteResultLimit, cts.Token);
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)
{
var tile = new MemeTileViewModel(result, _gifCache);
Items.Add(tile);
_ = tile.LoadThumbnailAsync(cts.Token);
AddRemoteTile(result, cts.Token);
}
SyncFavoriteFlags();
_remoteOffset = results.Count;
_remoteHasMore = results.Count >= RemoteResultLimit;
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
}
catch (OperationCanceledException)
@@ -330,6 +456,47 @@ public partial class PickerViewModel : ObservableObject
}
}
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)
@@ -339,60 +506,48 @@ public partial class PickerViewModel : ObservableObject
Items.Clear();
foreach (var record in records)
{
var tile = new MemeTileViewModel(record, _library.GetFullPath(record));
Items.Add(tile);
_ = tile.LoadThumbnailAsync();
AddLocalTile(record);
}
SyncFavoriteFlags();
StatusMessage = string.Empty;
}
private void RefreshFavorites()
/// <summary>Favourites for the active source, narrowed by the search box like any other view.</summary>
private void RefreshFavoriteItems()
{
Favorites.Clear();
Items.Clear();
var query = SearchText;
foreach (var fav in _favorites.GetForSource(ActiveSource))
{
MemeTileViewModel tile;
if (fav.Source == MemeSource.Local)
{
var record = _library.GetAll().FirstOrDefault(r => r.Id == fav.Key);
// Favourite pointing at a meme that's since been deleted - skip it rather than
// rendering a broken tile.
// Favourite pointing at a meme that has since been deleted - skip it rather
// than rendering a broken tile.
if (record is null) continue;
tile = new MemeTileViewModel(record, _library.GetFullPath(record));
if (!Matches(record.OriginalFileName, query)) continue;
AddLocalTile(record);
}
else
{
if (fav.PreviewUrl is null || fav.FullUrl is null) continue;
tile = new MemeTileViewModel(
new GifSearchResult
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,
},
_gifCache);
});
}
}
tile.IsFavorite = true;
Favorites.Add(tile);
_ = tile.LoadThumbnailAsync();
StatusMessage = Items.Count == 0 ? string.Empty : $"{Items.Count} favourite(s).";
}
HasFavorites = Favorites.Count > 0;
}
/// <summary>Keeps the star state on the main grid in step with the favourites store.</summary>
private void SyncFavoriteFlags()
{
foreach (var tile in Items)
{
tile.IsFavorite = _favorites.IsFavorite(tile.Source, tile.FavoriteKey);
}
}
private static bool Matches(string text, string query) =>
string.IsNullOrWhiteSpace(query) || text.Contains(query, StringComparison.OrdinalIgnoreCase);
}
@@ -26,6 +26,9 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty]
private string _giphyApiKey = string.Empty;
[ObservableProperty]
private bool _playGifPreviews;
public bool IsCopyOnly
{
get => InsertMode == InsertMode.CopyOnly;
@@ -52,6 +55,7 @@ public partial class SettingsViewModel : ObservableObject
_insertMode = _settings.Current.InsertMode;
_giphyApiKey = _settings.Current.GiphyApiKey ?? string.Empty;
_playGifPreviews = _settings.Current.PlayGifPreviews;
_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.
@@ -69,6 +73,12 @@ public partial class SettingsViewModel : ObservableObject
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();
+48 -41
View File
@@ -4,6 +4,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:EbbesMemeClipboard.ViewModels"
xmlns:models="clr-namespace:EbbesMemeClipboard.Models"
xmlns:gif="clr-namespace:XamlAnimatedGif;assembly=XamlAnimatedGif"
Title="Meme Clipboard"
Width="420" Height="520"
WindowStyle="None"
@@ -98,7 +99,13 @@
CommandParameter="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
</ContextMenu>
</Button.ContextMenu>
<Image Source="{Binding Thumbnail}" Stretch="Uniform" Margin="5"/>
<!-- 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>
<!-- Star overlay: marks favourites at a glance without needing a second grid. -->
@@ -133,7 +140,8 @@
</Grid>
</Border>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,4,0,8">
<Grid Grid.Row="1" Margin="0,4,0,8">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button Content="Local"
Style="{StaticResource TabButtonStyle}"
Tag="{Binding IsLocalSource}"
@@ -145,6 +153,14 @@
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="&#9733; 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>
@@ -152,58 +168,50 @@
<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"/>
</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 IsLocalSource, Converter={StaticResource BoolToVisibility}}"
ToolTip="Add memes"/>
</Grid>
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
<StackPanel>
<!-- Favourites for the active source. Scrolls with the content rather than
being pinned, so it doesn't permanently eat height in a 520px window. -->
<StackPanel Visibility="{Binding HasFavorites, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="★ Favourites" Foreground="#9A9CA3" FontSize="11"
FontWeight="SemiBold" Margin="4,0,0,4"/>
<ItemsControl ItemsSource="{Binding Favorites}"
ItemTemplate="{StaticResource MemeTileTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="3"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Border Height="1" Background="#3A3B3E" Margin="4,10,4,10"/>
</StackPanel>
<Grid>
<TextBlock Text="No memes yet. Click +, drag files in, or paste with Ctrl+V."
Foreground="#7B7D85" FontSize="13"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center" Width="260"
Margin="0,40,0,0">
<!-- 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>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Items.Count}" Value="0"/>
<Condition Binding="{Binding IsLocalSource}" Value="True"/>
</MultiDataTrigger.Conditions>
<DataTrigger Binding="{Binding SearchText}" Value="">
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</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}">
@@ -214,7 +222,6 @@
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="4" Margin="2,8,0,0">
@@ -29,6 +29,8 @@ public partial class PickerWindow : Window
_autoPaste.CaptureForegroundWindow();
PositionNearCursor();
_viewModel.OnShown();
// Always reopen at the top rather than wherever the last session was left scrolled to.
ResultsScrollViewer.ScrollToTop();
Show();
Activate();
SearchBox.Focus();
@@ -88,6 +90,15 @@ public partial class PickerWindow : Window
{
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)
@@ -99,6 +110,22 @@ public partial class PickerWindow : Window
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_OnContextMenuClosing(object sender, ContextMenuEventArgs e) => _isContextMenuOpen = false;
@@ -37,6 +37,11 @@
<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}"