2020-11-05 18:58:23 +02:00
|
|
|
import { State } from '../reducer';
|
|
|
|
import eventManager from '../eventManager';
|
|
|
|
import BaseService from './BaseService';
|
|
|
|
import shim from '../shim';
|
2020-10-18 22:52:10 +02:00
|
|
|
import WhenClause from './WhenClause';
|
2020-10-09 19:35:46 +02:00
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
type LabelFunction = ()=> string;
|
2020-10-18 22:52:10 +02:00
|
|
|
type EnabledCondition = string;
|
2020-07-03 23:32:39 +02:00
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
export interface CommandContext {
|
|
|
|
// The state may also be of type "AppState" (used by the desktop app), which inherits from "State" (used by all apps)
|
2020-11-12 21:29:22 +02:00
|
|
|
state: State;
|
|
|
|
dispatch: Function;
|
2020-10-18 22:52:10 +02:00
|
|
|
}
|
2020-10-09 19:35:46 +02:00
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
export interface CommandRuntime {
|
2020-11-12 21:29:22 +02:00
|
|
|
execute(context: CommandContext, ...args: any[]): Promise<any | void>;
|
2020-10-18 22:52:10 +02:00
|
|
|
enabledCondition?: EnabledCondition;
|
2020-07-03 23:32:39 +02:00
|
|
|
// Used for the (optional) toolbar button title
|
2020-11-12 21:29:22 +02:00
|
|
|
mapStateToTitle?(state: any): string;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export interface CommandDeclaration {
|
2020-11-12 21:29:22 +02:00
|
|
|
name: string;
|
2020-09-13 18:21:11 +02:00
|
|
|
|
2020-07-03 23:32:39 +02:00
|
|
|
// Used for the menu item label, and toolbar button tooltip
|
2020-11-12 21:29:22 +02:00
|
|
|
label?: LabelFunction | string;
|
2020-09-13 18:21:11 +02:00
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
// Command description - if none is provided, the label will be used as description
|
2020-11-12 21:29:22 +02:00
|
|
|
description?: string;
|
2020-10-18 22:52:10 +02:00
|
|
|
|
2020-09-13 18:21:11 +02:00
|
|
|
// This is a bit of a hack because some labels don't make much sense in isolation. For example,
|
|
|
|
// the commmand to focus the note list is called just "Note list". This makes sense within the menu
|
|
|
|
// but not so much within the keymap config screen, where the parent item is not displayed. Because
|
|
|
|
// of this we have this "parentLabel()" property to clarify the meaning of the certain items.
|
|
|
|
// For example, the focusElementNoteList will have these two properties:
|
|
|
|
// label() => _('Note list'),
|
|
|
|
// parentLabel() => _('Focus'),
|
|
|
|
// Which will be displayed as "Focus: Note list" in the keymap config screen.
|
2020-11-12 21:29:22 +02:00
|
|
|
parentLabel?: LabelFunction | string;
|
2020-10-09 19:35:46 +02:00
|
|
|
|
|
|
|
// All free Font Awesome icons are available: https://fontawesome.com/icons?d=gallery&m=free
|
2020-11-12 21:29:22 +02:00
|
|
|
iconName?: string;
|
2020-10-09 19:35:46 +02:00
|
|
|
|
|
|
|
// Will be used by TinyMCE (which doesn't support Font Awesome icons).
|
|
|
|
// Defaults to the "preferences" icon (a cog) if not specified.
|
|
|
|
// https://www.tiny.cloud/docs/advanced/editor-icon-identifiers/
|
2020-11-12 21:29:22 +02:00
|
|
|
tinymceIconName?: string;
|
2020-10-09 19:35:46 +02:00
|
|
|
|
2020-07-03 23:32:39 +02:00
|
|
|
// Same as `role` key in Electron MenuItem:
|
|
|
|
// https://www.electronjs.org/docs/api/menu-item#new-menuitemoptions
|
|
|
|
// Note that due to a bug in Electron, menu items with a role cannot
|
|
|
|
// be disabled.
|
2020-11-12 21:29:22 +02:00
|
|
|
role?: string;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export interface Command {
|
2020-11-12 21:29:22 +02:00
|
|
|
declaration: CommandDeclaration;
|
|
|
|
runtime?: CommandRuntime;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
interface Commands {
|
2020-11-12 21:13:28 +02:00
|
|
|
[key: string]: Command;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
interface ReduxStore {
|
2020-11-12 21:13:28 +02:00
|
|
|
dispatch(action: any): void;
|
|
|
|
getState(): any;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
interface Utils {
|
|
|
|
store: ReduxStore;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
export const utils: Utils = {
|
2020-07-03 23:32:39 +02:00
|
|
|
store: {
|
|
|
|
dispatch: () => {},
|
|
|
|
getState: () => {},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
interface CommandByNameOptions {
|
2020-11-12 21:29:22 +02:00
|
|
|
mustExist?: boolean;
|
|
|
|
runtimeMustBeRegistered?: boolean;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
export interface SearchResult {
|
2020-11-12 21:29:22 +02:00
|
|
|
commandName: string;
|
|
|
|
title: string;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export default class CommandService extends BaseService {
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
private static instance_: CommandService;
|
2020-07-03 23:32:39 +02:00
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public static instance(): CommandService {
|
2020-07-03 23:32:39 +02:00
|
|
|
if (this.instance_) return this.instance_;
|
|
|
|
this.instance_ = new CommandService();
|
|
|
|
return this.instance_;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
private commands_: Commands = {};
|
|
|
|
private store_: any;
|
|
|
|
private devMode_: boolean;
|
2020-11-13 19:09:28 +02:00
|
|
|
private stateToWhenClauseContext_: Function;
|
2020-07-03 23:32:39 +02:00
|
|
|
|
2020-11-13 19:09:28 +02:00
|
|
|
public initialize(store: any, devMode: boolean, stateToWhenClauseContext: Function) {
|
2020-07-03 23:32:39 +02:00
|
|
|
utils.store = store;
|
2020-10-18 22:52:10 +02:00
|
|
|
this.store_ = store;
|
|
|
|
this.devMode_ = devMode;
|
2020-11-13 19:09:28 +02:00
|
|
|
this.stateToWhenClauseContext_ = stateToWhenClauseContext;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public on(eventName: string, callback: Function) {
|
2020-07-03 23:32:39 +02:00
|
|
|
eventManager.on(eventName, callback);
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public off(eventName: string, callback: Function) {
|
2020-07-03 23:32:39 +02:00
|
|
|
eventManager.off(eventName, callback);
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public searchCommands(query: string, returnAllWhenEmpty: boolean, excludeWithoutLabel: boolean = true): SearchResult[] {
|
2020-10-18 22:52:10 +02:00
|
|
|
query = query.toLowerCase();
|
|
|
|
|
|
|
|
const output = [];
|
|
|
|
|
2021-10-05 19:09:09 +02:00
|
|
|
const whenClauseContext = this.currentWhenClauseContext();
|
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
for (const commandName of this.commandNames()) {
|
|
|
|
const label = this.label(commandName, true);
|
|
|
|
if (!label && excludeWithoutLabel) continue;
|
2021-10-05 19:09:09 +02:00
|
|
|
if (!this.isEnabled(commandName, whenClauseContext)) continue;
|
2020-10-18 22:52:10 +02:00
|
|
|
|
|
|
|
const title = label ? `${label} (${commandName})` : commandName;
|
|
|
|
|
|
|
|
if ((returnAllWhenEmpty && !query) || title.toLowerCase().includes(query)) {
|
|
|
|
output.push({
|
|
|
|
commandName: commandName,
|
|
|
|
title: title,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
output.sort((a: SearchResult, b: SearchResult) => {
|
2020-10-18 22:52:10 +02:00
|
|
|
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
|
|
|
|
});
|
|
|
|
|
|
|
|
return output;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public commandNames(publicOnly: boolean = false) {
|
2020-10-31 14:25:12 +02:00
|
|
|
if (publicOnly) {
|
|
|
|
const output = [];
|
|
|
|
for (const name in this.commands_) {
|
|
|
|
if (!this.isPublic(name)) continue;
|
|
|
|
output.push(name);
|
|
|
|
}
|
|
|
|
return output;
|
|
|
|
} else {
|
|
|
|
return Object.keys(this.commands_);
|
|
|
|
}
|
2020-10-18 22:52:10 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public commandByName(name: string, options: CommandByNameOptions = null): Command {
|
2020-07-03 23:32:39 +02:00
|
|
|
options = {
|
|
|
|
mustExist: true,
|
|
|
|
runtimeMustBeRegistered: false,
|
2020-09-06 14:00:25 +02:00
|
|
|
...options,
|
2020-07-03 23:32:39 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
const command = this.commands_[name];
|
|
|
|
|
|
|
|
if (!command) {
|
|
|
|
if (options.mustExist) throw new Error(`Command not found: ${name}. Make sure the declaration has been registered.`);
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (options.runtimeMustBeRegistered && !command.runtime) throw new Error(`Runtime is not registered for command ${name}`);
|
|
|
|
return command;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public registerDeclaration(declaration: CommandDeclaration) {
|
2020-07-03 23:32:39 +02:00
|
|
|
declaration = { ...declaration };
|
2020-10-09 19:35:46 +02:00
|
|
|
if (!declaration.label) declaration.label = '';
|
2020-07-03 23:32:39 +02:00
|
|
|
if (!declaration.iconName) declaration.iconName = '';
|
|
|
|
|
|
|
|
this.commands_[declaration.name] = {
|
|
|
|
declaration: declaration,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public registerRuntime(commandName: string, runtime: CommandRuntime) {
|
2020-07-03 23:32:39 +02:00
|
|
|
if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`);
|
|
|
|
|
|
|
|
const command = this.commandByName(commandName);
|
|
|
|
|
|
|
|
runtime = Object.assign({}, runtime);
|
2020-10-18 22:52:10 +02:00
|
|
|
if (!runtime.enabledCondition) runtime.enabledCondition = 'true';
|
2020-07-03 23:32:39 +02:00
|
|
|
command.runtime = runtime;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public componentRegisterCommands(component: any, commands: any[]) {
|
2020-07-03 23:32:39 +02:00
|
|
|
for (const command of commands) {
|
|
|
|
CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public componentUnregisterCommands(commands: any[]) {
|
2020-07-03 23:32:39 +02:00
|
|
|
for (const command of commands) {
|
|
|
|
CommandService.instance().unregisterRuntime(command.declaration.name);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public unregisterRuntime(commandName: string) {
|
2020-07-03 23:32:39 +02:00
|
|
|
const command = this.commandByName(commandName, { mustExist: false });
|
|
|
|
if (!command || !command.runtime) return;
|
|
|
|
delete command.runtime;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
private createContext(): CommandContext {
|
2020-10-25 19:22:59 +02:00
|
|
|
return {
|
|
|
|
state: this.store_.getState(),
|
2020-11-12 21:13:28 +02:00
|
|
|
dispatch: (action: any) => {
|
2020-10-25 19:22:59 +02:00
|
|
|
this.store_.dispatch(action);
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public async execute(commandName: string, ...args: any[]): Promise<any | void> {
|
2020-07-03 23:32:39 +02:00
|
|
|
const command = this.commandByName(commandName);
|
2020-12-23 19:25:26 +02:00
|
|
|
// Some commands such as "showModalMessage" can be executed many
|
|
|
|
// times per seconds, so we should only display this message in
|
|
|
|
// debug mode.
|
2021-01-08 19:49:53 +02:00
|
|
|
if (commandName !== 'showModalMessage') this.logger().debug('CommandService::execute:', commandName, args);
|
2020-10-25 19:22:59 +02:00
|
|
|
if (!command.runtime) throw new Error(`Cannot execute a command without a runtime: ${commandName}`);
|
|
|
|
return command.runtime.execute(this.createContext(), ...args);
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public scheduleExecute(commandName: string, args: any) {
|
2020-10-09 19:35:46 +02:00
|
|
|
shim.setTimeout(() => {
|
2020-11-25 16:40:25 +02:00
|
|
|
void this.execute(commandName, args);
|
2020-07-03 23:32:39 +02:00
|
|
|
}, 10);
|
|
|
|
}
|
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
public currentWhenClauseContext() {
|
2020-11-13 19:09:28 +02:00
|
|
|
return this.stateToWhenClauseContext_(this.store_.getState());
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public isPublic(commandName: string) {
|
2020-10-31 14:25:12 +02:00
|
|
|
return !!this.label(commandName);
|
|
|
|
}
|
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
// When looping on commands and checking their enabled state, the whenClauseContext
|
|
|
|
// should be specified (created using currentWhenClauseContext) to avoid having
|
|
|
|
// to re-create it on each call.
|
2020-11-12 21:13:28 +02:00
|
|
|
public isEnabled(commandName: string, whenClauseContext: any = null): boolean {
|
2020-07-03 23:32:39 +02:00
|
|
|
const command = this.commandByName(commandName);
|
2020-10-18 22:52:10 +02:00
|
|
|
if (!command || !command.runtime) return false;
|
|
|
|
|
|
|
|
if (!whenClauseContext) whenClauseContext = this.currentWhenClauseContext();
|
|
|
|
|
|
|
|
const exp = new WhenClause(command.runtime.enabledCondition, this.devMode_);
|
|
|
|
return exp.evaluate(whenClauseContext);
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
|
2020-10-18 22:52:10 +02:00
|
|
|
// The title is dynamic and derived from the state, which is why the state is passed
|
|
|
|
// as an argument. Title can be used for example to display the alarm date on the
|
|
|
|
// "set alarm" toolbar button.
|
2020-11-12 21:13:28 +02:00
|
|
|
public title(commandName: string, state: any = null): string {
|
2020-10-09 19:35:46 +02:00
|
|
|
const command = this.commandByName(commandName);
|
|
|
|
if (!command || !command.runtime) return null;
|
2020-10-18 22:52:10 +02:00
|
|
|
|
|
|
|
state = state || this.store_.getState();
|
|
|
|
|
|
|
|
if (command.runtime.mapStateToTitle) {
|
|
|
|
return command.runtime.mapStateToTitle(state);
|
|
|
|
} else {
|
|
|
|
return '';
|
|
|
|
}
|
2020-10-09 19:35:46 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public iconName(commandName: string, variant: string = null): string {
|
2020-09-15 15:01:07 +02:00
|
|
|
const command = this.commandByName(commandName);
|
|
|
|
if (!command) throw new Error(`No such command: ${commandName}`);
|
2020-10-09 19:35:46 +02:00
|
|
|
if (variant === 'tinymce') return command.declaration.tinymceIconName ? command.declaration.tinymceIconName : 'preferences';
|
2020-09-15 15:01:07 +02:00
|
|
|
return command.declaration.iconName;
|
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public label(commandName: string, fullLabel: boolean = false): string {
|
2020-09-06 14:00:25 +02:00
|
|
|
const command = this.commandByName(commandName);
|
|
|
|
if (!command) throw new Error(`Command: ${commandName} is not declared`);
|
2020-09-13 18:21:11 +02:00
|
|
|
const output = [];
|
2020-10-09 19:35:46 +02:00
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
const parentLabel = (d: CommandDeclaration): string => {
|
2020-10-09 19:35:46 +02:00
|
|
|
if (!d.parentLabel) return '';
|
|
|
|
if (typeof d.parentLabel === 'function') return d.parentLabel();
|
|
|
|
return d.parentLabel as string;
|
|
|
|
};
|
|
|
|
|
|
|
|
if (fullLabel && parentLabel(command.declaration)) output.push(parentLabel(command.declaration));
|
|
|
|
output.push(typeof command.declaration.label === 'function' ? command.declaration.label() : command.declaration.label);
|
2020-09-13 18:21:11 +02:00
|
|
|
return output.join(': ');
|
2020-09-06 14:00:25 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public description(commandName: string): string {
|
2020-10-18 22:52:10 +02:00
|
|
|
const command = this.commandByName(commandName);
|
|
|
|
if (command.declaration.description) return command.declaration.description;
|
|
|
|
return this.label(commandName, true);
|
2020-09-06 14:00:25 +02:00
|
|
|
}
|
|
|
|
|
2020-11-12 21:13:28 +02:00
|
|
|
public exists(commandName: string): boolean {
|
2020-10-18 22:52:10 +02:00
|
|
|
const command = this.commandByName(commandName, { mustExist: false });
|
|
|
|
return !!command;
|
2020-07-03 23:32:39 +02:00
|
|
|
}
|
|
|
|
}
|