mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-18 09:35:20 +02:00
c63c6370b5
The goal is to make the command system more modular, so each command can be defined as a single object that includes a declaration (name, label, etc.) and a runtime (to execute the command, test if it should be enabled, etc.) Utility methods are provided to convert a command to a menu item or a toolbar button, thus reducing duplicated and boiler plate code across the codebase (often the menu item logic was duplicated in the toolbar button logic and vice versa). The goal is to make it easier to add new commands (and associated menu item and toolbar buttons) and to call them from anywhere. This is also useful for plugins, which can also easily define new commands. Could also allow creating a command palette.
290 lines
8.0 KiB
TypeScript
290 lines
8.0 KiB
TypeScript
const BaseService = require('lib/services/BaseService');
|
|
const eventManager = require('lib/eventManager');
|
|
|
|
export interface CommandRuntime {
|
|
execute(props:any):void
|
|
isEnabled?(props:any):boolean
|
|
mapStateToProps?(state:any):any
|
|
// Used for the (optional) toolbar button title
|
|
title?(props:any):string,
|
|
props?:any
|
|
}
|
|
|
|
export interface CommandDeclaration {
|
|
name: string
|
|
// Used for the menu item label, and toolbar button tooltip
|
|
label?():string,
|
|
iconName?: string,
|
|
// 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.
|
|
role?: string,
|
|
}
|
|
|
|
export interface Command {
|
|
declaration: CommandDeclaration,
|
|
runtime?: CommandRuntime,
|
|
}
|
|
|
|
interface Commands {
|
|
[key:string]: Command;
|
|
}
|
|
|
|
interface ReduxStore {
|
|
dispatch(action:any):void;
|
|
getState():any;
|
|
}
|
|
|
|
interface Utils {
|
|
store: ReduxStore;
|
|
}
|
|
|
|
export const utils:Utils = {
|
|
store: {
|
|
dispatch: () => {},
|
|
getState: () => {},
|
|
},
|
|
};
|
|
|
|
interface CommandByNameOptions {
|
|
mustExist?:boolean,
|
|
runtimeMustBeRegistered?:boolean,
|
|
}
|
|
|
|
interface CommandState {
|
|
title: string,
|
|
enabled: boolean,
|
|
}
|
|
|
|
interface CommandStates {
|
|
[key:string]: CommandState
|
|
}
|
|
|
|
export default class CommandService extends BaseService {
|
|
|
|
private static instance_:CommandService;
|
|
|
|
static instance():CommandService {
|
|
if (this.instance_) return this.instance_;
|
|
this.instance_ = new CommandService();
|
|
return this.instance_;
|
|
}
|
|
|
|
private commands_:Commands = {};
|
|
private commandPreviousStates_:CommandStates = {};
|
|
private mapStateToPropsIID_:any = null;
|
|
|
|
initialize(store:any) {
|
|
utils.store = store;
|
|
}
|
|
|
|
public on(eventName:string, callback:Function) {
|
|
eventManager.on(eventName, callback);
|
|
}
|
|
|
|
public off(eventName:string, callback:Function) {
|
|
eventManager.off(eventName, callback);
|
|
}
|
|
|
|
private propsHaveChanged(previous:any, next:any) {
|
|
if (!previous && next) return true;
|
|
|
|
for (const n in previous) {
|
|
if (previous[n] !== next[n]) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
scheduleMapStateToProps(state:any) {
|
|
if (this.mapStateToPropsIID_) clearTimeout(this.mapStateToPropsIID_);
|
|
|
|
this.mapStateToPropsIID_ = setTimeout(() => {
|
|
this.mapStateToProps(state);
|
|
}, 50);
|
|
}
|
|
|
|
private mapStateToProps(state:any) {
|
|
const newState = state;
|
|
|
|
const changedCommands:any = {};
|
|
|
|
for (const name in this.commands_) {
|
|
const command = this.commands_[name];
|
|
if (!command.runtime || !command.runtime.mapStateToProps) continue;
|
|
const newProps = command.runtime.mapStateToProps(state);
|
|
|
|
const haveChanged = this.propsHaveChanged(command.runtime.props, newProps);
|
|
|
|
if (haveChanged) {
|
|
const previousState = this.commandPreviousStates_[name];
|
|
|
|
command.runtime.props = newProps;
|
|
|
|
const newState:CommandState = {
|
|
enabled: this.isEnabled(name),
|
|
title: this.title(name),
|
|
};
|
|
|
|
if (!previousState || previousState.title !== newState.title || previousState.enabled !== newState.enabled) {
|
|
changedCommands[name] = newState;
|
|
}
|
|
|
|
this.commandPreviousStates_[name] = newState;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(changedCommands).length) {
|
|
eventManager.emit('commandsEnabledStateChange', { commands: changedCommands });
|
|
}
|
|
|
|
return newState;
|
|
}
|
|
|
|
private commandByName(name:string, options:CommandByNameOptions = null):Command {
|
|
options = {
|
|
mustExist: true,
|
|
runtimeMustBeRegistered: false,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
registerDeclaration(declaration:CommandDeclaration) {
|
|
// if (this.commands_[declaration.name]) throw new Error(`There is already a command with name ${declaration.name}`);
|
|
|
|
declaration = { ...declaration };
|
|
if (!declaration.label) declaration.label = () => '';
|
|
if (!declaration.iconName) declaration.iconName = '';
|
|
|
|
// In TypeScript it's not an issue, but in JavaScript it's easy to accidentally set the label
|
|
// to a string instead of a function, and it will cause strange errors that are hard to debug.
|
|
// So here check early that we have the right type.
|
|
if (typeof declaration.label !== 'function') throw new Error(`declaration.label must be a function: ${declaration.name}`);
|
|
|
|
this.commands_[declaration.name] = {
|
|
declaration: declaration,
|
|
};
|
|
}
|
|
|
|
registerRuntime(commandName:string, runtime:CommandRuntime) {
|
|
// console.info('CommandService::registerRuntime:', commandName);
|
|
|
|
if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`);
|
|
|
|
const command = this.commandByName(commandName);
|
|
// if (command.runtime) throw new Error(`Runtime is already registered for command: ${commandName}`);
|
|
|
|
runtime = Object.assign({}, runtime);
|
|
if (!runtime.isEnabled) runtime.isEnabled = () => true;
|
|
if (!runtime.title) runtime.title = () => null;
|
|
command.runtime = runtime;
|
|
}
|
|
|
|
componentRegisterCommands(component:any, commands:any[]) {
|
|
for (const command of commands) {
|
|
CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component));
|
|
}
|
|
}
|
|
|
|
componentUnregisterCommands(commands:any[]) {
|
|
for (const command of commands) {
|
|
CommandService.instance().unregisterRuntime(command.declaration.name);
|
|
}
|
|
}
|
|
|
|
unregisterRuntime(commandName:string) {
|
|
// console.info('CommandService::unregisterRuntime:', commandName);
|
|
|
|
const command = this.commandByName(commandName, { mustExist: false });
|
|
if (!command || !command.runtime) return;
|
|
delete command.runtime;
|
|
}
|
|
|
|
execute(commandName:string, args:any = null) {
|
|
console.info('CommandService::execute:', commandName, args);
|
|
|
|
const command = this.commandByName(commandName);
|
|
command.runtime.execute(args ? args : {});
|
|
}
|
|
|
|
scheduleExecute(commandName:string, args:any = null) {
|
|
setTimeout(() => {
|
|
this.execute(commandName, args);
|
|
}, 10);
|
|
}
|
|
|
|
isEnabled(commandName:string):boolean {
|
|
const command = this.commandByName(commandName);
|
|
if (!command || !command.runtime) return false;
|
|
return command.runtime.props ? command.runtime.isEnabled(command.runtime.props ? command.runtime.props : {}) : true;
|
|
}
|
|
|
|
title(commandName:string):string {
|
|
const command = this.commandByName(commandName);
|
|
if (!command || !command.runtime) return null;
|
|
return command.runtime.props ? command.runtime.title(command.runtime.props ? command.runtime.props : {}) : null;
|
|
}
|
|
|
|
private extractExecuteArgs(command:Command, executeArgs:any) {
|
|
if (executeArgs) return executeArgs;
|
|
if (!command.runtime) throw new Error(`Command: ${command.declaration.name}: Runtime is not defined - make sure it has been registered.`);
|
|
if (command.runtime.props) return command.runtime.props;
|
|
return {};
|
|
}
|
|
|
|
commandToToolbarButton(commandName:string, executeArgs:any = null) {
|
|
const command = this.commandByName(commandName, { runtimeMustBeRegistered: true });
|
|
|
|
return {
|
|
tooltip: command.declaration.label(),
|
|
iconName: command.declaration.iconName,
|
|
enabled: this.isEnabled(commandName),
|
|
onClick: () => {
|
|
this.execute(commandName, this.extractExecuteArgs(command, executeArgs));
|
|
},
|
|
title: this.title(commandName),
|
|
};
|
|
}
|
|
|
|
commandToMenuItem(commandName:string, accelerator:string = null, executeArgs:any = null) {
|
|
const command = this.commandByName(commandName);
|
|
|
|
const item:any = {
|
|
id: command.declaration.name,
|
|
label: command.declaration.label(),
|
|
click: () => {
|
|
this.execute(commandName, this.extractExecuteArgs(command, executeArgs));
|
|
},
|
|
};
|
|
|
|
if (accelerator) item.accelerator = accelerator;
|
|
if (command.declaration.role) item.role = command.declaration.role;
|
|
|
|
return item;
|
|
}
|
|
|
|
commandsEnabledState(previousState:any = null):any {
|
|
const output:any = {};
|
|
|
|
for (const name in this.commands_) {
|
|
const enabled = this.isEnabled(name);
|
|
if (!previousState || previousState[name] !== enabled) {
|
|
output[name] = enabled;
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
}
|