diff --git a/application/utils.inc.php b/application/utils.inc.php index 70cf1f4e8..692047e82 100644 --- a/application/utils.inc.php +++ b/application/utils.inc.php @@ -2771,4 +2771,28 @@ HTML; return $aMatchingClasses; } + /** + * Return keyboard shortcuts config as an array + * + * @return array + * @throws \CoreException + * @throws \CoreUnexpectedValue + * @throws \MySQLException + * @since 3.0.0 + */ + public static function GetKeyboardShortcutPref(): array + { + $aResultPref = []; + $aShortcutPrefs = appUserPreferences::GetPref('keyboard_shortcuts', []); + $aShortcutClasses = utils::GetClassesForInterface('iKeyboardShortcut','', array('/lib/', 'node_modules', 'test')); + + foreach($aShortcutClasses as $cShortcutPlugin) { + $sTriggeredElement = $cShortcutPlugin::GetShortcutTriggeredElementSelector(); + foreach ($cShortcutPlugin::GetShortcutKeys() as $aShortcutKey) { + $sKey = isset($aShortcutPrefs[$aShortcutKey['id']]) ? $aShortcutPrefs[$aShortcutKey['id']] : $aShortcutKey['key']; + $aResultPref[$aShortcutKey['id']] = ['key' => $sKey, 'label' => $aShortcutKey['label'], 'event' => $aShortcutKey['event'], 'triggered_element_selector' => $sTriggeredElement]; + } + } + return $aResultPref; + } } diff --git a/css/backoffice/pages/_preferences.scss b/css/backoffice/pages/_preferences.scss index 523809e85..c209cc7da 100644 --- a/css/backoffice/pages/_preferences.scss +++ b/css/backoffice/pages/_preferences.scss @@ -8,6 +8,19 @@ $ibo-preferences--user-preferences--picture-placeholder--image--background-color $ibo-preferences--user-preferences--picture-placeholder--image--active--border-color: $ibo-color-blue-800; $ibo-preferences--user-preferences--picture-placeholder--image--hover--border-color: $ibo-color-blue-600; +$ibo-keyboard-shortcut--shortcut--width: 30% !default; + +$ibo-keyboard-shortcut--input--color: $ibo-color-grey-800 !default; +$ibo-keyboard-shortcut--input--background-color: transparent !default; +$ibo-keyboard-shortcut--input--border-color: $ibo-color-grey-500 !default; +$ibo-keyboard-shortcut--input--border-radius: $ibo-border-radius-300 !default; +$ibo-keyboard-shortcut--input--padding-y: 2px !default; +$ibo-keyboard-shortcut--input--padding-x: 4px !default; +$ibo-keyboard-shortcut--input--margin-bottom: 5px !default; + +$ibo-keyboard-shortcut--input--is-focus--color: $ibo-color-primary-800 !default; +$ibo-keyboard-shortcut--input--is-focus--border-color: $ibo-color-primary-600 !default; + #ibo-main-content >.ibo-panel{ margin-left: $ibo-preferences--panel--margin-x; margin-right: $ibo-preferences--panel--margin-x; @@ -35,4 +48,35 @@ $ibo-preferences--user-preferences--picture-placeholder--image--hover--border-co } .ibo-preferences--user-preferences--picture-placeholder--image:hover{ border-color: $ibo-preferences--user-preferences--picture-placeholder--image--hover--border-color; +} + +#ibo-form-for-user-interface-preferences > .ibo-keyboard-shortcut--shortcut{ + display: table; + width: 100%; + >*:not(.ibo-button){ + width: $ibo-keyboard-shortcut--shortcut--width; + display: table-cell; + } +} + +.ibo-keyboard-shortcut--input, .ibo-keyboard-shortcut--input:focus{ + display: inline-block; + width: auto; + text-transform: capitalize; + text-align: center; + + color: $ibo-keyboard-shortcut--input--color; + background-color: $ibo-keyboard-shortcut--input--background-color; + border: 1px solid $ibo-keyboard-shortcut--input--border-color; + border-bottom: 2px solid $ibo-keyboard-shortcut--input--border-color; + border-radius: $ibo-keyboard-shortcut--input--border-radius; + + padding: $ibo-keyboard-shortcut--input--padding-y $ibo-keyboard-shortcut--input--padding-x; + margin-bottom: $ibo-keyboard-shortcut--input--margin-bottom; + + &.ibo-is-focus{ + text-transform: none; + color: $ibo-keyboard-shortcut--input--is-focus--color; + border-color: $ibo-keyboard-shortcut--input--is-focus--border-color; + } } \ No newline at end of file diff --git a/dictionaries/ui/components/en.dictionary.itop.global-search.php b/dictionaries/ui/components/en.dictionary.itop.global-search.php index ed1c2a10d..8ba6482b4 100644 --- a/dictionaries/ui/components/en.dictionary.itop.global-search.php +++ b/dictionaries/ui/components/en.dictionary.itop.global-search.php @@ -24,4 +24,5 @@ Dict::Add('EN US', 'English', 'English', array( 'UI:Component:GlobalSearch:Recents:Title' => 'Recents', 'UI:Component:GlobalSearch:LastQueries:NoQuery:Placeholder' => 'You haven\'t run any search yet', 'UI:Component:GlobalSearch:HistoryDisabled' => 'History is disabled', + 'UI:Component:GlobalSearch:KeyboardShortcut:OpenDrawer' => 'Open global search', )); \ No newline at end of file diff --git a/dictionaries/ui/components/en.dictionary.itop.quick-create.php b/dictionaries/ui/components/en.dictionary.itop.quick-create.php index b724dbccf..d17b8bb86 100644 --- a/dictionaries/ui/components/en.dictionary.itop.quick-create.php +++ b/dictionaries/ui/components/en.dictionary.itop.quick-create.php @@ -24,4 +24,5 @@ Dict::Add('EN US', 'English', 'English', array( 'UI:Component:QuickCreate:Recents:Title' => 'Recents', 'UI:Component:QuickCreate:LastClasses:NoClass:Placeholder' => 'You haven\'t create any object yet', 'UI:Component:QuickCreate:HistoryDisabled' => 'History is disabled', + 'UI:Component:QuickCreate:KeyboardShortcut:OpenDrawer' => 'Open quick create', )); \ No newline at end of file diff --git a/dictionaries/ui/layouts/en.dictionary.itop.navigation-menu.php b/dictionaries/ui/layouts/en.dictionary.itop.navigation-menu.php index b215b7d75..17ee576c8 100644 --- a/dictionaries/ui/layouts/en.dictionary.itop.navigation-menu.php +++ b/dictionaries/ui/layouts/en.dictionary.itop.navigation-menu.php @@ -28,6 +28,7 @@ Dict::Add('EN US', 'English', 'English', array( 'UI:Layout:NavigationMenu:MenuFilter:Input:Hint' => 'Matches from all menu groups will be displayed', 'UI:Layout:NavigationMenu:MenuFilter:Placeholder:Hint' => 'No result for this menu filter', 'UI:Layout:NavigationMenu:UserInfo:WelcomeMessage:Text' => 'Hi %1$s!', - 'UI:Layout:NavigationMenu:UserInfo:Picture:AltText' => '%1$s\'s contact picture' + 'UI:Layout:NavigationMenu:UserInfo:Picture:AltText' => '%1$s\'s contact picture', + 'UI:Layout:NavigationMenu:KeyboardShortcut:FocusFilter' => 'Filter menu entries' )); \ No newline at end of file diff --git a/dictionaries/ui/layouts/en.dictionary.itop.object-details.php b/dictionaries/ui/layouts/en.dictionary.itop.object-details.php index 6f8c61202..7fc5c73e6 100644 --- a/dictionaries/ui/layouts/en.dictionary.itop.object-details.php +++ b/dictionaries/ui/layouts/en.dictionary.itop.object-details.php @@ -5,4 +5,8 @@ */ Dict::Add('EN US', 'English', 'English', [ + 'UI:Layout:ObjectDetails:KeyboardShortcut:EditObject' => 'Edit displayed object', + 'UI:Layout:ObjectDetails:KeyboardShortcut:DeleteObject' => 'Delete displayed object', + 'UI:Layout:ObjectDetails:KeyboardShortcut:NewObject' => 'Create a new object (with same class as displayed object)', + 'UI:Layout:ObjectDetails:KeyboardShortcut:SaveObject' => 'Save displayed object', ]); diff --git a/dictionaries/ui/pages/en.dictionary.itop.preferences.php b/dictionaries/ui/pages/en.dictionary.itop.preferences.php index 834a06321..611ae897a 100644 --- a/dictionaries/ui/pages/en.dictionary.itop.preferences.php +++ b/dictionaries/ui/pages/en.dictionary.itop.preferences.php @@ -20,7 +20,6 @@ // Navigation menu Dict::Add('EN US', 'English', 'English', array( 'UI:Preferences:Title' => 'Preferences', - 'UI:Preferences:UserInterface:Title' => 'User interface', 'UI:Preferences:Lists:Title' => 'Lists', 'UI:Preferences:RichText:Title' => 'Rich text editor', @@ -30,6 +29,9 @@ Dict::Add('EN US', 'English', 'English', array( 'UI:Preferences:ActivityPanel:Title' => 'Activity panel', 'UI:Preferences:ActivityPanel:EntryFormOpened' => 'Entry form opened by default', 'UI:Preferences:ActivityPanel:EntryFormOpened+' => 'Whether the entry form will be opened when displaying an object. If unchecked, you will still be able to open it by clicking the compose button', + 'UI:Preferences:PersonalizeKeyboardShortcuts:Title' => 'Application keyboard shortcuts', + 'UI:Preferences:PersonalizeKeyboardShortcuts:Input:Hint' => 'Type a keyboard shortcut', + 'UI:Preferences:PersonalizeKeyboardShortcuts:Button:Tooltip' => 'Record a keyboard shortcut', 'UI:Preferences:Tabs:Title' => 'Tabs', 'UI:Preferences:Tabs:Layout:Label' => 'Layout', 'UI:Preferences:Tabs:Layout:Horizontal' => 'Horizontal', diff --git a/js/components/global-search.js b/js/components/global-search.js index 063212e4c..af7b6c2a9 100644 --- a/js/components/global-search.js +++ b/js/components/global-search.js @@ -80,6 +80,9 @@ $(function() this.element.find(this.js_selectors.compartment_element).on('click', function(oEvent){ me._onCompartmentElementClick(oEvent, $(this)); }); + this.element.on('open_drawer', function(oEvent){ + me._onIconClick(oEvent); + }); // Mostly for outside clicks that should close elements oBodyElem.on('click', function(oEvent){ me._onBodyClick(oEvent); @@ -134,20 +137,6 @@ $(function() }, _onBodyKeyUp: function(oEvent) { - // Note: We thought about extracting the oEvent.key in a variable to lower case it, but this would be done - // on every single key up in the application, which might not be what we want... (time consuming) - if((oEvent.altKey === true) && (oEvent.key === 'h' || oEvent.key === 'H')) - { - if(this._isDrawerOpened()) - { - this._setFocusOnInput(); - } - // If drawer is closed, we trigger the click on the icon in order for the other widget to behave like they should (eg. close themselves) - else - { - this.element.find(this.js_selectors.icon).trigger('click'); - } - } }, // Methods diff --git a/js/components/quick-create.js b/js/components/quick-create.js index a59bf49ed..c1ff197a1 100644 --- a/js/components/quick-create.js +++ b/js/components/quick-create.js @@ -86,6 +86,9 @@ $(function() this.element.find(this.js_selectors.input).on('change', function(oEvent){ me._onInputOptionSelected(oEvent, $(this)); }); + this.element.on('open_drawer', function(oEvent){ + me._onIconClick(oEvent); + }); // Mostly for outside clicks that should close elements oBodyElem.on('click', function(oEvent){ me._onBodyClick(oEvent); diff --git a/js/layouts/navigation-menu.js b/js/layouts/navigation-menu.js index 4660968e8..f6db17315 100644 --- a/js/layouts/navigation-menu.js +++ b/js/layouts/navigation-menu.js @@ -76,6 +76,9 @@ $(function() this.element.find(this.js_selectors.menu_group).on('click', function (oEvent) { me._onMenuGroupClick(oEvent, $(this)) }); + this.element.on('filter_shortcut', function(oEvent){ + me._filterShortcut(); + }); // Mostly for outside clicks that should close elements oBodyElem.on('click', function (oEvent) { me._onBodyClick(oEvent); @@ -140,18 +143,16 @@ $(function() }, _onBodyKeyUp: function(oEvent) { - // Note: We thought about extracting the oEvent.key in a variable to lower case it, but this would be done - // on every single key up in the application, which might not be what we want... (time consuming) - if((oEvent.altKey === true) && (oEvent.key === 'm' || oEvent.key === 'M')) + }, + _filterShortcut: function() + { + if(this._getActiveMenuGroupId() === null) { - if(this._getActiveMenuGroupId() === null) - { - const sFirstMenuGroupId = this.element.find(this.js_selectors.menu_group+':first').attr('data-menu-group-id'); - this._openDrawer(sFirstMenuGroupId); - } - - this._focusFilter(); + const sFirstMenuGroupId = this.element.find(this.js_selectors.menu_group+':first').attr('data-menu-group-id'); + this._openDrawer(sFirstMenuGroupId); } + + this._focusFilter(); }, _onFilterKeyUp: function(oEvent) diff --git a/js/mousetrap/mousetrap-record.min.js b/js/mousetrap/mousetrap-record.min.js new file mode 100644 index 000000000..4a87ec863 --- /dev/null +++ b/js/mousetrap/mousetrap-record.min.js @@ -0,0 +1,2 @@ +(function(d){function n(b,a,h){if(this.recording)if("keydown"==h.type){1===b.length&&g&&k();for(i=0;ib?1:-1}),b[a]= + b[a].join("+")}function q(){f&&(r(e),f(e));e=[];f=null;c=[]}var e=[],f=null,c=[],g=!1,m=null,p=d.prototype.handleKey;d.prototype.record=function(b){var a=this;a.recording=!0;f=function(){a.recording=!1;b.apply(a,arguments)}};d.prototype.handleKey=function(){n.apply(this,arguments)};d.init()})(Mousetrap); \ No newline at end of file diff --git a/js/mousetrap/mousetrap.min.js b/js/mousetrap/mousetrap.min.js new file mode 100644 index 000000000..76bb14107 --- /dev/null +++ b/js/mousetrap/mousetrap.min.js @@ -0,0 +1,11 @@ +/* mousetrap v1.6.5 craig.is/killing/mice */ +(function(q,u,c){function v(a,b,g){a.addEventListener?a.addEventListener(b,g,!1):a.attachEvent("on"+b,g)}function z(a){if("keypress"==a.type){var b=String.fromCharCode(a.which);a.shiftKey||(b=b.toLowerCase());return b}return n[a.which]?n[a.which]:r[a.which]?r[a.which]:String.fromCharCode(a.which).toLowerCase()}function F(a){var b=[];a.shiftKey&&b.push("shift");a.altKey&&b.push("alt");a.ctrlKey&&b.push("ctrl");a.metaKey&&b.push("meta");return b}function w(a){return"shift"==a||"ctrl"==a||"alt"==a|| + "meta"==a}function A(a,b){var g,d=[];var e=a;"+"===e?e=["+"]:(e=e.replace(/\+{2}/g,"+plus"),e=e.split("+"));for(g=0;gc||n.hasOwnProperty(c)&&(p[n[c]]=c)}g=p[e]?"keydown":"keypress"}"keypress"==g&&d.length&&(g="keydown");return{key:m,modifiers:d,action:g}}function D(a,b){return null===a||a===u?!1:a===b?!0:D(a.parentNode,b)}function d(a){function b(a){a= + a||{};var b=!1,l;for(l in p)a[l]?b=!0:p[l]=0;b||(x=!1)}function g(a,b,t,f,g,d){var l,E=[],h=t.type;if(!k._callbacks[a])return[];"keyup"==h&&w(a)&&(b=[a]);for(l=0;l":".","?":"/","|":"\\"},B={option:"alt",command:"meta","return":"enter", + escape:"esc",plus:"+",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},p;for(c=1;20>c;++c)n[111+c]="f"+c;for(c=0;9>=c;++c)n[c+96]=c.toString();d.prototype.bind=function(a,b,c){a=a instanceof Array?a:[a];this._bindMultiple.call(this,a,b,c);return this};d.prototype.unbind=function(a,b){return this.bind.call(this,a,function(){},b)};d.prototype.trigger=function(a,b){if(this._directMap[a+":"+b])this._directMap[a+":"+b]({},a);return this};d.prototype.reset=function(){this._callbacks={}; + this._directMap={};return this};d.prototype.stopCallback=function(a,b){if(-1<(" "+b.className+" ").indexOf(" mousetrap ")||D(b,this.target))return!1;if("composedPath"in a&&"function"===typeof a.composedPath){var c=a.composedPath()[0];c!==a.target&&(b=c)}return"INPUT"==b.tagName||"SELECT"==b.tagName||"TEXTAREA"==b.tagName||b.isContentEditable};d.prototype.handleKey=function(){return this._handleKey.apply(this,arguments)};d.addKeycodes=function(a){for(var b in a)a.hasOwnProperty(b)&&(n[b]=a[b]);p=null}; + d.init=function(){var a=d(u),b;for(b in a)"_"!==b.charAt(0)&&(d[b]=function(b){return function(){return a[b].apply(a,arguments)}}(b))};d.init();q.Mousetrap=d;"undefined"!==typeof module&&module.exports&&(module.exports=d);"function"===typeof define&&define.amd&&define(function(){return d})}})("undefined"!==typeof window?window:null,"undefined"!==typeof window?document:null); \ No newline at end of file diff --git a/js/pages/backoffice/keyboard-shortcuts.js b/js/pages/backoffice/keyboard-shortcuts.js new file mode 100644 index 000000000..b24e95f8a --- /dev/null +++ b/js/pages/backoffice/keyboard-shortcuts.js @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2013-2020 Combodo SARL + * + * This file is part of iTop. + * + * iTop is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * iTop is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + */ + +$(function() { + $.widget('itop.keyboard_shortcuts', + { + // default options + options: + { + shortcuts: {} + }, + _create: function(){ + this._initializeBinds(); + }, + _initializeBinds: function (){ + for(let sShortcutId in this.options.shortcuts){ + let aShortcut = this.options.shortcuts[sShortcutId]; + Mousetrap.bind(aShortcut.key, function() { + $(aShortcut.triggered_element_selector).trigger(aShortcut.event); + },'keyup'); + }; + } + }); +}); \ No newline at end of file diff --git a/js/pages/backoffice/toolbox.js b/js/pages/backoffice/toolbox.js index 7294849e9..0b1cec8e4 100644 --- a/js/pages/backoffice/toolbox.js +++ b/js/pages/backoffice/toolbox.js @@ -132,6 +132,9 @@ CKEDITOR.plugins.add( 'disabler', // Processing on each pages of the backoffice $(document).ready(function(){ + // Initialize global keyboard shortcuts + $('body').keyboard_shortcuts({shortcuts: aKeyboardShortcuts}); + // Enable tooltips based on existing HTML markup, won't work on markup added dynamically after DOM ready (AJAX, ...) $('[data-tooltip-content]:not([data-tooltip-instantiated="true"])').each(function () { CombodoTooltip.InitTooltipFromMarkup($(this)); diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index 62be6df58..d5c76c9bf 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -2293,6 +2293,7 @@ return array( 'iDBObjectSetIterator' => $baseDir . '/core/dbobjectiterator.php', 'iDBObjectURLMaker' => $baseDir . '/application/applicationcontext.class.inc.php', 'iDisplay' => $baseDir . '/core/dbobject.class.php', + 'iKeyboardShortcut' => $baseDir . '/sources/application/UI/Hook/iKeyboardShortcut.php', 'iLogFileNameBuilder' => $baseDir . '/core/log.class.inc.php', 'iLoginExtension' => $baseDir . '/application/applicationextension.inc.php', 'iLoginFSMExtension' => $baseDir . '/application/applicationextension.inc.php', diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php index 407ea4787..0d853571b 100644 --- a/lib/composer/autoload_static.php +++ b/lib/composer/autoload_static.php @@ -2523,6 +2523,7 @@ class ComposerStaticInit0018331147de7601e7552f7da8e3bb8b 'iDBObjectSetIterator' => __DIR__ . '/../..' . '/core/dbobjectiterator.php', 'iDBObjectURLMaker' => __DIR__ . '/../..' . '/application/applicationcontext.class.inc.php', 'iDisplay' => __DIR__ . '/../..' . '/core/dbobject.class.php', + 'iKeyboardShortcut' => __DIR__ . '/../..' . '/sources/application/UI/Hook/iKeyboardShortcut.php', 'iLogFileNameBuilder' => __DIR__ . '/../..' . '/core/log.class.inc.php', 'iLoginExtension' => __DIR__ . '/../..' . '/application/applicationextension.inc.php', 'iLoginFSMExtension' => __DIR__ . '/../..' . '/application/applicationextension.inc.php', diff --git a/pages/preferences.php b/pages/preferences.php index ee58e232c..284de8a6d 100644 --- a/pages/preferences.php +++ b/pages/preferences.php @@ -308,6 +308,72 @@ JS $oContentLayout->AddMainBlock($oNewsroomBlock); } + ////////////////////////////////////////////////////////////////////////// + // + // User defined keyboard shortcut + // + ////////////////////////////////////////////////////////////////////////// + + // Panel + $oKeyboardShortcutBlock = new Panel(Dict::S('UI:Preferences:PersonalizeKeyboardShortcuts:Title'), array(), 'grey', 'ibo_keyboard_shortcuts'); + // Form + $oKeyboardShortcutForm = new Form('ibo-form-for-user-interface-preferences'); + $oKeyboardShortcutForm->AddSubBlock(InputUIBlockFactory::MakeForHidden('operation', 'apply_keyboard_shortcuts')) + ->AddSubBlock($oAppContext->GetForFormBlock()); + + $oKeyboardShortcutBlock->AddSubBlock($oKeyboardShortcutForm); + + $sKeyboardShortcutBlockId = $oKeyboardShortcutBlock->GetId(); + // JS keyboard listener + $oP->add_script( + << $aKeyboardShortcut){ + // Recording button + $oButton = ButtonUIBlockFactory::MakeForAlternativeSecondaryAction(''); + $oButton->SetIconClass('fas fa-pen')->SetTooltip($sKeyboardShortcutsButtonTooltip)->SetOnClickJsCode( + <<GetInput()->AddCSSClasses(['ibo-keyboard-shortcut--input']); + $oKeyboardShortcutForm->AddSubBlock(new Html('
')); + $oKeyboardShortcutForm->AddSubBlock($oInput); + $oKeyboardShortcutForm->AddSubBlock($oButton); + $oKeyboardShortcutForm->AddSubBlock(new Html('
')); + } + + // Prepare buttons + $oKeyboardShortcutToolbar = ToolbarUIBlockFactory::MakeForButton(null, ['ibo-is-fullwidth']); + $oKeyboardShortcutForm->AddSubBlock($oKeyboardShortcutToolbar); + + // - Cancel button + $oKeyboardShortcutCancelButton = ButtonUIBlockFactory::MakeForCancel(); + $oKeyboardShortcutToolbar->AddSubBlock($oKeyboardShortcutCancelButton); + $oKeyboardShortcutCancelButton->SetOnClickJsCode("window.location.href = '$sURL'"); + // - Submit button + $oKeyboardShortcutSubmitButton = ButtonUIBlockFactory::MakeForPrimaryAction(Dict::S('UI:Button:Apply'), 'operation', 'apply_keyboard_shortcuts', true); + $oKeyboardShortcutToolbar->AddSubBlock($oKeyboardShortcutSubmitButton); + + $oContentLayout->AddMainBlock($oKeyboardShortcutBlock); + ////////////////////////////////////////////////////////////////////////// // // User picture placeholder @@ -642,7 +708,19 @@ try { $sURL = utils::GetAbsoluteUrlAppRoot().'pages/preferences.php?'.$oAppContext->GetForLink(); $oPage->add_header('Location: '.$sURL); break; - + case 'apply_keyboard_shortcuts': + $aShortcutClasses = utils::GetClassesForInterface('iKeyboardShortcut','', array('/lib/', 'node_modules', 'test')); + $aShortcutPrefs = []; + foreach($aShortcutClasses as $cShortcutPlugin) { + foreach ($cShortcutPlugin::GetShortcutKeys() as $aShortcutKey) { + $sKey = utils::ReadParam($aShortcutKey['id'], $aShortcutKey['key'], true,'raw_data'); + $aShortcutPrefs[$aShortcutKey['id']] = strtolower($sKey); + } + } + appUserPreferences::SetPref('keyboard_shortcuts', $aShortcutPrefs); + + DisplayPreferences($oPage); + break; case 'apply_newsroom_preferences': $iCountProviders = 0; $oUser = UserRights::GetUserObject(); diff --git a/sources/application/UI/Base/Component/GlobalSearch/GlobalSearch.php b/sources/application/UI/Base/Component/GlobalSearch/GlobalSearch.php index c6a562561..b6445e808 100644 --- a/sources/application/UI/Base/Component/GlobalSearch/GlobalSearch.php +++ b/sources/application/UI/Base/Component/GlobalSearch/GlobalSearch.php @@ -21,6 +21,7 @@ namespace Combodo\iTop\Application\UI\Base\Component\GlobalSearch; use Combodo\iTop\Application\UI\Base\UIBlock; +use iKeyboardShortcut; use MetaModel; use utils; @@ -32,7 +33,7 @@ use utils; * @internal * @since 3.0.0 */ -class GlobalSearch extends UIBlock +class GlobalSearch extends UIBlock implements iKeyboardShortcut { // Overloaded constants public const BLOCK_CODE = 'ibo-global-search'; @@ -172,4 +173,14 @@ class GlobalSearch extends UIBlock { return $this->iMaxHistoryResults; } + + public static function GetShortcutKeys(): array + { + return [['id' => 'ibo-open-global-search', 'label' => 'UI:Component:GlobalSearch:KeyboardShortcut:OpenDrawer', 'key' => 'g', 'event' => 'open_drawer']]; + } + + public static function GetShortcutTriggeredElementSelector(): string + { + return "[data-role='".static::BLOCK_CODE."']"; + } } \ No newline at end of file diff --git a/sources/application/UI/Base/Component/QuickCreate/QuickCreate.php b/sources/application/UI/Base/Component/QuickCreate/QuickCreate.php index ec5695f00..acf8e8c08 100644 --- a/sources/application/UI/Base/Component/QuickCreate/QuickCreate.php +++ b/sources/application/UI/Base/Component/QuickCreate/QuickCreate.php @@ -21,6 +21,7 @@ namespace Combodo\iTop\Application\UI\Base\Component\QuickCreate; use Combodo\iTop\Application\UI\Base\UIBlock; +use iKeyboardShortcut; use MetaModel; use UserRights; use utils; @@ -33,7 +34,7 @@ use utils; * @internal * @since 3.0.0 */ -class QuickCreate extends UIBlock +class QuickCreate extends UIBlock implements iKeyboardShortcut { // Overloaded constants public const BLOCK_CODE = 'ibo-quick-create'; @@ -201,4 +202,14 @@ class QuickCreate extends UIBlock { return $this->iMaxHistoryResults; } + + public static function GetShortcutKeys(): array + { + return [['id' => 'ibo-open-quick-create', 'label' => 'UI:Component:QuickCreate:KeyboardShortcut:OpenDrawer', 'key' => 'c', 'event' => 'open_drawer']]; + } + + public static function GetShortcutTriggeredElementSelector(): string + { + return "[data-role='".static::BLOCK_CODE."']"; + } } \ No newline at end of file diff --git a/sources/application/UI/Base/Layout/NavigationMenu/NavigationMenu.php b/sources/application/UI/Base/Layout/NavigationMenu/NavigationMenu.php index 0a02f20ae..9c648fa34 100644 --- a/sources/application/UI/Base/Layout/NavigationMenu/NavigationMenu.php +++ b/sources/application/UI/Base/Layout/NavigationMenu/NavigationMenu.php @@ -30,6 +30,7 @@ use Combodo\iTop\Application\UI\Base\Component\PopoverMenu\PopoverMenu; use Combodo\iTop\Application\UI\Base\UIBlock; use DBObjectSearch; use Dict; +use iKeyboardShortcut; use MetaModel; use UIExtKeyWidget; use UserRights; @@ -43,7 +44,7 @@ use utils; * @internal * @since 3.0.0 */ -class NavigationMenu extends UIBlock +class NavigationMenu extends UIBlock implements iKeyboardShortcut { // Overloaded constants public const BLOCK_CODE = 'ibo-navigation-menu'; @@ -444,4 +445,14 @@ JS; return $this; } + + public static function GetShortcutKeys(): array + { + return [['id' => 'ibo-open-menu-filter', 'label' => 'UI:Layout:NavigationMenu:KeyboardShortcut:FocusFilter', 'key'=> 'alt+m', 'event' => 'filter_shortcut']]; + } + + public static function GetShortcutTriggeredElementSelector(): string + { + return "[data-role='".static::BLOCK_CODE."']"; + } } \ No newline at end of file diff --git a/sources/application/UI/Base/Layout/Object/ObjectDetails.php b/sources/application/UI/Base/Layout/Object/ObjectDetails.php index 289eff65b..e7d59fdd7 100644 --- a/sources/application/UI/Base/Layout/Object/ObjectDetails.php +++ b/sources/application/UI/Base/Layout/Object/ObjectDetails.php @@ -11,13 +11,15 @@ use cmdbAbstractObject; use Combodo\iTop\Application\UI\Base\Component\Panel\Panel; use Combodo\iTop\Application\UI\Helper\UIHelper; use DBObject; +use iKeyboardShortcut; use MetaModel; -class ObjectDetails extends Panel +class ObjectDetails extends Panel implements iKeyboardShortcut { // Overloaded constants public const BLOCK_CODE = 'ibo-object-details'; public const DEFAULT_HTML_TEMPLATE_REL_PATH = 'base/layouts/object/object-details/layout'; + public const DEFAULT_JS_TEMPLATE_REL_PATH = 'base/layouts/object/object-details/layout'; /** @var string Class name of the object (eg. "UserRequest") */ protected $sClassName; @@ -197,4 +199,17 @@ class ObjectDetails extends Panel $this->sStatusColor = UIHelper::GetColorFromStatus($this->sClassName, $this->sStatusCode); } } + + public static function GetShortcutKeys(): array + { + return [['id' => 'ibo-edit-object', 'label' => 'UI:Layout:ObjectDetails:KeyboardShortcut:EditObject', 'key' => 'e', 'event' => 'edit_object'], + ['id' => 'ibo-delete-object', 'label' => 'UI:Layout:ObjectDetails:KeyboardShortcut:DeleteObject', 'key' => 'd', 'event' => 'delete_object'], + ['id' => 'ibo-new-object', 'label' => 'UI:Layout:ObjectDetails:KeyboardShortcut:NewObject', 'key' => 'n', 'event' => 'new_object'], + ['id' => 'ibo-save-object', 'label' => 'UI:Layout:ObjectDetails:KeyboardShortcut:SaveObject', 'key' => 's', 'event' => 'save_object']]; + } + + public static function GetShortcutTriggeredElementSelector(): string + { + return "[data-role='".static::BLOCK_CODE."']"; + } } \ No newline at end of file diff --git a/sources/application/UI/Hook/iKeyboardShortcut.php b/sources/application/UI/Hook/iKeyboardShortcut.php new file mode 100644 index 000000000..d10feee7a --- /dev/null +++ b/sources/application/UI/Hook/iKeyboardShortcut.php @@ -0,0 +1,19 @@ +SetTopBarLayout(TopBarFactory::MakeStandard($this->GetBreadCrumbsNewEntry())); utils::InitArchiveMode(); - + $this->m_aMessages = array(); $this->SetRootUrl(utils::GetAbsoluteUrlAppRoot()); $this->add_header("Content-type: text/html; charset=".self::PAGES_CHARSET); @@ -134,6 +134,9 @@ class iTopWebPage extends NiceWebPage implements iTabbedPage $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/jquery.magnific-popup.min.js'); $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/moment-with-locales.min.js'); $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/showdown.min.js'); + $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/mousetrap/mousetrap.min.js'); + $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/mousetrap/mousetrap-record.min.js'); + $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/pages/backoffice/keyboard-shortcuts.js'); $this->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/pages/backoffice/toolbox.js'); } @@ -176,6 +179,16 @@ class iTopWebPage extends NiceWebPage implements iTabbedPage $this->add_linked_stylesheet(utils::GetAbsoluteUrlAppRoot().'css/selectize.default.css'); } + /** + * @since 3.0.0 + */ + protected function InitializeKeyboardShortcuts(): void + { + $aShortcuts = utils::GetKeyboardShortcutPref(); + $sShortcuts = json_encode($aShortcuts); + $this->add_script("aKeyboardShortcuts = $sShortcuts;"); + } + /** * */ @@ -861,6 +874,8 @@ HTML; // Components // Note: For now all components are either included in the layouts above or put in page through the AddUiBlock() API, so there is no need to do anything more. + $this->InitializeKeyboardShortcuts(); + // Variable content of the page $aData['aPage'] = array_merge( $aData['aPage'], diff --git a/templates/base/layouts/navigation-menu/layout.html.twig b/templates/base/layouts/navigation-menu/layout.html.twig index f3c690d91..3e34d2517 100644 --- a/templates/base/layouts/navigation-menu/layout.html.twig +++ b/templates/base/layouts/navigation-menu/layout.html.twig @@ -1,4 +1,4 @@ -