From b4c4a62a7340d1cea34be07c40b791222aa487e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Fri, 21 Apr 2023 13:31:10 +0200 Subject: [PATCH] (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 --- app/client/components/GristDoc.ts | 8 +- .../{commandList.js => commandList.ts} | 121 +++++- app/client/components/commands.js | 342 ----------------- app/client/components/commands.ts | 352 ++++++++++++++++++ app/client/declarations.d.ts | 19 - app/client/ui/App.ts | 10 +- 6 files changed, 481 insertions(+), 371 deletions(-) rename app/client/components/{commandList.js => commandList.ts} (85%) delete mode 100644 app/client/components/commands.js create mode 100644 app/client/components/commands.ts diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 5fe7744f..0009a207 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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, diff --git a/app/client/components/commandList.js b/app/client/components/commandList.ts similarity index 85% rename from app/client/components/commandList.js rename to app/client/components/commandList.ts index b0159fa9..09b88142 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.ts @@ -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: [ { diff --git a/app/client/components/commands.js b/app/client/components/commands.js deleted file mode 100644 index 4cd6197f..00000000 --- a/app/client/components/commands.js +++ /dev/null @@ -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} 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); -}); diff --git a/app/client/components/commands.ts b/app/client/components/commands.ts new file mode 100644 index 00000000..4042ac12 --- /dev/null +++ b/app/client/components/commands.ts @@ -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|ko.Computed; + +// 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 = {}; + +/** + * 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; + 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) { + 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} activate: Whether to activate this group immediately, false if + * omitted. This may be an Observable. + */ +export class CommandGroup extends Disposable { + public commands: Record; + public isActive: boolean; + public knownKeys: Record; + /** + * 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 = (this: T, ...args: any[]) => any; +type BoundedMap = { [key in CommandName]?: BoundedFunc }; + +/** + * Just a shorthand for CommandGroup.create constructor. + */ +export function createGroup(commands: BoundedMap, 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); +}); diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index 719910b4..56bfca96 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -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'; diff --git a/app/client/ui/App.ts b/app/client/ui/App.ts index 32dc1200..cd5f323c 100644 --- a/app/client/ui/App.ts +++ b/app/client/ui/App.ts @@ -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) ) )