Built controller actions and initual UI.
Still needs JS logic for shortcut input handling.
// 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'),
namespace BookStack\Http\Controllers;
use BookStack\Auth\UserRepo;
+use BookStack\Settings\UserShortcutMap;
use Illuminate\Http\Request;
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.
*/
--- /dev/null
+<?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) ?: []);
+ }
+}
--- /dev/null
+<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
-/**
- * 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)) {
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();
}
.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
<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
</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>
</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>
{{ 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>
<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">
<!-- 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')
--- /dev/null
+<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
--- /dev/null
+@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
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']);