mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Migrating commands to typescript
Summary: Migrating commands and commandList to typescript Test Plan: Existing Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3871
This commit is contained in:
parent
b13fb1d97e
commit
b4c4a62a73
@ -433,13 +433,13 @@ export class GristDoc extends DisposableWithEvents {
|
||||
|
||||
/* Command binding */
|
||||
this.autoDispose(commands.createGroup({
|
||||
undo(this: GristDoc) { this._undoStack.sendUndoAction().catch(reportError); },
|
||||
redo(this: GristDoc) { this._undoStack.sendRedoAction().catch(reportError); },
|
||||
reloadPlugins() { this.docComm.reloadPlugins().then(() => G.window.location.reload(false)); },
|
||||
undo() { this._undoStack.sendUndoAction().catch(reportError); },
|
||||
redo() { this._undoStack.sendRedoAction().catch(reportError); },
|
||||
reloadPlugins() { void this.docComm.reloadPlugins().then(() => G.window.location.reload(false)); },
|
||||
|
||||
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
|
||||
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
|
||||
setCursor(this: GristDoc, rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
|
||||
setCursor(rowModel: BaseRowModel, fieldModel?: ViewFieldRec) {
|
||||
return this.setCursorPos({
|
||||
rowIndex: rowModel?._index() || 0,
|
||||
fieldIndex: fieldModel?._index() || 0,
|
||||
|
@ -1,5 +1,124 @@
|
||||
export type CommandName =
|
||||
| 'shortcuts'
|
||||
| 'help'
|
||||
| 'undo'
|
||||
| 'redo'
|
||||
| 'accept'
|
||||
| 'cancel'
|
||||
| 'find'
|
||||
| 'findNext'
|
||||
| 'findPrev'
|
||||
| 'historyBack'
|
||||
| 'historyForward'
|
||||
| 'reloadPlugins'
|
||||
| 'closeActiveMenu'
|
||||
| 'docTabOpen'
|
||||
| 'viewTabOpen'
|
||||
| 'viewTabFocus'
|
||||
| 'fieldTabOpen'
|
||||
| 'sortFilterTabOpen'
|
||||
| 'sortFilterMenuOpen'
|
||||
| 'dataSelectionTabOpen'
|
||||
| 'printSection'
|
||||
| 'showRawData'
|
||||
| 'openWidgetConfiguration'
|
||||
| 'maximizeActiveSection'
|
||||
| 'leftPanelOpen'
|
||||
| 'rightPanelOpen'
|
||||
| 'videoTourToolsOpen'
|
||||
| 'cursorDown'
|
||||
| 'cursorUp'
|
||||
| 'cursorRight'
|
||||
| 'cursorLeft'
|
||||
| 'nextField'
|
||||
| 'prevField'
|
||||
| 'pageDown'
|
||||
| 'pageUp'
|
||||
| 'moveToFirstRecord'
|
||||
| 'moveToLastRecord'
|
||||
| 'moveToFirstField'
|
||||
| 'moveToLastField'
|
||||
| 'skipDown'
|
||||
| 'skipUp'
|
||||
| 'setCursor'
|
||||
| 'openDocumentList'
|
||||
| 'nextPage'
|
||||
| 'prevPage'
|
||||
| 'nextSection'
|
||||
| 'prevSection'
|
||||
| 'shiftDown'
|
||||
| 'shiftUp'
|
||||
| 'shiftRight'
|
||||
| 'shiftLeft'
|
||||
| 'selectAll'
|
||||
| 'copyLink'
|
||||
| 'editField'
|
||||
| 'fieldEditSave'
|
||||
| 'fieldEditSaveHere'
|
||||
| 'fieldEditCancel'
|
||||
| 'copy'
|
||||
| 'cut'
|
||||
| 'paste'
|
||||
| 'fillSelectionDown'
|
||||
| 'clearValues'
|
||||
| 'input'
|
||||
| 'editLabel'
|
||||
| 'editLayout'
|
||||
| 'toggleCheckbox'
|
||||
| 'historyPrevious'
|
||||
| 'historyNext'
|
||||
| 'makeFormula'
|
||||
| 'unmakeFormula'
|
||||
| 'insertCurrentDate'
|
||||
| 'insertCurrentDateTime'
|
||||
| 'datepickerFocus'
|
||||
| 'openDiscussion'
|
||||
| 'insertRecordBefore'
|
||||
| 'insertRecordAfter'
|
||||
| 'deleteRecords'
|
||||
| 'insertFieldBefore'
|
||||
| 'insertFieldAfter'
|
||||
| 'renameField'
|
||||
| 'hideFields'
|
||||
| 'toggleFreeze'
|
||||
| 'deleteFields'
|
||||
| 'clearColumns'
|
||||
| 'convertFormulasToData'
|
||||
| 'addSection'
|
||||
| 'deleteSection'
|
||||
| 'collapseSection'
|
||||
| 'expandSection'
|
||||
| 'deleteCollapsedSection'
|
||||
| 'duplicateRows'
|
||||
| 'sortAsc'
|
||||
| 'sortDesc'
|
||||
| 'addSortAsc'
|
||||
| 'addSortDesc'
|
||||
| 'filterByThisCellValue'
|
||||
| 'enterLinkMode'
|
||||
| 'exitLinkMode'
|
||||
| 'saveLinks'
|
||||
| 'revertLinks'
|
||||
| 'clearLinks'
|
||||
| 'clearSectionLinks'
|
||||
| 'transformUpdate'
|
||||
;
|
||||
|
||||
|
||||
export interface CommandDef {
|
||||
name: CommandName;
|
||||
keys: string[];
|
||||
desc: string | null;
|
||||
deprecated?: boolean;
|
||||
}
|
||||
|
||||
export interface CommendGroupDef {
|
||||
group: string;
|
||||
commands: CommandDef[];
|
||||
}
|
||||
|
||||
// The top-level groups, and the ordering within them are for user-facing documentation.
|
||||
exports.groups = [{
|
||||
export const groups: CommendGroupDef[] = [{
|
||||
group: 'General',
|
||||
commands: [
|
||||
{
|
@ -1,342 +0,0 @@
|
||||
/**
|
||||
* Commands are invoked by the user via keyboard shortcuts or mouse clicks, for example, to move
|
||||
* the cursor or to delete the selected records.
|
||||
*
|
||||
* This module provides APIs for other components to implement groups of commands. Any given
|
||||
* command may be implemented by different components, but at most one implementation of any
|
||||
* command is active at any time.
|
||||
*/
|
||||
|
||||
|
||||
/* global navigator */
|
||||
|
||||
var _ = require('underscore');
|
||||
var ko = require('knockout');
|
||||
var Mousetrap = require('../lib/Mousetrap');
|
||||
var dom = require('../lib/dom');
|
||||
var gutil = require('app/common/gutil');
|
||||
var dispose = require('../lib/dispose');
|
||||
var commandList = require('./commandList');
|
||||
require('../lib/koUtil'); // for subscribeInit
|
||||
|
||||
var G = require('../lib/browserGlobals').get('window');
|
||||
|
||||
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
|
||||
var isMac = (typeof navigator !== 'undefined' && navigator &&
|
||||
/Mac|iPod|iPhone|iPad/.test(navigator.platform));
|
||||
|
||||
/**
|
||||
* Globally-exposed map of command names to Command objects. E.g. typing "cmd.cursorDown.run()" in
|
||||
* the browser console should move the cursor down as long as it makes sense in the currently
|
||||
* shown view. If the command is inactive, its run() function is a no-op.
|
||||
*
|
||||
* See also Command object below.
|
||||
*/
|
||||
var allCommands = {};
|
||||
exports.allCommands = allCommands;
|
||||
|
||||
/**
|
||||
* This is an internal variable, mapping key combinations to the stack of CommandGroups which
|
||||
* include them (see also CommandGroup.knownKeys). It's used for deciding which CommandGroup to
|
||||
* use when different Commands use the same key.
|
||||
*/
|
||||
var _allKeys = {};
|
||||
|
||||
/**
|
||||
* Populate allCommands from those provided, or listed in commandList.js. Also populates the
|
||||
* globally exposed `cmd` object whose properties invoke commands: e.g. typing `cmd.cursorDown` in
|
||||
* the browser console will run allCommands.cursorDown.run().
|
||||
*/
|
||||
function init(optCommandGroups) {
|
||||
var commandGroups = optCommandGroups || commandList.groups;
|
||||
|
||||
// Clear out the objects holding the global state.
|
||||
Object.keys(allCommands).forEach(function(c) {
|
||||
delete allCommands[c];
|
||||
});
|
||||
Object.keys(_allKeys).forEach(function(k) {
|
||||
delete _allKeys[k];
|
||||
});
|
||||
|
||||
commandGroups.forEach(function(commandGroup) {
|
||||
commandGroup.commands.forEach(function(c) {
|
||||
if (allCommands[c.name]) {
|
||||
console.error("Ignoring duplicate command %s in commandList", c.name);
|
||||
} else {
|
||||
allCommands[c.name] = new Command(c.name, c.desc, c.keys, c.deprecated);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Define the browser console interface.
|
||||
G.window.cmd = {};
|
||||
_.each(allCommands, function(cmd, name) {
|
||||
Object.defineProperty(G.window.cmd, name, {get: cmd.run});
|
||||
});
|
||||
}
|
||||
exports.init = init;
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
const KEY_MAP_MAC = {
|
||||
Mod: '⌘',
|
||||
Alt: '⌥',
|
||||
Shift: '⇧',
|
||||
Ctrl: '⌃',
|
||||
Left: '←',
|
||||
Right: '→',
|
||||
Up: '↑',
|
||||
Down: '↓',
|
||||
};
|
||||
|
||||
const KEY_MAP_WIN = {
|
||||
Mod: 'Ctrl',
|
||||
Left: '←',
|
||||
Right: '→',
|
||||
Up: '↑',
|
||||
Down: '↓',
|
||||
};
|
||||
|
||||
function getHumanKey(key, isMac) {
|
||||
const keyMap = isMac ? KEY_MAP_MAC : KEY_MAP_WIN;
|
||||
let keys = key.split('+').map(s => s.trim());
|
||||
keys = keys.map(k => {
|
||||
if (k in keyMap) { return keyMap[k]; }
|
||||
if (k.length === 1) { return k.toUpperCase(); }
|
||||
return k;
|
||||
});
|
||||
return keys.join( isMac ? '' : ' + ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Command represents a single command. It is exposed via the `allCommands` map.
|
||||
* @property {String} name: The name of the command, same as the key into the `allCommands` map.
|
||||
* @property {String} desc: The description of the command.
|
||||
* @property {Array} keys: The array of keyboard shortcuts for the command.
|
||||
* @property {Function} run: A bound function that will run the currently active implementation.
|
||||
* @property {Observable} isActive: Knockout observable for whether this command is active.
|
||||
*/
|
||||
function Command(name, desc, keys, deprecated) {
|
||||
this.name = name;
|
||||
this.desc = desc;
|
||||
this.humanKeys = keys.map(key => getHumanKey(key, isMac));
|
||||
this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); });
|
||||
this.isActive = ko.observable(false);
|
||||
this._implGroupStack = [];
|
||||
this._activeFunc = _.noop; // The function to run when this command is invoked.
|
||||
this.deprecated = deprecated || false;
|
||||
// Let .run bind the Command object, so that it can be used as a stand-alone callback.
|
||||
this.run = this._run.bind(this);
|
||||
}
|
||||
exports.Command = Command;
|
||||
|
||||
Command.prototype._run = function() {
|
||||
return this._activeFunc.apply(null, arguments);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a comma-separated string of all keyboard shortcuts, or `null` if no
|
||||
* shortcuts exist.
|
||||
*/
|
||||
Command.prototype.getKeysDesc = function() {
|
||||
if (this.humanKeys.length === 0) { return null; }
|
||||
|
||||
return `(${this.humanKeys.join(', ')})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the text description for the command, including the keyboard shortcuts.
|
||||
*/
|
||||
Command.prototype.getDesc = function() {
|
||||
var parts = [this.desc];
|
||||
|
||||
var keysDesc = this.getKeysDesc();
|
||||
if (keysDesc) { parts.push(keysDesc); }
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns DOM for the keyboard shortcuts, wrapped in cute boxes that look like keyboard keys.
|
||||
*/
|
||||
Command.prototype.getKeysDom = function(separator) {
|
||||
return dom('span.shortcut_keys',
|
||||
separator ? this.humanKeys.map((key, i) => [i ? separator() : null, dom('span.shortcut_key_image', key)])
|
||||
: this.humanKeys.map(key => dom('span.shortcut_key_image', key))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a CommandGroup that implements this Command to the top of the stack of groups.
|
||||
*/
|
||||
Command.prototype._addGroup = function(cmdGroup) {
|
||||
this._implGroupStack.push(cmdGroup);
|
||||
this._updateActive();
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a CommandGroup from the stack of groups implementing this Command.
|
||||
*/
|
||||
Command.prototype._removeGroup = function(cmdGroup) {
|
||||
gutil.arrayRemove(this._implGroupStack, cmdGroup);
|
||||
this._updateActive();
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the command's state to reflect the currently active group, if any.
|
||||
*/
|
||||
Command.prototype._updateActive = function() {
|
||||
if (this._implGroupStack.length > 0) {
|
||||
this.isActive(true);
|
||||
this._activeFunc = _.last(this._implGroupStack).commands[this.name];
|
||||
} else {
|
||||
this.isActive(false);
|
||||
this._activeFunc = _.noop;
|
||||
}
|
||||
|
||||
// Now bind or unbind the affected key combinations.
|
||||
this.keys.forEach(function(key) {
|
||||
var keyGroups = _allKeys[key];
|
||||
if (keyGroups && keyGroups.length > 0) {
|
||||
var commandGroup = _.last(keyGroups);
|
||||
// Command name might be different from this.name in case we are deactivating a command, and
|
||||
// the previous meaning of the key points to a different command.
|
||||
var commandName = commandGroup.knownKeys[key];
|
||||
Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName]));
|
||||
} else {
|
||||
Mousetrap.unbind(key);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper for mousetrap callbacks, which returns a version of the callback that by default stops
|
||||
* the propagation of the keyboard event (unless the callback returns a true value).
|
||||
*/
|
||||
function wrapKeyCallback(callback) {
|
||||
return function() {
|
||||
return callback.apply(null, arguments) || false;
|
||||
};
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* CommandGroup is the way for other components to provide implementations for a group of
|
||||
* commands. Note that CommandGroups are stacked, with groups activated later having priority over
|
||||
* groups activated earlier.
|
||||
* @param {String->Function} commands: The map of command names to implementations.
|
||||
* @param {Object} context: "this" context with which to invoke implementation functions.
|
||||
* @param {Boolean|Observable<boolean>} activate: Whether to activate this group immediately, false if
|
||||
* omitted. This may be an Observable.
|
||||
*/
|
||||
function CommandGroup(commands, context, activate) {
|
||||
// Keep only valid commands, so that we don't have to check for validity elsewhere, and bind
|
||||
// each to the passed-in context object.
|
||||
this.commands = {};
|
||||
this.isActive = false;
|
||||
|
||||
var name;
|
||||
for (name in commands) {
|
||||
if (allCommands[name]) {
|
||||
this.commands[name] = commands[name].bind(context);
|
||||
} else {
|
||||
console.warn("Ignoring unknown command %s", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Map recognized key combinations to the corresponding command names.
|
||||
this.knownKeys = {};
|
||||
for (name in this.commands) {
|
||||
var keys = allCommands[name].keys;
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
this.knownKeys[keys[i]] = name;
|
||||
}
|
||||
}
|
||||
|
||||
// On disposal, remove the CommandGroup from all the commands and keys.
|
||||
this.autoDisposeCallback(this._removeGroup);
|
||||
|
||||
// Finally, set the activatation status of the command group, subscribing if an observable.
|
||||
if (ko.isObservable(activate)) {
|
||||
this.autoDispose(activate.subscribeInit(this.activate, this));
|
||||
} else {
|
||||
this.activate(activate);
|
||||
}
|
||||
}
|
||||
exports.CommandGroup = CommandGroup;
|
||||
dispose.makeDisposable(CommandGroup);
|
||||
|
||||
/**
|
||||
* Just a shorthand for CommandGroup.create constructor.
|
||||
*/
|
||||
function createGroup(commands, context, activate) {
|
||||
return CommandGroup.create(commands, context, activate);
|
||||
}
|
||||
exports.createGroup = createGroup;
|
||||
|
||||
|
||||
/**
|
||||
* Activate or deactivate this implementation group.
|
||||
*/
|
||||
CommandGroup.prototype.activate = function(yesNo) {
|
||||
if (yesNo) {
|
||||
this._addGroup();
|
||||
} else {
|
||||
this._removeGroup();
|
||||
}
|
||||
};
|
||||
|
||||
CommandGroup.prototype._addGroup = function() {
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
// Add this CommandGroup to each key combination that it recognizes.
|
||||
for (var key in this.knownKeys) {
|
||||
(_allKeys[key] || (_allKeys[key] = [])).push(this);
|
||||
}
|
||||
// Add this CommandGroup to each command that it implements.
|
||||
for (var name in this.commands) {
|
||||
allCommands[name]._addGroup(this);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
CommandGroup.prototype._removeGroup = function() {
|
||||
if (this.isActive) {
|
||||
// On disposal, remove the CommandGroup from all the commands and keys.
|
||||
for (var key in this.knownKeys) {
|
||||
gutil.arrayRemove(_allKeys[key], this);
|
||||
}
|
||||
for (var name in this.commands) {
|
||||
allCommands[name]._removeGroup(this);
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach this CommandGroup to a DOM element, to allow it to accept key events, limiting them to
|
||||
* this group only. This is useful for inputs and textareas, where only a limited set of keyboard
|
||||
* shortcuts should be applicable and where by default mousetrap ignores shortcuts completely.
|
||||
*
|
||||
* See also stopCallback in app/client/lib/Mousetrap.js.
|
||||
*/
|
||||
CommandGroup.prototype.attach = dom.inlinable(function(elem) {
|
||||
Mousetrap.setCustomStopCallback(elem, (combo) => !this.knownKeys.hasOwnProperty(combo));
|
||||
});
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tie the button to an command listed in commandList.js, triggering the callback from the
|
||||
* currently active CommandLayer (if any), and showing a description and keyboard shortcuts in its
|
||||
* tooltip.
|
||||
*
|
||||
* You may use this inline while building dom, as in
|
||||
* dom('button', commands.setButtomCommand(dom, 'command'))
|
||||
*/
|
||||
exports.setButtonCommand = dom.inlinable(function(elem, commandName) {
|
||||
var cmd = allCommands[commandName];
|
||||
elem.setAttribute('title', cmd.getDesc());
|
||||
dom.on(elem, 'click', cmd.run);
|
||||
});
|
352
app/client/components/commands.ts
Normal file
352
app/client/components/commands.ts
Normal file
@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Commands are invoked by the user via keyboard shortcuts or mouse clicks, for example, to move
|
||||
* the cursor or to delete the selected records.
|
||||
*
|
||||
* This module provides APIs for other components to implement groups of commands. Any given
|
||||
* command may be implemented by different components, but at most one implementation of any
|
||||
* command is active at any time.
|
||||
*/
|
||||
|
||||
import * as Mousetrap from 'app/client/lib/Mousetrap';
|
||||
import { arrayRemove } from 'app/common/gutil';
|
||||
import dom from 'app/client/lib/dom';
|
||||
import 'app/client/lib/koUtil'; // for subscribeInit
|
||||
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
|
||||
import {CommandDef, CommandName, CommendGroupDef, groups} from 'app/client/components/commandList';
|
||||
|
||||
import {Disposable} from 'grainjs';
|
||||
import * as _ from 'underscore';
|
||||
import * as ko from 'knockout';
|
||||
|
||||
const G = getBrowserGlobals('window');
|
||||
type BoolLike = boolean|ko.Observable<boolean>|ko.Computed<boolean>;
|
||||
|
||||
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
|
||||
const isMac = (typeof navigator !== 'undefined' && navigator &&
|
||||
/Mac|iPod|iPhone|iPad/.test(navigator.platform));
|
||||
|
||||
/**
|
||||
* Globally-exposed map of command names to Command objects. E.g. typing "cmd.cursorDown.run()" in
|
||||
* the browser console should move the cursor down as long as it makes sense in the currently
|
||||
* shown view. If the command is inactive, its run() function is a no-op.
|
||||
*
|
||||
* See also Command object below.
|
||||
*/
|
||||
export const allCommands: { [key in CommandName]: Command } = {} as any;
|
||||
|
||||
/**
|
||||
* This is an internal variable, mapping key combinations to the stack of CommandGroups which
|
||||
* include them (see also CommandGroup.knownKeys). It's used for deciding which CommandGroup to
|
||||
* use when different Commands use the same key.
|
||||
*/
|
||||
const _allKeys: Record<string, CommandGroup[]> = {};
|
||||
|
||||
/**
|
||||
* Populate allCommands from those provided, or listed in commandList.js. Also populates the
|
||||
* globally exposed `cmd` object whose properties invoke commands: e.g. typing `cmd.cursorDown` in
|
||||
* the browser console will run allCommands.cursorDown.run().
|
||||
*/
|
||||
export function init(optCommandGroups?: CommendGroupDef[]) {
|
||||
const commandGroups = optCommandGroups || groups;
|
||||
|
||||
// Clear out the objects holding the global state.
|
||||
Object.keys(allCommands).forEach(function(c) {
|
||||
delete allCommands[c as CommandName];
|
||||
});
|
||||
Object.keys(_allKeys).forEach(function(k) {
|
||||
delete _allKeys[k as CommandName];
|
||||
});
|
||||
|
||||
commandGroups.forEach(function(commandGroup) {
|
||||
commandGroup.commands.forEach(function(c) {
|
||||
if (allCommands[c.name]) {
|
||||
console.error("Ignoring duplicate command %s in commandList", c.name);
|
||||
} else {
|
||||
allCommands[c.name] = new Command(c.name, c.desc, c.keys, c.deprecated);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Define the browser console interface.
|
||||
G.window.cmd = {};
|
||||
_.each(allCommands, function(cmd, name) {
|
||||
Object.defineProperty(G.window.cmd, name, {get: cmd.run});
|
||||
});
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
const KEY_MAP_MAC = {
|
||||
Mod: '⌘',
|
||||
Alt: '⌥',
|
||||
Shift: '⇧',
|
||||
Ctrl: '⌃',
|
||||
Left: '←',
|
||||
Right: '→',
|
||||
Up: '↑',
|
||||
Down: '↓',
|
||||
};
|
||||
|
||||
const KEY_MAP_WIN = {
|
||||
Mod: 'Ctrl',
|
||||
Left: '←',
|
||||
Right: '→',
|
||||
Up: '↑',
|
||||
Down: '↓',
|
||||
};
|
||||
|
||||
function getHumanKey(key: string, mac: boolean): string {
|
||||
const keyMap = mac ? KEY_MAP_MAC : KEY_MAP_WIN;
|
||||
let keys = key.split('+').map(s => s.trim());
|
||||
keys = keys.map(k => {
|
||||
if (k in keyMap) { return (keyMap as any)[k]; }
|
||||
if (k.length === 1) { return k.toUpperCase(); }
|
||||
return k;
|
||||
});
|
||||
return keys.join( mac ? '' : ' + ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Command represents a single command. It is exposed via the `allCommands` map.
|
||||
* @property {String} name: The name of the command, same as the key into the `allCommands` map.
|
||||
* @property {String} desc: The description of the command.
|
||||
* @property {Array} keys: The array of keyboard shortcuts for the command.
|
||||
* @property {Function} run: A bound function that will run the currently active implementation.
|
||||
* @property {Observable} isActive: Knockout observable for whether this command is active.
|
||||
*/
|
||||
export class Command implements CommandDef {
|
||||
public name: CommandName;
|
||||
public desc: string|null;
|
||||
public humanKeys: string[];
|
||||
public keys: string[];
|
||||
public isActive: ko.Observable<boolean>;
|
||||
public deprecated: boolean;
|
||||
public run: (...args: any[]) => any;
|
||||
private _implGroupStack: CommandGroup[] = [];
|
||||
private _activeFunc: (...args: any[]) => any = _.noop;
|
||||
|
||||
constructor(name: CommandName, desc: string|null, keys: string[], deprecated?: boolean) {
|
||||
this.name = name;
|
||||
this.desc = desc;
|
||||
this.humanKeys = keys.map(key => getHumanKey(key, isMac));
|
||||
this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); });
|
||||
this.isActive = ko.observable(false);
|
||||
this._implGroupStack = [];
|
||||
this._activeFunc = _.noop; // The function to run when this command is invoked.
|
||||
this.deprecated = deprecated || false;
|
||||
// Let .run bind the Command object, so that it can be used as a stand-alone callback.
|
||||
this.run = this._run.bind(this);
|
||||
}
|
||||
/**
|
||||
* Returns a comma-separated string of all keyboard shortcuts, or `null` if no
|
||||
* shortcuts exist.
|
||||
*/
|
||||
public getKeysDesc() {
|
||||
if (this.humanKeys.length === 0) { return null; }
|
||||
|
||||
return `(${this.humanKeys.join(', ')})`;
|
||||
}
|
||||
/**
|
||||
* Returns the text description for the command, including the keyboard shortcuts.
|
||||
*/
|
||||
public getDesc() {
|
||||
const parts = [this.desc];
|
||||
|
||||
const keysDesc = this.getKeysDesc();
|
||||
if (keysDesc) { parts.push(keysDesc); }
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
/**
|
||||
* Returns DOM for the keyboard shortcuts, wrapped in cute boxes that look like keyboard keys.
|
||||
*/
|
||||
public getKeysDom(separator?: ko.Observable<string>) {
|
||||
return dom('span.shortcut_keys',
|
||||
separator ? this.humanKeys.map((key, i) => [i ? separator() : null, dom('span.shortcut_key_image', key)])
|
||||
: this.humanKeys.map(key => dom('span.shortcut_key_image', key))
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Adds a CommandGroup that implements this Command to the top of the stack of groups.
|
||||
*/
|
||||
public addGroup(cmdGroup: CommandGroup) {
|
||||
this._implGroupStack.push(cmdGroup);
|
||||
this._updateActive();
|
||||
}
|
||||
/**
|
||||
* Removes a CommandGroup from the stack of groups implementing this Command.
|
||||
*/
|
||||
public removeGroup(cmdGroup: CommandGroup) {
|
||||
arrayRemove(this._implGroupStack, cmdGroup);
|
||||
this._updateActive();
|
||||
}
|
||||
/**
|
||||
* Updates the command's state to reflect the currently active group, if any.
|
||||
*/
|
||||
private _updateActive() {
|
||||
if (this._implGroupStack.length > 0) {
|
||||
this.isActive(true);
|
||||
this._activeFunc = _.last(this._implGroupStack)!.commands[this.name];
|
||||
} else {
|
||||
this.isActive(false);
|
||||
this._activeFunc = _.noop;
|
||||
}
|
||||
|
||||
// Now bind or unbind the affected key combinations.
|
||||
this.keys.forEach(function(key) {
|
||||
const keyGroups = _allKeys[key];
|
||||
if (keyGroups && keyGroups.length > 0) {
|
||||
const commandGroup = _.last(keyGroups)!;
|
||||
// Command name might be different from this.name in case we are deactivating a command, and
|
||||
// the previous meaning of the key points to a different command.
|
||||
const commandName = commandGroup.knownKeys[key];
|
||||
Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName]));
|
||||
} else {
|
||||
Mousetrap.unbind(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _run(...args: any[]) {
|
||||
return this._activeFunc(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for mousetrap callbacks, which returns a version of the callback that by default stops
|
||||
* the propagation of the keyboard event (unless the callback returns a true value).
|
||||
*/
|
||||
function wrapKeyCallback(callback: Func) {
|
||||
return function() {
|
||||
return callback(...arguments) || false;
|
||||
};
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
type Func = (...args: any[]) => any;
|
||||
type CommandMap = { [key in CommandName]?: Func };
|
||||
|
||||
/**
|
||||
* CommandGroup is the way for other components to provide implementations for a group of
|
||||
* commands. Note that CommandGroups are stacked, with groups activated later having priority over
|
||||
* groups activated earlier.
|
||||
* @param {String->Function} commands: The map of command names to implementations.
|
||||
* @param {Object} context: "this" context with which to invoke implementation functions.
|
||||
* @param {Boolean|Observable<boolean>} activate: Whether to activate this group immediately, false if
|
||||
* omitted. This may be an Observable.
|
||||
*/
|
||||
export class CommandGroup extends Disposable {
|
||||
public commands: Record<string, Func>;
|
||||
public isActive: boolean;
|
||||
public knownKeys: Record<string, string>;
|
||||
/**
|
||||
* Attach this CommandGroup to a DOM element, to allow it to accept key events, limiting them to
|
||||
* this group only. This is useful for inputs and textareas, where only a limited set of keyboard
|
||||
* shortcuts should be applicable and where by default mousetrap ignores shortcuts completely.
|
||||
*
|
||||
* See also stopCallback in app/client/lib/Mousetrap.js.
|
||||
*/
|
||||
public attach = dom.inlinable(function(this: any, elem: any) {
|
||||
Mousetrap.setCustomStopCallback(elem, (combo: any) => !this.knownKeys.hasOwnProperty(combo));
|
||||
});
|
||||
|
||||
constructor(commands: CommandMap, context: any, activate?: BoolLike) {
|
||||
super();
|
||||
// Keep only valid commands, so that we don't have to check for validity elsewhere, and bind
|
||||
// each to the passed-in context object.
|
||||
this.commands = {};
|
||||
this.isActive = false;
|
||||
|
||||
for (const name in commands) {
|
||||
if (allCommands[name as CommandName]) {
|
||||
this.commands[name] = commands[name as CommandName]!.bind(context);
|
||||
} else {
|
||||
console.warn("Ignoring unknown command %s", name);
|
||||
}
|
||||
}
|
||||
|
||||
// Map recognized key combinations to the corresponding command names.
|
||||
this.knownKeys = {};
|
||||
for (const name in this.commands) {
|
||||
const keys = allCommands[name as CommandName]!.keys;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
this.knownKeys[keys[i]] = name;
|
||||
}
|
||||
}
|
||||
|
||||
// On disposal, remove the CommandGroup from all the commands and keys.
|
||||
this.onDispose(this._removeGroup.bind(this));
|
||||
|
||||
// Finally, set the activation status of the command group, subscribing if an observable.
|
||||
if (ko.isObservable(activate)) {
|
||||
this.autoDispose((activate as any).subscribeInit(this.activate, this));
|
||||
} else {
|
||||
this.activate(activate as boolean);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate or deactivate this implementation group.
|
||||
*/
|
||||
public activate(yesNo: boolean) {
|
||||
if (yesNo) {
|
||||
this._addGroup();
|
||||
} else {
|
||||
this._removeGroup();
|
||||
}
|
||||
}
|
||||
|
||||
private _addGroup() {
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
// Add this CommandGroup to each key combination that it recognizes.
|
||||
for (const key in this.knownKeys) {
|
||||
(_allKeys[key] || (_allKeys[key] = [])).push(this);
|
||||
}
|
||||
// Add this CommandGroup to each command that it implements.
|
||||
for (const name in this.commands) {
|
||||
allCommands[name as CommandName]!.addGroup(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
private _removeGroup() {
|
||||
if (this.isActive) {
|
||||
// On disposal, remove the CommandGroup from all the commands and keys.
|
||||
for (const key in this.knownKeys) {
|
||||
arrayRemove(_allKeys[key], this);
|
||||
}
|
||||
for (const name in this.commands) {
|
||||
allCommands[name as CommandName]!.removeGroup(this);
|
||||
}
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type BoundedFunc<T> = (this: T, ...args: any[]) => any;
|
||||
type BoundedMap<T> = { [key in CommandName]?: BoundedFunc<T> };
|
||||
|
||||
/**
|
||||
* Just a shorthand for CommandGroup.create constructor.
|
||||
*/
|
||||
export function createGroup<T>(commands: BoundedMap<T>, context: T, activate?: BoolLike) {
|
||||
return CommandGroup.create(null, commands, context, activate);
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Tie the button to an command listed in commandList.js, triggering the callback from the
|
||||
* currently active CommandLayer (if any), and showing a description and keyboard shortcuts in its
|
||||
* tooltip.
|
||||
*
|
||||
* You may use this inline while building dom, as in
|
||||
* dom('button', commands.setButtonCommand(dom, 'command'))
|
||||
*/
|
||||
export const setButtonCommand = dom.inlinable(function(elem: Element, commandName: CommandName) {
|
||||
const cmd = allCommands[commandName]!;
|
||||
elem.setAttribute('title', cmd.getDesc());
|
||||
dom.on(elem, 'click', cmd.run);
|
||||
});
|
19
app/client/declarations.d.ts
vendored
19
app/client/declarations.d.ts
vendored
@ -4,7 +4,6 @@ declare module "app/client/components/CodeEditorPanel";
|
||||
declare module "app/client/components/DetailView";
|
||||
declare module "app/client/components/DocConfigTab";
|
||||
declare module "app/client/components/GridView";
|
||||
declare module "app/client/components/commandList";
|
||||
declare module "app/client/lib/Mousetrap";
|
||||
declare module "app/client/lib/browserGlobals";
|
||||
declare module "app/client/lib/dom";
|
||||
@ -108,24 +107,6 @@ declare module "app/client/components/ViewConfigTab" {
|
||||
export = ViewConfigTab;
|
||||
}
|
||||
|
||||
declare module "app/client/components/commands" {
|
||||
export class Command {
|
||||
public name: string;
|
||||
public deprecated: boolean;
|
||||
public desc: string;
|
||||
public humanKeys: string[];
|
||||
public keys: string[];
|
||||
public getDesc(): string;
|
||||
public getKeysDesc(): string;
|
||||
public run(): any;
|
||||
}
|
||||
|
||||
export type CommandsGroup = any;
|
||||
export const init: any;
|
||||
export const allCommands: any;
|
||||
export const createGroup: any;
|
||||
}
|
||||
|
||||
declare module "app/client/models/BaseRowModel" {
|
||||
import {Disposable} from 'app/client/lib/dispose';
|
||||
import TableModel from 'app/client/models/TableModel';
|
||||
|
@ -97,16 +97,16 @@ export class App extends DisposableWithEvents {
|
||||
dom('th', t("Description"))
|
||||
)
|
||||
),
|
||||
dom.forEach(commandList.groups, (group: any) => {
|
||||
const cmds = group.commands.filter((cmd: any) => Boolean(cmd.desc && cmd.keys.length && !cmd.deprecated));
|
||||
dom.forEach(commandList.groups, (group) => {
|
||||
const cmds = group.commands.filter((cmd) => Boolean(cmd.desc && cmd.keys.length && !cmd.deprecated));
|
||||
return cmds.length > 0 ?
|
||||
dom('tbody',
|
||||
dom('tr',
|
||||
dom('td', {colspan: 2}, group.group)
|
||||
dom('td', {colspan: '2'}, group.group)
|
||||
),
|
||||
dom.forEach(cmds, (cmd: any) =>
|
||||
dom.forEach(cmds, (cmd) =>
|
||||
dom('tr',
|
||||
dom('td', commands.allCommands[cmd.name].getKeysDom()),
|
||||
dom('td', commands.allCommands[cmd.name]!.getKeysDom()),
|
||||
dom('td', cmd.desc)
|
||||
)
|
||||
)
|
||||
|
Loading…
Reference in New Issue
Block a user