/* * (c) Copyright Ascensio System SIA 2010-2024 * * This program is a free software product. You can redistribute it and/or * modify it under the terms of the GNU Affero General Public License (AGPL) * version 3 as published by the Free Software Foundation. In accordance with * Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect * that Ascensio System SIA expressly excludes the warranty of non-infringement * of any third-party rights. * * This program is distributed WITHOUT ANY WARRANTY; without even the implied * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For * details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html * * You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish * street, Riga, Latvia, EU, LV-1050. * * The interactive user interfaces in modified source and object code versions * of the Program must display Appropriate Legal Notices, as required under * Section 5 of the GNU AGPL version 3. * * Pursuant to Section 7(b) of the License you must retain the original Product * logo when distributing the program. Pursuant to Section 7(e) we decline to * grant you any rights under trademark law for use of our trademarks. * * All the Product's GUI elements, including illustrations and icon sets, as * well as technical writing content are licensed under the terms of the * Creative Commons Attribution-ShareAlike 4.0 International. See the License * terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode * */ /** * Created on 1/9/2025. */ define([ 'core' ], function () { 'use strict'; Common.Controllers.Shortcuts = Backbone.Controller.extend(_.extend({ initialize: function() { this.localStorageKey = ''; if(window.DE) { this.localStorageKey = 'de-shortcuts'; } else if(window.PDFE) { this.localStorageKey = 'pdfe-shortcuts'; } else if(window.PE) { this.localStorageKey = 'pe-shortcuts'; } else if(window.SSE) { this.localStorageKey = 'sse-shortcuts'; } else if(window.VE) { this.localStorageKey = 've-shortcuts'; } this.actionsMap = {}; this.eventsMap = {}; }, setApi: function(api) { this.api = api; this._fillActionsMap(); api && this._applyShortcutsInSDK(); this._eventsTrigger(); return this; }, keyCodeToKeyName: function(code) { const specialKeys = { 8: 'Backspace', 9: 'Tab', 12: 'Clear', 13: 'Enter', 16: 'Shift', 17: 'Ctrl', 18: 'Alt', 19: 'Pause', 20: 'CapsLock', 27: 'Escape', 32: 'Space', 33: 'PageUp', 34: 'PageDown', 35: 'End', 36: 'Home', 37: 'ArrowLeft', 38: 'ArrowUp', 39: 'ArrowRight', 40: 'ArrowDown', 44: 'PrintScreen', 45: 'Insert', 46: 'Delete', 91: 'Meta', 93: 'ContextMenu', 96: "Num 0", 97: "Num 1", 98: "Num 2", 99: "Num 3", 100: "Num 4", 101: "Num 5", 102: "Num 6", 103: "Num 7", 104: "Num 8", 105: "Num 9", 106: "Num *", 107: "Num +", 108: "Num Enter", 109: "Num -", 110: "Num .", 111: "Num /", 112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4', 116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8', 120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12', 144: 'NumLock', 145: 'ScrollLock', 173: 'ff-', 61: 'ff=', 186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`', 219: '[', 220: '\\', 221: ']', 222: '\'', }; if (specialKeys[code]) { return specialKeys[code]; } return String.fromCharCode(code); }, resetAllShortcuts: function() { const me = this; this.api.asc_resetAllShortcutTypes(); for (const actionType in Asc.c_oAscDefaultShortcuts) { const actionItem = this.getActionsMap()[actionType]; actionItem.shortcuts = Asc.c_oAscDefaultShortcuts[actionType].map(function(ascShortcut) { return { keys: me._getAscShortcutKeys(ascShortcut), isCustom: false, ascShortcut: ascShortcut, } }); } this._eventsTrigger(); Common.localStorage.setItem(this.localStorageKey, ''); Common.NotificationCenter.trigger('shortcuts:update'); }, updateShortcutsForAction: function(actionType, updatedShortcuts) { let resultShortcuts = []; const originalShortcuts = this.getActionsMap()[actionType].shortcuts; // Update hidden status and create copies for those not in updatedShortcuts originalShortcuts.forEach(function(item) { const existsInUpdated = _.some(updatedShortcuts, function(el) { return el.ascShortcut.asc_GetShortcutIndex() === item.ascShortcut.asc_GetShortcutIndex(); }); if(!existsInUpdated && !item.ascShortcut.asc_IsHidden()) { const copyAscShortcut = new Asc.CAscShortcut(); copyAscShortcut.asc_FromJson(item.ascShortcut.asc_ToJson()); item.ascShortcut = copyAscShortcut; } item.ascShortcut.asc_SetIsHidden(!existsInUpdated); resultShortcuts.push(item); }); // Add new custom shortcuts that are not in originalShortcuts updatedShortcuts.forEach(function(item) { const existsInOriginal = _.some(originalShortcuts, function(el) { return el.ascShortcut.asc_GetShortcutIndex() === item.ascShortcut.asc_GetShortcutIndex(); }); if(!existsInOriginal) { item.isCustom = true; resultShortcuts.push(item); } }); const action = this.getActionsMap()[actionType]; resultShortcuts = resultShortcuts.sort(this._sortComparator); action && (action.shortcuts = resultShortcuts); // Remove shortcuts from other actions if they conflict with updated shortcuts const removableIndexes = updatedShortcuts.map(function(item) { return item.ascShortcut.asc_GetShortcutIndex() }); const removableFromStorage = {}; for (const type in this.getActionsMap()) { if(removableIndexes.length == 0) break; if(type == actionType) continue; const shortcuts = this.getActionsMap()[type].shortcuts; const foundIndex = _.findIndex(shortcuts, function(shortcut) { const foundIndex = _.indexOf(removableIndexes, shortcut.ascShortcut.asc_GetShortcutIndex()); (foundIndex != -1) && removableIndexes.splice(foundIndex, 1); return foundIndex != -1; }); if(foundIndex != -1) { const copyAscShortcut = new Asc.CAscShortcut(); copyAscShortcut.asc_FromJson(shortcuts[foundIndex].ascShortcut.asc_ToJson()); copyAscShortcut.asc_SetIsHidden(true); shortcuts[foundIndex].ascShortcut = copyAscShortcut; !removableFromStorage[type] && (removableFromStorage[type] = []); removableFromStorage[type].push(shortcuts[foundIndex].ascShortcut); } } this.api.asc_applyAscShortcuts(resultShortcuts.map(function(shortcut) { return shortcut.ascShortcut; })); //Filter for save in local storage const savedInStorage = { [actionType] : _.filter(resultShortcuts, function(item) { return item.isCustom !== item.ascShortcut.asc_IsHidden(); }).map(function(shortcut) { return shortcut.ascShortcut; }) }; this._saveModifiedShortcuts(savedInStorage, removableFromStorage); Common.NotificationCenter.trigger('shortcuts:update'); this._eventsTrigger([actionType].concat(_.keys(removableFromStorage))); }, getActionsMap: function() { return this.actionsMap; }, getDefaultShortcutActions: function() { if(window.DE || window.PDFE) { return Asc.c_oAscDocumentShortcutType; } else if(window.PE) { return Asc.c_oAscPresentationShortcutType; } else if(window.SSE) { return Asc.c_oAscSpreadsheetShortcutType; } else if(window.VE) { return Asc.c_oAscDiagramShortcutType; } return {}; }, /** * Returns a list of shortcuts for the given action type. * * @param {string|number} actionType * The action type identifier. Can be: * - `string` — action type name ('Save', 'Undo', 'Copy' and so on) * - `number` — numeric action type id * * @returns {Array<{keys: string[], isCustom: boolean, ascShortcut: CAscShortcut}>|null} * * @example * getShortcutsByActionType('Save'); * getShortcutsByActionType(7); */ getShortcutsByActionType: function(actionType, withHidden) { const actionTypeNumber = (typeof actionType === 'number') ? actionType : this.getDefaultShortcutActions()[actionType]; const actionItem = this.actionsMap[actionTypeNumber]; let shortcuts = actionItem ? actionItem.shortcuts : null; if(!withHidden && shortcuts) { shortcuts = shortcuts.filter(function(shortcut) { return !shortcut.ascShortcut.asc_IsHidden(); }); } return shortcuts; }, /** * Updates button hints for shortcuts. * * For each action type, finds the first visible shortcut (non-hidden) * and appends its key combination to the base label. Then applies the * updated hint to the button or via a custom callback. * * @param {Object} shortcutHints * * An object where: * - key is the action type (actionType), * - value is a config object: * * - `btn` {Object} — button object that provides `updateHint(string)` method. * - `label` {string} — the base label text for the hint. * - `applyCallback` [optional] — custom function to apply the generated hint text. * Receives `(item, hintText)` as arguments. * - `ignoreUpdates` [optional] — if `true`, will not be updated when shortcuts are updated * * @example * updateShortcutHints({ * EditUndo: { * btn: btnUndo, * label: 'Undo' * }, * EditRedo: { * label: 'Redo', * applyCallback: function(item, hintText) { * console.log('Custom hint:', text); * }, * ignoreUpdates: true * } * }); */ updateShortcutHints: function(shortcutHints) { for (const actionType in shortcutHints) { const item = shortcutHints[actionType]; if(item) { const callback = function() { const defaultShortcutsActions = this.getDefaultShortcutActions(); const type = defaultShortcutsActions[actionType]; const action = type ? this.actionsMap[type] : null; const firstShortcut = action ? _.find(action.shortcuts, (shortcut) => { return !shortcut.ascShortcut.asc_IsHidden(); }) : null; const hintText = (item.label || '') + (firstShortcut ? ' (' + firstShortcut.keys.join('+') + ')' : ''); if(item.applyCallback) { item.applyCallback(item, hintText); } else { item.btn.updateHint(hintText); } }.bind(this); callback(); if(!item.ignoreUpdates) { !this.eventsMap[actionType] && (this.eventsMap[actionType] = []); this.eventsMap[actionType].push(callback); } } } }, _getDefaultShortcutActionsInvert: function() { if(!this._defaultShortcutsActionsInvert) { this._defaultShortcutsActionsInvert = _.invert(this.getDefaultShortcutActions()); } return this._defaultShortcutsActionsInvert; }, _eventsTrigger: function(actionTypes) { const me = this; let lists = []; if(actionTypes) { lists = actionTypes.map(function(type) { type = (typeof +type === 'number') ? me._getDefaultShortcutActionsInvert()[type] : type; return me.eventsMap[type]; }); } else { lists = _.values(this.eventsMap); } _.each(lists, function(callbacks) { _.each(callbacks, function(cb) { cb && cb(); }); }); }, _fillActionsMap: function() { this.actionsMap = {}; const me = this; const shortcutActions = this.getDefaultShortcutActions(); const unlockedTypes = Asc.c_oAscUnlockedShortcutActionTypes || {}; for (let actionName in shortcutActions) { const type = shortcutActions[actionName]; this.actionsMap[type] = { action: { name: this['txtLabel' + actionName], description: this['txtDescription' + actionName], type: type, isLocked: !unlockedTypes[type] }, shortcuts: [] } } _.pairs(Asc.c_oAscDefaultShortcuts).forEach(function(item) { const actionType = item[0]; const shortcuts = item[1]; const actionItem = me.actionsMap[actionType]; if(actionItem) { actionItem.shortcuts = shortcuts.map(function(ascShortcut) { return { keys: me._getAscShortcutKeys(ascShortcut), isCustom: false, ascShortcut: ascShortcut, } }); } }); let removableIndexes = {}; _.pairs(me._getModifiedShortcuts()).forEach(function(item) { const actionType = item[0]; const shortcuts = item[1]; const actionItem = me.actionsMap[actionType]; if(actionItem) { shortcuts.forEach(function(ascShortcut) { const ascShortcutIndex = ascShortcut.asc_GetShortcutIndex(); const defaultShortcutIndex = _.findIndex(actionItem.shortcuts, function(shortcut) { return shortcut.ascShortcut.asc_GetShortcutIndex() == ascShortcutIndex; }); if(defaultShortcutIndex != -1) { actionItem.shortcuts[defaultShortcutIndex].ascShortcut = ascShortcut; } else { removableIndexes[ascShortcutIndex] = actionType; actionItem.shortcuts.push({ keys: me._getAscShortcutKeys(ascShortcut), isCustom: true, ascShortcut: ascShortcut, }); } }); } }) for (const actionType in this.actionsMap) { const item = this.actionsMap[actionType]; const shortcuts = this.actionsMap[actionType].shortcuts; if(shortcuts.length == 0 && item.action.isLocked) { // Delete actions if it has no shortcuts and the action is locked delete this.actionsMap[actionType]; } else if(Object.keys(removableIndexes).length > 0) { // Remove shortcuts from other actions if they conflict with updated shortcuts const foundIndex = _.findIndex(shortcuts, function(shortcut) { const ascShortcutIndex = shortcut.ascShortcut.asc_GetShortcutIndex(); if(removableIndexes[ascShortcutIndex] && removableIndexes[ascShortcutIndex] != actionType) { delete removableIndexes[ascShortcutIndex]; return true; } return false; }); if(foundIndex != -1) { const copyAscShortcut = new Asc.CAscShortcut(); copyAscShortcut.asc_FromJson(shortcuts[foundIndex].ascShortcut.asc_ToJson()); copyAscShortcut.asc_SetIsHidden(true); shortcuts[foundIndex].ascShortcut = copyAscShortcut; } } } }, _applyShortcutsInSDK: function() { const applyMethod = function(storage) { storage = JSON.parse(storage || Common.localStorage.getItem(this.localStorageKey) || "{}"); for (const actionType in storage) { storage[actionType] = storage[actionType].map(function(ascShortcutJson) { const ascShortcut = new Asc.CAscShortcut(); ascShortcut.asc_FromJson(ascShortcutJson); return ascShortcut; }); } this.api.asc_resetAllShortcutTypes(); const modifiedShortcuts = _.flatten(_.values(storage)); if(modifiedShortcuts.length) { this.api.asc_applyAscShortcuts(modifiedShortcuts); } }.bind(this); $(window).on('storage', function (e) { if(e.key == this.localStorageKey) { applyMethod(e.originalEvent.newValue); this._fillActionsMap(); this._eventsTrigger(); Common.NotificationCenter.trigger('shortcuts:update'); } }.bind(this)) applyMethod(); }, /** * Retrieves user-modified shortcuts from localStorage. * @returns {Object} * An object where keys are action types and values are arrays of ascShortcut instances. */ _getModifiedShortcuts: function() { const storage = JSON.parse(Common.localStorage.getItem(this.localStorageKey) || "{}"); for (const actionType in storage) { storage[actionType] = storage[actionType].map(function(ascShortcutJson) { const ascShortcut = new Asc.CAscShortcut(); ascShortcut.asc_FromJson(ascShortcutJson); return ascShortcut; }); } return storage; }, /** * Saves modified shortcuts to localStorage by applying added and removed changes. * @param {Object} savedActionsMap * An object containing new or updated shortcuts, grouped by action type. * @param {Object} removedActionsMap * An object containing shortcuts to be removed, grouped by action type. * @example * this._saveModifiedShortcuts( * { "7": [ascShortcut1, ascShortcut2] }, * { "52": [ascShortcut3] } * ); * // Result: * // - For action type "7", shortcuts are replaced with [ascShortcut1, ascShortcut2] * // - For action type "52", ascShortcut3 is removed * // - The final shortcuts are saved to localStorage */ _saveModifiedShortcuts: function(savedActionsMap, removedActionsMap) { const customShortcuts = _.extend({}, this._getModifiedShortcuts(), savedActionsMap || {}); for (const actionType in removedActionsMap || {}) { if (!customShortcuts[actionType]) continue; const removed = removedActionsMap[actionType]; customShortcuts[actionType] = _.filter(customShortcuts[actionType], function(ascShortcut) { return !_.some(removed, function(r) { return ascShortcut.asc_GetShortcutIndex() === r.asc_GetShortcutIndex(); }); }); } for (const actionType in customShortcuts) { customShortcuts[actionType] = customShortcuts[actionType].map(function(ascShortcut) { return ascShortcut.asc_ToJson(); }); } Common.localStorage.setItem(this.localStorageKey, JSON.stringify(customShortcuts)); }, _getAscShortcutKeys: function(ascShortcut) { const keys = []; ascShortcut.asc_IsCommand() && keys.push('⌘'); ascShortcut.asc_IsCtrl() && keys.push('Ctrl'); ascShortcut.asc_IsAlt() && keys.push('Alt'); ascShortcut.asc_IsShift() && keys.push('Shift'); keys.push(this.keyCodeToKeyName(ascShortcut.asc_GetKeyCode())); return keys; }, _sortComparator: function(first, second) { const priorityModifierKeys = ['asc_IsCommand', 'asc_IsCtrl', 'asc_IsAlt', 'asc_IsShift']; function getWeight(ascShortcut) { // Search for the first modifier key let keyIndex = priorityModifierKeys.length; for (let i = 0; i < priorityModifierKeys.length; i++) { if (ascShortcut[priorityModifierKeys[i]]()) { keyIndex = i; break; } } if (keyIndex === priorityModifierKeys.length) return -1; // Count extra modifier keys let extras = 0; for (let j = 0; j < priorityModifierKeys.length; j++) { if (j !== keyIndex && ascShortcut[priorityModifierKeys[j]]()) extras++; } // weight = range for main key + “cost” of extra keys return keyIndex * 100 + extras; } if (first.ascShortcut.asc_IsLocked() && !second.ascShortcut.asc_IsLocked()) return -1; if (!first.ascShortcut.asc_IsLocked() && second.ascShortcut.asc_IsLocked()) return 1; let wFirst = getWeight(first.ascShortcut); let wSecond = getWeight(second.ascShortcut); if (wFirst !== wSecond) return wFirst - wSecond; return first.ascShortcut.asc_GetKeyCode() - second.ascShortcut.asc_GetKeyCode(); } }, Common.Controllers.Shortcuts || {})); });