diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index cb6082c52..5e1e4348a 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -26,6 +26,8 @@ return [ // User-level default settings 'user' => [ + 'ui-shortcuts' => '{}', + 'ui-shortcuts-enabled' => false, 'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false), 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), diff --git a/app/Http/Controllers/UserPreferencesController.php b/app/Http/Controllers/UserPreferencesController.php index 972742e03..b8bb31468 100644 --- a/app/Http/Controllers/UserPreferencesController.php +++ b/app/Http/Controllers/UserPreferencesController.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Controllers; use BookStack\Auth\UserRepo; +use BookStack\Settings\UserShortcutMap; use Illuminate\Http\Request; class UserPreferencesController extends Controller @@ -14,6 +15,37 @@ class UserPreferencesController extends Controller $this->userRepo = $userRepo; } + /** + * Show the user-specific interface shortcuts. + */ + public function showShortcuts() + { + $shortcuts = UserShortcutMap::fromUserPreferences(); + $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false); + + return view('users.preferences.shortcuts', [ + 'shortcuts' => $shortcuts, + 'enabled' => $enabled, + ]); + } + + /** + * Update the user-specific interface shortcuts. + */ + public function updateShortcuts(Request $request) + { + $enabled = $request->get('enabled') === 'true'; + $providedShortcuts = $request->get('shortcuts', []); + $shortcuts = new UserShortcutMap($providedShortcuts); + + setting()->putUser(user(), 'ui-shortcuts', $shortcuts->toJson()); + setting()->putUser(user(), 'ui-shortcuts-enabled', $enabled); + + $this->showSuccessNotification('Shortcuts preferences have been updated!'); + + return redirect('/preferences/shortcuts'); + } + /** * Update the user's preferred book-list display setting. */ diff --git a/app/Settings/UserShortcutMap.php b/app/Settings/UserShortcutMap.php new file mode 100644 index 000000000..da2ea3c10 --- /dev/null +++ b/app/Settings/UserShortcutMap.php @@ -0,0 +1,82 @@ +<?php + +namespace BookStack\Settings; + +class UserShortcutMap +{ + protected const DEFAULTS = [ + // Header actions + "home_view" => "1", + "shelves_view" => "2", + "books_view" => "3", + "settings_view" => "4", + "favourites_view" => "5", + "profile_view" => "6", + "global_search" => "/", + "logout" => "0", + + // Common actions + "edit" => "e", + "new" => "n", + "copy" => "c", + "delete" => "d", + "favourite" => "f", + "export" => "x", + "sort" => "s", + "permissions" => "p", + "move" => "m", + "revisions" => "r", + + // Navigation + "next" => "ArrowRight", + "previous" => "ArrowLeft", + ]; + + /** + * @var array<string, string> + */ + protected array $mapping; + + public function __construct(array $map) + { + $this->mapping = static::DEFAULTS; + $this->merge($map); + } + + /** + * Merge the given map into the current shortcut mapping. + */ + protected function merge(array $map): void + { + foreach ($map as $key => $value) { + if (is_string($value) && isset($this->mapping[$key])) { + $this->mapping[$key] = $value; + } + } + } + + /** + * Get the shortcut defined for the given ID. + */ + public function getShortcut(string $id): string + { + return $this->mapping[$id] ?? ''; + } + + /** + * Convert this mapping to JSON. + */ + public function toJson(): string + { + return json_encode($this->mapping); + } + + /** + * Create a new instance from the current user's preferences. + */ + public static function fromUserPreferences(): self + { + $userKeyMap = setting()->getForCurrentUser('ui-shortcuts'); + return new self(json_decode($userKeyMap, true) ?: []); + } +} diff --git a/resources/icons/shortcuts.svg b/resources/icons/shortcuts.svg new file mode 100644 index 000000000..8d23aac2b --- /dev/null +++ b/resources/icons/shortcuts.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm8 7H9c-.55 0-1-.45-1-1s.45-1 1-1h6c.55 0 1 .45 1 1s-.45 1-1 1zm1-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg> \ No newline at end of file diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js index a3cca5ddc..ccad00f5d 100644 --- a/resources/js/components/shortcuts.js +++ b/resources/js/components/shortcuts.js @@ -1,35 +1,3 @@ -/** - * The default mapping of unique id to shortcut key. - * @type {Object<string, string>} - */ -const defaultMap = { - // Header actions - "home": "1", - "shelves_view": "2", - "books_view": "3", - "settings_view": "4", - "favorites_view": "5", - "profile_view": "6", - "global_search": "/", - "logout": "0", - - // Generic actions - "edit": "e", - "new": "n", - "copy": "c", - "delete": "d", - "favorite": "f", - "export": "x", - "sort": "s", - "permissions": "p", - "move": "m", - "revisions": "r", - - // Navigation - "next": "ArrowRight", - "prev": "ArrowLeft", -}; - function reverseMap(map) { const reversed = {}; for (const [key, value] of Object.entries(map)) { @@ -45,14 +13,12 @@ class Shortcuts { setup() { this.container = this.$el; - this.mapById = defaultMap; + this.mapById = JSON.parse(this.$opts.keyMap); this.mapByShortcut = reverseMap(this.mapById); this.hintsShowing = false; this.hideHints = this.hideHints.bind(this); - // TODO - Allow custom key maps - // TODO - Allow turning off shortcuts this.setupListeners(); } diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 7e0f72355..7de8a9d7d 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -473,4 +473,10 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] { .custom-file-input:focus + label { border-color: var(--color-primary); outline: 1px solid var(--color-primary); +} + +input.shortcut-input { + width: auto; + max-width: 120px; + height: auto; } \ No newline at end of file diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 0481b3412..dd463b76e 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -2,7 +2,7 @@ <div class="grid mx-l"> <div> - <a href="{{ url('/') }}" data-shortcut="home" class="logo"> + <a href="{{ url('/') }}" data-shortcut="home_view" class="logo"> @if(setting('app-logo', '') !== 'none') <img class="logo-image" src="{{ setting('app-logo', '') === '' ? url('/logo.png') : url(setting('app-logo', '')) }}" alt="Logo"> @endif @@ -62,7 +62,7 @@ </span> <ul refs="dropdown@menu" class="dropdown-menu" role="menu"> <li> - <a href="{{ url('/favourites') }}" data-shortcut="favorites_view" class="icon-item"> + <a href="{{ url('/favourites') }}" data-shortcut="favourites_view" class="icon-item"> @icon('star') <div>{{ trans('entities.my_favourites') }}</div> </a> @@ -90,6 +90,12 @@ </form> </li> <li><hr></li> + <li> + <a href="{{ url('/preferences/shortcuts') }}" class="icon-item"> + @icon('shortcuts') + <div>{{ 'Shortcuts' }}</div> + </a> + </li> <li> @include('common.dark-mode-toggle', ['classes' => 'icon-item']) </li> diff --git a/resources/views/entities/favourite-action.blade.php b/resources/views/entities/favourite-action.blade.php index f38899e3e..24bd40950 100644 --- a/resources/views/entities/favourite-action.blade.php +++ b/resources/views/entities/favourite-action.blade.php @@ -5,7 +5,7 @@ {{ csrf_field() }} <input type="hidden" name="type" value="{{ get_class($entity) }}"> <input type="hidden" name="id" value="{{ $entity->id }}"> - <button type="submit" data-shortcut="favorite" class="icon-list-item text-primary"> + <button type="submit" data-shortcut="favourite" class="icon-list-item text-primary"> <span>@icon($isFavourite ? 'star' : 'star-outline')</span> <span>{{ $isFavourite ? trans('common.unfavourite') : trans('common.favourite') }}</span> </button> diff --git a/resources/views/entities/sibling-navigation.blade.php b/resources/views/entities/sibling-navigation.blade.php index 629a8ff32..28a9cb029 100644 --- a/resources/views/entities/sibling-navigation.blade.php +++ b/resources/views/entities/sibling-navigation.blade.php @@ -1,7 +1,7 @@ <div id="sibling-navigation" class="grid half collapse-xs items-center mb-m px-m no-row-gap fade-in-when-active print-hidden"> <div> @if($previous) - <a href="{{ $previous->getUrl() }}" data-shortcut="prev" class="outline-hover no-link-style block rounded"> + <a href="{{ $previous->getUrl() }}" data-shortcut="previous" class="outline-hover no-link-style block rounded"> <div class="px-m pt-xs text-muted">{{ trans('common.previous') }}</div> <div class="inline-block"> <div class="icon-list-item no-hover"> diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 928eb17a0..2f649423d 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -31,7 +31,12 @@ <!-- Translations for JS --> @stack('translations') </head> -<body component="shortcuts" class="@stack('body-class')"> +<body + @if(setting()->getForCurrentUser('ui-shortcuts-enabled', false)) + component="shortcuts" + option:shortcuts:key-map="{{ \BookStack\Settings\UserShortcutMap::fromUserPreferences()->toJson() }}" + @endif + class="@stack('body-class')"> @include('layouts.parts.base-body-start') @include('common.skip-to-content') diff --git a/resources/views/users/preferences/parts/shortcut-control.blade.php b/resources/views/users/preferences/parts/shortcut-control.blade.php new file mode 100644 index 000000000..47fec3a5e --- /dev/null +++ b/resources/views/users/preferences/parts/shortcut-control.blade.php @@ -0,0 +1,11 @@ +<div class="flex-container-row justify-space-between items-center gap-m item-list-row"> + <label for="shortcut-{{ $label }}" class="bold flex px-m py-xs">{{ $label }}</label> + <div class="px-m py-xs"> + <input type="text" + class="small flex-none shortcut-input px-s py-xs" + id="shortcut-{{ $id }}" + name="shortcut[{{ $id }}]" + readonly + value="{{ $shortcuts->getShortcut($id) }}"> + </div> +</div> \ No newline at end of file diff --git a/resources/views/users/preferences/shortcuts.blade.php b/resources/views/users/preferences/shortcuts.blade.php new file mode 100644 index 000000000..9bb8e8175 --- /dev/null +++ b/resources/views/users/preferences/shortcuts.blade.php @@ -0,0 +1,78 @@ +@extends('layouts.simple') + +@section('body') + <div class="container small my-xl"> + + <section class="card content-wrap"> + <form action="{{ url('/preferences/shortcuts') }}" method="post"> + {{ method_field('put') }} + {{ csrf_field() }} + + <h1 class="list-heading">Interface Keyboard Shortcuts</h1> + + <div class="flex-container-row items-center gap-m wrap mb-m"> + <p class="flex mb-none min-width-m text-small text-muted"> + Here you can enable or disable keyboard system interface shortcuts, used for navigation + and actions. You can customize each of the shortcuts below. + </p> + <div class="flex min-width-m text-m-right"> + @include('form.toggle-switch', [ + 'name' => 'enabled', + 'value' => $enabled, + 'label' => 'Keyboard shortcuts enabled', + ]) + </div> + </div> + + <hr> + + <h2 class="list-heading mb-m">Navigation</h2> + <div class="flex-container-row wrap gap-m mb-xl"> + <div class="flex min-width-l item-list"> + @include('users.preferences.parts.shortcut-control', ['label' => 'Homepage', 'id' => 'home_view']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.shelves'), 'id' => 'shelves_view']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.books'), 'id' => 'books_view']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('settings.settings'), 'id' => 'settings_view']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.my_favourites'), 'id' => 'favourites_view']) + </div> + <div class="flex min-width-l item-list"> + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.view_profile'), 'id' => 'profile_view']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('auth.logout'), 'id' => 'logout']) + @include('users.preferences.parts.shortcut-control', ['label' => 'Global Search', 'id' => 'global_search']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.next'), 'id' => 'next']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.previous'), 'id' => 'previous']) + </div> + </div> + + <h2 class="list-heading mb-m">Common Actions</h2> + <div class="flex-container-row wrap gap-m mb-xl"> + <div class="flex min-width-l item-list"> + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.new'), 'id' => 'new']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.edit'), 'id' => 'edit']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.copy'), 'id' => 'copy']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.delete'), 'id' => 'delete']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.favourite'), 'id' => 'favourite']) + </div> + <div class="flex min-width-l item-list"> + @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.export'), 'id' => 'export']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.sort'), 'id' => 'sort']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.permissions'), 'id' => 'permissions']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('common.move'), 'id' => 'move']) + @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.revisions'), 'id' => 'revisions']) + </div> + </div> + + <p class="text-small text-muted"> + Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will + highlight the available shortcuts for actions currently visible on the screen. + </p> + + <div class="form-group text-right"> + <button class="button">{{ 'Save Shortcuts' }}</button> + </div> + + </form> + </section> + + </div> +@stop diff --git a/routes/web.php b/routes/web.php index b3f11f53a..f9899dba6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -246,6 +246,9 @@ Route::middleware('auth')->group(function () { Route::delete('/settings/users/{id}', [UserController::class, 'destroy']); // User Preferences + Route::redirect('/preferences', '/'); + Route::get('/preferences/shortcuts', [UserPreferencesController::class, 'showShortcuts']); + Route::put('/preferences/shortcuts', [UserPreferencesController::class, 'updateShortcuts']); Route::patch('/settings/users/{id}/switch-books-view', [UserPreferencesController::class, 'switchBooksView']); Route::patch('/settings/users/{id}/switch-shelves-view', [UserPreferencesController::class, 'switchShelvesView']); Route::patch('/settings/users/{id}/switch-shelf-view', [UserPreferencesController::class, 'switchShelfView']);