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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
-
-
- @icon('book')
- {{ $book->name }}
-
+
+
+
+
+ @icon('caret-right')
+
+
+ @icon('book')
+ {{ $book->name }}
+
+
+
@@ -14,29 +21,39 @@
@foreach($bookChildren as $bookChild)
\ No newline at end of file
+
\ 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 @@