diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 3ffadf991..5ae283fd0 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -1,4 +1,4 @@ -import Sortable from "sortablejs"; +import Sortable, {MultiDrag} from "sortablejs"; import {Component} from "./component"; import {htmlToDom} from "../services/dom"; @@ -37,6 +37,113 @@ const sortOperations = { }, }; +/** + * The available move actions. + * The active function indicates if the action is possible for the given item. + * The run function performs the move. + * @type {{up: {active(Element, ?Element, Element): boolean, run(Element, ?Element, Element)}}} + */ +const moveActions = { + up: { + active(elem, parent, book) { + return !(elem.previousElementSibling === null && !parent); + }, + run(elem, parent, book) { + const newSibling = elem.previousElementSibling || parent; + newSibling.insertAdjacentElement('beforebegin', elem); + } + }, + down: { + active(elem, parent, book) { + return !(elem.nextElementSibling === null && !parent); + }, + run(elem, parent, book) { + const newSibling = elem.nextElementSibling || parent; + newSibling.insertAdjacentElement('afterend', elem); + } + }, + next_book: { + active(elem, parent, book) { + return book.nextElementSibling !== null; + }, + run(elem, parent, book) { + const newList = book.nextElementSibling.querySelector('ul'); + newList.prepend(elem); + } + }, + prev_book: { + active(elem, parent, book) { + return book.previousElementSibling !== null; + }, + run(elem, parent, book) { + const newList = book.previousElementSibling.querySelector('ul'); + newList.appendChild(elem); + } + }, + next_chapter: { + active(elem, parent, book) { + return elem.dataset.type === 'page' && this.getNextChapter(elem, parent); + }, + run(elem, parent, book) { + const nextChapter = this.getNextChapter(elem, parent); + nextChapter.querySelector('ul').prepend(elem); + }, + getNextChapter(elem, parent) { + const topLevel = (parent || elem); + const topItems = Array.from(topLevel.parentElement.children); + const index = topItems.indexOf(topLevel); + return topItems.slice(index + 1).find(elem => elem.dataset.type === 'chapter'); + } + }, + prev_chapter: { + active(elem, parent, book) { + return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent); + }, + run(elem, parent, book) { + const prevChapter = this.getPrevChapter(elem, parent); + prevChapter.querySelector('ul').append(elem); + }, + getPrevChapter(elem, parent) { + const topLevel = (parent || elem); + const topItems = Array.from(topLevel.parentElement.children); + const index = topItems.indexOf(topLevel); + return topItems.slice(0, index).reverse().find(elem => elem.dataset.type === 'chapter'); + } + }, + book_end: { + active(elem, parent, book) { + return parent || (parent === null && elem.nextElementSibling); + }, + run(elem, parent, book) { + book.querySelector('ul').append(elem); + } + }, + book_start: { + active(elem, parent, book) { + return parent || (parent === null && elem.previousElementSibling); + }, + run(elem, parent, book) { + book.querySelector('ul').prepend(elem); + } + }, + before_chapter: { + active(elem, parent, book) { + return parent; + }, + run(elem, parent, book) { + parent.insertAdjacentElement('beforebegin', elem); + } + }, + after_chapter: { + active(elem, parent, book) { + return parent; + }, + run(elem, parent, book) { + parent.insertAdjacentElement('afterend', elem); + } + }, +}; + export class BookSort extends Component { setup() { @@ -44,15 +151,34 @@ export class BookSort extends Component { this.sortContainer = this.$refs.sortContainer; this.input = this.$refs.input; + Sortable.mount(new MultiDrag()); + const initialSortBox = this.container.querySelector('.sort-box'); this.setupBookSortable(initialSortBox); this.setupSortPresets(); + this.setupMoveActions(); - window.$events.listen('entity-select-confirm', this.bookSelect.bind(this)); + window.$events.listen('entity-select-change', this.bookSelect.bind(this)); } /** - * Setup the handlers for the preset sort type buttons. + * Set up the handlers for the item-level move buttons. + */ + setupMoveActions() { + // Handle move button click + this.container.addEventListener('click', event => { + if (event.target.matches('[data-move]')) { + const action = event.target.getAttribute('data-move'); + const sortItem = event.target.closest('[data-id]'); + this.runSortAction(sortItem, action); + } + }); + + this.updateMoveActionStateForAll(); + } + + /** + * Set up the handlers for the preset sort type buttons. */ setupSortPresets() { let lastSort = ''; @@ -100,16 +226,19 @@ export class BookSort extends Component { const newBookContainer = htmlToDom(resp.data); this.sortContainer.append(newBookContainer); this.setupBookSortable(newBookContainer); + this.updateMoveActionStateForAll(); + + const summary = newBookContainer.querySelector('summary'); + summary.focus(); }); } /** - * Setup the given book container element to have sortable items. + * Set up the given book container element to have sortable items. * @param {Element} bookContainer */ setupBookSortable(bookContainer) { - const sortElems = [bookContainer.querySelector('.sort-list')]; - sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul')); + const sortElems = Array.from(bookContainer.querySelectorAll('.sort-list, .sortable-page-sublist')); const bookGroupConfig = { name: 'book', @@ -125,22 +254,40 @@ export class BookSort extends Component { } }; - for (let sortElem of sortElems) { - new Sortable(sortElem, { + for (const sortElem of sortElems) { + Sortable.create(sortElem, { group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig, animation: 150, fallbackOnBody: true, swapThreshold: 0.65, - onSort: this.updateMapInput.bind(this), + onSort: (event) => { + this.ensureNoNestedChapters() + this.updateMapInput(); + this.updateMoveActionStateForAll(); + }, dragClass: 'bg-white', ghostClass: 'primary-background-light', multiDrag: true, - multiDragKey: 'CTRL', + multiDragKey: 'Control', selectedClass: 'sortable-selected', }); } } + /** + * Handle nested chapters by moving them to the parent book. + * Needed since sorting with multi-sort only checks group rules based on the active item, + * not all in group, therefore need to manually check after a sort. + * Must be done before updating the map input. + */ + ensureNoNestedChapters() { + const nestedChapters = this.container.querySelectorAll('[data-type="chapter"] [data-type="chapter"]'); + for (const chapter of nestedChapters) { + const parentChapter = chapter.parentElement.closest('[data-type="chapter"]'); + parentChapter.insertAdjacentElement('afterend', chapter); + } + } + /** * Update the input with our sort data. */ @@ -202,4 +349,38 @@ export class BookSort extends Component { } } + /** + * Run the given sort action up the provided sort item. + * @param {Element} item + * @param {String} action + */ + runSortAction(item, action) { + const parentItem = item.parentElement.closest('li[data-id]'); + const parentBook = item.parentElement.closest('[data-type="book"]'); + moveActions[action].run(item, parentItem, parentBook); + this.updateMapInput(); + this.updateMoveActionStateForAll(); + item.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + item.focus(); + } + + /** + * Update the state of the available move actions on this item. + * @param {Element} item + */ + updateMoveActionState(item) { + const parentItem = item.parentElement.closest('li[data-id]'); + const parentBook = item.parentElement.closest('[data-type="book"]'); + for (const [action, functions] of Object.entries(moveActions)) { + const moveButton = item.querySelector(`[data-move="${action}"]`); + moveButton.disabled = !functions.active(item, parentItem, parentBook); + } + } + + updateMoveActionStateForAll() { + const items = this.container.querySelectorAll('[data-type="chapter"],[data-type="page"]'); + for (const item of items) { + this.updateMoveActionState(item); + } + } } \ No newline at end of file diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js index 1384b33a9..09d14b233 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -15,7 +15,6 @@ export class EntitySelector extends Component { this.searchInput = this.$refs.search; this.loading = this.$refs.loading; this.resultsContainer = this.$refs.results; - this.addButton = this.$refs.add; this.search = ''; this.lastClick = 0; @@ -43,15 +42,6 @@ export class EntitySelector extends Component { if (event.keyCode === 13) event.preventDefault(); }); - if (this.addButton) { - this.addButton.addEventListener('click', event => { - if (this.selectedItemData) { - this.confirmSelection(this.selectedItemData); - this.unselectAll(); - } - }); - } - // Keyboard navigation onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => { if (e.ctrlKey && e.code === 'Enter') { diff --git a/resources/js/services/keyboard-navigation.js b/resources/js/services/keyboard-navigation.js index 0e1dcf1a7..0f866ceaa 100644 --- a/resources/js/services/keyboard-navigation.js +++ b/resources/js/services/keyboard-navigation.js @@ -86,7 +86,7 @@ export class KeyboardNavigationHandler { */ #getFocusable() { const focusable = []; - const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])'; + const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])'; for (const container of this.containers) { focusable.push(...container.querySelectorAll(selector)) } diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index fa2586f8d..8bf805774 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -141,6 +141,7 @@ return [ 'books_search_this' => 'Search this book', 'books_navigation' => 'Book Navigation', 'books_sort' => 'Sort Book Contents', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', 'books_sort_named' => 'Sort Book :bookName', 'books_sort_name' => 'Sort by Name', 'books_sort_created' => 'Sort by Created Date', @@ -149,6 +150,17 @@ return [ 'books_sort_chapters_last' => 'Chapters Last', 'books_sort_show_other' => 'Show Other Books', 'books_sort_save' => 'Save New Order', + 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', + 'books_sort_move_up' => 'Move Up', + 'books_sort_move_down' => 'Move Down', + 'books_sort_move_prev_book' => 'Move to Previous Book', + 'books_sort_move_next_book' => 'Move to Next Book', + 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', + 'books_sort_move_next_chapter' => 'Move Into Next Chapter', + 'books_sort_move_book_start' => 'Move to Start of Book', + 'books_sort_move_book_end' => 'Move to End of Book', + 'books_sort_move_before_chapter' => 'Move to Before Chapter', + 'books_sort_move_after_chapter' => 'Move to After Chapter', 'books_copy' => 'Copy Book', 'books_copy_success' => 'Book successfully copied', diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 4c7de600b..3fc419046 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -268,6 +268,11 @@ body.flexbox { } } +.sticky-top-m { + position: sticky; + top: $-m; +} + /** * Visibility */ diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 86a89051f..33e500d6a 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -232,7 +232,7 @@ } // Sortable Lists -.sortable-page-list, .sortable-page-list ul { +.sortable-page-list, .sortable-page-sublist { list-style: none; } .sort-box { @@ -267,7 +267,7 @@ .entity-list-item > span:first-child { align-self: flex-start; } - .sortable-selected .entity-list-item, .sortable-selected .entity-list-item:hover { + .sortable-selected, .sortable-selected:hover { outline: 1px dotted var(--color-primary); background-color: var(--color-primary-light) !important; } @@ -278,12 +278,13 @@ > ul { margin-inline-start: 0; } - ul { + .sortable-page-sublist { margin-bottom: $-m; margin-top: 0; padding-inline-start: $-m; } li { + @include lightDark(background-color, #FFF, #222); border: 1px solid; @include lightDark(border-color, #DDD, #666); margin-top: -1px; @@ -302,6 +303,36 @@ .sortable-page-list li.placeholder:before { position: absolute; } +.sort-box summary { + list-style: none; + font-size: .9rem; + cursor: pointer; +} +.sort-box summary::-webkit-details-marker { + display: none; +} +details.sort-box summary .caret-container svg { + transition: transform ease-in-out 120ms; +} +details.sort-box[open] summary .caret-container svg { + transform: rotate(90deg); +} +.sort-box-actions .icon-button { + opacity: .6; +} +.sort-box .flex-container-row:hover .sort-box-actions .icon-button, +.sort-box .flex-container-row:focus-within .sort-box-actions .icon-button { + opacity: 1; +} +.sort-box-actions .icon-button[disabled] { + visibility: hidden; +} +.sort-box-actions .dropdown-menu button[disabled] { + display: none; +} +.sort-list-handle { + cursor: grab; +} .activity-list-item { padding: $-s 0; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 23959d1f8..e50a2f96a 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -187,18 +187,14 @@ $loadingSize: 10px; height: 400px; padding-top: $-l; } - .entity-selector-add button { - margin: 0; - display: block; - width: 100%; - border: 0; - border-top: 1px solid #DDD; - } &.compact { font-size: 10px; .entity-item-snippet { display: none; } + h4 { + font-size: 14px; + } } } diff --git a/resources/views/books/parts/sort-box-actions.blade.php b/resources/views/books/parts/sort-box-actions.blade.php new file mode 100644 index 000000000..3796ffafb --- /dev/null +++ b/resources/views/books/parts/sort-box-actions.blade.php @@ -0,0 +1,26 @@ +<div class="sort-box-actions flex-container-row items-center px-s gap-xxs"> + <button type="button" data-move="up" class="icon-button p-xs text-bigger" + title="{{ trans('entities.books_sort_move_up') }}">@icon('chevron-up')</button> + <button type="button" data-move="down" class="icon-button p-xs text-bigger" + title="{{ trans('entities.books_sort_move_down') }}">@icon('chevron-down')</button> + <div class="dropdown-container" component="dropdown"> + <button refs="dropdown@toggle" + type="button" + title="{{ trans('common.more') }}" + class="icon-button p-xs text-bigger" + aria-haspopup="true" + aria-expanded="false"> + @icon('more') + </button> + <div refs="dropdown@menu" class="dropdown-menu" role="menu"> + <button type="button" class="text-item" data-move="prev_book">{{ trans('entities.books_sort_move_prev_book') }}</button> + <button type="button" class="text-item" data-move="next_book">{{ trans('entities.books_sort_move_next_book') }}</button> + <button type="button" class="text-item" data-move="prev_chapter">{{ trans('entities.books_sort_move_prev_chapter') }}</button> + <button type="button" class="text-item" data-move="next_chapter">{{ trans('entities.books_sort_move_next_chapter') }}</button> + <button type="button" class="text-item" data-move="book_start">{{ trans('entities.books_sort_move_book_start') }}</button> + <button type="button" class="text-item" data-move="book_end">{{ trans('entities.books_sort_move_book_end') }}</button> + <button type="button" class="text-item" data-move="before_chapter">{{ trans('entities.books_sort_move_before_chapter') }}</button> + <button type="button" class="text-item" data-move="after_chapter">{{ trans('entities.books_sort_move_after_chapter') }}</button> + </div> + </div> +</div> \ No newline at end of file diff --git a/resources/views/books/parts/sort-box.blade.php b/resources/views/books/parts/sort-box.blade.php index ef9929e46..03998e261 100644 --- a/resources/views/books/parts/sort-box.blade.php +++ b/resources/views/books/parts/sort-box.blade.php @@ -1,8 +1,15 @@ -<div class="sort-box" data-type="book" data-id="{{ $book->id }}"> - <h5 class="text-book entity-list-item no-hover py-xs pl-none"> - <span>@icon('book')</span> - <span>{{ $book->name }}</span> - </h5> +<details class="sort-box" data-type="book" data-id="{{ $book->id }}" open> + <summary> + <h5 class="flex-container-row items-center justify-flex-start gap-xs"> + <div class="text-book text-bigger caret-container"> + @icon('caret-right') + </div> + <div class="entity-list-item no-hover py-s text-book px-none"> + <span>@icon('book')</span> + <span>{{ $book->name }}</span> + </div> + </h5> + </summary> <div class="sort-box-options pb-sm"> <button type="button" data-sort="name" class="button outline small">{{ trans('entities.books_sort_name') }}</button> <button type="button" data-sort="created" class="button outline small">{{ trans('entities.books_sort_created') }}</button> @@ -14,29 +21,39 @@ @foreach($bookChildren as $bookChild) <li class="text-{{ $bookChild->getType() }}" - data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getType() }}" - data-name="{{ $bookChild->name }}" data-created="{{ $bookChild->created_at->timestamp }}" - data-updated="{{ $bookChild->updated_at->timestamp }}"> - <div class="entity-list-item"> - <span>@icon($bookChild->getType()) </span> - <div> - {{ $bookChild->name }} + data-id="{{$bookChild->id}}" + data-type="{{ $bookChild->getType() }}" + data-name="{{ $bookChild->name }}" + data-created="{{ $bookChild->created_at->timestamp }}" + data-updated="{{ $bookChild->updated_at->timestamp }}" + tabindex="-1"> + <div class="flex-container-row items-center"> + <div class="text-muted sort-list-handle px-s py-m">@icon('grip')</div> + <div class="entity-list-item px-none no-hover"> + <span>@icon($bookChild->getType()) </span> <div> + {{ $bookChild->name }} + <div> + </div> </div> </div> + @include('books.parts.sort-box-actions') </div> @if($bookChild->isA('chapter')) - <ul> + <ul class="sortable-page-sublist"> @foreach($bookChild->visible_pages as $page) - <li class="text-page" + <li class="text-page flex-container-row items-center" data-id="{{$page->id}}" data-type="page" data-name="{{ $page->name }}" data-created="{{ $page->created_at->timestamp }}" - data-updated="{{ $page->updated_at->timestamp }}"> - <div class="entity-list-item"> + data-updated="{{ $page->updated_at->timestamp }}" + tabindex="-1"> + <div class="text-muted sort-list-handle px-s py-m">@icon('grip')</div> + <div class="entity-list-item px-none no-hover"> <span>@icon('page')</span> <span>{{ $page->name }}</span> </div> + @include('books.parts.sort-box-actions') </li> @endforeach </ul> @@ -45,4 +62,4 @@ @endforeach </ul> -</div> \ No newline at end of file +</details> \ No newline at end of file diff --git a/resources/views/books/sort.blade.php b/resources/views/books/sort.blade.php index 077da101d..c82ad4e3b 100644 --- a/resources/views/books/sort.blade.php +++ b/resources/views/books/sort.blade.php @@ -16,8 +16,10 @@ <div class="grid left-focus gap-xl"> <div> - <div component="book-sort" class="card content-wrap"> - <h1 class="list-heading mb-l">{{ trans('entities.books_sort') }}</h1> + <div component="book-sort" class="card content-wrap auto-height"> + <h1 class="list-heading">{{ trans('entities.books_sort') }}</h1> + <p class="text-muted">{{ trans('entities.books_sort_desc') }}</p> + <div refs="book-sort@sortContainer"> @include('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]) </div> @@ -35,10 +37,11 @@ </div> <div> - <main class="card content-wrap"> - <h2 class="list-heading mb-m">{{ trans('entities.books_sort_show_other') }}</h2> + <main class="card content-wrap auto-height sticky-top-m"> + <h2 class="list-heading">{{ trans('entities.books_sort_show_other') }}</h2> + <p class="text-muted">{{ trans('entities.books_sort_show_other_desc') }}</p> - @include('entities.selector', ['name' => 'books_list', 'selectorSize' => 'compact', 'entityTypes' => 'book', 'entityPermission' => 'update', 'showAdd' => true]) + @include('entities.selector', ['name' => 'books_list', 'selectorSize' => 'compact', 'entityTypes' => 'book', 'entityPermission' => 'update']) </main> </div> diff --git a/resources/views/entities/selector.blade.php b/resources/views/entities/selector.blade.php index 45fae4707..a9f5b932c 100644 --- a/resources/views/entities/selector.blade.php +++ b/resources/views/entities/selector.blade.php @@ -8,11 +8,5 @@ <input refs="entity-selector@search" type="text" placeholder="{{ trans('common.search') }}" @if($autofocus ?? false) autofocus @endif> <div class="text-center loading" refs="entity-selector@loading">@include('common.loading-icon')</div> <div refs="entity-selector@results"></div> - @if($showAdd ?? false) - <div class="entity-selector-add"> - <button refs="entity-selector@add" type="button" - class="button outline">@icon('add'){{ trans('common.add') }}</button> - </div> - @endif </div> </div> \ No newline at end of file