From e8d00f882ada0709f78ab4295f426a05fa6fd97a Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Fri, 25 Aug 2023 21:11:46 -0400 Subject: [PATCH 1/6] stabilize HotKeyObserver --- .../src/assets/scripts/HotkeyObserver.ts | 176 ++++++++++++++---- .../Controls/ContextMenuListing.vue | 3 +- .../src/store/Stores/HotkeyStore.ts | 24 ++- 3 files changed, 161 insertions(+), 42 deletions(-) diff --git a/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts b/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts index c8df197f..34c30d2c 100644 --- a/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts +++ b/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts @@ -6,14 +6,24 @@ export class HotkeyObserver { /** - * The advance key state function, bound to the current object. + * This map defines the evaluation order of modifier keys. */ - private _boundAdvanceKeyState: (e: KeyboardEvent) => void; + private static MODIFIER_KEYS = new Map Boolean>([ + ["control", e => e.ctrlKey], + ["meta", e => e.metaKey], + ["alt", e => e.altKey], + ["shift", e => e.shiftKey] + ]); - /** - * The reverse key state function, bound to the current object. - */ - private _boundReverseKeyState: (e: KeyboardEvent) => void; + /** + * The key down function, bound to the current object. + */ + private _boundOnKeyDown: (e: KeyboardEvent) => void; + + /** + * The key up function, bound to the current object. + */ + private _boundOnKeyUp: (e: KeyboardEvent) => void; /** * The function to call when a hotkey sequence is matched. @@ -31,7 +41,7 @@ export class HotkeyObserver { private _hotkeyIdMap: Map>; /** - * The current key state. + * The last observed key state. */ private _keyState: string; @@ -44,12 +54,12 @@ export class HotkeyObserver { * The time to wait (in milliseconds) before firing the hotkey callback. */ constructor(callback: (command: T) => void, recognitionDelay: number) { - this._boundAdvanceKeyState = this.advanceKeyState.bind(this); - this._boundReverseKeyState = this.reverseKeyState.bind(this); + this._boundOnKeyDown = this.onKeyDown.bind(this); + this._boundOnKeyUp = this.onKeyUp.bind(this); this._callback = callback; this._container = null; this._hotkeyIdMap = new Map(); - this._keyState = "."; + this._keyState = ""; } @@ -60,16 +70,16 @@ export class HotkeyObserver { */ public observe(container: HTMLElement) { this._container = container; - this._container.addEventListener("keydown", this._boundAdvanceKeyState); - this._container.addEventListener("keyup", this._boundReverseKeyState); + this._container.addEventListener("keydown", this._boundOnKeyDown); + this._container.addEventListener("keyup", this._boundOnKeyUp); } /** * Unbinds the HotkeyObserver from the HTML element. */ public disconnect() { - this._container?.removeEventListener("keydown", this._boundAdvanceKeyState); - this._container?.removeEventListener("keyup", this._boundReverseKeyState); + this._container?.removeEventListener("keydown", this._boundOnKeyDown); + this._container?.removeEventListener("keyup", this._boundOnKeyUp); } /** @@ -104,6 +114,11 @@ export class HotkeyObserver { /** * Tests if a hotkey sequence is active. + * @remarks + * Due to the inconsistent nature of the `keyup` event, this is not + * guaranteed to be correct. Refer to: `HotKeyObserver.onKeyUp()` for more + * information. This function will need to be refactored or depreciated in + * the future. * @param sequence * The hotkey sequence. * @param strict @@ -121,12 +136,11 @@ export class HotkeyObserver { } /** - * Adds a key event to the current key state. + * Key down behavior. * @param e * The keydown event. */ - private advanceKeyState(e: KeyboardEvent) { - let key = e.key.toLocaleLowerCase(); + private onKeyDown(e: KeyboardEvent) { // If inside input field, ignore hotkeys if((e.target as any).tagName === "INPUT") { @@ -134,16 +148,15 @@ export class HotkeyObserver { } // Advanced current key state - let isRepeat = this._keyState.endsWith(`.${ key }.`); - if(!isRepeat) { - this._keyState += `${ key }.` - } - + let nextKeyState = this.keyEventToHotKeyId(e); + let isRepeat = this._keyState === nextKeyState; + this._keyState = nextKeyState; + // Check key state if (this._hotkeyIdMap.has(this._keyState)) { let hotkey = this._hotkeyIdMap.get(this._keyState)!; - // If in repeat state and hotkey is not repeatable: - if(isRepeat && !hotkey.repeatable) { + // If disabled or if in repeat state and hotkey is not repeatable: + if(hotkey.disabled || (isRepeat && !hotkey.repeatable)) { // Prevent all browser behavior e.preventDefault(); // Bail @@ -163,13 +176,38 @@ export class HotkeyObserver { } /** - * Removes a key event from the current key state. + * Key up behavior. + * @remarks + * The `keyup` event will not fire in all cases. For example: + * + * - If a hotkey opens a separate window, there will be no `keyup` event. + * - If the Command key is held (on MacOS), any other `keyup` is ignored. + * - ...there are probably others. + * + * For these reasons, do not rely on `keyup` events for critical + * functionality (at least until these issues can be addressed somehow). * @param e * The keyup event. */ - private reverseKeyState(e: KeyboardEvent) { - let key = e.key.toLocaleLowerCase(); - this._keyState = this._keyState.replace(`.${ key }.`, "."); + private onKeyUp(e: KeyboardEvent) { + // Resolve next modifier keys state + let nextKeyState = this.keyEventToHotKeyId(e).split("."); + let lostKey = nextKeyState.splice(-2, 1)[0]; + // Resolve previous non-modifier + let prevNonModifier = this._keyState.split(".").at(-2)!; + // If lost key was non-modifier... + if(lostKey === prevNonModifier) { + // ...remove non-modifier. + nextKeyState.push(""); + } + // If lost key was anything else... + else { + // ...keep non-modifier. + nextKeyState.push(prevNonModifier) + } + nextKeyState.push(""); + // Update key state + this._keyState = nextKeyState.join("."); } /** @@ -178,14 +216,71 @@ export class HotkeyObserver { * The sequence to evaluate. * @returns * The sequence's hotkey id. + * @throws {InvalidKeySequenceError} + * If the key sequence contains more than one non-modifier key. */ private keySequenceToHotKeyId(sequence: string): string { - let hotkeyId = sequence - .toLocaleLowerCase() + let id = ""; + // Parse tokens + let tokens = sequence .replace(/\s+/g, '') - .split("+") - .join("."); - return `.${hotkeyId}.` + .toLocaleLowerCase() + .split("+"); + // Order modifier keys + for(let key of HotkeyObserver.MODIFIER_KEYS.keys()) { + let index = tokens.findIndex(o => o === key); + if(index !== -1) { + id += `.${ tokens.splice(index, 1)[0] }`; + } + } + // Resolve single non-modifier key + if(0 === tokens.length) { + id += `.`; + } + else if(2 <= tokens.length) { + throw new InvalidKeySequenceError( + `Hotkey contains ${ + tokens.length + } non-modifier keys (${ + tokens.join(",") + }).`); + } else if(!HotkeyObserver.MODIFIER_KEYS.has(tokens[0])) { + id += `.${ tokens[0] }` + } else { + throw new InvalidKeySequenceError( + `Hotkey duplicate modifier key '${ + tokens[0] + }'.` + ); + } + // Return id + return `${ id }.`; + } + + /** + * Converts a key event to its hotkey id. + * @param event + * The keyboard event. + * @returns + * The key event's hotkey id. + */ + private keyEventToHotKeyId(event: KeyboardEvent): string { + let id = "" + // Parse modifier keys + for(let [key, isKey] of HotkeyObserver.MODIFIER_KEYS) { + if(isKey(event)) { + id += `.${ key }`; + } + } + // Parse key pressed + let keyPressed = event.key.toLocaleLowerCase(); + if(!HotkeyObserver.MODIFIER_KEYS.has(keyPressed)) { + id += `.${ keyPressed }`; + } else { + id += `.`; + } + // Return id + return `${ id }.`; } } @@ -193,7 +288,20 @@ export class HotkeyObserver { export class OverlappingHotkeysError extends Error { /** - * Creates a new OverlappingHotkeysError. + * Creates a new {@link OverlappingHotkeysError}. + * @param message + * The error message. + */ + constructor(message: string) { + super(message); + } + +} + +export class InvalidKeySequenceError extends Error { + + /** + * Creates a new {@link InvalidKeySequenceError}. * @param message * The error message. */ diff --git a/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue b/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue index 8691333e..5fecf254 100644 --- a/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue +++ b/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue @@ -67,7 +67,8 @@ const KeyToText: { [key: string]: string } = { ArrowUp : "↑", ArrowRight : "→", ArrowDown : "↓", - Delete : "Del" + Delete : "Del", + Meta : "⌘" } export default defineComponent({ diff --git a/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts b/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts index 9e7a4bb9..be21b7d8 100644 --- a/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts +++ b/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts @@ -25,6 +25,16 @@ export default { shortcut: "Control+Shift+R", repeatable: true, allowBrowserBehavior: true + }, + { + shortcut: "Meta+R", + repeatable: true, + allowBrowserBehavior: true + }, + { + shortcut: "Meta+Shift+R", + repeatable: true, + allowBrowserBehavior: true } ] }, @@ -122,7 +132,7 @@ export default { { data: () => new Page.PasteToObject(ctx, page), shortcut: edit.paste, - repeatable: false + repeatable: true }, { data: () => new Page.RemoveSelectedChildren(page), @@ -166,12 +176,12 @@ export default { { data: () => new Page.RelayerSelection(page, Page.Order.OneBelow), shortcut: layout.selection_to_back, - repeatable: false + repeatable: true }, { data: () => new Page.RelayerSelection(page, Page.Order.OneAbove), shortcut: layout.bring_selection_forward, - repeatable: false + repeatable: true }, { data: () => new Page.RelayerSelection(page, Page.Order.Bottom), @@ -215,12 +225,12 @@ export default { { data: () => new Page.ZoomCamera(ctx, page, 0.25), shortcut: view.zoom_in, - repeatable: false + repeatable: true }, { data: () => new Page.ZoomCamera(ctx, page, -0.25), shortcut: view.zoom_out, - repeatable: false + repeatable: true }, { data: () => new Page.MoveCameraToSelection(ctx, page), @@ -230,12 +240,12 @@ export default { { data: () => new Page.MoveCameraToParents(ctx, page), shortcut: view.jump_to_parents, - repeatable: false + repeatable: true }, { data: () => new Page.MoveCameraToChildren(ctx, page), shortcut: view.jump_to_children, - repeatable: false + repeatable: true }, { data: () => new App.SwitchToFullscreen(ctx), From b6563807170d2101e59e7c705ed7ba26a712b5eb Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Fri, 25 Aug 2023 21:14:39 -0400 Subject: [PATCH 2/6] create two settings files for Windows/Linux and MacOS --- .../public/settings_macos.json | 72 +++++++++++++++++++ .../{settings.json => settings_win.json} | 0 src/attack_flow_builder/src/App.vue | 23 +++++- .../src/assets/scripts/Browser.ts | 37 +++++++++- 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/attack_flow_builder/public/settings_macos.json rename src/attack_flow_builder/public/{settings.json => settings_win.json} (100%) diff --git a/src/attack_flow_builder/public/settings_macos.json b/src/attack_flow_builder/public/settings_macos.json new file mode 100644 index 00000000..38acab13 --- /dev/null +++ b/src/attack_flow_builder/public/settings_macos.json @@ -0,0 +1,72 @@ +{ + "file": { + "image_export": { + "padding": 30 + } + }, + "edit": { + "clone_offset": [20, 20] + }, + "view": { + "diagram": { + "display_grid": true, + "display_shadows": true, + "display_debug_mode": false, + "render_high_quality": true, + "disable_shadows_at": 0.7 + } + }, + "hotkeys": { + "file": { + "new_file": "Meta+N", + "open_file": "Meta+O", + "save_file": "Meta+S", + "save_image": "", + "save_select_image": "", + "publish_file": "", + "open_library": "", + "save_library": "" + }, + "edit": { + "undo": "Meta+Z", + "redo": "Meta+Y", + "cut": "Meta+X", + "copy": "Meta+C", + "paste": "Meta+V", + "delete": "Backspace", + "duplicate": "Meta+D", + "select_all": "Meta+A" + }, + "layout": { + "selection_to_front": "Meta+Shift+F", + "selection_to_back": "Meta+Shift+B", + "bring_selection_forward": "", + "send_selection_backward": "", + "align_left": "", + "align_center": "", + "align_right": "", + "align_top": "", + "align_middle": "", + "align_bottom": "", + "group": "Meta+G", + "ungroup": "Meta+Shift+U", + "open_group": "", + "close_group": "" + }, + "view": { + "toggle_grid": "Meta+Shift+G", + "toggle_shadows": "", + "reset_view": "Escape", + "zoom_in": "", + "zoom_out": "", + "fullscreen": "", + "jump_to_selection": "Shift+Z", + "jump_to_parents": "Shift+P", + "jump_to_children": "Shift+C", + "toggle_debug_view": "" + }, + "select": { + "many": "Meta" + } + } +} diff --git a/src/attack_flow_builder/public/settings.json b/src/attack_flow_builder/public/settings_win.json similarity index 100% rename from src/attack_flow_builder/public/settings.json rename to src/attack_flow_builder/public/settings_win.json diff --git a/src/attack_flow_builder/src/App.vue b/src/attack_flow_builder/src/App.vue index 1a3cf6fb..aeb95727 100644 --- a/src/attack_flow_builder/src/App.vue +++ b/src/attack_flow_builder/src/App.vue @@ -34,6 +34,7 @@ import BlockDiagram from "@/components/Elements/BlockDiagram.vue"; import AppFooterBar from "@/components/Elements/AppFooterBar.vue"; import EditorSidebar from "@/components/Elements/EditorSidebar.vue"; import { ShowSplashMenu } from "./store/Commands/AppCommands/ShowSplashMenu"; +import { Browser, OperatingSystem } from "./assets/scripts/Browser"; const Handle = { None : 0, @@ -151,12 +152,30 @@ export default defineComponent({ }, async created() { + // Detect operating system + let os: OperatingSystem; + switch(Browser.getOperatingSystemClass()) { + case OperatingSystem.MacOS: + os = OperatingSystem.MacOS; + break; + default: + os = OperatingSystem.Other; + break; + } // Import settings let settings; if(Configuration.is_web_hosted) { - settings = await (await fetch("./settings.json")).json(); + let options = { + [OperatingSystem.Other]: "../public/settings_win.json", + [OperatingSystem.MacOS]: "../public/settings_macos.json" + } + settings = await (await fetch(options[os])).json(); } else { - settings = require("../public/settings.json"); + let options = { + [OperatingSystem.Other]: require("../public/settings_win.json"), + [OperatingSystem.MacOS]: require("../public/settings_macos.json"), + } + settings = options[os]; } // Load settings this.execute(new App.LoadSettings(this.context, settings)); diff --git a/src/attack_flow_builder/src/assets/scripts/Browser.ts b/src/attack_flow_builder/src/assets/scripts/Browser.ts index 2df644fb..36f5080c 100644 --- a/src/attack_flow_builder/src/assets/scripts/Browser.ts +++ b/src/attack_flow_builder/src/assets/scripts/Browser.ts @@ -118,8 +118,43 @@ export class Browser { cast.msRequestFullscreen(); } } + + + /////////////////////////////////////////////////////////////////////////// + // 4. Operating System Detection //////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////// + + + /** + * Returns the device's current operating system class. + * @returns + * The device's current operating system class. + */ + public static getOperatingSystemClass(): OperatingSystem { + if(navigator.userAgent.search("Win") !== -1) { + return OperatingSystem.Windows + } else if(navigator.userAgent.search("Mac") !== -1) { + return OperatingSystem.MacOS; + } else if(navigator.userAgent.search("X11") !== -1) { + return OperatingSystem.UNIX; + } else if(navigator.userAgent.search("Linux") !== -1) { + return OperatingSystem.Linux; + } else { + return OperatingSystem.Other; + } + } - +} + +/** + * Recognized operating systems. + */ +export enum OperatingSystem { + Windows, + MacOS, + UNIX, + Linux, + Other } From 76c4a1b6c3c7516542fc49654a32b57431f66222 Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Mon, 28 Aug 2023 09:52:57 -0400 Subject: [PATCH 3/6] refactor settings import --- src/attack_flow_builder/src/App.vue | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/attack_flow_builder/src/App.vue b/src/attack_flow_builder/src/App.vue index aeb95727..2f8316a8 100644 --- a/src/attack_flow_builder/src/App.vue +++ b/src/attack_flow_builder/src/App.vue @@ -152,30 +152,21 @@ export default defineComponent({ }, async created() { - // Detect operating system - let os: OperatingSystem; - switch(Browser.getOperatingSystemClass()) { - case OperatingSystem.MacOS: - os = OperatingSystem.MacOS; - break; - default: - os = OperatingSystem.Other; - break; - } // Import settings + let os = Browser.getOperatingSystemClass(); let settings; if(Configuration.is_web_hosted) { - let options = { - [OperatingSystem.Other]: "../public/settings_win.json", - [OperatingSystem.MacOS]: "../public/settings_macos.json" - } - settings = await (await fetch(options[os])).json(); + if(os === OperatingSystem.MacOS) { + settings = await (await fetch("../public/settings_macos.json")).json(); + } else { + settings = await (await fetch("../public/settings_win.json")).json(); + } } else { - let options = { - [OperatingSystem.Other]: require("../public/settings_win.json"), - [OperatingSystem.MacOS]: require("../public/settings_macos.json"), + if(os === OperatingSystem.MacOS) { + settings = require("../public/settings_macos.json"); + } else { + settings = require("../public/settings_win.json"); } - settings = options[os]; } // Load settings this.execute(new App.LoadSettings(this.context, settings)); From d6c705480f152ee5781550cb7b881e83ce4ce96b Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Mon, 28 Aug 2023 09:53:08 -0400 Subject: [PATCH 4/6] fix HotKeyObserver typo --- src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts b/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts index 34c30d2c..1af24a46 100644 --- a/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts +++ b/src/attack_flow_builder/src/assets/scripts/HotkeyObserver.ts @@ -117,7 +117,7 @@ export class HotkeyObserver { * @remarks * Due to the inconsistent nature of the `keyup` event, this is not * guaranteed to be correct. Refer to: `HotKeyObserver.onKeyUp()` for more - * information. This function will need to be refactored or depreciated in + * information. This function will need to be refactored or deprecated in * the future. * @param sequence * The hotkey sequence. From 1af548b3dfe0fd89a9c671ea9cc803824faf259d Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Mon, 28 Aug 2023 09:55:48 -0400 Subject: [PATCH 5/6] Switch new_file from Meta+N to Control+N --- src/attack_flow_builder/public/settings_macos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/attack_flow_builder/public/settings_macos.json b/src/attack_flow_builder/public/settings_macos.json index 38acab13..08117cc4 100644 --- a/src/attack_flow_builder/public/settings_macos.json +++ b/src/attack_flow_builder/public/settings_macos.json @@ -18,7 +18,7 @@ }, "hotkeys": { "file": { - "new_file": "Meta+N", + "new_file": "Control+N", "open_file": "Meta+O", "save_file": "Meta+S", "save_image": "", From 704f317e9f74faebaba841570622100b1016d155 Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Tue, 29 Aug 2023 11:06:13 -0400 Subject: [PATCH 6/6] format shortcuts text according to OS conventions --- .../public/settings_macos.json | 6 ++-- .../Controls/ContextMenuListing.vue | 33 +++++++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/attack_flow_builder/public/settings_macos.json b/src/attack_flow_builder/public/settings_macos.json index cbba19df..0027f5fb 100644 --- a/src/attack_flow_builder/public/settings_macos.json +++ b/src/attack_flow_builder/public/settings_macos.json @@ -37,12 +37,12 @@ "duplicate": "Meta+D", "find": "Meta+F", "find_next": "Meta+G", - "find_previous": "Meta+Shift+G", + "find_previous": "Shift+Meta+G", "select_all": "Meta+A" }, "layout": { - "selection_to_front": "Meta+Shift+F", - "selection_to_back": "Meta+Shift+B", + "selection_to_front": "Shift+Meta+F", + "selection_to_back": "Shift+Meta+B", "bring_selection_forward": "", "send_selection_backward": "", "align_left": "", diff --git a/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue b/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue index 5fecf254..ba5f59b3 100644 --- a/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue +++ b/src/attack_flow_builder/src/components/Controls/ContextMenuListing.vue @@ -54,13 +54,14 @@