From cd7bf0fe018271b506a08fdbf87d16c7392795d8 Mon Sep 17 00:00:00 2001 From: coderaiser Date: Fri, 15 Jan 2021 21:12:11 +0200 Subject: [PATCH] feature(cloudcmd) add ability to toggle vim hotkes using Esc --- HELP.md | 2 +- client/client.js | 8 +- client/key/binder.js | 18 + client/key/index.js | 914 +++++++++++++++++++-------------------- client/key/index.spec.js | 65 +++ client/key/key.js | 1 + 6 files changed, 541 insertions(+), 467 deletions(-) create mode 100644 client/key/binder.js create mode 100644 client/key/index.spec.js diff --git a/HELP.md b/HELP.md index 7e7b4e59..6004d8af 100644 --- a/HELP.md +++ b/HELP.md @@ -201,7 +201,7 @@ Then, start the server again with `cloudcmd` and reload the page. | `Insert` | select current file (and move to next) | `F9` | context menu | `~` | console -| `Ctrl + Click` | open file on new tab +| `Esc` | toggle vim hot keys ### Vim diff --git a/client/client.js b/client/client.js index 0e0aaecf..6ccff85b 100644 --- a/client/client.js +++ b/client/client.js @@ -8,15 +8,15 @@ const rendy = require('rendy'); const load = require('load.js'); const tryToCatch = require('try-to-catch'); const {addSlashToEnd} = require('format-io'); - const pascalCase = require('just-pascal-case'); +const currify = require('currify'); + const isDev = process.env.NODE_ENV === 'development'; const Images = require('./dom/images'); const {unregisterSW} = require('./sw/register'); const getJsonFromFileTable = require('./get-json-from-file-table'); - -const currify = require('currify'); +const Key = require('./key'); const noJS = (a) => a.replace(/.js$/, ''); @@ -206,7 +206,7 @@ function CloudCmdProto(DOM) { }; const initModules = async () => { - CloudCmd.Key = new CloudCmd.Key(); + CloudCmd.Key = Key; CloudCmd.Key.bind(); const [, modules] = await tryToCatch(Files.get, 'modules'); diff --git a/client/key/binder.js b/client/key/binder.js new file mode 100644 index 00000000..6765f931 --- /dev/null +++ b/client/key/binder.js @@ -0,0 +1,18 @@ +'use strict'; + +module.exports.createBinder = () => { + let binded = false; + + return { + isBind() { + return binded; + }, + setBind() { + binded = true; + }, + unsetBind() { + binded = false; + }, + }; +}; + diff --git a/client/key/index.js b/client/key/index.js index ca2e7df3..e93d936a 100644 --- a/client/key/index.js +++ b/client/key/index.js @@ -7,495 +7,485 @@ const Info = DOM.CurrentInfo; const exec = require('execon'); const clipboard = require('@cloudcmd/clipboard'); -const Events = require('../dom/events'); const Buffer = require('../dom/buffer'); +const Events = require('../dom/events'); + const KEY = require('./key'); const vim = require('./vim'); const setCurrentByChar = require('./set-current-by-char'); +const {createBinder} = require('./binder'); + const fullstore = require('fullstore'); const Chars = fullstore(); +const isVimEnabled = fullstore(false); + +const toggleVim = (keyCode) => keyCode === KEY.ESC && isVimEnabled(!isVimEnabled()); Chars([]); -KeyProto.prototype = KEY; -CloudCmd.Key = KeyProto; -const {loadDir} = CloudCmd; +const {assign} = Object; -function KeyProto() { - let Binded; +const binder = createBinder(); +module.exports = assign(binder, KEY); +module.exports.bind = () => { + Events.addKey(listener, true); + binder.setBind(); +}; + +module.exports._listener = listener; + +function getChar(event) { + /* + * event.keyIdentifier deprecated in chrome v51 + * but event.key is absent in chrome <= v51 + */ - const Key = this; + const { + key, + shift, + keyCode, + keyIdentifier, + } = event; + const char = key || fromCharCode(keyIdentifier); + const symbol = getSymbol(shift, keyCode); - this.isBind = () => { - return Binded; - }; + return [symbol, char]; +} + +async function listener(event) { + const {keyCode} = event; - this.setBind = () => { - Binded = true; - }; + // strange chrome bug calles listener twice + // in second time event misses a lot fields + if (typeof event.altKey === 'undefined') + return; - this.unsetBind = () => { - Binded = false; - }; + const alt = event.altKey; + const ctrl = event.ctrlKey; + const meta = event.metaKey; + const isBetween = keyCode >= KEY.ZERO && keyCode <= KEY.Z; + const isNumpad = /Numpad/.test(event.code); - this.bind = () => { - Events.addKey(listener, true); - Binded = true; - }; + const [symbol, char] = getChar(event); - function getChar(event) { - /* - * event.keyIdentifier deprecated in chrome v51 - * but event.key is absent in chrome <= v51 - */ - - if (event.key) - return event.key; - - return fromCharCode(event.keyIdentifier); + if (!binder.isBind()) + return; + + toggleVim(keyCode); + const isVim = isVimEnabled() || CloudCmd.config('vim'); + + if (!isVim && !isNumpad && !alt && !ctrl && !meta && (isBetween || symbol)) + return setCurrentByChar(char, Chars); + + Chars([]); + await switchKey(event); + + if (keyCode >= KEY.F1 && keyCode <= KEY.F10) + return; + + if (isVim) + vim(char, event); +} + +function getSymbol(shift, keyCode) { + switch(keyCode) { + case KEY.DOT: + return '.'; + + case KEY.HYPHEN: + return shift ? '_' : '-'; + + case KEY.EQUAL: + return shift ? '+' : '='; } - async function listener(event) { - const {keyCode} = event; - - // strange chrome bug calles listener twice - // in second time event misses a lot fields - if (typeof event.altKey === 'undefined') - return; - - const alt = event.altKey; - const ctrl = event.ctrlKey; - const shift = event.shiftKey; - const meta = event.metaKey; - const isBetween = keyCode >= KEY.ZERO && keyCode <= KEY.Z; - const isNumpad = /Numpad/.test(event.code); - - let char = getChar(event); - let isSymbol = ['.', '_', '-', '+', '='].includes(char); - - if (!isSymbol) { - isSymbol = getSymbol(shift, keyCode); - - if (isSymbol) - char = isSymbol; - } - - if (!Key.isBind()) - return; - - const isVim = CloudCmd.config('vim'); - - if (!isVim && !isNumpad && !alt && !ctrl && !meta && (isBetween || isSymbol)) - return setCurrentByChar(char, Chars); - - Chars([]); - await switchKey(event); - - if (keyCode >= KEY.F1 && keyCode <= KEY.F10) - return; - - if (isVim) - vim(char, event); + return ''; +} + +function fromCharCode(keyIdentifier) { + const code = keyIdentifier.substring(2); + const hex = parseInt(code, 16); + const char = String.fromCharCode(hex); + + return char; +} + +async function switchKey(event) { + let i; + let isSelected; + let prev; + let next; + let current = Info.element; + let dataName; + + const { + name, + panel, + path, + isDir, + } = Info; + + const {Operation, loadDir} = CloudCmd; + const {keyCode} = event; + + const alt = event.altKey; + const shift = event.shiftKey; + const ctrl = event.ctrlKey; + const meta = event.metaKey; + const ctrlMeta = ctrl || meta; + + if (current) { + prev = current.previousSibling; + next = current.nextSibling; } - function getSymbol(shift, keyCode) { - switch(keyCode) { - case KEY.DOT: - return '.'; - - case KEY.HYPHEN: - return shift ? '_' : '-'; - - case KEY.EQUAL: - return shift ? '+' : '='; - } - } + switch(keyCode) { + case KEY.TAB: + DOM.changePanel(); + event.preventDefault(); + break; - function fromCharCode(keyIdentifier) { - const code = keyIdentifier.substring(2); - const hex = parseInt(code, 16); - const char = String.fromCharCode(hex); - - return char; - } + case KEY.INSERT: + DOM .toggleSelectedFile(current) + .setCurrentFile(next); + break; - async function switchKey(event) { - let i; - let isSelected; - let prev; - let next; - let current = Info.element; - let dataName; - - const { - name, - panel, - path, - isDir, - } = Info; - - const {Operation} = CloudCmd; - const {keyCode} = event; - - const alt = event.altKey; - const shift = event.shiftKey; - const ctrl = event.ctrlKey; - const meta = event.metaKey; - const ctrlMeta = ctrl || meta; - - if (current) { - prev = current.previousSibling; - next = current.nextSibling; - } - - switch(keyCode) { - case Key.TAB: - DOM.changePanel(); - event.preventDefault(); - break; - - case Key.INSERT: - DOM .toggleSelectedFile(current) - .setCurrentFile(next); - break; - - case Key.INSERT_MAC: - DOM .toggleSelectedFile(current) - .setCurrentFile(next); - break; - - case Key.DELETE: - if (shift) - Operation.show('delete:silent'); - else - Operation.show('delete'); - break; - - case Key.ASTERISK: - DOM.toggleAllSelectedFiles(current); - break; - - case Key.PLUS: - DOM.expandSelection(); - event.preventDefault(); - break; - - case Key.MINUS: - DOM.shrinkSelection(); - event.preventDefault(); - break; - - case Key.F1: - CloudCmd.Help.show(); - event.preventDefault(); - break; - - case Key.F2: - CloudCmd.UserMenu.show(); - break; - - case Key.F3: - event.preventDefault(); - - if (Info.isDir) - await loadDir({ - path, - }); - else if (shift) - CloudCmd.Markdown.show(path); - else if (ctrlMeta) - CloudCmd.sortPanel('name'); - else - CloudCmd.View.show(); - - break; - - case Key.F4: - if (shift) - CloudCmd.EditFileVim.show(); - else - CloudCmd.EditFile.show(); - - event.preventDefault(); - break; - - case Key.F5: - if (ctrlMeta) - CloudCmd.sortPanel('date'); - else if (alt) - Operation.show('pack'); - else - Operation.show('copy'); - - event.preventDefault(); - break; - - case Key.F6: - if (ctrlMeta) - CloudCmd.sortPanel('size'); - else if (shift) - DOM.renameCurrent(current); - else - Operation.show('move'); - - event.preventDefault(); - break; - - case Key.F7: - if (shift) - DOM.promptNewFile(); - else - DOM.promptNewDir(); - - event.preventDefault(); - break; - - case Key.F8: + case KEY.INSERT_MAC: + DOM .toggleSelectedFile(current) + .setCurrentFile(next); + break; + + case KEY.DELETE: + if (shift) + Operation.show('delete:silent'); + else Operation.show('delete'); - event.preventDefault(); - break; + break; + + case KEY.ASTERISK: + DOM.toggleAllSelectedFiles(current); + break; + + case KEY.PLUS: + DOM.expandSelection(); + event.preventDefault(); + break; + + case KEY.MINUS: + DOM.shrinkSelection(); + event.preventDefault(); + break; + + case KEY.F1: + CloudCmd.Help.show(); + event.preventDefault(); + break; + + case KEY.F2: + CloudCmd.UserMenu.show(); + break; + + case KEY.F3: + event.preventDefault(); - case Key.F9: - if (alt) - Operation.show('extract'); - else - CloudCmd.Menu.show(); - event.preventDefault(); - break; - - case Key.F10: - CloudCmd.Config.show(); - event.preventDefault(); - break; - - case Key.TRA: - event.preventDefault(); - - if (shift) - return CloudCmd.Terminal.show(); - - CloudCmd.Konsole.show(); - break; - - case KEY.BRACKET_CLOSE: - CloudCmd.Konsole.show(); - event.preventDefault(); - break; - - case Key.SPACE: - if (!isDir || name === '..') - isSelected = true; - else - isSelected = DOM.isSelected(current); - - exec.if(isSelected, () => { - DOM.toggleSelectedFile(current); - }, (callback) => { - DOM.loadCurrentSize(current, callback); + if (Info.isDir) + await loadDir({ + path, }); - + else if (shift) + CloudCmd.Markdown.show(path); + else if (ctrlMeta) + CloudCmd.sortPanel('name'); + else + CloudCmd.View.show(); + + break; + + case KEY.F4: + if (shift) + CloudCmd.EditFileVim.show(); + else + CloudCmd.EditFile.show(); + + event.preventDefault(); + break; + + case KEY.F5: + if (ctrlMeta) + CloudCmd.sortPanel('date'); + else if (alt) + Operation.show('pack'); + else + Operation.show('copy'); + + event.preventDefault(); + break; + + case KEY.F6: + if (ctrlMeta) + CloudCmd.sortPanel('size'); + else if (shift) + DOM.renameCurrent(current); + else + Operation.show('move'); + + event.preventDefault(); + break; + + case KEY.F7: + if (shift) + DOM.promptNewFile(); + else + DOM.promptNewDir(); + + event.preventDefault(); + break; + + case KEY.F8: + Operation.show('delete'); + event.preventDefault(); + break; + + case KEY.F9: + if (alt) + Operation.show('extract'); + else + CloudCmd.Menu.show(); + event.preventDefault(); + break; + + case KEY.F10: + CloudCmd.Config.show(); + event.preventDefault(); + break; + + case KEY.TRA: + event.preventDefault(); + + if (shift) + return CloudCmd.Terminal.show(); + + CloudCmd.Konsole.show(); + break; + + case KEY.BRACKET_CLOSE: + CloudCmd.Konsole.show(); + event.preventDefault(); + break; + + case KEY.SPACE: + if (!isDir || name === '..') + isSelected = true; + else + isSelected = DOM.isSelected(current); + + exec.if(isSelected, () => { + DOM.toggleSelectedFile(current); + }, (callback) => { + DOM.loadCurrentSize(current, callback); + }); + + event.preventDefault(); + break; + + case KEY.U: + if (ctrlMeta) { + DOM.swapPanels(); event.preventDefault(); - break; - - case Key.U: - if (ctrlMeta) { - DOM.swapPanels(); - event.preventDefault(); - } - break; - - /* navigation on file table: * - * in case of pressing button 'up', * - * select previous row */ - case Key.UP: - if (shift) - DOM.toggleSelectedFile(current); - - DOM.setCurrentFile(prev); - event.preventDefault(); - break; - - /* in case of pressing button 'down', * - * select next row */ - case Key.DOWN: - if (shift) - DOM.toggleSelectedFile(current); - - DOM.setCurrentFile(next); - event.preventDefault(); - break; - - case Key.LEFT: - if (!alt) - return; - - event.preventDefault(); - - dataName = Info.panel.getAttribute('data-name'); - - if (dataName === 'js-right') - DOM.duplicatePanel(); - - break; - - case Key.RIGHT: - if (!alt) - return; - - event.preventDefault(); - - dataName = Info.panel.getAttribute('data-name'); - - if (dataName === 'js-left') - DOM.duplicatePanel(); - - break; - - /* in case of pressing button 'Home', * - * go to top element */ - case Key.HOME: - DOM.setCurrentFile(Info.first); - event.preventDefault(); - break; - - /* in case of pressing button 'End', select last element */ - case Key.END: - DOM.setCurrentFile(Info.last); - event.preventDefault(); - break; - - /* если нажали клавишу page down проматываем экран */ - case Key.PAGE_DOWN: - DOM.scrollByPages(panel, 1); - - for (i = 0; i < 30; i++) { - if (!current.nextSibling) - break; - - current = current.nextSibling; - } - - DOM.setCurrentFile(current); - event.preventDefault(); - break; - - /* если нажали клавишу page up проматываем экран */ - case Key.PAGE_UP: - DOM.scrollByPages(panel, -1); - - for (i = 0; i < 30; i++) { - if (!current.previousSibling) - break; - - current = current.previousSibling; - } - - DOM.setCurrentFile(current); - event.preventDefault(); - break; - - case Key.ENTER: - if (Info.isDir) - await loadDir({path}); - else - CloudCmd.View.show(); - break; - - case Key.BACKSPACE: - CloudCmd.goToParentDir(); - event.preventDefault(); - break; - - case Key.BACKSLASH: - if (ctrlMeta) - await loadDir({ - path: '/', - }); - break; - - case Key.A: - if (ctrlMeta) { - DOM.selectAllFiles(); - event.preventDefault(); - } - - break; - - case Key.G: - if (alt) { - DOM.goToDirectory(); - event.preventDefault(); - } - - break; - - case Key.M: - if (ctrlMeta) { - if (shift) - CloudCmd.EditNamesVim.show(); - else - CloudCmd.EditNames.show(); - - event.preventDefault(); - } - - break; - - case Key.P: - if (!ctrlMeta) - return; - - event.preventDefault(); - clipboard - .writeText(Info.dirPath) - .catch(CloudCmd.log); - - break; - /** - * обновляем страницу, - * загружаем содержимое каталога - * при этом данные берём всегда с - * сервера, а не из кэша - * (обновляем кэш) - */ - case Key.R: - if (ctrlMeta) { - CloudCmd.log('reloading page...\n'); - CloudCmd.refresh(); - event.preventDefault(); - } - break; - - case Key.C: - if (ctrlMeta) - Buffer.copy(); - break; - - case Key.X: - if (ctrlMeta) - Buffer.cut(); - break; - - case Key.V: - if (ctrlMeta) - Buffer.paste(); - break; - - case Key.Z: - if (ctrlMeta) - Buffer.clear(); - break; - - /* чистим хранилище */ - case Key.D: - if (ctrlMeta) { - CloudCmd.log('clearing storage...'); - await DOM.Storage.clear(); - CloudCmd.log('storage cleared'); - event.preventDefault(); - } - break; } + break; + + /* navigation on file table: * + * in case of pressing button 'up', * + * select previous row */ + case KEY.UP: + if (shift) + DOM.toggleSelectedFile(current); + + DOM.setCurrentFile(prev); + event.preventDefault(); + break; + + /* in case of pressing button 'down', * + * select next row */ + case KEY.DOWN: + if (shift) + DOM.toggleSelectedFile(current); + + DOM.setCurrentFile(next); + event.preventDefault(); + break; + + case KEY.LEFT: + if (!alt) + return; + + event.preventDefault(); + + dataName = Info.panel.getAttribute('data-name'); + + if (dataName === 'js-right') + DOM.duplicatePanel(); + + break; + + case KEY.RIGHT: + if (!alt) + return; + + event.preventDefault(); + + dataName = Info.panel.getAttribute('data-name'); + + if (dataName === 'js-left') + DOM.duplicatePanel(); + + break; + + /* in case of pressing button 'Home', * + * go to top element */ + case KEY.HOME: + DOM.setCurrentFile(Info.first); + event.preventDefault(); + break; + + /* in case of pressing button 'End', select last element */ + case KEY.END: + DOM.setCurrentFile(Info.last); + event.preventDefault(); + break; + + /* если нажали клавишу page down проматываем экран */ + case KEY.PAGE_DOWN: + DOM.scrollByPages(panel, 1); + + for (i = 0; i < 30; i++) { + if (!current.nextSibling) + break; + + current = current.nextSibling; + } + + DOM.setCurrentFile(current); + event.preventDefault(); + break; + + /* если нажали клавишу page up проматываем экран */ + case KEY.PAGE_UP: + DOM.scrollByPages(panel, -1); + + for (i = 0; i < 30; i++) { + if (!current.previousSibling) + break; + + current = current.previousSibling; + } + + DOM.setCurrentFile(current); + event.preventDefault(); + break; + + case KEY.ENTER: + if (Info.isDir) + await loadDir({path}); + else + CloudCmd.View.show(); + break; + + case KEY.BACKSPACE: + CloudCmd.goToParentDir(); + event.preventDefault(); + break; + + case KEY.BACKSLASH: + if (ctrlMeta) + await loadDir({ + path: '/', + }); + break; + + case KEY.A: + if (ctrlMeta) { + DOM.selectAllFiles(); + event.preventDefault(); + } + + break; + + case KEY.G: + if (alt) { + DOM.goToDirectory(); + event.preventDefault(); + } + + break; + + case KEY.M: + if (ctrlMeta) { + if (shift) + CloudCmd.EditNamesVim.show(); + else + CloudCmd.EditNames.show(); + + event.preventDefault(); + } + + break; + + case KEY.P: + if (!ctrlMeta) + return; + + event.preventDefault(); + clipboard + .writeText(Info.dirPath) + .catch(CloudCmd.log); + + break; + /** + * обновляем страницу, + * загружаем содержимое каталога + * при этом данные берём всегда с + * сервера, а не из кэша + * (обновляем кэш) + */ + case KEY.R: + if (ctrlMeta) { + CloudCmd.log('reloading page...\n'); + CloudCmd.refresh(); + event.preventDefault(); + } + break; + + case KEY.C: + if (ctrlMeta) + Buffer.copy(); + break; + + case KEY.X: + if (ctrlMeta) + Buffer.cut(); + break; + + case KEY.V: + if (ctrlMeta) + Buffer.paste(); + break; + + case KEY.Z: + if (ctrlMeta) + Buffer.clear(); + break; + + /* чистим хранилище */ + case KEY.D: + if (ctrlMeta) { + CloudCmd.log('clearing storage...'); + await DOM.Storage.clear(); + CloudCmd.log('storage cleared'); + event.preventDefault(); + } + break; } } diff --git a/client/key/index.spec.js b/client/key/index.spec.js new file mode 100644 index 00000000..2a61660c --- /dev/null +++ b/client/key/index.spec.js @@ -0,0 +1,65 @@ +'use strict'; + +const autoGlobals = require('auto-globals'); +const test = autoGlobals(require('supertape')); +const stub = require('@cloudcmd/stub'); +const mockRequire = require('mock-require'); +const {reRequire, stopAll} = mockRequire; + +const {ESC} = require('./key'); + +const { + getDOM, + getCloudCmd, +} = require('./vim/globals.fixture'); + +global.DOM = getDOM(); +global.CloudCmd = getCloudCmd(); + +test('cloudcmd: client: key: enable vim', async (t) => { + const vim = stub(); + + mockRequire('./vim', vim); + const {_listener, setBind} = reRequire('.'); + + const event = { + keyCode: ESC, + key: 'Escape', + altKey: false, + }; + + setBind(); + await _listener(event); + + stopAll(); + + t.calledWith(vim, ['Escape', event]); + t.end(); +}); + +test('cloudcmd: client: key: disable vim', async (t) => { + const vim = stub(); + const _config = stub(); + + const {_listener, setBind} = reRequire('.'); + + const event = { + keyCode: ESC, + key: 'Escape', + altKey: false, + }; + + const {CloudCmd} = global; + const {config} = CloudCmd; + CloudCmd.config = _config; + + setBind(); + await _listener(event); + await _listener(event); + + CloudCmd.config = config; + + t.calledWith(_config, ['vim']); + t.end(); +}); + diff --git a/client/key/key.js b/client/key/key.js index 8db26d12..0ca7963f 100644 --- a/client/key/key.js +++ b/client/key/key.js @@ -4,6 +4,7 @@ module.exports = { BACKSPACE : 8, TAB : 9, ENTER : 13, + CAPSLOCK : 20, ESC : 27, SPACE : 32,