diff --git a/src/attack_flow_builder/public/settings.json b/src/attack_flow_builder/public/settings.json index 185ad077..bb640cf8 100644 --- a/src/attack_flow_builder/public/settings.json +++ b/src/attack_flow_builder/public/settings.json @@ -35,6 +35,9 @@ "paste": "Control+V", "delete": "Backspace", "duplicate": "Control+D", + "find": "Control+F", + "find_next": "F3", + "find_previous": "Shift+F3", "select_all": "Control+A" }, "layout": { diff --git a/src/attack_flow_builder/src/App.vue b/src/attack_flow_builder/src/App.vue index 1a3cf6fb..d2fd4a03 100644 --- a/src/attack_flow_builder/src/App.vue +++ b/src/attack_flow_builder/src/App.vue @@ -1,6 +1,7 @@ diff --git a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Debouncer.ts b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Debouncer.ts new file mode 100644 index 00000000..7ec0fb2a --- /dev/null +++ b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Debouncer.ts @@ -0,0 +1,28 @@ +export default class Debouncer { + private timer: number | null; + private seconds: number; + + /** + * Creates a {@link Debouncer}. + * @param duration + * The number of seconds that the debouncer waits before calling its target function. + */ + constructor(seconds: number) { + this.timer = null; + this.seconds = seconds; + } + + /** + * Debounce a function call. + * @param fn + * The function to call. This is only called if not superseded by another call before the debounce + * duration elapses. + */ + public call(fn: TimerHandler) { + if (this.timer !== null) { + clearTimeout(this.timer); + } + + this.timer = setTimeout(fn, this.seconds * 1000); + } +} diff --git a/src/attack_flow_builder/src/components/Elements/FindDialog.vue b/src/attack_flow_builder/src/components/Elements/FindDialog.vue new file mode 100644 index 00000000..9ad4e51d --- /dev/null +++ b/src/attack_flow_builder/src/components/Elements/FindDialog.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/src/attack_flow_builder/src/components/Icons/Close.vue b/src/attack_flow_builder/src/components/Icons/Close.vue new file mode 100644 index 00000000..68aa28d6 --- /dev/null +++ b/src/attack_flow_builder/src/components/Icons/Close.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/attack_flow_builder/src/components/Icons/DownArrow.vue b/src/attack_flow_builder/src/components/Icons/DownArrow.vue new file mode 100644 index 00000000..b64aafbe --- /dev/null +++ b/src/attack_flow_builder/src/components/Icons/DownArrow.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/attack_flow_builder/src/components/Icons/UpArrow.vue b/src/attack_flow_builder/src/components/Icons/UpArrow.vue new file mode 100644 index 00000000..25e66ea8 --- /dev/null +++ b/src/attack_flow_builder/src/components/Icons/UpArrow.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/attack_flow_builder/src/store/Commands/AppCommands/HideFindDialog.ts b/src/attack_flow_builder/src/store/Commands/AppCommands/HideFindDialog.ts new file mode 100644 index 00000000..dfcbf8b9 --- /dev/null +++ b/src/attack_flow_builder/src/store/Commands/AppCommands/HideFindDialog.ts @@ -0,0 +1,22 @@ +import { AppCommand } from "../AppCommand"; +import { ApplicationStore } from "@/store/StoreTypes"; + +export class HideFindDialog extends AppCommand { + + /** + * Hide the find dialog + * @param context + * The application context. + */ + constructor(context: ApplicationStore) { + super(context); + } + + /** + * Executes the command. + */ + public execute(): void { + this._context.finder.dialogIsVisible = false; + } + +} diff --git a/src/attack_flow_builder/src/store/Commands/AppCommands/MoveToNextFindResult.ts b/src/attack_flow_builder/src/store/Commands/AppCommands/MoveToNextFindResult.ts new file mode 100644 index 00000000..e163012a --- /dev/null +++ b/src/attack_flow_builder/src/store/Commands/AppCommands/MoveToNextFindResult.ts @@ -0,0 +1,22 @@ +import { AppCommand } from "../AppCommand"; +import { ApplicationStore } from "@/store/StoreTypes"; + +export class MoveToNextFindResult extends AppCommand { + + /** + * Display the find dialog + * @param context + * The application context. + */ + constructor(context: ApplicationStore) { + super(context); + } + + /** + * Executes the command. + */ + public execute(): void { + this._context.finder.dialogIsVisible = true; + this._context.finder.moveToNextResult(); + } +} diff --git a/src/attack_flow_builder/src/store/Commands/AppCommands/MoveToPreviousFindResult.ts b/src/attack_flow_builder/src/store/Commands/AppCommands/MoveToPreviousFindResult.ts new file mode 100644 index 00000000..c0de5486 --- /dev/null +++ b/src/attack_flow_builder/src/store/Commands/AppCommands/MoveToPreviousFindResult.ts @@ -0,0 +1,22 @@ +import { AppCommand } from "../AppCommand"; +import { ApplicationStore } from "@/store/StoreTypes"; + +export class MoveToPreviousFindResult extends AppCommand { + + /** + * Display the find dialog + * @param context + * The application context. + */ + constructor(context: ApplicationStore) { + super(context); + } + + /** + * Executes the command. + */ + public execute(): void { + this._context.finder.dialogIsVisible = true; + this._context.finder.movetoPreviousResult(); + } +} diff --git a/src/attack_flow_builder/src/store/Commands/AppCommands/ShowFindDialog.ts b/src/attack_flow_builder/src/store/Commands/AppCommands/ShowFindDialog.ts new file mode 100644 index 00000000..46eb8923 --- /dev/null +++ b/src/attack_flow_builder/src/store/Commands/AppCommands/ShowFindDialog.ts @@ -0,0 +1,22 @@ +import { AppCommand } from "../AppCommand"; +import { ApplicationStore } from "@/store/StoreTypes"; + +export class ShowFindDialog extends AppCommand { + + /** + * Display the find dialog + * @param context + * The application context. + */ + constructor(context: ApplicationStore) { + super(context); + } + + /** + * Executes the command. + */ + public execute(): void { + this._context.finder.dialogIsVisible = true; + } + +} diff --git a/src/attack_flow_builder/src/store/Commands/AppCommands/index.ts b/src/attack_flow_builder/src/store/Commands/AppCommands/index.ts index 55dd4dc0..ee01efcf 100644 --- a/src/attack_flow_builder/src/store/Commands/AppCommands/index.ts +++ b/src/attack_flow_builder/src/store/Commands/AppCommands/index.ts @@ -1,9 +1,12 @@ export * from "./ClearPageRecoveryBank"; export * from "./CopySelectedChildren"; export * from "./GroupCommand"; +export * from "./HideFindDialog"; export * from "./HideSplashMenu"; export * from "./LoadFile"; export * from "./LoadSettings"; +export * from "./MoveToNextFindResult"; +export * from "./MoveToPreviousFindResult"; export * from "./NullCommand"; export * from "./OpenHyperlink"; export * from "./PublishPageToDevice"; @@ -13,6 +16,7 @@ export * from "./SaveSelectionImageToDevice"; export * from "./SetEditorPointerLocation"; export * from "./SetEditorViewParams"; export * from "./SetRenderQuality"; +export * from "./ShowFindDialog"; export * from "./ShowSplashMenu"; export * from "./SwitchToFullscreen"; export * from "./ToggleDebugDisplay"; diff --git a/src/attack_flow_builder/src/store/Finder.ts b/src/attack_flow_builder/src/store/Finder.ts new file mode 100644 index 00000000..62669733 --- /dev/null +++ b/src/attack_flow_builder/src/store/Finder.ts @@ -0,0 +1,148 @@ +import { DiagramObjectModel, DictionaryBlockModel, Property } from "@/assets/scripts/BlockDiagram"; +import * as Prop from "@/assets/scripts/BlockDiagram/Property"; +import { PropertyType } from "@/assets/scripts/BlockDiagram/Property/PropertyDescriptorTypes"; +import { PageEditor } from "./PageEditor"; + +export interface FindResult { + totalResults: number; + index: number; + diagramObject: DiagramObjectModel; +} + +export class Finder { + + /** + * True if the find dialog is currently displayed. + */ + public dialogIsVisible: boolean; + + private currentIndex: number; + private results: Array; + + /** + * Creates a {@link Finder}. + */ + constructor() { + this.dialogIsVisible = false; + this.currentIndex = 0; + this.results = []; + } + + /** + * Execute a query to find matching blocks in the diagram. + * @param editor + * The page editor. + * @param query + * The string to search for. + */ + public runQuery(editor: PageEditor, query: string) { + const currentDiagramObject = this.results.length > 0 ? this.results[this.currentIndex] : null; + this.currentIndex = 0; + this.results = []; + const nodeFilter = (o: DiagramObjectModel) => o instanceof DictionaryBlockModel; + query = query.toLowerCase(); + + // Don't run empty queries. + if (query.trim() === "") { + return null; + } + + for (const node of editor.page.getSubtree(nodeFilter)) { + if (this.nodeMatchesQuery(node, query)) { + this.results.push(node); + + // If the selected object from the previous query exists in the new result set, then make sure + // it is still the selected object in the new result set, even if its index changed. + if (currentDiagramObject !== null && node.id === currentDiagramObject.id) { + this.currentIndex = this.results.length - 1; + } + } + } + } + + /** + * Advance to the next result. + * + * After the last result, this loops back to the first result. If there are no results, does nothing. + */ + public moveToNextResult() { + if (this.results.length > 0) { + this.currentIndex = (this.currentIndex + 1) % this.results.length; + } + } + + /** + * Move to the previous result. + * + * If currently on the first result, then loops around to the last result. If there are no results, does + * nothing. + */ + public movetoPreviousResult() { + if (this.results.length > 0) { + this.currentIndex = (this.currentIndex + this.results.length - 1) % this.results.length; + } + } + + /** + * Get the current result. + * @returns + * A result. + */ + public getCurrentResult(): FindResult | null { + if (this.results.length === 0) { + return null; + } else { + return { + totalResults: this.results.length, + index: this.currentIndex, + diagramObject: this.results[this.currentIndex], + }; + } + } + + /** + * Check if a node should be included in the result set. + * @param node + * A candidate node + * @param query + * A query string + * @returns + * True if the node satisfies the query + */ + private nodeMatchesQuery(node: DiagramObjectModel, query: string): boolean { + return this.propMatchesQuery(node.props, query); + } + + /** + * Recursively heck if a property matches a query. + * @param prop + * A property of a candidate node + * @param query + * A query string + * @returns + * True if the property satisfies the query + */ + private propMatchesQuery(prop: Property, query: string): boolean { + switch (prop.type) { + case PropertyType.Int: // falls through + case PropertyType.Float: // falls through + case PropertyType.String: // falls through + case PropertyType.Date: // falls through + case PropertyType.Enum: // falls through + // These types match if the string representation includes the query string (case + // insensitive). + return prop.toString().toLowerCase().includes(query); + case PropertyType.List: // falls through + case PropertyType.Dictionary: + // A list or dictionary match if any of its values match. + for (const innerProp of (prop as Prop.DictionaryProperty).value.values()) { + if (this.propMatchesQuery(innerProp, query)) { + return true; + } + } + return false; + default: + throw new Error(`Unexpected property type: ${prop.type}`) + } + } +} diff --git a/src/attack_flow_builder/src/store/StoreTypes.ts b/src/attack_flow_builder/src/store/StoreTypes.ts index d9dcd44a..842d83ec 100644 --- a/src/attack_flow_builder/src/store/StoreTypes.ts +++ b/src/attack_flow_builder/src/store/StoreTypes.ts @@ -1,3 +1,4 @@ +import { Finder } from "./Finder" import { PageEditor } from "@/store/PageEditor" import { DiagramValidator } from "@/assets/scripts/DiagramValidator/DiagramValidator" import { DiagramPublisher } from "@/assets/scripts/DiagramPublisher/DiagramPublisher" @@ -31,6 +32,7 @@ export type ApplicationStore = { processor: DiagramProcessor | undefined, publisher: DiagramPublisher | undefined, activePage: PageEditor, + finder: Finder, recoveryBank: PageRecoveryBank, splashIsVisible: boolean, } @@ -91,6 +93,9 @@ export const BaseAppSettings: AppSettings = { paste: "", delete: "", duplicate: "", + find: "", + find_next: "", + find_previous: "", select_all: "" }, layout: { @@ -187,6 +192,9 @@ export type EditHotkeys = { paste: string, delete: string, duplicate: string, + find: string, + find_next: string, + find_previous: string, select_all: string } diff --git a/src/attack_flow_builder/src/store/Stores/ApplicationStore.ts b/src/attack_flow_builder/src/store/Stores/ApplicationStore.ts index a99d1834..65f975e4 100644 --- a/src/attack_flow_builder/src/store/Stores/ApplicationStore.ts +++ b/src/attack_flow_builder/src/store/Stores/ApplicationStore.ts @@ -3,10 +3,12 @@ import { Module } from "vuex" import { PageEditor } from "@/store/PageEditor"; import { AppCommand } from "@/store/Commands/AppCommand"; import { PageCommand } from "@/store/Commands/PageCommand"; +import { Finder } from "../Finder"; import { PageRecoveryBank } from "../PageRecoveryBank"; import { DiagramObjectModel } from "@/assets/scripts/BlockDiagram"; import { ValidationErrorResult, ValidationWarningResult } from "@/assets/scripts/DiagramValidator"; import { ModuleStore, ApplicationStore, BaseAppSettings } from "@/store/StoreTypes" +import { FindResult } from "@/store/Finder"; const Publisher = Configuration.publisher ? new Configuration.publisher() : undefined; @@ -22,6 +24,7 @@ export default { publisher: Publisher, processor: Processor, activePage: PageEditor.createDummy(), + finder: new Finder(), recoveryBank: new PageRecoveryBank(), splashIsVisible: false, }, @@ -130,6 +133,39 @@ export default { }, /** + * Indicates whether the find dialog is visible. + * @param state + * The Vuex state. + * @returns + * True if the find dialog is visible. + */ + isShowingFindDialog(state): boolean { + return state.finder.dialogIsVisible; + }, + + /** + * Indicates if there are any find results. + * @param state + * The Vuex state. + * @returns + * True if there are any find results. + */ + hasFindResults(state): boolean { + return state.finder.getCurrentResult() !== null; + }, + + /** + * Returns the current item in the find results. + * @param state + * The Vuex state. + * @returns + * The current item in the result set. + */ + currentFindResult(state): FindResult | null { + return state.finder.getCurrentResult(); + }, + + /* * Indicates whether the splash menu is visible. * @param state * The Vuex state. @@ -138,7 +174,7 @@ export default { */ isShowingSplash(state): boolean { return state.splashIsVisible; - }, + } }, mutations: { diff --git a/src/attack_flow_builder/src/store/Stores/ContextMenuStore.ts b/src/attack_flow_builder/src/store/Stores/ContextMenuStore.ts index fa321875..ff7d55a8 100644 --- a/src/attack_flow_builder/src/store/Stores/ContextMenuStore.ts +++ b/src/attack_flow_builder/src/store/Stores/ContextMenuStore.ts @@ -268,6 +268,7 @@ export default { getters.clipboardMenu, getters.deleteMenu, getters.duplicateMenu, + getters.findMenu, getters.createMenu, getters.selectAllMenu ] @@ -425,6 +426,51 @@ export default { }; }, + /** + * Returns the find menu section. + * @param _s + * The Vuex state. (Unused) + * @param _g + * The Vuex getters. (Unused) + * @param rootState + * The Vuex root state. + * @param rootGetters + * The Vuex root getters. + * @returns + * The undo/redo menu section. + */ + findMenu(_s, _g, rootState, rootGetters): ContextMenuSection { + let ctx = rootState.ApplicationStore; + let edit = ctx.settings.hotkeys.edit; + let hasFindResults = rootGetters["ApplicationStore/hasFindResults"]; + return { + id: "find_options", + items: [ + { + text: "Find…", + type: MenuType.Item, + data: () => new App.ShowFindDialog(ctx), + shortcut: edit.find + }, + { + text: "Find Next", + type: MenuType.Item, + data: () => new App.MoveToNextFindResult(ctx), + shortcut: edit.find_next, + disabled: !hasFindResults + }, + { + text: "Find Previous", + type: MenuType.Item, + data: () => new App.MoveToPreviousFindResult(ctx), + shortcut: edit.find_previous, + disabled: !hasFindResults + }, + ], + } + }, + + /** * Returns the 'select all' menu section. * @param _s diff --git a/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts b/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts index 9e7a4bb9..d5301934 100644 --- a/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts +++ b/src/attack_flow_builder/src/store/Stores/HotkeyStore.ts @@ -134,6 +134,21 @@ export default { shortcut: edit.duplicate, repeatable: false }, + { + data: () => new App.ShowFindDialog(ctx), + shortcut: edit.find, + repeatable: false + }, + { + data: () => new App.MoveToNextFindResult(ctx), + shortcut: edit.find_next, + repeatable: false + }, + { + data: () => new App.MoveToPreviousFindResult(ctx), + shortcut: edit.find_previous, + repeatable: false + }, { data: () => new Page.SelectChildren(page), shortcut: edit.select_all,