Merge pull request #6082 from harryadel/mousetrap-migration

Replace mousetrap
This commit is contained in:
Lauri Ojansivu 2026-01-21 13:33:09 +02:00 committed by GitHub
commit 8586fa8ce0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 236 additions and 147 deletions

View file

@ -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

View file

@ -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

View file

@ -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();
});

View file

@ -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',
},
{

View file

@ -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(

51
package-lock.json generated
View file

@ -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",

View file

@ -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",