Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions definitions/missing-defs.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,3 @@ declare module "element-resize-detector" {
}
export = ElementResizeDetectorMaker;
}

// https://craig.is/killing/mice
declare module "mousetrap" {
function bind(key: string, callback: Function);
function unbind(key: string, callback: Function);
function trigger(key: string);
function reset();
}
70 changes: 70 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Add key bindings in the application

## Core keyboard bindings
Events that are core to the component function. Arrow navigation for a table or list for example should not be implemented this way. Those bindings are probably required by accessibility and the component should listen for keyboard event and deal with it internaly


## Key bindings that can be defined as a command
For all key bindings that are not core to the component functionality you can use the following.

Keybindings works as follow:
- A command is defined
- Command has a default keyboard binding
- Command can have rules on when it can get executed
- If that's the case the component should update the context accordingly
- Command has a handler that will be executed whtn the shortcut is pressed and the condition are matched


### Defining a new command/key binding

In `src/app/commands` update or create a new file `[name].appcmd.ts` and update the `index.ts` with `import [name].appcmd.ts`.

Define your command with its default key binding there:
* `id` unique identifier for the command that can be used for user keybindings overrides(when supported)
* `binding` Default key binding
* `when` Condition on when the command can be executed. The command context will be passed. You can use the `ContextService` to update the context from the coresponding component
* `execute` Action to perform when the key binding is peformed and the condition are matched. THe injector and context are passed so you can retrieve other service instance and get information from the context.

```ts
import { Injector } from "@angular/core";
import { CommandContext, CommandRegistry } from "@batch-flask/core";
import { AbstractListBase } from "@batch-flask/ui";

CommandRegistry.register({
id: "list.selectAll",
binding: "ctrl+a",
when: (context: CommandContext) => {
return context.has("list.focused");
},
execute: (_: Injector, context: CommandContext) => {
const list = context.get("list.focused");
if (!(list instanceof AbstractListBase)) {
log.error("Cannot delete item command context is not of enitty command type");
return;
}
list.selectAll();
},
});
```

## Update the context
If your command is context dependent you can in your component or service update the context


```ts
class MyComponent {

constructor(private contextService: ContextService) {

}

@HostListener("focus")
public onFocus() {
this.contextService.setContext("my.focused", true);
}
@HostListener("blur")
public onBlur() {
this.contextService.removeContext("my.focused");
}
}
```
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"sideEffects": [
"src/@batch-flask/extensions/*",
"src/app/environment.ts",
"src/app/commands/**/*",
"*.scss"
],
"productName": "Batch Explorer",
Expand Down Expand Up @@ -186,7 +187,6 @@
"luxon": "^1.11.4",
"make-dir": "^2.1.0",
"monaco-editor": "^0.16.0",
"mousetrap": "^1.6.3",
"node-abi": "^2.7.0",
"node-forge": "^0.8.1",
"patternomaly": "^1.3.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { CommandRegistry } from "./command-registry";

const cmd1 = {
id: "foo",
binding: "ctrl+f",
execute: () => null,
};

describe("CommandRegistry", () => {
beforeEach(() => {
CommandRegistry.register(cmd1);
});

afterEach(() => {
(CommandRegistry as any)._commands.clear();
});

it("registered the first command", () => {
expect(CommandRegistry.getCommands()).toEqual([cmd1]);
expect(CommandRegistry.getCommand("foo")).toEqual(cmd1);
});

it("registered another command", () => {
const cmd2 = {
id: "bar",
binding: "ctrl+b",
when: (context) => context.has("isFocused"),
execute: () => null,
};
CommandRegistry.register(cmd2);

expect(CommandRegistry.getCommands()).toEqual([cmd1, cmd2]);
expect(CommandRegistry.getCommand("foo")).toEqual(cmd1);
expect(CommandRegistry.getCommand("bar")).toEqual(cmd2);
});

it("raise error when registering another command with the same id", () => {

const cmd2 = {
id: "foo",
binding: "ctrl+b",
when: (context) => context.has("isFocused"),
execute: () => null,
};
expect(() => {
CommandRegistry.register(cmd2);
}).toThrowError("Command with id 'foo' was already defined. "
+ "Make sure to have a unique id (Shortcut: ctrl+b, Existing: ctrl+f)");

});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Injector } from "@angular/core";
import { SanitizedError } from "@batch-flask/utils";
import { CommandContext } from "../context";

export interface Command {
id: string;
binding: string;
when?: (context: CommandContext) => boolean;
execute: (injector: Injector, context: CommandContext) => Promise<any> | void;
}

export class CommandRegistry {
public static register(command: Command) {
if (this._commands.has(command.id)) {
const existingCommand = this._commands.get(command.id);
throw new SanitizedError(`Command with id '${command.id}' was already defined. `
+ `Make sure to have a unique id (Shortcut: ${command.binding}, `
+ `Existing: ${existingCommand && existingCommand.binding})`);
}
this._commands.set(command.id, command);
}

public static getCommand(id: string): Command | null {
return this._commands.get(id) || null;
}

public static getCommands(): Command[] {
return [...this._commands.values()];
}

private static readonly _commands = new Map<string, Command>();
}
1 change: 1 addition & 0 deletions src/@batch-flask/core/commands/command-registry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./command-registry";
20 changes: 20 additions & 0 deletions src/@batch-flask/core/commands/context/context.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Injectable } from "@angular/core";

export type CommandContext = Map<string, any>;

@Injectable({ providedIn: "root" })
export class ContextService {
private _current: CommandContext = new Map();

public setContext(key: string, value: any) {
this._current.set(key, value);
}

public removeContext(key: string) {
this._current.delete(key);
}

public get context() {
return this._current;
}
}
1 change: 1 addition & 0 deletions src/@batch-flask/core/commands/context/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./context.service";
3 changes: 3 additions & 0 deletions src/@batch-flask/core/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./command-registry";
export * from "./context";
export * from "./keybindings";
1 change: 1 addition & 0 deletions src/@batch-flask/core/commands/keybindings/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./keybindings.service";
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Subscription } from "rxjs";
import { keydown } from "test/utils/helpers";
import { CommandRegistry } from "../command-registry";
import { ContextService } from "../context";
import { KeyBindingsService } from "./keybindings.service";

describe("Keybinding service", () => {
let injectorSpy;
let contextService: ContextService;
let service: KeyBindingsService;

let cmd1Spy: jasmine.Spy;
let cmd2Spy: jasmine.Spy;
let cmd3Spy: jasmine.Spy;
let sub: Subscription;

beforeEach(() => {
cmd1Spy = jasmine.createSpy("cmd1");
cmd2Spy = jasmine.createSpy("cmd2");
cmd3Spy = jasmine.createSpy("cmd3");

CommandRegistry.register({
id: "foo",
binding: "ctrl+f",
execute: cmd1Spy,
});
CommandRegistry.register({
id: "bar",
binding: "ctrl+b",
when: (context) => context.has("barAllowed"),
execute: cmd2Spy,
});
CommandRegistry.register({
id: "barAlt",
binding: "ctrl+b",
when: (context) => !context.has("barAllowed"),
execute: cmd3Spy,
});

injectorSpy = {
get: () => "foo",
};

contextService = new ContextService();
service = new KeyBindingsService(contextService, injectorSpy);
sub = service.listen();
});

afterEach(() => {
(CommandRegistry as any)._commands.clear();
sub.unsubscribe();
});

it("runs no shortcut if it doesn't match ", () => {
keydown(document, "ctrl");
keydown(document, "o");

expect(cmd1Spy).not.toHaveBeenCalled();
expect(cmd2Spy).not.toHaveBeenCalled();
expect(cmd3Spy).not.toHaveBeenCalled();
});

it("runs a global command without condition", () => {
keydown(document, "ctrl");
keydown(document, "f");

expect(cmd1Spy).toHaveBeenCalledOnce();
expect(cmd2Spy).not.toHaveBeenCalled();
expect(cmd3Spy).not.toHaveBeenCalled();
});

it("runs a command when condition is a certain way", () => {
keydown(document, "ctrl");
keydown(document, "b");

expect(cmd1Spy).not.toHaveBeenCalled();
expect(cmd2Spy).not.toHaveBeenCalled();
expect(cmd3Spy).toHaveBeenCalledOnce();
});

it("runs another command when condition change", () => {
contextService.setContext("barAllowed", true);

keydown(document, "ctrl");
keydown(document, "b");

expect(cmd1Spy).not.toHaveBeenCalled();
expect(cmd2Spy).toHaveBeenCalledOnce();
expect(cmd3Spy).not.toHaveBeenCalled();
});
});
Loading