diff --git a/.meteor/packages b/.meteor/packages index 92234d4e6..1aa8f209e 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -55,9 +55,6 @@ http@2.0.0! # force new http package # UI components ostrio:i18n reactive-var@1.0.12 -mousetrap:mousetrap -mquandalle:jquery-textcomplete -mquandalle:mousetrap-bindglobal templates:tabs meteor-autosize shell-server@0.5.0 diff --git a/.meteor/versions b/.meteor/versions index 3487f7653..e60f1d201 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -80,13 +80,10 @@ mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 mongo-livedata@1.0.12 -mousetrap:mousetrap@1.4.6_1 mquandalle:autofocus@1.0.0 mquandalle:collection-mutations@0.1.0 mquandalle:jade@0.4.9 mquandalle:jade-compiler@0.4.5 -mquandalle:jquery-textcomplete@0.8.0_1 -mquandalle:mousetrap-bindglobal@0.0.1 msavin:usercache@1.8.0 npm-mongo@4.17.2 oauth@2.2.1 diff --git a/client/lib/escapeActions.js b/client/lib/escapeActions.js index 986611326..e76221074 100644 --- a/client/lib/escapeActions.js +++ b/client/lib/escapeActions.js @@ -1,4 +1,6 @@ -// Pressing `Escape` should close the last opened “element” and only the last +const hotkeys = require('hotkeys-js').default; + +// Pressing `Escape` should close the last opened "element" and only the last // one. Components can register themselves using a label a condition, and an // action. This is used by Popup or inlinedForm for instance. When we press // escape we execute the action which have a valid condition and his the highest @@ -119,9 +121,9 @@ EscapeActions = { }, }; -// Pressing escape to execute one escape action. We use `bindGloabal` vecause -// the shortcut sould work on textarea and inputs as well. -Mousetrap.bindGlobal('esc', () => { +// Pressing escape to execute one escape action. ESC is allowed globally +// in the hotkeys filter (keyboard.js) so it works in textarea and inputs. +hotkeys('escape', () => { EscapeActions.executeLowest(); Sidebar.hide(); }); diff --git a/client/lib/keyboard.js b/client/lib/keyboard.js index e557cd186..e06a6463e 100644 --- a/client/lib/keyboard.js +++ b/client/lib/keyboard.js @@ -1,9 +1,42 @@ import { ReactiveCache } from '/imports/reactiveCache'; import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; +const hotkeys = require('hotkeys-js').default; // XXX There is no reason to define these shortcuts globally, they should be // attached to a template (most of them will go in the `board` template). +// Configure hotkeys filter (replaces Mousetrap.stopCallback) +// CRITICAL: Return values are INVERTED from Mousetrap's stopCallback +// hotkeys filter: true = ALLOW shortcut, false = STOP shortcut +hotkeys.filter = (event) => { + // Are shortcuts enabled for the user? + if (ReactiveCache.getCurrentUser() && !ReactiveCache.getCurrentUser().isKeyboardShortcuts()) + return false; + + // Always handle escape + if (event.keyCode === 27) + return true; + + // Make sure there are no selected characters + if (window.getSelection().type === "Range") + return false; + + // Decide what the current element is + const currentElement = event.target || document.activeElement; + + // If the current element is editable, we don't want to trigger an event + if (currentElement.isContentEditable) + return false; + + // Make sure we are not in an input element + if (currentElement instanceof HTMLInputElement || currentElement instanceof HTMLSelectElement || currentElement instanceof HTMLTextAreaElement) + return false; + + // We can trigger events! + return true; +}; + +// Handle non-Latin keyboards window.addEventListener('keydown', (e) => { // Only handle event if coming from body if (e.target !== document.body) return; @@ -11,39 +44,19 @@ window.addEventListener('keydown', (e) => { // Only handle event if it's in another language if (String.fromCharCode(e.which).toLowerCase() === e.key) return; - // Trigger the corresponding action - Mousetrap.handleKey(String.fromCharCode(e.which).toLowerCase(), [], {type: "keypress"}); + // Trigger the corresponding action by dispatching a new event with the ASCII key + const key = String.fromCharCode(e.which).toLowerCase(); + // Create a synthetic event for hotkeys to handle + const syntheticEvent = new KeyboardEvent('keydown', { + key: key, + keyCode: e.which, + which: e.which, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(syntheticEvent); }); -// Overwrite the stopCallback to allow for more keyboard shortcut customizations -Mousetrap.stopCallback = (event, element) => { - // Are shortcuts enabled for the user? - if (ReactiveCache.getCurrentUser() && !ReactiveCache.getCurrentUser().isKeyboardShortcuts()) - return true; - - // Always handle escape - if (event.keyCode === 27) - return false; - - // Make sure there are no selected characters - if (window.getSelection().type === "Range") - return true; - - // Decide what the current element is - const currentElement = event.target || document.activeElement; - - // If the current element is editable, we don't want to trigger an event - if (currentElement.isContentEditable) - return true; - - // Make sure we are not in an input element - if (currentElement instanceof HTMLInputElement || currentElement instanceof HTMLSelectElement || currentElement instanceof HTMLTextAreaElement) - return true; - - // We can trigger events! - return false; -} - function getHoveredCardId() { const card = $('.js-minicard:hover').get(0); if (!card) return null; @@ -54,11 +67,13 @@ function getSelectedCardId() { return Session.get('currentCard') || Session.get('selectedCard') || getHoveredCardId(); } -Mousetrap.bind('?', () => { +hotkeys('?', (event) => { + event.preventDefault(); FlowRouter.go('shortcuts'); }); -Mousetrap.bind('w', () => { +hotkeys('w', (event) => { + event.preventDefault(); if (Sidebar.isOpen() && Sidebar.getView() === 'home') { Sidebar.toggle(); } else { @@ -66,7 +81,8 @@ Mousetrap.bind('w', () => { } }); -Mousetrap.bind('q', () => { +hotkeys('q', (event) => { + event.preventDefault(); const currentBoardId = Session.get('currentBoard'); const currentUserId = Meteor.userId(); if (currentBoardId && currentUserId) { @@ -74,7 +90,8 @@ Mousetrap.bind('q', () => { } }); -Mousetrap.bind('a', () => { +hotkeys('a', (event) => { + event.preventDefault(); const currentBoardId = Session.get('currentBoard'); const currentUserId = Meteor.userId(); if (currentBoardId && currentUserId) { @@ -82,13 +99,15 @@ Mousetrap.bind('a', () => { } }); -Mousetrap.bind('x', () => { +hotkeys('x', (event) => { + event.preventDefault(); if (Filter.isActive()) { Filter.reset(); } }); -Mousetrap.bind('f', () => { +hotkeys('f', (event) => { + event.preventDefault(); if (Sidebar.isOpen() && Sidebar.getView() === 'filter') { Sidebar.toggle(); } else { @@ -96,7 +115,8 @@ Mousetrap.bind('f', () => { } }); -Mousetrap.bind('/', () => { +hotkeys('/', (event) => { + event.preventDefault(); if (Sidebar.isOpen() && Sidebar.getView() === 'search') { Sidebar.toggle(); } else { @@ -104,12 +124,13 @@ Mousetrap.bind('/', () => { } }); -Mousetrap.bind(['down', 'up'], (evt, key) => { +hotkeys('down,up', (event, handler) => { + event.preventDefault(); if (!Utils.getCurrentCardId()) { return; } - const nextFunc = key === 'down' ? 'next' : 'prev'; + const nextFunc = handler.key === 'down' ? 'next' : 'prev'; const nextCard = $('.js-minicard.is-selected') [nextFunc]('.js-minicard') .get(0); @@ -119,49 +140,47 @@ Mousetrap.bind(['down', 'up'], (evt, key) => { } }); -numbArray = _.range(1,10).map(x => 'shift+'+String(x)) -Mousetrap.bind(numbArray, (evt, key) => { - num = parseInt(key.substr(6, key.length)); +// Shift + number keys to remove labels in multiselect +const shiftNums = _.range(1, 10).map(x => `shift+${x}`).join(','); +hotkeys(shiftNums, (event, handler) => { + event.preventDefault(); + const num = parseInt(handler.key.split('+')[1]); const currentUserId = Meteor.userId(); if (currentUserId === null) { return; } const currentBoardId = Session.get('currentBoard'); - board = ReactiveCache.getBoard(currentBoardId); - labels = board.labels; - if(MultiSelection.isActive()) - { + const board = ReactiveCache.getBoard(currentBoardId); + const labels = board.labels; + if (MultiSelection.isActive()) { const cardIds = MultiSelection.getSelectedCardIds(); - for (const cardId of cardIds) - { - card = Cards.findOne(cardId); - if(num <= board.labels.length) - { - card.removeLabel(labels[num-1]["_id"]); + for (const cardId of cardIds) { + const card = Cards.findOne(cardId); + if (num <= board.labels.length) { + card.removeLabel(labels[num - 1]["_id"]); } } } }); -numArray = _.range(1,10).map(x => String(x)) -Mousetrap.bind(numArray, (evt, key) => { - num = parseInt(key); +// Number keys to toggle labels +const nums = _.range(1, 10).join(','); +hotkeys(nums, (event, handler) => { + event.preventDefault(); + const num = parseInt(handler.key); const currentUserId = Meteor.userId(); const currentBoardId = Session.get('currentBoard'); if (currentUserId === null) { return; } - board = ReactiveCache.getBoard(currentBoardId); - labels = board.labels; - if(MultiSelection.isActive() && ReactiveCache.getCurrentUser().isBoardMember()) - { + const board = ReactiveCache.getBoard(currentBoardId); + const labels = board.labels; + if (MultiSelection.isActive() && ReactiveCache.getCurrentUser().isBoardMember()) { const cardIds = MultiSelection.getSelectedCardIds(); - for (const cardId of cardIds) - { - card = Cards.findOne(cardId); - if(num <= board.labels.length) - { - card.addLabel(labels[num-1]["_id"]); + for (const cardId of cardIds) { + const card = Cards.findOne(cardId); + if (num <= board.labels.length) { + card.addLabel(labels[num - 1]["_id"]); } } return; @@ -173,14 +192,16 @@ Mousetrap.bind(numArray, (evt, key) => { } if (ReactiveCache.getCurrentUser().isBoardMember()) { const card = Cards.findOne(cardId); - if(num <= board.labels.length) - { - card.toggleLabel(labels[num-1]["_id"]); + if (num <= board.labels.length) { + card.toggleLabel(labels[num - 1]["_id"]); } } }); -Mousetrap.bind(_.range(1, 10).map(x => `ctrl+alt+${x}`), (evt, key) => { +// Ctrl+Alt + number keys to toggle assignees +const ctrlAltNums = _.range(1, 10).map(x => `ctrl+alt+${x}`).join(','); +hotkeys(ctrlAltNums, (event, handler) => { + event.preventDefault(); // Make sure the current user is defined if (!ReactiveCache.getCurrentUser()) return; @@ -189,7 +210,7 @@ Mousetrap.bind(_.range(1, 10).map(x => `ctrl+alt+${x}`), (evt, key) => { if (!ReactiveCache.getCurrentUser().isBoardMember()) return; - const memberIndex = parseInt(key.split("+").pop()) - 1; + const memberIndex = parseInt(handler.key.split("+").pop()) - 1; const currentBoard = Utils.getCurrentBoard(); const validBoardMembers = currentBoard.memberUsers().filter(member => member.isBoardMember()); @@ -211,7 +232,8 @@ Mousetrap.bind(_.range(1, 10).map(x => `ctrl+alt+${x}`), (evt, key) => { } }); -Mousetrap.bind('m', evt => { +hotkeys('m', (event) => { + event.preventDefault(); const cardId = getSelectedCardId(); if (!cardId) { return; @@ -225,13 +247,11 @@ Mousetrap.bind('m', evt => { if (ReactiveCache.getCurrentUser().isBoardMember()) { const card = Cards.findOne(cardId); card.toggleAssignee(currentUserId); - // We should prevent scrolling in card when spacebar is clicked - // This should do it according to Mousetrap docs, but it doesn't - evt.preventDefault(); } }); -Mousetrap.bind('space', evt => { +hotkeys('space', (event) => { + event.preventDefault(); const cardId = getSelectedCardId(); if (!cardId) { return; @@ -245,13 +265,11 @@ Mousetrap.bind('space', evt => { if (ReactiveCache.getCurrentUser().isBoardMember()) { const card = Cards.findOne(cardId); card.toggleMember(currentUserId); - // We should prevent scrolling in card when spacebar is clicked - // This should do it according to Mousetrap docs, but it doesn't - evt.preventDefault(); } }); -const archiveCard = evt => { +const archiveCard = (event) => { + event.preventDefault(); const cardId = getSelectedCardId(); if (!cardId) { return; @@ -265,21 +283,19 @@ const archiveCard = evt => { if (Utils.canModifyBoard()) { const card = Cards.findOne(cardId); card.archive(); - // We should prevent scrolling in card when spacebar is clicked - // This should do it according to Mousetrap docs, but it doesn't - evt.preventDefault(); } }; // Archive card has multiple shortcuts -Mousetrap.bind('c', archiveCard); -Mousetrap.bind('-', archiveCard); +hotkeys('c', archiveCard); +hotkeys('-', archiveCard); // Same as above, this time for Persian keyboard. // https://github.com/wekan/wekan/pull/5589#issuecomment-2516776519 -Mousetrap.bind('÷', archiveCard); +hotkeys('\xf7', archiveCard); -Mousetrap.bind('n', evt => { +hotkeys('n', (event) => { + event.preventDefault(); const cardId = getSelectedCardId(); if (!cardId) { return; @@ -296,10 +312,6 @@ Mousetrap.bind('n', evt => { // Find the button and click it $(`#js-list-${card.listId} .list-body .minicards .open-minicard-composer`).click(); - - // We should prevent scrolling in card when spacebar is clicked - // This should do it according to Mousetrap docs, but it doesn't - evt.preventDefault(); } }); @@ -354,7 +366,7 @@ Template.keyboardShortcuts.helpers({ action: 'shortcut-assign-self', }, { - keys: ['c', '÷', '-'], + keys: ['c', '\xf7', '-'], action: 'archive-card', }, { diff --git a/client/lib/textComplete.js b/client/lib/textComplete.js index fe1864e3c..8a231b859 100644 --- a/client/lib/textComplete.js +++ b/client/lib/textComplete.js @@ -1,54 +1,80 @@ -// We “inherit” the jquery-textcomplete plugin to integrate with our -// EscapeActions system. You should always use `escapeableTextComplete` instead -// of the vanilla `textcomplete`. +// We use @textcomplete packages to integrate with our EscapeActions system. +// You should always use `createEscapeableTextComplete` or the jQuery extension +// `escapeableTextComplete` instead of the vanilla textcomplete. +import { Textcomplete } from '@textcomplete/core'; +import { TextareaEditor } from '@textcomplete/textarea'; +import { ContenteditableEditor } from '@textcomplete/contenteditable'; + let dropdownMenuIsOpened = false; -$.fn.escapeableTextComplete = function(strategies, options, ...otherArgs) { - // When the autocomplete menu is shown we want both a press of both `Tab` - // or `Enter` to validation the auto-completion. We also need to stop the - // event propagation to prevent EscapeActions side effect, for instance the - // minicard submission (on `Enter`) or going on the next column (on `Tab`). - options = { - onKeydown(evt, commands) { - if (evt.keyCode === 9 || evt.keyCode === 13) { - evt.stopPropagation(); - return commands.KEY_ENTER; - } - return null; +/** + * Create an escapeable textcomplete instance for a textarea or contenteditable element + * @param {HTMLTextAreaElement|HTMLElement} element - The target element + * @param {Array} strategies - Array of strategy objects + * @param {Object} options - Additional options + * @returns {Textcomplete} The textcomplete instance + */ +export function createEscapeableTextComplete(element, strategies, options = {}) { + // Determine the appropriate editor based on element type + const isContentEditable = element.isContentEditable || element.contentEditable === 'true'; + const Editor = isContentEditable ? ContenteditableEditor : TextareaEditor; + + const editor = new Editor(element); + + // Merge default options + const mergedOptions = { + dropdown: { + className: 'textcomplete-dropdown', + maxCount: 10, + placement: 'bottom', + ...options.dropdown, }, - ...options, }; - // Proxy to the vanilla jQuery component - this.textcomplete(strategies, options, ...otherArgs); + const textcomplete = new Textcomplete(editor, strategies, mergedOptions); - // Since commit d474017 jquery-textComplete automatically closes a potential - // opened dropdown menu when the user press Escape. This behavior conflicts - // with our EscapeActions system, but it's too complicated and hacky to - // monkey-pach textComplete to disable it -- I tried. Instead we listen to - // 'open' and 'hide' events, and create a ghost escapeAction when the dropdown - // is opened (and rely on textComplete to execute the actual action). - this.on({ - 'textComplete:show'() { - dropdownMenuIsOpened = true; - }, - 'textComplete:select'() { - EscapeActions.preventNextClick(); - }, - 'textComplete:hide'() { - Tracker.afterFlush(() => { - // XXX Hack. We unfortunately need to set a setTimeout here to make the - // `noClickEscapeOn` work bellow, otherwise clicking on a autocomplete - // item will close both the autocomplete menu (as expected) but also the - // next item in the stack (for example the minicard editor) which we - // don't want. - setTimeout(() => { - dropdownMenuIsOpened = false; - }, 100); - }); - }, + // When the autocomplete menu is shown we want both a press of both `Tab` + // or `Enter` to validate the auto-completion. We also need to stop the + // event propagation to prevent EscapeActions side effect, for instance the + // minicard submission (on `Enter`) or going on the next column (on `Tab`). + element.addEventListener('keydown', (evt) => { + if (dropdownMenuIsOpened && (evt.keyCode === 9 || evt.keyCode === 13)) { + evt.stopPropagation(); + } + }); + + // Track dropdown state for EscapeActions integration + // Since @textcomplete automatically closes when Escape is pressed, we + // integrate with our EscapeActions system by tracking open/close state. + textcomplete.on('show', () => { + dropdownMenuIsOpened = true; + }); + + textcomplete.on('selected', () => { + EscapeActions.preventNextClick(); + }); + + textcomplete.on('hidden', () => { + Tracker.afterFlush(() => { + // XXX Hack. We unfortunately need to set a setTimeout here to make the + // `noClickEscapeOn` work below, otherwise clicking on a autocomplete + // item will close both the autocomplete menu (as expected) but also the + // next item in the stack (for example the minicard editor) which we + // don't want. + setTimeout(() => { + dropdownMenuIsOpened = false; + }, 100); + }); + }); + + return textcomplete; +} + +// jQuery extension for backward compatibility +$.fn.escapeableTextComplete = function(strategies, options = {}) { + return this.each(function() { + createEscapeableTextComplete(this, strategies, options); }); - return this; }; EscapeActions.register( diff --git a/package-lock.json b/package-lock.json index 5b33b5042..19814c750 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,37 @@ } } }, + "@textcomplete/contenteditable": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@textcomplete/contenteditable/-/contenteditable-0.1.13.tgz", + "integrity": "sha512-O2BNqtvP0I1lL8WIwJ/ilCVi6rEJu2Jtj7Nnx8+XSN66aoBV5pdl0c1IXFfNvGU5kJh+6EOxkDEmm2NhYCIXlw==", + "requires": { + "@textcomplete/utils": "^0.1.13" + } + }, + "@textcomplete/core": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@textcomplete/core/-/core-0.1.13.tgz", + "integrity": "sha512-C4S+ihQU5HsKQ/TbsmS0e7hfPZtLZbEXj5NDUgRnhu/1Nezpu892bjNZGeErZm+R8iyDIT6wDu6EgIhng4M8eQ==", + "requires": { + "eventemitter3": "^5.0.1" + } + }, + "@textcomplete/textarea": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@textcomplete/textarea/-/textarea-0.1.13.tgz", + "integrity": "sha512-GNathnXpV361YuZrBVXvVqFYZ5NQZsjGC7Bt2sCUA/RTWlIgxHxC0ruDChYyRDx4siQZiZZOO5pWz+z1x8pZFQ==", + "requires": { + "@textcomplete/utils": "^0.1.13", + "textarea-caret": "^3.1.0", + "undate": "^0.3.0" + } + }, + "@textcomplete/utils": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@textcomplete/utils/-/utils-0.1.13.tgz", + "integrity": "sha512-5UW9Ee0WEX1s9K8MFffo5sfUjYm3YVhtqRhAor/ih7p0tnnpaMB7AwMRDKwhSIQL6O+g1fmEkxCeO8WqjPzjUA==" + }, "@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -724,6 +755,11 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" }, + "eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, "events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -907,6 +943,11 @@ "function-bind": "^1.1.2" } }, + "hotkeys-js": { + "version": "3.13.15", + "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.15.tgz", + "integrity": "sha512-gHh8a/cPTCpanraePpjRxyIlxDFrIhYqjuh01UHWEwDpglJKCnvLW8kqSx5gQtOuSsJogNZXLhOdbSExpgUiqg==" + }, "htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -2812,6 +2853,11 @@ "readable-stream": "^3.1.1" } }, + "textarea-caret": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/textarea-caret/-/textarea-caret-3.1.0.tgz", + "integrity": "sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q==" + }, "tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -2884,6 +2930,11 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, + "undate": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/undate/-/undate-0.3.0.tgz", + "integrity": "sha512-ssH8QTNBY6B+2fRr3stSQ+9m2NT8qTaun3ExTx5ibzYQvP7yX4+BnX0McNxFCvh6S5ia/DYu6bsCKQx/U4nb/Q==" + }, "unzipper": { "version": "0.10.14", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", diff --git a/package.json b/package.json index 08f17d1ba..d5aa8ae55 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "@mapbox/node-pre-gyp": "^2.0.3", "@meteorjs/reify": "^0.25.4", "@rwap/jquery-ui-touch-punch": "^1.0.11", + "@textcomplete/contenteditable": "^0.1.13", + "@textcomplete/core": "^0.1.13", + "@textcomplete/textarea": "^0.1.13", "@wekanteam/dragscroll": "^0.0.9", "@wekanteam/exceljs": "^4.6.0", "@wekanteam/html-to-markdown": "^1.0.2", "@wekanteam/meteor-globals": "^1.1.6", "@wekanteam/meteor-reactive-cache": "^1.0.7", - "meteor-node-stubs": "npm:@wekanteam/meteor-node-stubs@^1.2.7", "ajv": "^6.12.6", "bcryptjs": "^2.4.3", "bson": "^4.7.2", @@ -37,6 +39,7 @@ "fibers": "^5.0.3", "file-type": "^16.5.4", "filesize": "^8.0.7", + "hotkeys-js": "^3.13.15", "i18next": "^21.10.0", "i18next-sprintf-postprocessor": "^0.2.2", "jquery": "^3.7.1", @@ -47,6 +50,7 @@ "markdown-it-emoji": "^2.0.0", "markdown-it-mathjax3": "^4.3.2", "meteor-accounts-t9n": "^2.6.0", + "meteor-node-stubs": "npm:@wekanteam/meteor-node-stubs@^1.2.7", "os": "^0.1.2", "papaparse": "^5.5.3", "pretty-ms": "^7.0.1",