diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php index c589fd964..78bcb978e 100644 --- a/app/Auth/UserRepo.php +++ b/app/Auth/UserRepo.php @@ -158,6 +158,9 @@ class UserRepo // Delete user profile images $this->userAvatar->destroyAllForUser($user); + // Delete related activities + setting()->deleteUserSettings($user->id); + if (!empty($newOwnerId)) { $newOwner = User::query()->find($newOwnerId); if (!is_null($newOwner)) { 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..aef959712 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 @@ -15,70 +16,76 @@ class UserPreferencesController extends Controller } /** - * Update the user's preferred book-list display setting. + * Show the user-specific interface shortcuts. */ - public function switchBooksView(Request $request, int $id) + public function showShortcuts() { - return $this->switchViewType($id, $request, 'books'); + $shortcuts = UserShortcutMap::fromUserPreferences(); + $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false); + + return view('users.preferences.shortcuts', [ + 'shortcuts' => $shortcuts, + 'enabled' => $enabled, + ]); } /** - * Update the user's preferred shelf-list display setting. + * Update the user-specific interface shortcuts. */ - public function switchShelvesView(Request $request, int $id) + public function updateShortcuts(Request $request) { - return $this->switchViewType($id, $request, 'bookshelves'); + $enabled = $request->get('enabled') === 'true'; + $providedShortcuts = $request->get('shortcut', []); + $shortcuts = new UserShortcutMap($providedShortcuts); + + setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson()); + setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled); + + $this->showSuccessNotification(trans('preferences.shortcuts_update_success')); + + return redirect('/preferences/shortcuts'); } /** - * Update the user's preferred shelf-view book list display setting. + * Update the preferred view format for a list view of the given type. */ - public function switchShelfView(Request $request, int $id) + public function changeView(Request $request, string $type) { - return $this->switchViewType($id, $request, 'bookshelf'); - } - - /** - * For a type of list, switch with stored view type for a user. - */ - protected function switchViewType(int $userId, Request $request, string $listName) - { - $this->checkPermissionOrCurrentUser('users-manage', $userId); - - $viewType = $request->get('view_type'); - if (!in_array($viewType, ['grid', 'list'])) { - $viewType = 'list'; + $valueViewTypes = ['books', 'bookshelves', 'bookshelf']; + if (!in_array($type, $valueViewTypes)) { + return redirect()->back(500); } - $user = $this->userRepo->getById($userId); - $key = $listName . '_view_type'; - setting()->putUser($user, $key, $viewType); + $view = $request->get('view'); + if (!in_array($view, ['grid', 'list'])) { + $view = 'list'; + } - return redirect()->back(302, [], "/settings/users/$userId"); + $key = $type . '_view_type'; + setting()->putForCurrentUser($key, $view); + + return redirect()->back(302, [], "/"); } /** * Change the stored sort type for a particular view. */ - public function changeSort(Request $request, string $id, string $type) + public function changeSort(Request $request, string $type) { $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions']; if (!in_array($type, $validSortTypes)) { return redirect()->back(500); } - $this->checkPermissionOrCurrentUser('users-manage', $id); - $sort = substr($request->get('sort') ?: 'name', 0, 50); $order = $request->get('order') === 'desc' ? 'desc' : 'asc'; - $user = $this->userRepo->getById($id); $sortKey = $type . '_sort'; $orderKey = $type . '_sort_order'; - setting()->putUser($user, $sortKey, $sort); - setting()->putUser($user, $orderKey, $order); + setting()->putForCurrentUser($sortKey, $sort); + setting()->putForCurrentUser($orderKey, $order); - return redirect()->back(302, [], "/settings/users/{$id}"); + return redirect()->back(302, [], "/"); } /** @@ -87,7 +94,7 @@ class UserPreferencesController extends Controller public function toggleDarkMode() { $enabled = setting()->getForCurrentUser('dark-mode-enabled', false); - setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true'); + setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true'); return redirect()->back(); } @@ -95,18 +102,15 @@ class UserPreferencesController extends Controller /** * Update the stored section expansion preference for the given user. */ - public function updateExpansionPreference(Request $request, string $id, string $key) + public function changeExpansion(Request $request, string $type) { - $this->checkPermissionOrCurrentUser('users-manage', $id); - $keyWhitelist = ['home-details']; - if (!in_array($key, $keyWhitelist)) { + $typeWhitelist = ['home-details']; + if (!in_array($type, $typeWhitelist)) { return response('Invalid key', 500); } $newState = $request->get('expand', 'false'); - - $user = $this->userRepo->getById($id); - setting()->putUser($user, 'section_expansion#' . $key, $newState); + setting()->putForCurrentUser('section_expansion#' . $type, $newState); return response('', 204); } @@ -129,6 +133,6 @@ class UserPreferencesController extends Controller array_splice($currentFavorites, $index, 1); } - setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites)); + setting()->putForCurrentUser('code-language-favourites', implode(',', $currentFavorites)); } } diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index f2c4c8305..9f0a41ea2 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -194,6 +194,8 @@ class SettingService /** * Put a user-specific setting into the database. + * Can only take string value types since this may use + * the session which is less flexible to data types. */ public function putUser(User $user, string $key, string $value): bool { @@ -206,6 +208,16 @@ class SettingService return $this->put($this->userKey($user->id, $key), $value); } + /** + * Put a user-specific setting into the database for the current access user. + * Can only take string value types since this may use + * the session which is less flexible to data types. + */ + public function putForCurrentUser(string $key, string $value) + { + return $this->putUser(user(), $key, $value); + } + /** * Convert a setting key into a user-specific key. */ 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 @@ + "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 + */ + 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 @@ + \ No newline at end of file diff --git a/resources/js/components/code-editor.js b/resources/js/components/code-editor.js index 2d8031205..d0c6c432a 100644 --- a/resources/js/components/code-editor.js +++ b/resources/js/components/code-editor.js @@ -73,7 +73,7 @@ class CodeEditor { isFavorite ? this.favourites.add(language) : this.favourites.delete(language); button.setAttribute('data-favourite', isFavorite ? 'true' : 'false'); - window.$http.patch('/settings/users/update-code-language-favourite', { + window.$http.patch('/preferences/update-code-language-favourite', { language: language, active: isFavorite }); diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 5b84edba0..ee282b1fd 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -43,6 +43,8 @@ import popup from "./popup.js" import settingAppColorPicker from "./setting-app-color-picker.js" import settingColorPicker from "./setting-color-picker.js" import shelfSort from "./shelf-sort.js" +import shortcuts from "./shortcuts"; +import shortcutInput from "./shortcut-input"; import sidebar from "./sidebar.js" import sortableList from "./sortable-list.js" import submitOnChange from "./submit-on-change.js" @@ -101,6 +103,8 @@ const componentMapping = { "setting-app-color-picker": settingAppColorPicker, "setting-color-picker": settingColorPicker, "shelf-sort": shelfSort, + "shortcuts": shortcuts, + "shortcut-input": shortcutInput, "sidebar": sidebar, "sortable-list": sortableList, "submit-on-change": submitOnChange, diff --git a/resources/js/components/shortcut-input.js b/resources/js/components/shortcut-input.js new file mode 100644 index 000000000..fa1378988 --- /dev/null +++ b/resources/js/components/shortcut-input.js @@ -0,0 +1,57 @@ +/** + * Keys to ignore when recording shortcuts. + * @type {string[]} + */ +const ignoreKeys = ['Control', 'Alt', 'Shift', 'Meta', 'Super', ' ', '+', 'Tab', 'Escape']; + +/** + * @extends {Component} + */ +class ShortcutInput { + + setup() { + this.input = this.$el; + + this.setupListeners(); + } + + setupListeners() { + this.listenerRecordKey = this.listenerRecordKey.bind(this); + + this.input.addEventListener('focus', () => { + this.startListeningForInput(); + }); + + this.input.addEventListener('blur', () => { + this.stopListeningForInput(); + }) + } + + startListeningForInput() { + this.input.addEventListener('keydown', this.listenerRecordKey) + } + + /** + * @param {KeyboardEvent} event + */ + listenerRecordKey(event) { + if (ignoreKeys.includes(event.key)) { + return; + } + + const keys = [ + event.ctrlKey ? 'Ctrl' : '', + event.metaKey ? 'Cmd' : '', + event.key, + ]; + + this.input.value = keys.filter(s => Boolean(s)).join(' + '); + } + + stopListeningForInput() { + this.input.removeEventListener('keydown', this.listenerRecordKey); + } + +} + +export default ShortcutInput; \ No newline at end of file diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js new file mode 100644 index 000000000..4efa3d42b --- /dev/null +++ b/resources/js/components/shortcuts.js @@ -0,0 +1,164 @@ +function reverseMap(map) { + const reversed = {}; + for (const [key, value] of Object.entries(map)) { + reversed[value] = key; + } + return reversed; +} + +/** + * @extends {Component} + */ +class Shortcuts { + + setup() { + this.container = this.$el; + this.mapById = JSON.parse(this.$opts.keyMap); + this.mapByShortcut = reverseMap(this.mapById); + + this.hintsShowing = false; + + this.hideHints = this.hideHints.bind(this); + + this.setupListeners(); + } + + setupListeners() { + window.addEventListener('keydown', event => { + + if (event.target.closest('input, select, textarea')) { + return; + } + + this.handleShortcutPress(event); + }); + + window.addEventListener('keydown', event => { + if (event.key === '?') { + this.hintsShowing ? this.hideHints() : this.showHints(); + } + }); + } + + /** + * @param {KeyboardEvent} event + */ + handleShortcutPress(event) { + + const keys = [ + event.ctrlKey ? 'Ctrl' : '', + event.metaKey ? 'Cmd' : '', + event.key, + ]; + + const combo = keys.filter(s => Boolean(s)).join(' + '); + + const shortcutId = this.mapByShortcut[combo]; + if (shortcutId) { + const wasHandled = this.runShortcut(shortcutId); + if (wasHandled) { + event.preventDefault(); + } + } + } + + /** + * Run the given shortcut, and return a boolean to indicate if the event + * was successfully handled by a shortcut action. + * @param {String} id + * @return {boolean} + */ + runShortcut(id) { + const el = this.container.querySelector(`[data-shortcut="${id}"]`); + if (!el) { + return false; + } + + if (el.matches('input, textarea, select')) { + el.focus(); + return true; + } + + if (el.matches('a, button')) { + el.click(); + return true; + } + + if (el.matches('div[tabindex]')) { + el.click(); + el.focus(); + return true; + } + + console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el); + + return false; + } + + showHints() { + const wrapper = document.createElement('div'); + wrapper.classList.add('shortcut-container'); + this.container.append(wrapper); + + const shortcutEls = this.container.querySelectorAll('[data-shortcut]'); + const displayedIds = new Set(); + for (const shortcutEl of shortcutEls) { + const id = shortcutEl.getAttribute('data-shortcut'); + if (displayedIds.has(id)) { + continue; + } + + const key = this.mapById[id]; + this.showHintLabel(shortcutEl, key, wrapper); + displayedIds.add(id); + } + + window.addEventListener('scroll', this.hideHints); + window.addEventListener('focus', this.hideHints); + window.addEventListener('blur', this.hideHints); + window.addEventListener('click', this.hideHints); + + this.hintsShowing = true; + } + + /** + * @param {Element} targetEl + * @param {String} key + * @param {Element} wrapper + */ + showHintLabel(targetEl, key, wrapper) { + const targetBounds = targetEl.getBoundingClientRect(); + + const label = document.createElement('div'); + label.classList.add('shortcut-hint'); + label.textContent = key; + + const linkage = document.createElement('div'); + linkage.classList.add('shortcut-linkage'); + linkage.style.left = targetBounds.x + 'px'; + linkage.style.top = targetBounds.y + 'px'; + linkage.style.width = targetBounds.width + 'px'; + linkage.style.height = targetBounds.height + 'px'; + + wrapper.append(label, linkage); + + const labelBounds = label.getBoundingClientRect(); + + label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`; + label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`; + } + + hideHints() { + const wrapper = this.container.querySelector('.shortcut-container'); + wrapper.remove(); + + window.removeEventListener('scroll', this.hideHints); + window.removeEventListener('focus', this.hideHints); + window.removeEventListener('blur', this.hideHints); + window.removeEventListener('click', this.hideHints); + + this.hintsShowing = false; + } +} + +export default Shortcuts; \ No newline at end of file diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 703a70c7e..c74dcc907 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -25,6 +25,7 @@ return [ 'actions' => 'Actions', 'view' => 'View', 'view_all' => 'View All', + 'new' => 'New', 'create' => 'Create', 'update' => 'Update', 'edit' => 'Edit', @@ -80,12 +81,14 @@ return [ 'none' => 'None', // Header + 'homepage' => 'Homepage', 'header_menu_expand' => 'Expand Header Menu', 'profile_menu' => 'Profile Menu', 'view_profile' => 'View Profile', 'edit_profile' => 'Edit Profile', 'dark_mode' => 'Dark Mode', 'light_mode' => 'Light Mode', + 'global_search' => 'Global Search', // Layout tabs 'tab_info' => 'Info', diff --git a/resources/lang/en/preferences.php b/resources/lang/en/preferences.php new file mode 100644 index 000000000..e9a47461b --- /dev/null +++ b/resources/lang/en/preferences.php @@ -0,0 +1,18 @@ + 'Shortcuts', + 'shortcuts_interface' => 'Interface Keyboard Shortcuts', + 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', + 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', + 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', + 'shortcuts_section_navigation' => 'Navigation', + 'shortcuts_section_actions' => 'Common Actions', + 'shortcuts_save' => 'Save Shortcuts', + 'shortcuts_overlay_desc' => '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.', + 'shortcuts_update_success' => 'Shortcut preferences have been updated!', +]; \ No newline at end of file diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index acb45100f..66d76aaa2 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -982,4 +982,32 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .status-indicator-inactive { background-color: $negative; +} + +.shortcut-container { + background-color: rgba(0, 0, 0, 0.25); + pointer-events: none; + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 99; +} +.shortcut-linkage { + position: fixed; + box-shadow: 0 0 4px 0 #FFF; + border-radius: 3px; +} +.shortcut-hint { + position: fixed; + padding: $-xxs $-xxs; + font-size: .85rem; + font-weight: 700; + line-height: 1; + background-color: #eee; + border-radius: 3px; + border: 1px solid #b4b4b4; + box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset; + color: #333; } \ No newline at end of file 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/books/index.blade.php b/resources/views/books/index.blade.php index 447d6fd44..dc51a3a80 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -37,7 +37,7 @@
{{ trans('common.actions') }}
@if(user()->can('book-create-all')) - + @icon('add') {{ trans('entities.books_create') }} diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index b95b69d1b..884082456 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -94,13 +94,13 @@
@if(userCan('page-create', $book)) - + @icon('add') {{ trans('entities.pages_new') }} @endif @if(userCan('chapter-create', $book)) - + @icon('add') {{ trans('entities.chapters_new') }} @@ -109,29 +109,29 @@
@if(userCan('book-update', $book)) - + @icon('edit') {{ trans('common.edit') }} - + @icon('sort') {{ trans('common.sort') }} @endif @if(userCan('book-create-all')) - + @icon('copy') {{ trans('common.copy') }} @endif @if(userCan('restrictions-manage', $book)) - + @icon('lock') {{ trans('entities.permissions') }} @endif @if(userCan('book-delete', $book)) - + @icon('delete') {{ trans('common.delete') }} diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index b3496eae2..d2f8cec97 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -108,7 +108,7 @@
@if(userCan('page-create', $chapter)) - + @icon('add') {{ trans('entities.pages_new') }} @@ -117,31 +117,31 @@
@if(userCan('chapter-update', $chapter)) - + @icon('edit') {{ trans('common.edit') }} @endif @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCan('chapter-create-all') || userCan('chapter-create-own')) - + @icon('copy') {{ trans('common.copy') }} @endif @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter)) - + @icon('folder') {{ trans('common.move') }} @endif @if(userCan('restrictions-manage', $chapter)) - + @icon('lock') {{ trans('entities.permissions') }} @endif @if(userCan('chapter-delete', $chapter)) - + @icon('delete') {{ trans('common.delete') }} @@ -149,7 +149,7 @@ @if($chapter->book && userCan('book-update', $chapter->book))
- + @icon('sort') {{ trans('entities.chapter_sort_book') }} diff --git a/resources/views/common/dark-mode-toggle.blade.php b/resources/views/common/dark-mode-toggle.blade.php index 0812e487a..d6ecbc4d6 100644 --- a/resources/views/common/dark-mode-toggle.blade.php +++ b/resources/views/common/dark-mode-toggle.blade.php @@ -1,4 +1,4 @@ -
+ {{ csrf_field() }} {{ method_field('patch') }} @if(setting()->getForCurrentUser('dark-mode-enabled')) diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index 197b80c27..9fe97b853 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -2,7 +2,7 @@
- @icon('search'){{ trans('common.search') }} @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own')) - @icon('bookshelf'){{ trans('entities.shelves') }} + @icon('bookshelf'){{ trans('entities.shelves') }} @endif - @icon('books'){{ trans('entities.books') }} + @icon('books'){{ trans('entities.books') }} @if(signedInUser() && userCan('settings-manage')) - @icon('settings'){{ trans('settings.settings') }} + @icon('settings'){{ trans('settings.settings') }} @endif @if(signedInUser() && userCan('users-manage') && !userCan('settings-manage')) - @icon('users'){{ trans('settings.users') }} + @icon('users'){{ trans('settings.users') }} @endif @endif @@ -61,13 +62,13 @@