From 84ff335a04dd5b5b76825a1ad27757d7a95c17a0 Mon Sep 17 00:00:00 2001 From: Kasbolat Kumakhov Date: Sat, 6 Apr 2024 13:39:46 +0300 Subject: [PATCH 1/4] Added ability to specify custom state per key entry (useful to store custom data for cheat sheet). Added ability to disable key entry from C# code during runtime (useful to disable keys during modal dialogs from C# code). Currently for WASM only. Added appropriate tests. --- HotKeys2.E2ETest/HotKeysOnBrowserTest.cs | 89 ++++++++++++++- HotKeys2.E2ETest/Internals/SampleSite.cs | 2 +- HotKeys2/HotKeyEntry.cs | 9 ++ HotKeys2/HotKeyEntryState.cs | 15 +++ HotKeys2/HotKeyOptions.cs | 3 + HotKeys2/script.js | 55 ++++----- HotKeys2/script.ts | 41 ++++--- HotKeys2/tsconfig.json | 106 +++++++++--------- HotKeys2/wwwroot/script.min.js | 2 +- SampleSites/Components/Pages/Counter.razor | 27 ++++- .../Components/SampleSite.Components.csproj | 9 +- .../Components/Shared/CheatSheet.razor | 3 + 12 files changed, 253 insertions(+), 108 deletions(-) create mode 100644 HotKeys2/HotKeyEntryState.cs diff --git a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs index 6960d46..fdfd964 100644 --- a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs +++ b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs @@ -8,9 +8,9 @@ public class HotKeysOnBrowserTest HostingModel.Wasm60, HostingModel.Wasm70, HostingModel.Wasm80, - HostingModel.Server60, - HostingModel.Server70, - HostingModel.Server80, + //HostingModel.Server60, + //HostingModel.Server70, + //HostingModel.Server80, }; public static IEnumerable WasmHostingModels { get; } = new[] { @@ -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..967f62e 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; @@ -77,6 +82,7 @@ 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._ObjectRef = DotNetObjectReference.Create(this); } @@ -105,6 +111,9 @@ protected void CommonProcess(Func action) [JSInvokable(nameof(InvokeAction)), EditorBrowsable(Never)] public void InvokeAction() => this.InvokeCallbackAction(); + [JSInvokable(nameof(IsDisabled)), EditorBrowsable(Never)] + public bool IsDisabled() => this.State.IsDisabled; + /// /// Returns a String that combined key combination and description of this entry, like "Ctrl+A: Select All." /// diff --git a/HotKeys2/HotKeyEntryState.cs b/HotKeys2/HotKeyEntryState.cs new file mode 100644 index 0000000..9d9c41c --- /dev/null +++ b/HotKeys2/HotKeyEntryState.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Toolbelt.Blazor.HotKeys2; + +public class HotKeyEntryState +{ + /// + /// Controls if the current hot key is disabled or not. + /// + public virtual bool IsDisabled { get; set; } +} 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/script.js b/HotKeys2/script.js index 9d0a524..af9cef2 100644 --- a/HotKeys2/script.js +++ b/HotKeys2/script.js @@ -14,7 +14,10 @@ export var Toolbelt; this.excludeSelector = excludeSelector; } action() { - this.dotNetObj.invokeMethodAsync('InvokeAction'); + this.dotNetObj.invokeMethod('InvokeAction'); + } + isDisabled() { + return this.dotNetObj.invokeMethod('IsDisabled'); } } let idSeq = 0; @@ -36,7 +39,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; @@ -50,37 +53,37 @@ 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 = hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code); if (preventDefault1 || preventDefault2) ev.preventDefault(); - if (isWasm === false) - hotKeysWrpper.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 ddee1db..db3bd7e 100644 --- a/HotKeys2/script.ts +++ b/HotKeys2/script.ts @@ -33,7 +33,11 @@ ) { } public action(): void { - this.dotNetObj.invokeMethodAsync('InvokeAction'); + this.dotNetObj.invokeMethod('InvokeAction'); + } + + public isDisabled(): boolean { + return this.dotNetObj.invokeMethod('IsDisabled'); } } @@ -62,7 +66,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 = @@ -78,9 +82,8 @@ 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 = hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code); if (preventDefault1 || preventDefault2) ev.preventDefault(); - if (isWasm === false) hotKeysWrpper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); }); } @@ -89,24 +92,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 b80f3a4..59bdde8 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=>{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 u{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.invokeMethod("InvokeAction")}isDisabled(){return this.dotNetObj.invokeMethod("IsDisabled")}}let f=0;const t=new Map;n.register=(n,i,r,e,o,s)=>{const h=f++,c=new u(n,i,r,e,o,s);return t.set(h,c),h};n.unregister=n=>{t.delete(n)};const e={OS:"Meta",Decimal:"Period"},o=n=>e[n.key]||n.key,s="OnKeyDown";n.attach=n=>{document.addEventListener("keydown",t=>{if(typeof t.altKey!="undefined"){const r=(t.shiftKey?1:0)+(t.ctrlKey?2:0)+(t.altKey?4:0)+(t.metaKey?8:0),u=o(t),f=t.code,i=t.target,e=i.tagName,c=i.getAttribute("type"),l=h(r,u,f,i,e,c),a=n.invokeMethod(s,r,e,c,u,f);(l||a)&&t.preventDefault()}})};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},i=["button","checkbox","color","file","image","radio","range","reset","submit",],r="INPUT",c=(n,t,u,f)=>(n.exclude&1)!=0&&u===r&&i.every(n=>n!==f)?!0:(n.exclude&2)!=0&&u===r&&i.some(n=>n===f)?!0:(n.exclude&4)!=0&&u==="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/SampleSites/Components/Pages/Counter.razor b/SampleSites/Components/Pages/Counter.razor index f49a7e3..3393627 100644 --- a/SampleSites/Components/Pages/Counter.razor +++ b/SampleSites/Components/Pages/Counter.razor @@ -18,11 +18,23 @@ }
- +
- + +
+ +
+ +
+ +
+ +
+ +
+ State: @HotKeyState
@code { @@ -31,11 +43,15 @@ private int currentCount = 0; private HotKeysContext? HotKeysContext; + private readonly HotKeyEntryState HotKeyEntryState = new() { IsDisabled = true }; + + private string HotKeyState => HotKeyEntryState.IsDisabled ? "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 +60,11 @@ currentCount++; } + private void OnTriggerDisabledState() + { + HotKeyEntryState.IsDisabled = !HotKeyEntryState.IsDisabled; + } + 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 d92e218..de88f53 100644 --- a/SampleSites/Components/SampleSite.Components.csproj +++ b/SampleSites/Components/SampleSite.Components.csproj @@ -19,9 +19,12 @@ - - - + + + + + + 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.

    From 60d265c1fd55577da5591c665e824409300b814c Mon Sep 17 00:00:00 2001 From: jsakamoto Date: Sun, 14 Apr 2024 19:33:39 +0900 Subject: [PATCH 2/4] Make the hotkey state available even on Blazor Server apps. - Inverted the handling of the state. Instead of asking the state from JavaScript to Blazor every time, it now notifies the state from Blazor to JavaScript whenever the state has changed. --- HotKeys2.E2ETest/HotKeysOnBrowserTest.cs | 6 ++-- HotKeys2/HotKeyEntry.cs | 22 +++++++++---- HotKeys2/HotKeyEntryState.cs | 21 +++++++----- HotKeys2/HotKeysContext.cs | 33 ++++++++++++------- HotKeys2/script.js | 24 +++++++++----- HotKeys2/script.ts | 24 ++++++++------ HotKeys2/wwwroot/script.min.js | 2 +- .../Components/SampleSite.Components.csproj | 3 +- 8 files changed, 86 insertions(+), 49 deletions(-) diff --git a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs index 05543ee..eb246a1 100644 --- a/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs +++ b/HotKeys2.E2ETest/HotKeysOnBrowserTest.cs @@ -8,9 +8,9 @@ public class HotKeysOnBrowserTest HostingModel.Wasm60, HostingModel.Wasm70, HostingModel.Wasm80, - //HostingModel.Server60, - //HostingModel.Server70, - //HostingModel.Server80, + HostingModel.Server60, + HostingModel.Server70, + HostingModel.Server80, }; public static IEnumerable WasmHostingModels { get; } = new[] { diff --git a/HotKeys2/HotKeyEntry.cs b/HotKeys2/HotKeyEntry.cs index 967f62e..2b69c61 100644 --- a/HotKeys2/HotKeyEntry.cs +++ b/HotKeys2/HotKeyEntry.cs @@ -60,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))] @@ -83,6 +88,7 @@ internal HotKeyEntry(ILogger? logger, HotKeyMode mode, Type typeOfModifiers, int this.Exclude = options.Exclude; this.ExcludeSelector = options.ExcludeSelector; this.State = options.State; + this.State._NotifyStateChanged = () => this._NotifyStateChanged?.Invoke(this); this._ObjectRef = DotNetObjectReference.Create(this); } @@ -111,18 +117,17 @@ protected void CommonProcess(Func action) [JSInvokable(nameof(InvokeAction)), EditorBrowsable(Never)] public void InvokeAction() => this.InvokeCallbackAction(); - [JSInvokable(nameof(IsDisabled)), EditorBrowsable(Never)] - public bool IsDisabled() => this.State.IsDisabled; - /// /// 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()); @@ -132,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(); @@ -154,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 index 9d9c41c..01ecde2 100644 --- a/HotKeys2/HotKeyEntryState.cs +++ b/HotKeys2/HotKeyEntryState.cs @@ -1,15 +1,20 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Toolbelt.Blazor.HotKeys2; +namespace Toolbelt.Blazor.HotKeys2; public class HotKeyEntryState { + /// + /// Notifies when the property values of this state object has changed. + /// + internal Action? _NotifyStateChanged; + + private bool _IsDisabled; + /// /// Controls if the current hot key is disabled or not. /// - public virtual bool IsDisabled { get; set; } + public virtual bool IsDisabled + { + get => this._IsDisabled; + set { if (this._IsDisabled != value) { this._IsDisabled = value; this._NotifyStateChanged?.Invoke(); } } + } } diff --git a/HotKeys2/HotKeysContext.cs b/HotKeys2/HotKeysContext.cs index 2d2ae33..58891f2 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.IsDisabled); + }); + } + + 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.IsDisabled); }); } @@ -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; @@ -55,15 +59,17 @@ export var Toolbelt; const tagName = targetElement.tagName; const type = targetElement.getAttribute('type'); const preventDefault1 = onKeyDown(modifiers, key, code, targetElement, tagName, type); - const preventDefault2 = hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code); + const preventDefault2 = isWasm === true ? hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code) : false; if (preventDefault1 || preventDefault2) ev.preventDefault(); + if (isWasm === false) + hotKeysWrapper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); }); }; const onKeyDown = (modifiers, key, code, targetElement, tagName, type) => { let preventDefault = false; hotKeyEntries.forEach(entry => { - if (!entry.isDisabled()) { + if (!entry.isDisabled) { const byCode = entry.mode === 1; const eventKeyEntry = byCode ? code : key; const keyEntry = entry.keyEntry; diff --git a/HotKeys2/script.ts b/HotKeys2/script.ts index 8707abf..dd9b34d 100644 --- a/HotKeys2/script.ts +++ b/HotKeys2/script.ts @@ -29,28 +29,31 @@ public modifiers: ModCodes, public keyEntry: string, public exclude: Exclude, - public excludeSelector: string + public excludeSelector: string, + public isDisabled: boolean ) { } public action(): void { - this.dotNetObj.invokeMethod('InvokeAction'); - } - - public isDisabled(): boolean { - return this.dotNetObj.invokeMethod('IsDisabled'); + this.dotNetObj.invokeMethodAsync('InvokeAction'); } } 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); @@ -83,8 +86,9 @@ const type = targetElement.getAttribute('type'); const preventDefault1 = onKeyDown(modifiers, key, code, targetElement, tagName, type); - const preventDefault2 = hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code); + const preventDefault2 = isWasm === true ? hotKeysWrapper.invokeMethod(OnKeyDownMethodName, modifiers, tagName, type, key, code) : false; if (preventDefault1 || preventDefault2) ev.preventDefault(); + if (isWasm === false) hotKeysWrapper.invokeMethodAsync(OnKeyDownMethodName, modifiers, tagName, type, key, code); }); } @@ -93,7 +97,7 @@ hotKeyEntries.forEach(entry => { - if (!entry.isDisabled()) { + if (!entry.isDisabled) { const byCode = entry.mode === HotKeyMode.ByCode; const eventKeyEntry = byCode ? code : key; const keyEntry = entry.keyEntry; 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/SampleSites/Components/SampleSite.Components.csproj b/SampleSites/Components/SampleSite.Components.csproj index 770e90b..a1f89ea 100644 --- a/SampleSites/Components/SampleSite.Components.csproj +++ b/SampleSites/Components/SampleSite.Components.csproj @@ -20,7 +20,8 @@
    - + + From 44c1cd499f9321585913cec1436a6062eb9a598a Mon Sep 17 00:00:00 2001 From: jsakamoto Date: Tue, 16 Apr 2024 21:04:29 +0900 Subject: [PATCH 3/4] Rename the "Disabled" property of the HotKeyEntryState class - from "IsDisabled" --- HotKeys2/HotKeyEntryState.cs | 11 +++++++---- HotKeys2/HotKeysContext.cs | 4 ++-- SampleSites/Components/Pages/Counter.razor | 7 ++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/HotKeys2/HotKeyEntryState.cs b/HotKeys2/HotKeyEntryState.cs index 01ecde2..acd697b 100644 --- a/HotKeys2/HotKeyEntryState.cs +++ b/HotKeys2/HotKeyEntryState.cs @@ -1,5 +1,8 @@ namespace Toolbelt.Blazor.HotKeys2; +/// +/// Represents the state of a hot key entry. +/// public class HotKeyEntryState { /// @@ -7,14 +10,14 @@ public class HotKeyEntryState /// internal Action? _NotifyStateChanged; - private bool _IsDisabled; + private bool _Disabled; /// /// Controls if the current hot key is disabled or not. /// - public virtual bool IsDisabled + public virtual bool Disabled { - get => this._IsDisabled; - set { if (this._IsDisabled != value) { this._IsDisabled = value; this._NotifyStateChanged?.Invoke(); } } + get => this._Disabled; + set { if (this._Disabled != value) { this._Disabled = value; this._NotifyStateChanged?.Invoke(); } } } } diff --git a/HotKeys2/HotKeysContext.cs b/HotKeys2/HotKeysContext.cs index 58891f2..0b97388 100644 --- a/HotKeys2/HotKeysContext.cs +++ b/HotKeys2/HotKeysContext.cs @@ -611,7 +611,7 @@ private void RegisterAsync(HotKeyEntry hotKeyEntry) hotKeyEntry.Id = await module.InvokeAsync( "Toolbelt.Blazor.HotKeys2.register", - hotKeyEntry._ObjectRef, hotKeyEntry.Mode, hotKeyEntry._Modifiers, hotKeyEntry._KeyEntry, hotKeyEntry.Exclude, hotKeyEntry.ExcludeSelector, hotKeyEntry.State.IsDisabled); + hotKeyEntry._ObjectRef, hotKeyEntry.Mode, hotKeyEntry._Modifiers, hotKeyEntry._KeyEntry, hotKeyEntry.Exclude, hotKeyEntry.ExcludeSelector, hotKeyEntry.State.Disabled); }); } @@ -620,7 +620,7 @@ 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.IsDisabled); + await module.InvokeVoidAsync("Toolbelt.Blazor.HotKeys2.update", hotKeyEntry.Id, hotKeyEntry.State.Disabled); }); } diff --git a/SampleSites/Components/Pages/Counter.razor b/SampleSites/Components/Pages/Counter.razor index 3393627..4f5ac1d 100644 --- a/SampleSites/Components/Pages/Counter.razor +++ b/SampleSites/Components/Pages/Counter.razor @@ -43,9 +43,10 @@ private int currentCount = 0; private HotKeysContext? HotKeysContext; - private readonly HotKeyEntryState HotKeyEntryState = new() { IsDisabled = true }; - private string HotKeyState => HotKeyEntryState.IsDisabled ? "disabled" : "enabled"; + private readonly HotKeyEntryState HotKeyEntryState = new() { Disabled = true }; + + private string HotKeyState => HotKeyEntryState.Disabled ? "disabled" : "enabled"; protected override void OnInitialized() { @@ -62,7 +63,7 @@ private void OnTriggerDisabledState() { - HotKeyEntryState.IsDisabled = !HotKeyEntryState.IsDisabled; + HotKeyEntryState.Disabled = !HotKeyEntryState.Disabled; } private void HotKeys_KeyDown(object? sender, HotKeyDownEventArgs e) From 8c3e1aa3839e5d5d63fd23b124312927dbfbed02 Mon Sep 17 00:00:00 2001 From: jsakamoto Date: Tue, 16 Apr 2024 21:32:55 +0900 Subject: [PATCH 4/4] Update README to mention the new "Disabled" property --- README.md | 78 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 58 insertions(+), 20 deletions(-) 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
    • }