diff --git a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs index f7f3112..eb246a1 100644 --- a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs +++ b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs @@ -308,6 +308,89 @@ public async Task ExcludeSelector_Test(HostingModel hostingModel) await page.AssertEqualsAsync(_ => inputElement2.InputValueAsync(), "uu"); } + [Test] + [TestCaseSource(typeof(HotKeysOnBrowserTest), nameof(AllHostingModels))] + public async Task StateDisabled_Test(HostingModel hostingModel) + { + var context = TestContext.Instance; + var host = await context.StartHostAsync(hostingModel); + + // Navigate to the "Counter" page, + var page = await context.GetPageAsync(); + await page.GotoAndWaitForReadyAsync(host.GetUrl("/counter")); + + // Verify the counter is 0. + var counter = page.Locator("h1+p"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 0"); + + // Set focus to the "Hotkeys are enabled in this field" input element, and type "U" key. + // Then the counter should not be incremented. + var inputElement1 = await page.QuerySelectorAsync(".disabled-state-hotkeys"); + if (inputElement1 == null) + { + throw new InvalidOperationException("Test element is missing"); + } + + await inputElement1.FocusAsync(); + await page.Keyboard.DownAsync("y"); + await page.Keyboard.UpAsync("y"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 0"); + await page.Keyboard.DownAsync("y"); + await page.Keyboard.UpAsync("y"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 0"); + await page.AssertEqualsAsync(_ => inputElement1.InputValueAsync(), "yy"); + } + + [Test] + [TestCaseSource(typeof(HotKeysOnBrowserTest), nameof(AllHostingModels))] + public async Task StateDisabledTrigger_Test(HostingModel hostingModel) + { + var context = TestContext.Instance; + var host = await context.StartHostAsync(hostingModel); + + // Navigate to the "Counter" page, + var page = await context.GetPageAsync(); + await page.GotoAndWaitForReadyAsync(host.GetUrl("/counter")); + + // Verify the counter is 0. + var counter = page.Locator("h1+p"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 0"); + + // Set focus to the "Hotkeys are enabled in this field" input element, and type "U" key. + // Then the counter should not be incremented. + var inputElement1 = await page.QuerySelectorAsync(".disabled-state-hotkeys"); + if (inputElement1 == null) + { + throw new InvalidOperationException("Test element is missing"); + } + + await inputElement1.FocusAsync(); + await page.Keyboard.DownAsync("y"); + await page.Keyboard.UpAsync("y"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 0"); + await page.Keyboard.DownAsync("y"); + await page.Keyboard.UpAsync("y"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 0"); + await page.AssertEqualsAsync(_ => inputElement1.InputValueAsync(), "yy"); + + // Trigger disabled state + await page.ClickAsync(".state-trigger-button"); + + // Refocus and test again + // This time counter should increment + await inputElement1.FocusAsync(); + + await page.Keyboard.DownAsync("y"); + await page.Keyboard.UpAsync("y"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 1"); + + await page.Keyboard.DownAsync("y"); + await page.Keyboard.UpAsync("y"); + await page.AssertEqualsAsync(_ => counter.TextContentAsync(), "Current count: 2"); + + await page.AssertEqualsAsync(_ => inputElement1.InputValueAsync(), "yy"); + } + [Test] [TestCaseSource(typeof(HotKeysOnBrowserTest), nameof(AllHostingModels))] public async Task ByNativeKey_Test(HostingModel hostingModel) diff --git a/HotKeys2.E2ETest/Internals/SampleSite.cs b/HotKeys2.E2ETest/Internals/SampleSite.cs index d16ed40..8bb801f 100644 --- a/HotKeys2.E2ETest/Internals/SampleSite.cs +++ b/HotKeys2.E2ETest/Internals/SampleSite.cs @@ -40,7 +40,7 @@ public async ValueTask StartAsync() // Publish and... using var publishCommand = await Start( "dotnet", - $"publish -f:{this.TargetFramework} -c:Release -p:BlazorEnableCompression=false -p:UsingBrowserRuntimeWorkload=false", + $"publish -f:{this.TargetFramework} -c:Release -p:BlazorEnableCompression=false -p:UsingBrowserRuntimeWorkload=false /p:BuildMode=test", projDir) .WaitForExitAsync(); publishCommand.ExitCode.Is(0, message: publishCommand.Output); diff --git a/HotKeys2/HotKeyEntry.cs b/HotKeys2/HotKeyEntry.cs index fe64d18..2b69c61 100644 --- a/HotKeys2/HotKeyEntry.cs +++ b/HotKeys2/HotKeyEntry.cs @@ -32,6 +32,11 @@ public abstract class HotKeyEntry : IDisposable /// public string? Description { get; } + /// + /// Get the state data attached to this hot key entry. + /// + public HotKeyEntryState State { get; } + internal int Id = -1; internal readonly DotNetObjectReference _ObjectRef; @@ -55,14 +60,19 @@ public abstract class HotKeyEntry : IDisposable private readonly ILogger? _Logger; + /// + /// Notifies when the property values of the state object has changed. + /// + internal Action? _NotifyStateChanged; + /// /// Initialize a new instance of the HotKeyEntry class. /// /// The instance of that is used to log the error message. /// The mode that how to identificate the hot key. - /// - /// The combination of modifier flags - /// The key or code of the hot key + /// The type of the modifier flags. + /// The combination of modifier flags. + /// The key or code of the hot key. /// The instance of a Razor component that is an owner of the callback action method. /// The options for this hotkey entry. [DynamicDependency(nameof(InvokeAction), typeof(HotKeyEntry))] @@ -77,6 +87,8 @@ internal HotKeyEntry(ILogger? logger, HotKeyMode mode, Type typeOfModifiers, int this.Description = options.Description; this.Exclude = options.Exclude; this.ExcludeSelector = options.ExcludeSelector; + this.State = options.State; + this.State._NotifyStateChanged = () => this._NotifyStateChanged?.Invoke(this); this._ObjectRef = DotNetObjectReference.Create(this); } @@ -108,12 +120,14 @@ protected void CommonProcess(Func action) /// /// Returns a String that combined key combination and description of this entry, like "Ctrl+A: Select All." /// + /// A string that represents the key combination and description of this entry. public override string ToString() => this.ToString("{0}: {1}"); /// /// Returns a String formatted with specified format string. /// /// {0} will be replaced with key combination text, and {1} will be replaced with description of this hotkey entry object. + /// A string formatted with the specified format string. public string ToString(string format) { var keyComboText = string.Join(" + ", this.ToStringKeys()); @@ -123,6 +137,7 @@ public string ToString(string format) /// /// Returns an array of String formatted keys. /// + /// An array of string formatted keys. public string[] ToStringKeys() { var keyCombo = new List(); @@ -145,8 +160,12 @@ public string[] ToStringKeys() return keyCombo.ToArray(); } + /// + /// Disposes the hot key entry. + /// public void Dispose() { + this.State._NotifyStateChanged = null; this.Id = -1; this._ObjectRef.Dispose(); } diff --git a/HotKeys2/HotKeyEntryState.cs b/HotKeys2/HotKeyEntryState.cs new file mode 100644 index 0000000..acd697b --- /dev/null +++ b/HotKeys2/HotKeyEntryState.cs @@ -0,0 +1,23 @@ +namespace Toolbelt.Blazor.HotKeys2; + +/// +/// Represents the state of a hot key entry. +/// +public class HotKeyEntryState +{ + /// + /// Notifies when the property values of this state object has changed. + /// + internal Action? _NotifyStateChanged; + + private bool _Disabled; + + /// + /// Controls if the current hot key is disabled or not. + /// + public virtual bool Disabled + { + get => this._Disabled; + set { if (this._Disabled != value) { this._Disabled = value; this._NotifyStateChanged?.Invoke(); } } + } +} diff --git a/HotKeys2/HotKeyOptions.cs b/HotKeys2/HotKeyOptions.cs index b52f9de..8ec5dfd 100644 --- a/HotKeys2/HotKeyOptions.cs +++ b/HotKeys2/HotKeyOptions.cs @@ -15,4 +15,7 @@ public class HotKeyOptions /// Additional CSS selector for HTML elements that will not allow hotkey to work. public string ExcludeSelector { get; set; } = ""; + + /// State data attached to a hotkey. + public HotKeyEntryState State { get; set; } = new HotKeyEntryState(); } diff --git a/HotKeys2/HotKeysContext.cs b/HotKeys2/HotKeysContext.cs index 2d2ae33..0b97388 100644 --- a/HotKeys2/HotKeysContext.cs +++ b/HotKeys2/HotKeysContext.cs @@ -307,12 +307,7 @@ public HotKeysContext Add(ModKey modifiers, Key key, FuncThe options for this hotkey entry. /// This context. private HotKeysContext AddInternal(ModKey modifiers, Key key, Func action, IHandleEvent? ownerOfAction, HotKeyOptions options) - { - var hotkeyEntry = new HotKeyEntryByKey(this._Logger, modifiers, key, action, ownerOfAction, options); - lock (this.Keys) this.Keys.Add(hotkeyEntry); - var _ = this.RegisterAsync(hotkeyEntry); - return this; - } + => this.AddInternal(new HotKeyEntryByKey(this._Logger, modifiers, key, action, ownerOfAction, options)); // =============================================================================================== @@ -592,26 +587,40 @@ public HotKeysContext Add(ModCode modifiers, Code code, FuncThe options for this hotkey entry. /// This context. private HotKeysContext AddInternal(ModCode modifiers, Code code, Func action, IHandleEvent? ownerOfAction, HotKeyOptions options) + => this.AddInternal(new HotKeyEntryByCode(this._Logger, modifiers, code, action, ownerOfAction, options)); + + // =============================================================================================== + + private HotKeysContext AddInternal(HotKeyEntry hotkeyEntry) { - var hotkeyEntry = new HotKeyEntryByCode(this._Logger, modifiers, code, action, ownerOfAction, options); lock (this.Keys) this.Keys.Add(hotkeyEntry); - var _ = this.RegisterAsync(hotkeyEntry); + this.RegisterAsync(hotkeyEntry); + hotkeyEntry._NotifyStateChanged = this.OnNotifyStateChanged; return this; } // =============================================================================================== - private async ValueTask RegisterAsync(HotKeyEntry hotKeyEntry) + private void RegisterAsync(HotKeyEntry hotKeyEntry) { - await this.InvokeJsSafeAsync(async () => + var _ = this.InvokeJsSafeAsync(async () => { var module = await this._AttachTask; if (this._IsDisposed) return; hotKeyEntry.Id = await module.InvokeAsync( "Toolbelt.Blazor.HotKeys2.register", - hotKeyEntry._ObjectRef, hotKeyEntry.Mode, hotKeyEntry._Modifiers, hotKeyEntry._KeyEntry, hotKeyEntry.Exclude, hotKeyEntry.ExcludeSelector); + hotKeyEntry._ObjectRef, hotKeyEntry.Mode, hotKeyEntry._Modifiers, hotKeyEntry._KeyEntry, hotKeyEntry.Exclude, hotKeyEntry.ExcludeSelector, hotKeyEntry.State.Disabled); + }); + } + + private void OnNotifyStateChanged(HotKeyEntry hotKeyEntry) + { + var _ = this.InvokeJsSafeAsync(async () => + { + var module = await this._AttachTask; + await module.InvokeVoidAsync("Toolbelt.Blazor.HotKeys2.update", hotKeyEntry.Id, hotKeyEntry.State.Disabled); }); } @@ -735,6 +744,7 @@ public HotKeysContext Remove(Func, IEnumerable { + HotKeys2.register = (dotNetObj, mode, modifiers, keyEntry, exclude, excludeSelector, isDisabled) => { const id = idSeq++; - const hotKeyEntry = new HotkeyEntry(dotNetObj, mode, modifiers, keyEntry, exclude, excludeSelector); + const hotKeyEntry = new HotkeyEntry(dotNetObj, mode, modifiers, keyEntry, exclude, excludeSelector, isDisabled); hotKeyEntries.set(id, hotKeyEntry); return id; }; + HotKeys2.update = (id, isDisabled) => { + const hotkeyEntry = hotKeyEntries.get(id); + if (!hotkeyEntry) + return; + hotkeyEntry.isDisabled = isDisabled; + }; HotKeys2.unregister = (id) => { if (id === -1) return; @@ -38,7 +45,7 @@ export var Toolbelt; return convertToKeyNameMap[ev.key] || ev.key; }; const OnKeyDownMethodName = "OnKeyDown"; - HotKeys2.attach = (hotKeysWrpper, isWasm) => { + HotKeys2.attach = (hotKeysWrapper, isWasm) => { document.addEventListener('keydown', ev => { if (typeof (ev["altKey"]) === 'undefined') return; @@ -52,37 +59,39 @@ export var Toolbelt; const tagName = targetElement.tagName; const type = targetElement.getAttribute('type'); const preventDefault1 = onKeyDown(modifiers, key, code, targetElement, tagName, type); - const preventDefault2 = isWasm === true ? hotKeysWrpper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code) : false; + const preventDefault2 = isWasm === true ? hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code) : false; if (preventDefault1 || preventDefault2) ev.preventDefault(); if (isWasm === false) - hotKeysWrpper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); + hotKeysWrapper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); }); }; const onKeyDown = (modifiers, key, code, targetElement, tagName, type) => { let preventDefault = false; hotKeyEntries.forEach(entry => { - const byCode = entry.mode === 1; - const eventKeyEntry = byCode ? code : key; - const keyEntry = entry.keyEntry; - if (keyEntry !== eventKeyEntry) - return; - const eventModkeys = byCode ? modifiers : (modifiers & (0xffff ^ 1)); - let entryModKeys = byCode ? entry.modifiers : (entry.modifiers & (0xffff ^ 1)); - if (keyEntry.startsWith("Shift") && byCode) - entryModKeys |= 1; - if (keyEntry.startsWith("Control")) - entryModKeys |= 2; - if (keyEntry.startsWith("Alt")) - entryModKeys |= 4; - if (keyEntry.startsWith("Meta")) - entryModKeys |= 8; - if (eventModkeys !== entryModKeys) - return; - if (isExcludeTarget(entry, targetElement, tagName, type)) - return; - preventDefault = true; - entry.action(); + if (!entry.isDisabled) { + const byCode = entry.mode === 1; + const eventKeyEntry = byCode ? code : key; + const keyEntry = entry.keyEntry; + if (keyEntry !== eventKeyEntry) + return; + const eventModkeys = byCode ? modifiers : (modifiers & (0xffff ^ 1)); + let entryModKeys = byCode ? entry.modifiers : (entry.modifiers & (0xffff ^ 1)); + if (keyEntry.startsWith("Shift") && byCode) + entryModKeys |= 1; + if (keyEntry.startsWith("Control")) + entryModKeys |= 2; + if (keyEntry.startsWith("Alt")) + entryModKeys |= 4; + if (keyEntry.startsWith("Meta")) + entryModKeys |= 8; + if (eventModkeys !== entryModKeys) + return; + if (isExcludeTarget(entry, targetElement, tagName, type)) + return; + preventDefault = true; + entry.action(); + } }); return preventDefault; }; diff --git a/HotKeys2/script.ts b/HotKeys2/script.ts index e6371ec..dd9b34d 100644 --- a/HotKeys2/script.ts +++ b/HotKeys2/script.ts @@ -29,7 +29,8 @@ public modifiers: ModCodes, public keyEntry: string, public exclude: Exclude, - public excludeSelector: string + public excludeSelector: string, + public isDisabled: boolean ) { } public action(): void { @@ -40,13 +41,19 @@ let idSeq: number = 0; const hotKeyEntries = new Map(); - export const register = (dotNetObj: any, mode: HotKeyMode, modifiers: ModCodes, keyEntry: string, exclude: Exclude, excludeSelector: string): number => { + export const register = (dotNetObj: any, mode: HotKeyMode, modifiers: ModCodes, keyEntry: string, exclude: Exclude, excludeSelector: string, isDisabled: boolean): number => { const id = idSeq++; - const hotKeyEntry = new HotkeyEntry(dotNetObj, mode, modifiers, keyEntry, exclude, excludeSelector); + const hotKeyEntry = new HotkeyEntry(dotNetObj, mode, modifiers, keyEntry, exclude, excludeSelector, isDisabled); hotKeyEntries.set(id, hotKeyEntry); return id; } + export const update = (id: number, isDisabled: boolean): void => { + const hotkeyEntry = hotKeyEntries.get(id); + if (!hotkeyEntry) return; + hotkeyEntry.isDisabled = isDisabled; + } + export const unregister = (id: number): void => { if (id === -1) return; hotKeyEntries.delete(id); @@ -63,7 +70,7 @@ const OnKeyDownMethodName = "OnKeyDown"; - export const attach = (hotKeysWrpper: any, isWasm: boolean): void => { + export const attach = (hotKeysWrapper: any, isWasm: boolean): void => { document.addEventListener('keydown', ev => { if (typeof (ev["altKey"]) === 'undefined') return; const modifiers = @@ -79,9 +86,9 @@ const type = targetElement.getAttribute('type'); const preventDefault1 = onKeyDown(modifiers, key, code, targetElement, tagName, type); - const preventDefault2 = isWasm === true ? hotKeysWrpper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code) : false; + const preventDefault2 = isWasm === true ? hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code) : false; if (preventDefault1 || preventDefault2) ev.preventDefault(); - if (isWasm === false) hotKeysWrpper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); + if (isWasm === false) hotKeysWrapper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); }); } @@ -90,24 +97,26 @@ hotKeyEntries.forEach(entry => { - const byCode = entry.mode === HotKeyMode.ByCode; - const eventKeyEntry = byCode ? code : key; - const keyEntry = entry.keyEntry; + if (!entry.isDisabled) { + const byCode = entry.mode === HotKeyMode.ByCode; + const eventKeyEntry = byCode ? code : key; + const keyEntry = entry.keyEntry; - if (keyEntry !== eventKeyEntry) return; + if (keyEntry !== eventKeyEntry) return; - const eventModkeys = byCode ? modifiers : (modifiers & (0xffff ^ ModCodes.Shift)); - let entryModKeys = byCode ? entry.modifiers : (entry.modifiers & (0xffff ^ ModCodes.Shift)); - if (keyEntry.startsWith("Shift") && byCode) entryModKeys |= ModCodes.Shift; - if (keyEntry.startsWith("Control")) entryModKeys |= ModCodes.Control; - if (keyEntry.startsWith("Alt")) entryModKeys |= ModCodes.Alt; - if (keyEntry.startsWith("Meta")) entryModKeys |= ModCodes.Meta; - if (eventModkeys !== entryModKeys) return; + const eventModkeys = byCode ? modifiers : (modifiers & (0xffff ^ ModCodes.Shift)); + let entryModKeys = byCode ? entry.modifiers : (entry.modifiers & (0xffff ^ ModCodes.Shift)); + if (keyEntry.startsWith("Shift") && byCode) entryModKeys |= ModCodes.Shift; + if (keyEntry.startsWith("Control")) entryModKeys |= ModCodes.Control; + if (keyEntry.startsWith("Alt")) entryModKeys |= ModCodes.Alt; + if (keyEntry.startsWith("Meta")) entryModKeys |= ModCodes.Meta; + if (eventModkeys !== entryModKeys) return; - if (isExcludeTarget(entry, targetElement, tagName, type)) return; + if (isExcludeTarget(entry, targetElement, tagName, type)) return; - preventDefault = true; - entry.action(); + preventDefault = true; + entry.action(); + } }); return preventDefault; diff --git a/HotKeys2/tsconfig.json b/HotKeys2/tsconfig.json index fa8da93..5ae6d60 100644 --- a/HotKeys2/tsconfig.json +++ b/HotKeys2/tsconfig.json @@ -1,61 +1,61 @@ { - "compilerOptions": { - /* Basic Options */ - "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ - "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ - // "lib": [], /* Specify library files to be included in the compilation. */ - // "allowJs": true, /* Allow javascript files to be compiled. */ - // "checkJs": true, /* Report errors in .js files. */ - // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - // "declaration": true, /* Generates corresponding '.d.ts' file. */ - // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - "sourceMap": false, /* Generates corresponding '.map' file. */ - // "outFile": "./", /* Concatenate and emit output to single file. */ - // "outDir": "./", /* Redirect output structure to the directory. */ - // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - // "composite": true, /* Enable project compilation */ - "removeComments": true, /* Do not emit comments to output. */ - // "noEmit": true, /* Do not emit outputs. */ - // "importHelpers": true, /* Import emit helpers from 'tslib'. */ - // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ - // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + "compilerOptions": { + /* Basic Options */ + "target": "ES2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ + "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": false, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ - /* Strict Type-Checking Options */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* Enable strict null checks. */ - // "strictFunctionTypes": true, /* Enable strict checking of function types. */ - // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ - // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ - // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ - // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ - /* Additional Checks */ - // "noUnusedLocals": true, /* Report errors on unused locals. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - /* Module Resolution Options */ - // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ - // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ - // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ - /* Source Map Options */ - // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ - // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ - /* Experimental Options */ - // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ - // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ - }, + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + }, "compileOnSave": true } diff --git a/HotKeys2/wwwroot/script.min.js b/HotKeys2/wwwroot/script.min.js index 199f480..df14e1c 100644 --- a/HotKeys2/wwwroot/script.min.js +++ b/HotKeys2/wwwroot/script.min.js @@ -1 +1 @@ -export var Toolbelt;(function(n){var t;(function(n){var t;(function(n){class f{constructor(n,t,i,r,u,f){this.dotNetObj=n;this.mode=t;this.modifiers=i;this.keyEntry=r;this.exclude=u;this.excludeSelector=f}action(){this.dotNetObj.invokeMethodAsync("InvokeAction")}}let e=0;const t=new Map;n.register=(n,i,r,u,o,s)=>{const h=e++,c=new f(n,i,r,u,o,s);return t.set(h,c),h};n.unregister=n=>{n!==-1&&t.delete(n)};const o={OS:"Meta",Decimal:"Period"},s=n=>o[n.key]||n.key,i="OnKeyDown";n.attach=(n,t)=>{document.addEventListener("keydown",r=>{if(typeof r.altKey!="undefined"){const u=(r.shiftKey?1:0)+(r.ctrlKey?2:0)+(r.altKey?4:0)+(r.metaKey?8:0),f=s(r),e=r.code,o=r.target,c=o.tagName,l=o.getAttribute("type"),a=h(u,f,e,o,c,l),v=t===!0?n.invokeMethod(i,u,c,l,f,e):!1;(a||v)&&r.preventDefault();t===!1&&n.invokeMethodAsync(i,u,c,l,f,e)}})};const h=(n,i,r,u,f,e)=>{let o=!1;return t.forEach(t=>{const l=t.mode===1,a=l?r:i,s=t.keyEntry;if(s===a){const v=l?n:n&65534;let h=l?t.modifiers:t.modifiers&65534;(s.startsWith("Shift")&&l&&(h|=1),s.startsWith("Control")&&(h|=2),s.startsWith("Alt")&&(h|=4),s.startsWith("Meta")&&(h|=8),v===h)&&(c(t,u,f,e)||(o=!0,t.action()))}}),o},r=["button","checkbox","color","file","image","radio","range","reset","submit",],u="INPUT",c=(n,t,i,f)=>(n.exclude&1)!=0&&i===u&&r.every(n=>n!==f)?!0:(n.exclude&2)!=0&&i===u&&r.some(n=>n===f)?!0:(n.exclude&4)!=0&&i==="TEXTAREA"?!0:(n.exclude&8)!=0&&t.isContentEditable?!0:n.excludeSelector!==""&&t.matches(n.excludeSelector)?!0:!1})(t=n.HotKeys2||(n.HotKeys2={}))})(t=n.Blazor||(n.Blazor={}))})(Toolbelt||(Toolbelt={})); \ No newline at end of file +export var Toolbelt;(function(n){var t;(function(n){var t;(function(n){class f{constructor(n,t,i,r,u,f,e){this.dotNetObj=n;this.mode=t;this.modifiers=i;this.keyEntry=r;this.exclude=u;this.excludeSelector=f;this.isDisabled=e}action(){this.dotNetObj.invokeMethodAsync("InvokeAction")}}let e=0;const t=new Map;n.register=(n,i,r,u,o,s,h)=>{const c=e++,l=new f(n,i,r,u,o,s,h);return t.set(c,l),c};n.update=(n,i)=>{const r=t.get(n);r&&(r.isDisabled=i)};n.unregister=n=>{n!==-1&&t.delete(n)};const o={OS:"Meta",Decimal:"Period"},s=n=>o[n.key]||n.key,i="OnKeyDown";n.attach=(n,t)=>{document.addEventListener("keydown",r=>{if(typeof r.altKey!="undefined"){const u=(r.shiftKey?1:0)+(r.ctrlKey?2:0)+(r.altKey?4:0)+(r.metaKey?8:0),f=s(r),e=r.code,o=r.target,c=o.tagName,l=o.getAttribute("type"),a=h(u,f,e,o,c,l),v=t===!0?n.invokeMethod(i,u,c,l,f,e):!1;(a||v)&&r.preventDefault();t===!1&&n.invokeMethodAsync(i,u,c,l,f,e)}})};const h=(n,i,r,u,f,e)=>{let o=!1;return t.forEach(t=>{if(!t.isDisabled){const l=t.mode===1,a=l?r:i,s=t.keyEntry;if(s!==a)return;const v=l?n:n&65534;let h=l?t.modifiers:t.modifiers&65534;if(s.startsWith("Shift")&&l&&(h|=1),s.startsWith("Control")&&(h|=2),s.startsWith("Alt")&&(h|=4),s.startsWith("Meta")&&(h|=8),v!==h)return;if(c(t,u,f,e))return;o=!0;t.action()}}),o},r=["button","checkbox","color","file","image","radio","range","reset","submit",],u="INPUT",c=(n,t,i,f)=>(n.exclude&1)!=0&&i===u&&r.every(n=>n!==f)?!0:(n.exclude&2)!=0&&i===u&&r.some(n=>n===f)?!0:(n.exclude&4)!=0&&i==="TEXTAREA"?!0:(n.exclude&8)!=0&&t.isContentEditable?!0:n.excludeSelector!==""&&t.matches(n.excludeSelector)?!0:!1})(t=n.HotKeys2||(n.HotKeys2={}))})(t=n.Blazor||(n.Blazor={}))})(Toolbelt||(Toolbelt={})); \ No newline at end of file diff --git a/README.md b/README.md index 4c5dc5d..98f26e9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# Blazor HotKeys2 [![NuGet Package](https://img.shields.io/nuget/v/Toolbelt.Blazor.HotKeys2.svg)](https://www.nuget.org/packages/Toolbelt.Blazor.HotKeys2/) [![unit tests](https://github.com/jsakamoto/Toolbelt.Blazor.HotKeys2/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/jsakamoto/Toolbelt.Blazor.HotKeys2/actions/workflows/unit-tests.yml) +# Blazor HotKeys2 + +[![NuGet Package](https://img.shields.io/nuget/v/Toolbelt.Blazor.HotKeys2.svg)](https://www.nuget.org/packages/Toolbelt.Blazor.HotKeys2/) [![unit tests](https://github.com/jsakamoto/Toolbelt.Blazor.HotKeys2/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/jsakamoto/Toolbelt.Blazor.HotKeys2/actions/workflows/unit-tests.yml) [![Discord](https://img.shields.io/discord/798312431893348414?style=flat&logo=discord&logoColor=white&label=Blazor%20Community&labelColor=5865f2&color=gray)](https://discord.com/channels/798312431893348414/1202165955900473375) ## Summary @@ -14,7 +16,7 @@ You can declare associations of keyboard shortcut and callback action, like this ```csharp // The method "OnSelectAll" will be invoked // when the user typed Ctrl+A key combination. -this.HotKeysContext = this.HotKeys.CreateContext() +_hotKeysContext = this.HotKeys.CreateContext() .Add(ModCode.Ctrl, Code.A, OnSelectAll) .Add(...) ...; @@ -78,11 +80,11 @@ Please remember that you have to keep the `HotKeys Context` object in the compon ```csharp @code { - private HotKeysContext? HotKeysContext; + private HotKeysContext? _hotKeysContext; protected override void OnInitialized() { - this.HotKeysContext = this.HotKeys.CreateContext() + _hotKeysContext = this.HotKeys.CreateContext() .Add(ModCode.Ctrl|ModCode.Shift, Code.A, FooBar, new() { Description = "do foo bar." }) .Add(...) ...; @@ -109,7 +111,7 @@ Please remember that you have to keep the `HotKeys Context` object in the compon ... public void Dispose() { - this.HotKeysContext?.Dispose(); // 👈 1. Add this + _hotKeysContext?.Dispose(); // 👈 1. Add this } } ``` @@ -124,11 +126,11 @@ The complete source code (.razor) of this component is bellow. @code { - private HotKeysContext? HotKeysContext; + private HotKeysContext? _hotKeysContext; protected override void OnInitialized() { - this.HotKeysContext = this.HotKeys.CreateContext() + _hotKeysContext = this.HotKeys.CreateContext() .Add(ModCode.Ctrl|ModCode.Shift, Code.A, FooBar, new() { Description = "do foo bar." }) } @@ -139,7 +141,7 @@ The complete source code (.razor) of this component is bellow. public void Dispose() { - this.HotKeysContext?.Dispose(); + _hotKeysContext?.Dispose(); } } ``` @@ -177,25 +179,63 @@ You can also specify the elements that are disabled hotkeys by CSS query selecto And you can specify the `Exclude.ContentEditable` to register the unavailable hotkey when any "contenteditable" applied elements have focus. +### How to enable / disable hotkeys depending on application states + +You can also specify enabling/disabling hotkeys depending on the application states through the `Disabled` property of the `HotKeyEntryState` object included by a `HotKeyEntry` as its `State` property. You can initialize the `State` property of the `HotKeyEntry` object when you call the `HotKeysContext.Add()` method. + +```csharp +... +private HotKeyEntryState _state = new() { Disabled = true }; + +protected override void OnInitialized() +{ + _hotKeysContext = this.HotKeys.CreateContext() + // 👇 Specify the "State" property of the option object. + .Add(Code.A, OnHotKeyA, new() { State = _state }); +} +... +``` + +And you can change the `Disabled` property of the `HotKeyEntryState` object to enable/disable the hotkey whenever you want. + +```csharp +private void OnClickEnableHotKeyA() +{ + _state.Disabled = false; +} +``` + +You can also control enable/disable a hotkey more declaratively by updating the `Disabled` property in the `OnAfterRender()` lifecycle method. + +```csharp +protected override void OnAfterRender(bool firstRender) +{ + // Update the state of the hotkey entry every time + // the component is rendered. + // Because the causing of rendering means that + // some of the states of the component have been changed. + _state.Disabled = _showDialog || _panelPopuped; +} +``` ### How to remove hotkeys You can remove hotkkey entries by calling the `Remove()` method of the `HotKeysContext` object, like this. ```csharp -this.HotKeysContext.Remove(ModCode.Ctrl, Code.A); +_hotKeysContext.Remove(ModCode.Ctrl, Code.A); ``` Please remember that the `Remove` method will remove a hotkey entry identified by the `key`, `code`, and `modifiers` parameters even if other parameters are unmatched by the registered hotkey entry as long as it can identify a single hotkey entry. ```csharp ... - this.HotKeys.CreateContext() + _hotKeysContext = this.HotKeys.CreateContext() .Add(Code.A, OnKeyDownA, exclude: Exclude.InputNonText | Exclude.TextArea); ... // The following code will remove the hotkey entry registered by the above code // even though the "exclude" option is different. -this.HotKeysContext.Remove(Code.A); +_hotKeysContext.Remove(Code.A); ``` If the parameters for the `Remove` method can not determine a single hotkey entry, the `ArgumentException` exception will be thrown. @@ -203,16 +243,16 @@ If the parameters for the `Remove` method can not determine a single hotkey entr ```csharp ... - this.HotKeys.CreateContext() - .Add(Code.A, OnKeyDownAForTextArea, exclude: Exclude.InputNonText | Exclude.InputText) - .Add(Code.A, OnKeyDownAForInputText, exclude: Exclude.InputNonText | Exclude.TextArea); +_hotKeysContext = this.HotKeys.CreateContext() + .Add(Code.A, OnKeyDownAForTextArea, exclude: Exclude.InputNonText | Exclude.InputText) + .Add(Code.A, OnKeyDownAForInputText, exclude: Exclude.InputNonText | Exclude.TextArea); ... // The following code will throw an ArgumentException exception // because the "Remove" method can not determine a single hotkey entry. -this.HotKeysContext.Remove(Code.A); +_hotKeysContext.Remove(Code.A); ... // The following code will successfully remove the hotkey entry in the second one. -this.HotKeysContext.Remove(Code.A, exclude: Exclude.InputNonText | Exclude.TextArea); +_hotKeysContext.Remove(Code.A, exclude: Exclude.InputNonText | Exclude.TextArea); ``` If the `key`, `code`, and `modifires` parameters cannot find any hotkey entry, the `Remove` method will return without exception. @@ -222,14 +262,12 @@ The `HotKeysContext` also provides another `Remove` method overload version that ```csharp // The following code will remove all hotkey entries registered by the "Code. A", // regardless of what modifiers, exclude options, etc. -this.HotKeysContext.Remove(entries => +_hotKeysContext.Remove(entries => { return entries.Where(e => e is HotKeyEntryByCode codeEntry && codeEntry.Code == Code.A); }); ``` - - ## `Code` vs. `Key` - which way should I use to? There are two ways to register hotkeys in the `HotKeysContext`. @@ -271,7 +309,7 @@ Instead, the `HotKeysContext` object provides `Keys` property, so you can implem ```razor
    - @foreach (var key in this.HotKeysContext.Keys) + @foreach (var key in _hotKeysContext.Keys) {
  • @key
  • } diff --git a/SampleSites/Components/Pages/Counter.razor b/SampleSites/Components/Pages/Counter.razor index f49a7e3..4f5ac1d 100644 --- a/SampleSites/Components/Pages/Counter.razor +++ b/SampleSites/Components/Pages/Counter.razor @@ -18,11 +18,23 @@ }
    - +
    - + +
    + +
    + +
    + +
    + +
    + +
    + State: @HotKeyState
    @code { @@ -32,10 +44,15 @@ private HotKeysContext? HotKeysContext; + private readonly HotKeyEntryState HotKeyEntryState = new() { Disabled = true }; + + private string HotKeyState => HotKeyEntryState.Disabled ? "disabled" : "enabled"; + protected override void OnInitialized() { this.HotKeysContext = this.HotKeys.CreateContext() - .Add(Code.U, this.IncrementCount, new() { Exclude = Exclude.None, ExcludeSelector = ".disabled-hotkeys *" }); + .Add(Code.U, this.IncrementCount, new() { Exclude = Exclude.None, ExcludeSelector = ".disabled-hotkeys *" }) + .Add(Code.Y, this.IncrementCount, new() { Exclude = Exclude.None, State = HotKeyEntryState }); this.HotKeys.KeyDown += HotKeys_KeyDown; } @@ -44,6 +61,11 @@ currentCount++; } + private void OnTriggerDisabledState() + { + HotKeyEntryState.Disabled = !HotKeyEntryState.Disabled; + } + private void HotKeys_KeyDown(object? sender, HotKeyDownEventArgs e) { if (e.Modifiers == ModCode.Ctrl && e.Code == Code.A && e.IsWasm) diff --git a/SampleSites/Components/SampleSite.Components.csproj b/SampleSites/Components/SampleSite.Components.csproj index 4b5a4f6..a1f89ea 100644 --- a/SampleSites/Components/SampleSite.Components.csproj +++ b/SampleSites/Components/SampleSite.Components.csproj @@ -19,9 +19,13 @@ - - - + + + + + + + diff --git a/SampleSites/Components/Shared/CheatSheet.razor b/SampleSites/Components/Shared/CheatSheet.razor index 0db3aa4..d91fb43 100644 --- a/SampleSites/Components/Shared/CheatSheet.razor +++ b/SampleSites/Components/Shared/CheatSheet.razor @@ -13,6 +13,9 @@
  • U ... (only "Counter") Increment counter.
  • +
  • + Y ... (only "Counter") Increment counter (with state set from C#). +

The hot key H will work even if input element has focus.