added url search for giphy, install wizard, fixed ui and added minor changes
This commit is contained in:
@@ -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>).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
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.
|
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
|
## Where your data lives
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
; anyone who just wants the portable single exe.
|
; anyone who just wants the portable single exe.
|
||||||
|
|
||||||
#define AppName "Ebbe's Meme Clipboard"
|
#define AppName "Ebbe's Meme Clipboard"
|
||||||
#define AppVersion "1.1.0"
|
#define AppVersion "1.1.5"
|
||||||
#define AppPublisher "Ebbe Baß"
|
#define AppPublisher "Ebbe Baß"
|
||||||
#define AppExeName "EbbesMemeClipboard.exe"
|
#define AppExeName "EbbesMemeClipboard.exe"
|
||||||
#define SourceExe "..\publish-installer\EbbesMemeClipboard.exe"
|
#define SourceExe "..\publish-installer\EbbesMemeClipboard.exe"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using EbbesMemeClipboard.Models;
|
using EbbesMemeClipboard.Models;
|
||||||
|
|
||||||
namespace EbbesMemeClipboard.Services;
|
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 BaseUrl = "https://api.giphy.com/v1/gifs";
|
||||||
private const string Rating = "pg-13";
|
private const string Rating = "pg-13";
|
||||||
@@ -20,45 +22,112 @@ public sealed class GiphyGifProvider : IGifProvider
|
|||||||
public string NotConfiguredMessage =>
|
public string NotConfiguredMessage =>
|
||||||
"No Giphy API key set. Add one in Settings - a free key is available from developers.giphy.com.";
|
"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)
|
public GiphyGifProvider(HttpClient http, ISettingsService settings)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_settings = settings;
|
_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>();
|
if (!IsConfigured) return Array.Empty<GifSearchResult>();
|
||||||
|
|
||||||
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
|
var apiKey = Uri.EscapeDataString(_settings.Current.GiphyApiKey!);
|
||||||
var url = string.IsNullOrWhiteSpace(query)
|
var url = string.IsNullOrWhiteSpace(query)
|
||||||
? $"{BaseUrl}/trending?api_key={apiKey}&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}&rating={Rating}";
|
: $"{BaseUrl}/search?api_key={apiKey}&q={Uri.EscapeDataString(query)}&limit={limit}&offset={offset}&rating={Rating}";
|
||||||
|
|
||||||
var response = await _http.GetAsync(url, ct);
|
var response = await _http.GetAsync(url, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
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>();
|
if (payload?.Data is null) return Array.Empty<GifSearchResult>();
|
||||||
|
|
||||||
return payload.Data
|
return payload.Data.Select(ToResult).OfType<GifSearchResult>().ToList();
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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; }
|
[JsonPropertyName("data")] public List<GiphyItem>? Data { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class GiphySingleResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")] public GiphyItem? Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class GiphyItem
|
private sealed class GiphyItem
|
||||||
{
|
{
|
||||||
[JsonPropertyName("id")] public string? Id { get; set; }
|
[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>
|
/// <summary>Shown in the UI when IsConfigured is false, explaining how to fix it.</summary>
|
||||||
string NotConfiguredMessage { get; }
|
string NotConfiguredMessage { get; }
|
||||||
|
|
||||||
/// <summary>An empty query returns whatever the provider considers trending.</summary>
|
/// <summary>Placeholder shown in the search box while it's empty.</summary>
|
||||||
Task<IReadOnlyList<GifSearchResult>> SearchAsync(string query, int limit, CancellationToken ct);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ namespace EbbesMemeClipboard.ViewModels;
|
|||||||
|
|
||||||
public partial class PickerViewModel : ObservableObject
|
public partial class PickerViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private const int RemoteResultLimit = 30;
|
// 50 is Giphy's maximum per request; 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 static readonly TimeSpan SearchDebounce = TimeSpan.FromMilliseconds(350);
|
||||||
|
|
||||||
private readonly ILocalMemeLibraryService _library;
|
private readonly ILocalMemeLibraryService _library;
|
||||||
@@ -23,6 +25,13 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
|
/// <summary>Cancels the in-flight remote search when a newer keystroke supersedes it.</summary>
|
||||||
private CancellationTokenSource? _searchCts;
|
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();
|
||||||
|
|
||||||
/// <summary>Favourites for the currently active source only.</summary>
|
/// <summary>Favourites for the currently active source only.</summary>
|
||||||
@@ -49,6 +58,12 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
public bool IsLocalSource => ActiveSource == MemeSource.Local;
|
public bool IsLocalSource => ActiveSource == MemeSource.Local;
|
||||||
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
|
public bool IsGiphySource => ActiveSource == MemeSource.Giphy;
|
||||||
|
|
||||||
|
public string SearchPlaceholder => IsLocalSource
|
||||||
|
? "Search your memes..."
|
||||||
|
: ActiveProvider?.SearchPlaceholder ?? "Search...";
|
||||||
|
|
||||||
|
private IGifProvider? ActiveProvider => _providers.FirstOrDefault(p => p.Source == ActiveSource);
|
||||||
|
|
||||||
public event EventHandler? RequestClose;
|
public event EventHandler? RequestClose;
|
||||||
|
|
||||||
public PickerViewModel(
|
public PickerViewModel(
|
||||||
@@ -78,6 +93,7 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsLocalSource));
|
OnPropertyChanged(nameof(IsLocalSource));
|
||||||
OnPropertyChanged(nameof(IsGiphySource));
|
OnPropertyChanged(nameof(IsGiphySource));
|
||||||
|
OnPropertyChanged(nameof(SearchPlaceholder));
|
||||||
SearchText = string.Empty;
|
SearchText = string.Empty;
|
||||||
RefreshFavorites();
|
RefreshFavorites();
|
||||||
// Setting SearchText above only triggers a refresh if the value actually changed, so
|
// Setting SearchText above only triggers a refresh if the value actually changed, so
|
||||||
@@ -264,6 +280,50 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
RefreshFavorites();
|
RefreshFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 || _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)
|
||||||
|
{
|
||||||
|
var tile = new MemeTileViewModel(result, _gifCache);
|
||||||
|
Items.Add(tile);
|
||||||
|
_ = tile.LoadThumbnailAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_remoteOffset += results.Count;
|
||||||
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
||||||
|
SyncFavoriteFlags();
|
||||||
|
StatusMessage = $"{Items.Count} result(s).";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Couldn't load more: {ex.Message}";
|
||||||
|
_remoteHasMore = false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isLoadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RefreshAsync()
|
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 or tab changes.
|
||||||
@@ -271,13 +331,17 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
_searchCts?.Dispose();
|
_searchCts?.Dispose();
|
||||||
_searchCts = null;
|
_searchCts = null;
|
||||||
|
|
||||||
|
_remoteOffset = 0;
|
||||||
|
_remoteHasMore = false;
|
||||||
|
_showingResolvedLink = false;
|
||||||
|
|
||||||
if (IsLocalSource)
|
if (IsLocalSource)
|
||||||
{
|
{
|
||||||
RefreshLocalItems();
|
RefreshLocalItems();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var provider = _providers.FirstOrDefault(p => p.Source == ActiveSource);
|
var provider = ActiveProvider;
|
||||||
if (provider is null) return;
|
if (provider is null) return;
|
||||||
|
|
||||||
if (!provider.IsConfigured)
|
if (!provider.IsConfigured)
|
||||||
@@ -297,9 +361,15 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
await Task.Delay(SearchDebounce, cts.Token);
|
await Task.Delay(SearchDebounce, cts.Token);
|
||||||
|
|
||||||
IsBusy = true;
|
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();
|
cts.Token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
Items.Clear();
|
Items.Clear();
|
||||||
@@ -310,6 +380,9 @@ public partial class PickerViewModel : ObservableObject
|
|||||||
_ = tile.LoadThumbnailAsync(cts.Token);
|
_ = tile.LoadThumbnailAsync(cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_remoteOffset = results.Count;
|
||||||
|
_remoteHasMore = results.Count >= RemoteResultLimit;
|
||||||
|
|
||||||
SyncFavoriteFlags();
|
SyncFavoriteFlags();
|
||||||
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
|
StatusMessage = results.Count == 0 ? "No results." : $"{results.Count} result(s).";
|
||||||
}
|
}
|
||||||
@@ -330,6 +403,30 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tile = new MemeTileViewModel(result, _gifCache);
|
||||||
|
Items.Add(tile);
|
||||||
|
_ = tile.LoadThumbnailAsync(ct);
|
||||||
|
|
||||||
|
// A single resolved item has nothing to page through.
|
||||||
|
_showingResolvedLink = true;
|
||||||
|
SyncFavoriteFlags();
|
||||||
|
StatusMessage = "Found 1 GIF from link.";
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshLocalItems()
|
private void RefreshLocalItems()
|
||||||
{
|
{
|
||||||
var records = string.IsNullOrWhiteSpace(SearchText)
|
var records = string.IsNullOrWhiteSpace(SearchText)
|
||||||
|
|||||||
@@ -152,11 +152,32 @@
|
|||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
|
<Border Grid.Column="0" Background="#2B2D31" CornerRadius="8">
|
||||||
<TextBox x:Name="SearchBox"
|
<Grid>
|
||||||
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox x:Name="SearchBox"
|
||||||
Background="Transparent" Foreground="White" BorderThickness="0"
|
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
Padding="10,7" FontSize="14"
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
CaretBrush="White"/>
|
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>
|
</Border>
|
||||||
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
|
<Button Grid.Column="1" Content="+" Width="36" Height="36" Margin="8,0,0,0"
|
||||||
FontSize="18" Style="{StaticResource FlatButtonStyle}"
|
FontSize="18" Style="{StaticResource FlatButtonStyle}"
|
||||||
@@ -165,7 +186,8 @@
|
|||||||
ToolTip="Add memes"/>
|
ToolTip="Add memes"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0">
|
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" Padding="0,0,4,0"
|
||||||
|
ScrollChanged="ResultsScrollViewer_OnScrollChanged">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<!-- Favourites for the active source. Scrolls with the content rather than
|
<!-- Favourites for the active source. Scrolls with the content rather than
|
||||||
being pinned, so it doesn't permanently eat height in a 520px window. -->
|
being pinned, so it doesn't permanently eat height in a 520px window. -->
|
||||||
|
|||||||
@@ -99,6 +99,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user