Skip to content

Commit

Permalink
Merge branch 'dev/v5'
Browse files Browse the repository at this point in the history
  • Loading branch information
jsakamoto committed Jun 11, 2024
2 parents eed0794 + 37e8e7e commit d5539b2
Show file tree
Hide file tree
Showing 26 changed files with 516 additions and 324 deletions.
29 changes: 14 additions & 15 deletions HotKeys2.Test/HotKeysContextTest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;

namespace Toolbelt.Blazor.HotKeys2.Test;

Expand All @@ -9,7 +8,7 @@ public class HotKeysContextTest
public void Remove_by_Key_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(Code.A, () => { })
.Add(Key.F1, () => { }, description: "Show the help document.") // This entry should be removed even though the description is unmatched.
.Add(ModCode.Shift, Code.A, () => { })
Expand All @@ -19,7 +18,7 @@ public void Remove_by_Key_Test()
hotkeysContext.Remove(Key.F1);

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("A", "Shift+A", "Ctrl+Alt+F1");
}
Expand All @@ -28,7 +27,7 @@ public void Remove_by_Key_Test()
public void Remove_by_Code_and_Mod_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(Code.A, () => { })
.Add(Key.F1, () => { })
.Add(ModCode.Shift, Code.A, () => { }, exclude: Exclude.None) // This entry should be removed even though the exclude flag is unmatched.
Expand All @@ -38,7 +37,7 @@ public void Remove_by_Code_and_Mod_Test()
hotkeysContext.Remove(ModCode.Shift, Code.A);

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("A", "F1", "Ctrl+Alt+F1");
}
Expand All @@ -47,7 +46,7 @@ public void Remove_by_Code_and_Mod_Test()
public void Remove_by_Key_and_Exclude_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(Code.A, () => { })
.Add(ModKey.Meta, Key.F1, () => { }, new() { Exclude = Exclude.ContentEditable })
.Add(ModCode.Shift, Code.A, () => { })
Expand All @@ -57,7 +56,7 @@ public void Remove_by_Key_and_Exclude_Test()
hotkeysContext.Remove(ModKey.Meta, Key.F1, exclude: Exclude.ContentEditable);

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("A", "Shift+A", "Meta+F1");
}
Expand All @@ -66,7 +65,7 @@ public void Remove_by_Key_and_Exclude_Test()
public void Remove_by_Code_and_ExcludeSelector_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(Code.A, () => { })
.Add(ModKey.Meta, Key.F1, () => { })
.Add(Code.A, () => { }, new() { ExcludeSelector = "[data-no-hotkeys]" })
Expand All @@ -76,7 +75,7 @@ public void Remove_by_Code_and_ExcludeSelector_Test()
hotkeysContext.Remove(Code.A, excludeSelector: "[data-no-hotkeys]");

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("A", "Meta+F1", "Meta+F1");
}
Expand All @@ -85,7 +84,7 @@ public void Remove_by_Code_and_ExcludeSelector_Test()
public void Remove_by_Key_but_Ambiguous_Exception_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(ModCode.Shift, Code.A, () => { })
.Add(Key.F1, () => { }, exclude: Exclude.ContentEditable)
.Add(ModCode.Shift, Code.A, () => { })
Expand All @@ -95,7 +94,7 @@ public void Remove_by_Key_but_Ambiguous_Exception_Test()
Assert.Throws<ArgumentException>(() => hotkeysContext.Remove(Key.F1));

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("Shift+A", "F1", "Shift+A", "F1");
}
Expand All @@ -104,7 +103,7 @@ public void Remove_by_Key_but_Ambiguous_Exception_Test()
public void Remove_by_Code_but_Ambiguous_Exception_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(ModCode.Shift, Code.A, () => { }, exclude: Exclude.ContentEditable)
.Add(Key.F1, () => { })
.Add(ModCode.Shift, Code.A, () => { }, exclude: Exclude.InputNonText)
Expand All @@ -114,7 +113,7 @@ public void Remove_by_Code_but_Ambiguous_Exception_Test()
Assert.Throws<ArgumentException>(() => hotkeysContext.Remove(ModCode.Shift, Code.A));

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("Shift+A", "F1", "Shift+A", "F1");
}
Expand All @@ -123,7 +122,7 @@ public void Remove_by_Code_but_Ambiguous_Exception_Test()
public void Remove_by_Filter_Test()
{
// Given
using var hotkeysContext = new HotKeysContext(Task.FromResult(default(IJSObjectReference)!), NullLogger.Instance)
using var hotkeysContext = new HotKeysContext(null!, NullLogger.Instance)
.Add(ModCode.Shift, Code.A, () => { }, exclude: Exclude.ContentEditable)
.Add(Key.F1, () => { })
.Add(ModCode.Shift, Code.A, () => { }, exclude: Exclude.InputNonText)
Expand All @@ -136,7 +135,7 @@ public void Remove_by_Filter_Test()
});

// Then
hotkeysContext.Keys
hotkeysContext.HotKeyEntries
.Select(hotkey => string.Join("+", hotkey.ToStringKeys()))
.Is("F1", "F1");
}
Expand Down
29 changes: 29 additions & 0 deletions HotKeys2/Extensions/JS.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;

namespace Toolbelt.Blazor.HotKeys2.Extensions;

[SuppressMessage("Usage", "CA2254:Template should be a static expression")]
internal static class JS
{
public static async ValueTask<IJSObjectReference> ImportScriptAsync(this IJSRuntime jsRuntime, ILogger logger)
{
var scriptPath = "./_content/Toolbelt.Blazor.HotKeys2/script.min.js";
try
{
var isOnLine = await jsRuntime.InvokeAsync<bool>("Toolbelt.Blazor.getProperty", "navigator.onLine");
if (isOnLine) scriptPath += "?v=" + VersionInfo.VersionText;
}
catch (JSException e) { logger.LogError(e, e.Message); }

return await jsRuntime.InvokeAsync<IJSObjectReference>("import", scriptPath);
}

public static async ValueTask InvokeSafeAsync(Func<ValueTask> action, ILogger logger)
{
try { await action(); }
catch (JSDisconnectedException) { } // Ignore this exception because it is thrown when the user navigates to another page.
catch (Exception ex) { logger.LogError(ex, ex.Message); }
}
}
22 changes: 22 additions & 0 deletions HotKeys2/Extensions/SemaphoreSlimExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.Extensions.Logging;

namespace Toolbelt.Blazor.HotKeys2.Extensions;

internal static class SemaphoreSlimExtensions
{
public static async ValueTask<T> InvokeAsync<T>(this SemaphoreSlim semaphore, Func<ValueTask<T>> asyncAction, ILogger? logger = null)
{
await semaphore.WaitAsync();
try { return await asyncAction.Invoke(); }
catch (Exception ex)
{
if (logger != null)
{
logger.LogError(ex, ex.Message);
return default!;
}
throw;
}
finally { semaphore.Release(); }
}
}
8 changes: 7 additions & 1 deletion HotKeys2/HotKeyEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public abstract class HotKeyEntry : IDisposable

private readonly ILogger? _Logger;

private bool _Disposed = false;

/// <summary>
/// Notifies when the property values of the state object has changed.
/// </summary>
Expand Down Expand Up @@ -161,12 +163,16 @@ public string[] ToStringKeys()
}

/// <summary>
/// Disposes the hot key entry.
/// [Don't use this method. This method is for internal use only.]
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public void Dispose()
{
if (this._Disposed) return;
GC.SuppressFinalize(this);
this.State._NotifyStateChanged = null;
this.Id = -1;
this._ObjectRef.Dispose();
this._Disposed = true;
}
}
98 changes: 50 additions & 48 deletions HotKeys2/HotKeys.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Toolbelt.Blazor.HotKeys2.Extensions;

namespace Toolbelt.Blazor.HotKeys2;

Expand All @@ -12,76 +11,80 @@ namespace Toolbelt.Blazor.HotKeys2;
/// </summary>
public class HotKeys : IAsyncDisposable
{
private volatile bool _Attached = false;

private readonly ILogger<HotKeys> _Logger;

private readonly IJSRuntime _JSRuntime;

private IJSObjectReference? _JSModule = null;
internal readonly DotNetObjectReference<HotKeys> _ObjectRef;

private readonly SemaphoreSlim _Syncer = new(1, 1);

private readonly bool _IsWasm = RuntimeInformation.OSDescription == "web" || RuntimeInformation.OSDescription == "Browser";
private EventHandler<HotKeyDownEventArgs>? _KeyDown;

/// <summary>
/// Occurs when the user enter any keys on the browser.
/// </summary>
public event EventHandler<HotKeyDownEventArgs>? KeyDown;
public event EventHandler<HotKeyDownEventArgs>? KeyDown
{
add
{
this._KeyDown += value;
var _ = this.EnsureAttachedAsync();
}
remove
{
this._KeyDown -= value;
if (this._KeyDown == null) { var _ = this.DetachAsync(); }
}
}

private IJSObjectReference? _KeyEventHandler;

/// <summary>
/// Initialize a new instance of the HotKeys class.
/// </summary>
[DynamicDependency(nameof(OnKeyDown), typeof(HotKeys))]
internal HotKeys(IJSRuntime jSRuntime, ILogger<HotKeys> logger)
{
this._ObjectRef = DotNetObjectReference.Create(this);
this._JSRuntime = jSRuntime;
this._Logger = logger;
}

/// <summary>
/// Attach this HotKeys service instance to JavaScript DOM event handler.
/// </summary>
private async Task<IJSObjectReference> AttachAsync()
private async ValueTask EnsureAttachedAsync()
{
var module = await this.GetJsModuleAsync();
if (this._Attached) return module;
await this._Syncer.WaitAsync();
try
await this._Syncer.InvokeAsync(async () =>
{
if (this._Attached) return module;

await module.InvokeAsync<object>("Toolbelt.Blazor.HotKeys2.attach", DotNetObjectReference.Create(this), this._IsWasm);

this._Attached = true;
return module;
}
finally { this._Syncer.Release(); }
}
if (this._KeyEventHandler != null) return true;

private string GetVersionText()
{
var assembly = this.GetType().Assembly;
return assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? assembly.GetName().Version?.ToString() ?? "0.0.0";
await JS.InvokeSafeAsync(async () =>
{
await using var module = await this._JSRuntime.ImportScriptAsync(this._Logger);
this._KeyEventHandler = await module.InvokeAsync<IJSObjectReference>(
"Toolbelt.Blazor.HotKeys2.handleKeyEvent",
this._ObjectRef,
OperatingSystem.IsBrowser());
}, this._Logger);

return true;
}, this._Logger);
}

private async ValueTask<IJSObjectReference> GetJsModuleAsync()
private async ValueTask DetachAsync()
{
if (this._JSModule == null)
await this._Syncer.InvokeAsync(async () =>
{
var scriptPath = "./_content/Toolbelt.Blazor.HotKeys2/script.min.js";
try
if (this._KeyEventHandler != null)
{
var isOnLine = await this._JSRuntime.InvokeAsync<bool>("Toolbelt.Blazor.getProperty", "navigator.onLine");
if (isOnLine) scriptPath += $"?v={this.GetVersionText()}";
await JS.InvokeSafeAsync(async () =>
{
await this._KeyEventHandler.InvokeVoidAsync("dispose");
await this._KeyEventHandler.DisposeAsync();
}, this._Logger);
this._KeyEventHandler = null;
}
catch (JSException e) { this._Logger.LogError(e, e.Message); }

this._JSModule = await this._JSRuntime.InvokeAsync<IJSObjectReference>("import", scriptPath);
}
return this._JSModule;
return true;
}, this._Logger);
}

/// <summary>
Expand All @@ -90,8 +93,7 @@ private async ValueTask<IJSObjectReference> GetJsModuleAsync()
/// <returns></returns>
public HotKeysContext CreateContext()
{
var attachTask = this.AttachAsync();
return new HotKeysContext(attachTask, this._Logger);
return new HotKeysContext(this._JSRuntime, this._Logger);
}

/// <summary>
Expand All @@ -106,15 +108,15 @@ public HotKeysContext CreateContext()
[JSInvokable(nameof(OnKeyDown)), EditorBrowsable(EditorBrowsableState.Never)]
public bool OnKeyDown(ModCode modifiers, string srcElementTagName, string srcElementTypeName, string key, string code)
{
var args = new HotKeyDownEventArgs(modifiers, srcElementTagName, srcElementTypeName, this._IsWasm, key, code);
KeyDown?.Invoke(null, args);
var args = new HotKeyDownEventArgs(modifiers, srcElementTagName, srcElementTypeName, OperatingSystem.IsBrowser(), key, code);
this._KeyDown?.Invoke(null, args);
return args.PreventDefault;
}

public async ValueTask DisposeAsync()
{
try { if (this._JSModule != null) await this._JSModule.DisposeAsync(); }
catch (Exception ex) when (ex.GetType().FullName == "Microsoft.JSInterop.JSDisconnectedException") { }
catch (Exception ex) { this._Logger.LogError(ex, ex.Message); }
GC.SuppressFinalize(this);
await this.DetachAsync();
this._ObjectRef.Dispose();
}
}
Loading

0 comments on commit d5539b2

Please sign in to comment.