1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-03 23:50:33 +02:00

Desktop: Simplified and improve command service, and added command palette

- Commands "enabled" state is now expressed using a "when-clause" like in VSCode
- A command palette has been added to the Tools menu
This commit is contained in:
Laurent Cozic
2020-10-18 21:52:10 +01:00
parent f529adac99
commit 3a57cfea02
78 changed files with 897 additions and 756 deletions

View File

@ -590,7 +590,24 @@ class BaseModel {
}
}
BaseModel.typeEnum_ = [['TYPE_NOTE', 1], ['TYPE_FOLDER', 2], ['TYPE_SETTING', 3], ['TYPE_RESOURCE', 4], ['TYPE_TAG', 5], ['TYPE_NOTE_TAG', 6], ['TYPE_SEARCH', 7], ['TYPE_ALARM', 8], ['TYPE_MASTER_KEY', 9], ['TYPE_ITEM_CHANGE', 10], ['TYPE_NOTE_RESOURCE', 11], ['TYPE_RESOURCE_LOCAL_STATE', 12], ['TYPE_REVISION', 13], ['TYPE_MIGRATION', 14], ['TYPE_SMART_FILTER', 15]];
BaseModel.typeEnum_ = [
['TYPE_NOTE', 1],
['TYPE_FOLDER', 2],
['TYPE_SETTING', 3],
['TYPE_RESOURCE', 4],
['TYPE_TAG', 5],
['TYPE_NOTE_TAG', 6],
['TYPE_SEARCH', 7],
['TYPE_ALARM', 8],
['TYPE_MASTER_KEY', 9],
['TYPE_ITEM_CHANGE', 10],
['TYPE_NOTE_RESOURCE', 11],
['TYPE_RESOURCE_LOCAL_STATE', 12],
['TYPE_REVISION', 13],
['TYPE_MIGRATION', 14],
['TYPE_SMART_FILTER', 15],
['TYPE_COMMAND', 16],
];
for (let i = 0; i < BaseModel.typeEnum_.length; i++) {
const e = BaseModel.typeEnum_[i];

View File

@ -4,27 +4,16 @@ import { _ } from 'lib/locale';
export const declaration:CommandDeclaration = {
name: 'historyBackward',
label: () => _('Back'),
// iconName: 'fa-arrow-left',
iconName: 'icon-back',
};
interface Props {
hasBackwardNotes: boolean,
}
export const runtime = ():CommandRuntime => {
return {
execute: async (props:Props) => {
if (!props.hasBackwardNotes) return;
execute: async () => {
utils.store.dispatch({
type: 'HISTORY_BACKWARD',
});
},
isEnabled: (props:Props) => {
return props.hasBackwardNotes;
},
mapStateToProps: (state:any) => {
return { hasBackwardNotes: state.backwardHistoryNotes.length > 0 };
},
enabledCondition: 'historyhasBackwardNotes',
};
};

View File

@ -7,23 +7,13 @@ export const declaration:CommandDeclaration = {
iconName: 'icon-forward',
};
interface Props {
hasForwardNotes: boolean,
}
export const runtime = ():CommandRuntime => {
return {
execute: async (props:Props) => {
if (!props.hasForwardNotes) return;
execute: async () => {
utils.store.dispatch({
type: 'HISTORY_FORWARD',
});
},
isEnabled: (props:Props) => {
return props.hasForwardNotes;
},
mapStateToProps: (state:any) => {
return { hasForwardNotes: state.forwardHistoryNotes.length > 0 };
},
enabledCondition: 'historyhasForwardNotes',
};
};

View File

@ -1,4 +1,4 @@
import { utils, CommandRuntime, CommandDeclaration } from '../services/CommandService';
import { utils, CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
import { _ } from 'lib/locale';
const { reg } = require('lib/registry.js');
@ -8,9 +8,11 @@ export const declaration:CommandDeclaration = {
iconName: 'fa-sync-alt',
};
// Note that this command actually acts as a toggle - it starts or cancels
// synchronisation depending on the "syncStarted" parameter
export const runtime = ():CommandRuntime => {
return {
execute: async ({ syncStarted }:any) => {
execute: async (_context:CommandContext, syncStarted:boolean = false) => {
const action = syncStarted ? 'cancel' : 'start';
if (!(await reg.syncTarget().isAuthenticated())) {
@ -43,13 +45,5 @@ export const runtime = ():CommandRuntime => {
return 'sync';
}
},
isEnabled: (props:any) => {
return !props.syncStarted;
},
mapStateToProps: (state:any):any => {
return {
syncStarted: state.syncStarted,
};
},
};
};

View File

@ -2,6 +2,7 @@ import produce, { Draft } from 'immer';
import pluginServiceReducer, { stateRootKey as pluginServiceStateRootKey, defaultState as pluginServiceDefaultState, State as PluginServiceState } from 'lib/services/plugins/reducer';
const Note = require('lib/models/Note.js');
const Folder = require('lib/models/Folder.js');
const BaseModel = require('lib/BaseModel');
const ArrayUtils = require('lib/ArrayUtils.js');
const { ALL_NOTES_FILTER_ID } = require('lib/reserved-ids');
const { createSelectorCreator, defaultMemoize } = require('reselect');
@ -160,8 +161,6 @@ for (const additionalReducer of additionalReducers) {
export const MAX_HISTORY = 200;
export const stateUtils:any = {};
const derivedStateCache_:any = {};
// Allows, for a given state, to return the same derived
@ -185,9 +184,7 @@ const createShallowArrayEqualSelector = createSelectorCreator(
}
);
// Given an input array, this selector ensures that the same array is returned
// if its content hasn't changed.
stateUtils.selectArrayShallow = createCachedSelector(
const selectArrayShallow = createCachedSelector(
(state:any) => state.array,
(array:any[]) => array
)({
@ -197,85 +194,85 @@ stateUtils.selectArrayShallow = createCachedSelector(
selectorCreator: createShallowArrayEqualSelector,
});
stateUtils.hasOneSelectedNote = function(state:State):boolean {
return state.selectedNoteIds.length === 1;
};
class StateUtils {
stateUtils.notesOrder = function(stateSettings:any) {
if (stateSettings['notes.sortOrder.field'] === 'order') {
return cacheEnabledOutput('notesOrder', [
{
by: 'order',
dir: 'DESC',
},
{
by: 'user_created_time',
dir: 'DESC',
},
]);
} else {
return cacheEnabledOutput('notesOrder', [
{
by: stateSettings['notes.sortOrder.field'],
dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC',
},
]);
// Given an input array, this selector ensures that the same array is returned
// if its content hasn't changed.
public selectArrayShallow(props:any, cacheKey:any) {
return selectArrayShallow(props, cacheKey);
}
};
stateUtils.foldersOrder = function(stateSettings:any) {
return cacheEnabledOutput('foldersOrder', [
{
by: stateSettings['folders.sortOrder.field'],
dir: stateSettings['folders.sortOrder.reverse'] ? 'DESC' : 'ASC',
},
]);
};
stateUtils.hasNotesBeingSaved = function(state:State):boolean {
for (const id in state.editorNoteStatuses) {
if (state.editorNoteStatuses[id] === 'saving') return true;
public oneNoteSelected(state:State):boolean {
return state.selectedNoteIds.length === 1;
}
return false;
};
stateUtils.parentItem = function(state:State) {
const t = state.notesParentType;
let id = null;
if (t === 'Folder') id = state.selectedFolderId;
if (t === 'Tag') id = state.selectedTagId;
if (t === 'Search') id = state.selectedSearchId;
if (!t || !id) return null;
return { type: t, id: id };
};
stateUtils.lastSelectedNoteIds = function(state:State):string[] {
const parent = stateUtils.parentItem(state);
if (!parent) return [];
const output = (state.lastSelectedNotesIds as any)[parent.type][parent.id];
return output ? output : [];
};
stateUtils.getCurrentNote = function(state:State) {
const selectedNoteIds = state.selectedNoteIds;
const notes = state.notes;
if (selectedNoteIds != null && selectedNoteIds.length > 0) {
const currNote = notes.find(note => note.id === selectedNoteIds[0]);
if (currNote != null) {
return {
id: currNote.id,
parent_id: currNote.parent_id,
notesParentType: state.notesParentType,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
selectedSearchId: state.selectedSearchId,
searches: state.searches,
selectedSmartFilterId: state.selectedSmartFilterId,
};
public notesOrder(stateSettings:any) {
if (stateSettings['notes.sortOrder.field'] === 'order') {
return cacheEnabledOutput('notesOrder', [
{
by: 'order',
dir: 'DESC',
},
{
by: 'user_created_time',
dir: 'DESC',
},
]);
} else {
return cacheEnabledOutput('notesOrder', [
{
by: stateSettings['notes.sortOrder.field'],
dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC',
},
]);
}
}
return null;
};
public foldersOrder(stateSettings:any) {
return cacheEnabledOutput('foldersOrder', [
{
by: stateSettings['folders.sortOrder.field'],
dir: stateSettings['folders.sortOrder.reverse'] ? 'DESC' : 'ASC',
},
]);
}
public hasNotesBeingSaved(state:State):boolean {
for (const id in state.editorNoteStatuses) {
if (state.editorNoteStatuses[id] === 'saving') return true;
}
return false;
}
public parentItem(state:State) {
const t = state.notesParentType;
let id = null;
if (t === 'Folder') id = state.selectedFolderId;
if (t === 'Tag') id = state.selectedTagId;
if (t === 'Search') id = state.selectedSearchId;
if (!t || !id) return null;
return { type: t, id: id };
}
public lastSelectedNoteIds(state:State):string[] {
const parent = this.parentItem(state);
if (!parent) return [];
const output = (state.lastSelectedNotesIds as any)[parent.type][parent.id];
return output ? output : [];
}
public selectedNote(state:State):any {
const noteId = this.selectedNoteId(state);
return noteId ? BaseModel.byId(state.notes, noteId) : null;
}
public selectedNoteId(state:State):any {
return state.selectedNoteIds.length ? state.selectedNoteIds[0] : null;
}
}
export const stateUtils:StateUtils = new StateUtils();
function arrayHasEncryptedItems(array:any[]) {
for (let i = 0; i < array.length; i++) {
@ -526,8 +523,29 @@ const getContextFromHistory = (ctx:any) => {
return result;
};
function getNoteHistoryInfo(state:State) {
const selectedNoteIds = state.selectedNoteIds;
const notes = state.notes;
if (selectedNoteIds != null && selectedNoteIds.length > 0) {
const currNote = notes.find(note => note.id === selectedNoteIds[0]);
if (currNote != null) {
return {
id: currNote.id,
parent_id: currNote.parent_id,
notesParentType: state.notesParentType,
selectedFolderId: state.selectedFolderId,
selectedTagId: state.selectedTagId,
selectedSearchId: state.selectedSearchId,
searches: state.searches,
selectedSmartFilterId: state.selectedSmartFilterId,
};
}
}
return null;
}
function handleHistory(draft:Draft<State>, action:any) {
const currentNote = stateUtils.getCurrentNote(draft);
const currentNote = getNoteHistoryInfo(draft);
switch (action.type) {
case 'HISTORY_BACKWARD': {
const note = draft.backwardHistoryNotes[draft.backwardHistoryNotes.length - 1];
@ -1086,6 +1104,7 @@ const reducer = produce((draft: Draft<State> = defaultState, action:any) => {
const newPluginsLegacy = Object.assign({}, draft.pluginsLegacy);
const newPlugin = draft.pluginsLegacy[action.pluginName] ? Object.assign({}, draft.pluginsLegacy[action.pluginName]) : {};
if ('open' in action) newPlugin.dialogOpen = action.open;
if ('userData' in action) newPlugin.userData = action.userData;
newPluginsLegacy[action.pluginName] = newPlugin;
draft.pluginsLegacy = newPluginsLegacy;
}

View File

@ -1,30 +1,23 @@
import { State } from 'lib/reducer';
import eventManager from 'lib/eventManager';
import markdownUtils, { MarkdownTableHeader, MarkdownTableRow } from 'lib/markdownUtils';
import BaseService from 'lib/services/BaseService';
import shim from 'lib/shim';
import WhenClause from './WhenClause';
import stateToWhenClauseContext from './commands/stateToWhenClauseContext';
type LabelFunction = () => string;
type EnabledCondition = string;
export interface CommandContext {
// The state may also be of type "AppState" (used by the desktop app), which inherits from "State" (used by all apps)
state: State,
}
export interface CommandRuntime {
execute(props:any):Promise<any>
isEnabled?(props:any):boolean
// "state" type is "AppState" but in order not to introduce a
// dependency to the desktop app (so that the service can
// potentially be used by the mobile app too), we keep it as "any".
// Individual commands can define it as state:AppState when relevant.
//
// In general this method should reduce the provided state to only
// what's absolutely necessary. For example, if the property of a
// note is needed, return only that particular property and not the
// whole note object. This will ensure that components that depends
// on this command are not uncessarily re-rendered. A note object for
// example might change frequently but its markdown_language property
// will almost never change.
mapStateToProps?(state:any):any
execute(context:CommandContext, ...args:any[]):Promise<any>
enabledCondition?: EnabledCondition;
// Used for the (optional) toolbar button title
title?(props:any):string,
mapStateToTitle?(state:any):string,
}
export interface CommandDeclaration {
@ -33,6 +26,9 @@ export interface CommandDeclaration {
// Used for the menu item label, and toolbar button tooltip
label?: LabelFunction | string,
// Command description - if none is provided, the label will be used as description
description?: string,
// 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
@ -88,30 +84,29 @@ interface CommandByNameOptions {
runtimeMustBeRegistered?:boolean,
}
interface CommandState {
export interface SearchResult {
commandName: string,
title: string,
enabled: boolean,
}
interface CommandStates {
[key:string]: CommandState
}
export default class CommandService extends BaseService {
private static instance_:CommandService;
static instance():CommandService {
public static instance():CommandService {
if (this.instance_) return this.instance_;
this.instance_ = new CommandService();
return this.instance_;
}
private commands_:Commands = {};
private commandPreviousStates_:CommandStates = {};
private store_:any;
private devMode_:boolean;
initialize(store:any) {
public initialize(store:any, devMode:boolean) {
utils.store = store;
this.store_ = store;
this.devMode_ = devMode;
}
public on(eventName:string, callback:Function) {
@ -122,6 +117,36 @@ export default class CommandService extends BaseService {
eventManager.off(eventName, callback);
}
public searchCommands(query:string, returnAllWhenEmpty:boolean, excludeWithoutLabel:boolean = true):SearchResult[] {
query = query.toLowerCase();
const output = [];
for (const commandName of this.commandNames()) {
const label = this.label(commandName, true);
if (!label && excludeWithoutLabel) continue;
const title = label ? `${label} (${commandName})` : commandName;
if ((returnAllWhenEmpty && !query) || title.toLowerCase().includes(query)) {
output.push({
commandName: commandName,
title: title,
});
}
}
output.sort((a:SearchResult, b:SearchResult) => {
return a.title.toLowerCase() < b.title.toLowerCase() ? -1 : +1;
});
return output;
}
public commandNames() {
return Object.keys(this.commands_);
}
public commandByName(name:string, options:CommandByNameOptions = null):Command {
options = {
mustExist: true,
@ -140,7 +165,7 @@ export default class CommandService extends BaseService {
return command;
}
registerDeclaration(declaration:CommandDeclaration) {
public registerDeclaration(declaration:CommandDeclaration) {
declaration = { ...declaration };
if (!declaration.label) declaration.label = '';
if (!declaration.iconName) declaration.iconName = '';
@ -148,83 +173,89 @@ export default class CommandService extends BaseService {
this.commands_[declaration.name] = {
declaration: declaration,
};
delete this.commandPreviousStates_[declaration.name];
}
registerRuntime(commandName:string, runtime:CommandRuntime) {
public registerRuntime(commandName:string, runtime:CommandRuntime) {
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);
if (!runtime.isEnabled) runtime.isEnabled = () => true;
if (!runtime.title) runtime.title = () => null;
if (!runtime.enabledCondition) runtime.enabledCondition = 'true';
command.runtime = runtime;
delete this.commandPreviousStates_[commandName];
}
componentRegisterCommands(component:any, commands:any[]) {
public componentRegisterCommands(component:any, commands:any[]) {
for (const command of commands) {
CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component));
}
}
componentUnregisterCommands(commands:any[]) {
public componentUnregisterCommands(commands:any[]) {
for (const command of commands) {
CommandService.instance().unregisterRuntime(command.declaration.name);
}
}
unregisterRuntime(commandName:string) {
public unregisterRuntime(commandName:string) {
const command = this.commandByName(commandName, { mustExist: false });
if (!command || !command.runtime) return;
delete command.runtime;
delete this.commandPreviousStates_[commandName];
}
async execute(commandName:string, props:any = null):Promise<any> {
public async execute(commandName:string, ...args:any[]):Promise<any> {
const command = this.commandByName(commandName);
this.logger().info('CommandService::execute:', commandName, props);
return command.runtime.execute(props ? props : {});
this.logger().info('CommandService::execute:', commandName, args);
return command.runtime.execute({ state: this.store_.getState() }, ...args);
}
scheduleExecute(commandName:string, args:any) {
public scheduleExecute(commandName:string, args:any) {
shim.setTimeout(() => {
this.execute(commandName, args);
}, 10);
}
isEnabled(commandName:string, props:any):boolean {
public currentWhenClauseContext() {
return stateToWhenClauseContext(this.store_.getState());
}
// 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.
public isEnabled(commandName:string, whenClauseContext:any = null):boolean {
const command = this.commandByName(commandName);
if (!command || !command.runtime) return false;
// if (!command.runtime.props) return false;
return command.runtime.isEnabled(props);
if (!whenClauseContext) whenClauseContext = this.currentWhenClauseContext();
const exp = new WhenClause(command.runtime.enabledCondition, this.devMode_);
return exp.evaluate(whenClauseContext);
}
commandMapStateToProps(commandName:string, state:any):any {
const command = this.commandByName(commandName);
if (!command.runtime) return null;
if (!command.runtime.mapStateToProps) return {};
return command.runtime.mapStateToProps(state);
}
title(commandName:string, props:any):string {
// 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.
public title(commandName:string, state:any = null):string {
const command = this.commandByName(commandName);
if (!command || !command.runtime) return null;
return command.runtime.title(props);
state = state || this.store_.getState();
if (command.runtime.mapStateToTitle) {
return command.runtime.mapStateToTitle(state);
} else {
return '';
}
}
iconName(commandName:string, variant:string = null):string {
public iconName(commandName:string, variant:string = null):string {
const command = this.commandByName(commandName);
if (!command) throw new Error(`No such command: ${commandName}`);
if (variant === 'tinymce') return command.declaration.tinymceIconName ? command.declaration.tinymceIconName : 'preferences';
return command.declaration.iconName;
}
label(commandName:string, fullLabel:boolean = false):string {
public label(commandName:string, fullLabel:boolean = false):string {
const command = this.commandByName(commandName);
if (!command) throw new Error(`Command: ${commandName} is not declared`);
const output = [];
@ -240,42 +271,15 @@ export default class CommandService extends BaseService {
return output.join(': ');
}
exists(commandName:string):boolean {
public description(commandName:string):string {
const command = this.commandByName(commandName);
if (command.declaration.description) return command.declaration.description;
return this.label(commandName, true);
}
public exists(commandName:string):boolean {
const command = this.commandByName(commandName, { mustExist: false });
return !!command;
}
public commandsToMarkdownTable(state:any):string {
const headers:MarkdownTableHeader[] = [
{
name: 'commandName',
label: 'Name',
},
{
name: 'description',
label: 'Description',
},
{
name: 'props',
label: 'Props',
},
];
const rows:MarkdownTableRow[] = [];
for (const commandName in this.commands_) {
const props = this.commandMapStateToProps(commandName, state);
const row:MarkdownTableRow = {
commandName: commandName,
description: this.label(commandName),
props: JSON.stringify(props),
};
rows.push(row);
}
return markdownUtils.createMarkdownTable(headers, rows);
}
}

View File

@ -16,7 +16,7 @@ const defaultKeymapItems = {
{ accelerator: 'Cmd+N', command: 'newNote' },
{ accelerator: 'Cmd+T', command: 'newTodo' },
{ accelerator: 'Cmd+S', command: 'synchronize' },
{ accelerator: 'Cmd+P', command: 'print' },
{ accelerator: '', command: 'print' },
{ accelerator: 'Cmd+H', command: 'hideApp' },
{ accelerator: 'Cmd+Q', command: 'quit' },
{ accelerator: 'Cmd+,', command: 'config' },
@ -37,20 +37,21 @@ const defaultKeymapItems = {
{ accelerator: 'Shift+Cmd+L', command: 'focusElementNoteList' },
{ accelerator: 'Shift+Cmd+N', command: 'focusElementNoteTitle' },
{ accelerator: 'Shift+Cmd+B', command: 'focusElementNoteBody' },
{ accelerator: 'Option+Cmd+S', command: 'toggleSidebar' },
{ accelerator: 'Option+Cmd+S', command: 'toggleSideBar' },
{ accelerator: 'Option+Cmd+L', command: 'toggleNoteList' },
{ accelerator: 'Cmd+L', command: 'toggleVisiblePanes' },
{ accelerator: 'Cmd+0', command: 'zoomActualSize' },
{ accelerator: 'Cmd+E', command: 'toggleExternalEditing' },
{ accelerator: 'Option+Cmd+T', command: 'setTags' },
{ accelerator: 'Cmd+G', command: 'gotoAnything' },
{ accelerator: 'Cmd+P', command: 'gotoAnything' },
{ accelerator: 'Shift+Cmd+P', command: 'commandPalette' },
{ accelerator: 'F1', command: 'help' },
],
default: [
{ accelerator: 'Ctrl+N', command: 'newNote' },
{ accelerator: 'Ctrl+T', command: 'newTodo' },
{ accelerator: 'Ctrl+S', command: 'synchronize' },
{ accelerator: 'Ctrl+P', command: 'print' },
{ accelerator: '', command: 'print' },
{ accelerator: 'Ctrl+Q', command: 'quit' },
{ accelerator: 'Ctrl+Alt+I', command: 'insertTemplate' },
{ accelerator: 'Ctrl+C', command: 'textCopy' },
@ -68,14 +69,15 @@ const defaultKeymapItems = {
{ accelerator: 'Ctrl+Shift+L', command: 'focusElementNoteList' },
{ accelerator: 'Ctrl+Shift+N', command: 'focusElementNoteTitle' },
{ accelerator: 'Ctrl+Shift+B', command: 'focusElementNoteBody' },
{ accelerator: 'F10', command: 'toggleSidebar' },
{ accelerator: 'F10', command: 'toggleSideBar' },
{ accelerator: 'F11', command: 'toggleNoteList' },
{ accelerator: 'Ctrl+L', command: 'toggleVisiblePanes' },
{ accelerator: 'Ctrl+0', command: 'zoomActualSize' },
{ accelerator: 'Ctrl+E', command: 'toggleExternalEditing' },
{ accelerator: 'Ctrl+Alt+T', command: 'setTags' },
{ accelerator: 'Ctrl+,', command: 'config' },
{ accelerator: 'Ctrl+G', command: 'gotoAnything' },
{ accelerator: 'Ctrl+P', command: 'gotoAnything' },
{ accelerator: 'Ctrl+Shift+P', command: 'commandPalette' },
{ accelerator: 'F1', command: 'help' },
],
};
@ -90,13 +92,14 @@ interface Keymap {
}
export default class KeymapService extends BaseService {
private keymap: Keymap;
private platform: string;
private customKeymapPath: string;
private defaultKeymapItems: KeymapItem[];
private lastSaveTime_:number;
constructor() {
public constructor() {
super();
this.lastSaveTime_ = Date.now();
@ -106,11 +109,11 @@ export default class KeymapService extends BaseService {
this.initialize();
}
get lastSaveTime():number {
public get lastSaveTime():number {
return this.lastSaveTime_;
}
initialize(platform: string = shim.platformName()) {
public initialize(platform: string = shim.platformName()) {
this.platform = platform;
switch (platform) {
@ -131,7 +134,7 @@ export default class KeymapService extends BaseService {
}
}
async loadCustomKeymap(customKeymapPath: string) {
public async loadCustomKeymap(customKeymapPath: string) {
this.customKeymapPath = customKeymapPath; // Useful for saving the changes later
if (await shim.fsDriver().exists(customKeymapPath)) {
@ -143,7 +146,7 @@ export default class KeymapService extends BaseService {
}
}
async saveCustomKeymap(customKeymapPath: string = this.customKeymapPath) {
public async saveCustomKeymap(customKeymapPath: string = this.customKeymapPath) {
this.logger().info(`KeymapService: Saving keymap to file: ${customKeymapPath}`);
try {
@ -161,7 +164,7 @@ export default class KeymapService extends BaseService {
}
}
acceleratorExists(command: string) {
public acceleratorExists(command: string) {
return !!this.keymap[command];
}
@ -189,33 +192,33 @@ export default class KeymapService extends BaseService {
};
}
setAccelerator(command: string, accelerator: string) {
public setAccelerator(command: string, accelerator: string) {
this.keymap[command].accelerator = accelerator;
}
getAccelerator(command: string) {
public getAccelerator(command: string) {
const item = this.keymap[command];
if (!item) throw new Error(`KeymapService: "${command}" command does not exist!`);
return item.accelerator;
}
getDefaultAccelerator(command: string) {
public getDefaultAccelerator(command: string) {
const defaultItem = this.defaultKeymapItems.find((item => item.command === command));
if (!defaultItem) throw new Error(`KeymapService: "${command}" command does not exist!`);
return defaultItem.accelerator;
}
getCommandNames() {
public getCommandNames() {
return Object.keys(this.keymap);
}
getKeymapItems() {
public getKeymapItems() {
return Object.values(this.keymap);
}
getCustomKeymapItems() {
public getCustomKeymapItems() {
const customkeymapItems: KeymapItem[] = [];
this.defaultKeymapItems.forEach(({ command, accelerator }) => {
const currentAccelerator = this.getAccelerator(command);
@ -236,11 +239,11 @@ export default class KeymapService extends BaseService {
return customkeymapItems;
}
getDefaultKeymapItems() {
public getDefaultKeymapItems() {
return [...this.defaultKeymapItems];
}
overrideKeymap(customKeymapItems: KeymapItem[]) {
public overrideKeymap(customKeymapItems: KeymapItem[]) {
try {
for (let i = 0; i < customKeymapItems.length; i++) {
const item = customKeymapItems[i];
@ -284,7 +287,7 @@ export default class KeymapService extends BaseService {
}
}
validateKeymap(proposedKeymapItem: KeymapItem = null) {
public validateKeymap(proposedKeymapItem: KeymapItem = null) {
const usedAccelerators = new Set();
// Validate as if the proposed change is already present in the current keymap
@ -312,7 +315,7 @@ export default class KeymapService extends BaseService {
}
}
validateAccelerator(accelerator: string) {
public validateAccelerator(accelerator: string) {
let keyFound = false;
const parts = accelerator.split('+');
@ -334,7 +337,7 @@ export default class KeymapService extends BaseService {
if (!isValid) throw new Error(_('Accelerator "%s" is not valid.', accelerator));
}
domToElectronAccelerator(event: KeyboardEvent<HTMLDivElement>) {
public domToElectronAccelerator(event: KeyboardEvent<HTMLDivElement>) {
const parts = [];
const { key, ctrlKey, metaKey, altKey, shiftKey } = event;
@ -358,7 +361,7 @@ export default class KeymapService extends BaseService {
return parts.join('+');
}
static domToElectronKey(domKey: string) {
private static domToElectronKey(domKey: string) {
let electronKey;
if (/^([a-z])$/.test(domKey)) {
@ -398,7 +401,7 @@ export default class KeymapService extends BaseService {
private static instance_:KeymapService = null;
static instance():KeymapService {
public static instance():KeymapService {
if (this.instance_) return this.instance_;
this.instance_ = new KeymapService();

View File

@ -1,5 +1,4 @@
const Logger = require('lib/Logger').default;
const KeymapService = require('lib/services/KeymapService').default;
class PluginManager {
constructor() {
@ -52,6 +51,7 @@ class PluginManager {
const p = this.pluginInstance_(event.pluginName);
p.onTrigger({
itemName: event.itemName,
userData: event.userData,
});
}
@ -65,7 +65,7 @@ class PluginManager {
return {
Dialog: Class.Dialog,
props: this.dialogProps_(name),
props: Object.assign({}, this.dialogProps_(name), { userData: p.userData }),
};
}
@ -81,20 +81,24 @@ class PluginManager {
menuItems() {
let output = [];
const keymapService = KeymapService.instance();
for (const name in this.plugins_) {
const menuItems = this.plugins_[name].Class.manifest.menuItems;
const menuItems = this.plugins_[name].Class.manifest.menuItems.slice();
if (!menuItems) continue;
for (const item of menuItems) {
for (let i = 0; i < menuItems.length; i++) {
const item = Object.assign({}, menuItems[i]);
item.click = () => {
this.onPluginMenuItemTrigger_({
pluginName: name,
itemName: item.name,
userData: item.userData,
});
};
item.accelerator = keymapService.getAccelerator(name);
item.accelerator = menuItems[i].accelerator();
menuItems[i] = item;
}
output = output.concat(menuItems);

View File

@ -1,12 +1,14 @@
import { ContextKeyExpr, ContextKeyExpression } from './contextkey/contextkey';
export default class BooleanExpression {
export default class WhenClause {
private expression_:string;
private validate_:boolean;
private rules_:ContextKeyExpression = null;
constructor(expression:string) {
constructor(expression:string, validate:boolean) {
this.expression_ = expression;
this.validate_ = validate;
}
private createContext(ctx: any) {
@ -21,11 +23,20 @@ export default class BooleanExpression {
if (!this.rules_) {
this.rules_ = ContextKeyExpr.deserialize(this.expression_);
}
return this.rules_;
}
public evaluate(context:any):boolean {
if (this.validate_) this.validate(context);
return this.rules.evaluate(this.createContext(context));
}
public validate(context:any) {
const keys = this.rules.keys();
for (const key of keys) {
if (!(key in context)) throw new Error(`No such key: ${key}`);
}
}
}

View File

@ -87,9 +87,9 @@ export default class MenuUtils {
return item;
}
public commandToStatefulMenuItem(commandName:string, props:any = null):MenuItem {
public commandToStatefulMenuItem(commandName:string, ...args:any[]):MenuItem {
return this.commandToMenuItem(commandName, () => {
return this.service.execute(commandName, props ? props : {});
return this.service.execute(commandName, ...args);
});
}
@ -108,11 +108,14 @@ export default class MenuUtils {
return output;
}
public commandsToMenuItemProps(state:any, commandNames:string[]):MenuItemProps {
public commandsToMenuItemProps(commandNames:string[], whenClauseContext:any):MenuItemProps {
const output:MenuItemProps = {};
for (const commandName of commandNames) {
const newProps = this.service.commandMapStateToProps(commandName, state);
const newProps = {
enabled: this.service.isEnabled(commandName, whenClauseContext),
};
if (newProps === null || propsHaveChanged(this.menuItemPropsCache_[commandName], newProps)) {
output[commandName] = newProps;
this.menuItemPropsCache_[commandName] = newProps;

View File

@ -1,5 +1,4 @@
import CommandService from '../CommandService';
import propsHaveChanged from './propsHaveChanged';
import CommandService from 'lib/services/CommandService';
import { stateUtils } from 'lib/reducer';
const separatorItem = { type: 'separator' };
@ -14,7 +13,6 @@ export interface ToolbarButtonInfo {
}
interface ToolbarButtonCacheItem {
props: any,
info: ToolbarButtonInfo,
}
@ -35,8 +33,15 @@ export default class ToolbarButtonUtils {
return this.service_;
}
private commandToToolbarButton(commandName:string, props:any):ToolbarButtonInfo {
if (this.toolbarButtonCache_[commandName] && !propsHaveChanged(this.toolbarButtonCache_[commandName].props, props)) {
private commandToToolbarButton(commandName:string, whenClauseContext:any):ToolbarButtonInfo {
const newEnabled = this.service.isEnabled(commandName, whenClauseContext);
const newTitle = this.service.title(commandName);
if (
this.toolbarButtonCache_[commandName] &&
this.toolbarButtonCache_[commandName].info.enabled === newEnabled &&
this.toolbarButtonCache_[commandName].info.title === newTitle
) {
return this.toolbarButtonCache_[commandName].info;
}
@ -46,15 +51,14 @@ export default class ToolbarButtonUtils {
name: commandName,
tooltip: this.service.label(commandName),
iconName: command.declaration.iconName,
enabled: this.service.isEnabled(commandName, props),
enabled: newEnabled,
onClick: async () => {
this.service.execute(commandName, props);
this.service.execute(commandName);
},
title: this.service.title(commandName, props),
title: newTitle,
};
this.toolbarButtonCache_[commandName] = {
props: props,
info: output,
};
@ -64,7 +68,7 @@ export default class ToolbarButtonUtils {
// This method ensures that if the provided commandNames and state hasn't changed
// the output also won't change. Invididual toolbarButtonInfo also won't changed
// if the state they use hasn't changed. This is to avoid useless renders of the toolbars.
public commandsToToolbarButtons(state:any, commandNames:string[]):ToolbarButtonInfo[] {
public commandsToToolbarButtons(commandNames:string[], whenClauseContext:any):ToolbarButtonInfo[] {
const output:ToolbarButtonInfo[] = [];
for (const commandName of commandNames) {
@ -73,8 +77,7 @@ export default class ToolbarButtonUtils {
continue;
}
const props = this.service.commandMapStateToProps(commandName, state);
output.push(this.commandToToolbarButton(commandName, props));
output.push(this.commandToToolbarButton(commandName, whenClauseContext));
}
return stateUtils.selectArrayShallow({ array: output }, commandNames.join('_'));

View File

@ -0,0 +1,32 @@
import markdownUtils, { MarkdownTableHeader, MarkdownTableRow } from 'lib/markdownUtils';
export default function commandsToMarkdownTable():string {
const headers:MarkdownTableHeader[] = [
{
name: 'commandName',
label: 'Name',
},
{
name: 'description',
label: 'Description',
},
{
name: 'props',
label: 'Props',
},
];
const rows:MarkdownTableRow[] = [];
for (const commandName in this.commands_) {
const row:MarkdownTableRow = {
commandName: commandName,
description: this.label(commandName),
};
rows.push(row);
}
return markdownUtils.createMarkdownTable(headers, rows);
}

View File

@ -1,5 +1,7 @@
export default function propsHaveChanged(previous:any, next:any):boolean {
if (!previous && next) return true;
if (previous && !next) return true;
if (!previous && !next) return false;
if (Object.keys(previous).length !== Object.keys(next).length) return true;

View File

@ -0,0 +1,47 @@
import { stateUtils } from 'lib/reducer';
const BaseModel = require('lib/BaseModel');
const Folder = require('lib/models/Folder');
const MarkupToHtml = require('lib/joplin-renderer/MarkupToHtml');
export default function stateToWhenClauseContext(state:any) {
const noteId = state.selectedNoteIds.length === 1 ? state.selectedNoteIds[0] : null;
const note = noteId ? BaseModel.byId(state.notes, noteId) : null;
return {
// UI elements
markdownEditorVisible: !!state.settings['editor.codeView'],
richTextEditorVisible: !state.settings['editor.codeView'],
markdownEditorPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('editor'),
markdownViewerPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('viewer'),
modalDialogVisible: !!Object.keys(state.visibleDialogs).length,
sideBarVisible: !!state.sidebarVisibility,
noteListHasNotes: !!state.notes.length,
// Application state
notesAreBeingSaved: stateUtils.hasNotesBeingSaved(state),
syncStarted: state.syncStarted,
// Current location
inConflictFolder: state.selectedFolderId === Folder.conflictFolderId(),
// Note selection
oneNoteSelected: !!note,
someNotesSelected: state.selectedNoteIds.length > 0,
multipleNotesSelected: state.selectedNoteIds.length > 1,
noNotesSelected: !state.selectedNoteIds.length,
// Note history
historyhasBackwardNotes: state.backwardHistoryNotes.length > 0,
historyhasForwardNotes: state.forwardHistoryNotes.length > 0,
// Folder selection
oneFolderSelected: !!state.selectedFolderId,
// Current note properties
noteIsTodo: note ? !!note.is_todo : false,
noteTodoCompleted: note ? !!note.todo_completed : false,
noteIsMarkdown: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_MARKDOWN : false,
noteIsHtml: note ? note.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false,
};
}

View File

@ -30,11 +30,11 @@ export default class JoplinCommands {
*
* // Create a new sub-notebook under the provided notebook
* // Note: internally, notebooks are called "folders".
* await joplin.commands.execute('newFolder', { parent_id: "SOME_FOLDER_ID" });
* await joplin.commands.execute('newFolder', "SOME_FOLDER_ID");
* ```
*/
async execute(commandName: string, props: any = null):Promise<any> {
return CommandService.instance().execute(commandName, props);
async execute(commandName: string, ...args:any[]):Promise<any> {
return CommandService.instance().execute(commandName, ...args);
}
/**
@ -65,8 +65,7 @@ export default class JoplinCommands {
execute: command.execute,
};
if ('isEnabled' in command) runtime.isEnabled = command.isEnabled;
if ('mapStateToProps' in command) runtime.mapStateToProps = command.mapStateToProps;
if ('enabledCondition' in command) runtime.enabledCondition = command.enabledCondition;
CommandService.instance().registerDeclaration(declaration);
CommandService.instance().registerRuntime(declaration.name, runtime);

View File

@ -3,12 +3,47 @@
// =================================================================
export interface Command {
/**
* Name of command - must be globally unique
*/
name: string
/**
* Label to be displayed on menu items or keyboard shortcut editor for example
*/
label: string
/**
* Icon to be used on toolbar buttons for example
*/
iconName?: string,
/**
* Code to be ran when the command is executed. It maybe return a result.
*/
execute(props:any):Promise<any>
isEnabled?(props:any):boolean
mapStateToProps?(state:any):any
/**
* Defines whether the command should be enabled or disabled, which in turns affects
* the enabled state of any associated button or menu item.
*
* The condition should be expressed as a "when-clause" (as in Visual Studio Code). It's a simple boolean expression that evaluates to
* `true` or `false`. It supports the following operators:
*
* Operator | Symbol | Example
* -- | -- | --
* Equality | == | "editorType == markdown"
* Inequality | != | "currentScreen != config"
* Or | \|\| | "noteIsTodo \|\| noteTodoCompleted"
* And | && | "oneNoteSelected && !inConflictFolder"
*
* Currently the supported context variables aren't documented, but you can find the list there:
*
* https://github.com/laurent22/joplin/blob/dev/ReactNativeClient/lib/services/commands/stateToWhenClauseContext.ts
*
* Note: Commands are enabled by default unless you use this property.
*/
enabledCondition?: string
}
// =================================================================