]> BookStack Code Mirror - bookstack/blobdiff - resources/js/components/book-sort.js
Opensearch: Fixed XML declaration when php short tags enabled
[bookstack] / resources / js / components / book-sort.js
index da2b28d8e2a79fb0f4da1bf81d73bf5ca8c17f90..48557141f6f7d80e66c501bfe9655340694566f0 100644 (file)
@@ -1,23 +1,25 @@
-import Sortable from "sortablejs";
+import Sortable, {MultiDrag} from 'sortablejs';
+import {Component} from './component';
+import {htmlToDom} from '../services/dom.ts';
 
 // Auto sort control
 const sortOperations = {
-    name: function(a, b) {
+    name(a, b) {
         const aName = a.getAttribute('data-name').trim().toLowerCase();
         const bName = b.getAttribute('data-name').trim().toLowerCase();
         return aName.localeCompare(bName);
     },
-    created: function(a, b) {
+    created(a, b) {
         const aTime = Number(a.getAttribute('data-created'));
         const bTime = Number(b.getAttribute('data-created'));
         return bTime - aTime;
     },
-    updated: function(a, b) {
+    updated(a, b) {
         const aTime = Number(a.getAttribute('data-updated'));
         const bTime = Number(b.getAttribute('data-updated'));
         return bTime - aTime;
     },
-    chaptersFirst: function(a, b) {
+    chaptersFirst(a, b) {
         const aType = a.getAttribute('data-type');
         const bType = b.getAttribute('data-type');
         if (aType === bType) {
@@ -25,7 +27,7 @@ const sortOperations = {
         }
         return (aType === 'chapter' ? -1 : 1);
     },
-    chaptersLast: function(a, b) {
+    chaptersLast(a, b) {
         const aType = a.getAttribute('data-type');
         const bType = b.getAttribute('data-type');
         if (aType === bType) {
@@ -35,22 +37,148 @@ const sortOperations = {
     },
 };
 
-class BookSort {
+/**
+ * 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) {
+            return !(elem.previousElementSibling === null && !parent);
+        },
+        run(elem, parent) {
+            const newSibling = elem.previousElementSibling || parent;
+            newSibling.insertAdjacentElement('beforebegin', elem);
+        },
+    },
+    down: {
+        active(elem, parent) {
+            return !(elem.nextElementSibling === null && !parent);
+        },
+        run(elem, parent) {
+            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) {
+            return elem.dataset.type === 'page' && this.getNextChapter(elem, parent);
+        },
+        run(elem, parent) {
+            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(item => item.dataset.type === 'chapter');
+        },
+    },
+    prev_chapter: {
+        active(elem, parent) {
+            return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent);
+        },
+        run(elem, parent) {
+            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(item => item.dataset.type === 'chapter');
+        },
+    },
+    book_end: {
+        active(elem, parent) {
+            return parent || (parent === null && elem.nextElementSibling);
+        },
+        run(elem, parent, book) {
+            book.querySelector('ul').append(elem);
+        },
+    },
+    book_start: {
+        active(elem, parent) {
+            return parent || (parent === null && elem.previousElementSibling);
+        },
+        run(elem, parent, book) {
+            book.querySelector('ul').prepend(elem);
+        },
+    },
+    before_chapter: {
+        active(elem, parent) {
+            return parent;
+        },
+        run(elem, parent) {
+            parent.insertAdjacentElement('beforebegin', elem);
+        },
+    },
+    after_chapter: {
+        active(elem, parent) {
+            return parent;
+        },
+        run(elem, parent) {
+            parent.insertAdjacentElement('afterend', elem);
+        },
+    },
+};
+
+export class BookSort extends Component {
+
+    setup() {
+        this.container = this.$el;
+        this.sortContainer = this.$refs.sortContainer;
+        this.input = this.$refs.input;
 
-    constructor(elem) {
-        this.elem = elem;
-        this.sortContainer = elem.querySelector('[book-sort-boxes]');
-        this.input = elem.querySelector('[book-sort-input]');
+        Sortable.mount(new MultiDrag());
 
-        const initialSortBox = elem.querySelector('.sort-box');
+        const initialSortBox = this.container.querySelector('.sort-box');
         this.setupBookSortable(initialSortBox);
         this.setupSortPresets();
+        this.setupMoveActions();
+
+        window.$events.listen('entity-select-change', this.bookSelect.bind(this));
+    }
+
+    /**
+     * 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);
+            }
+        });
 
-        window.$events.listen('entity-select-confirm', this.bookSelect.bind(this));
+        this.updateMoveActionStateForAll();
     }
 
     /**
-     * Setup the handlers for the preset sort type buttons.
+     * Set up the handlers for the preset sort type buttons.
      */
     setupSortPresets() {
         let lastSort = '';
@@ -68,12 +196,12 @@ class BookSort {
             reverse = (lastSort === sort) ? !reverse : false;
             let sortFunction = sortOperations[sort];
             if (reverse && reversibleTypes.includes(sort)) {
-                sortFunction = function(a, b) {
-                    return 0 - sortOperations[sort](a, b)
+                sortFunction = function reverseSortOperation(a, b) {
+                    return 0 - sortOperations[sort](a, b);
                 };
             }
 
-            for (let list of sortLists) {
+            for (const list of sortLists) {
                 const directItems = Array.from(list.children).filter(child => child.matches('li'));
                 directItems.sort(sortFunction).forEach(sortedItem => {
                     list.appendChild(sortedItem);
@@ -90,26 +218,27 @@ class BookSort {
      * @param {Object} entityInfo
      */
     bookSelect(entityInfo) {
-        const alreadyAdded = this.elem.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
+        const alreadyAdded = this.container.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
         if (alreadyAdded) return;
 
-        const entitySortItemUrl = entityInfo.link + '/sort-item';
+        const entitySortItemUrl = `${entityInfo.link}/sort-item`;
         window.$http.get(entitySortItemUrl).then(resp => {
-            const wrap = document.createElement('div');
-            wrap.innerHTML = resp.data;
-            const newBookContainer = wrap.children[0];
+            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',
@@ -120,24 +249,45 @@ class BookSort {
         const chapterGroupConfig = {
             name: 'chapter',
             pull: ['book', 'chapter'],
-            put: function(toList, fromList, draggedElem) {
+            put(toList, fromList, draggedElem) {
                 return draggedElem.getAttribute('data-type') === 'page';
-            }
+            },
         };
 
-        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: () => {
+                    this.ensureNoNestedChapters();
+                    this.updateMapInput();
+                    this.updateMoveActionStateForAll();
+                },
                 dragClass: 'bg-white',
                 ghostClass: 'primary-background-light',
+                multiDrag: true,
+                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.
      */
@@ -152,9 +302,9 @@ class BookSort {
      */
     buildEntityMap() {
         const entityMap = [];
-        const lists = this.elem.querySelectorAll('.sort-list');
+        const lists = this.container.querySelectorAll('.sort-list');
 
-        for (let list of lists) {
+        for (const list of lists) {
             const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
             const directChildren = Array.from(list.children)
                 .filter(elem => elem.matches('[data-type="page"], [data-type="chapter"]'));
@@ -182,9 +332,9 @@ class BookSort {
         entityMap.push({
             id: childId,
             sort: index,
-            parentChapter: parentChapter,
-            type: type,
-            book: bookId
+            parentChapter,
+            type,
+            book: bookId,
         });
 
         const subPages = childElem.querySelectorAll('[data-type="page"]');
@@ -194,11 +344,44 @@ class BookSort {
                 sort: i,
                 parentChapter: childId,
                 type: 'page',
-                book: bookId
+                book: bookId,
             });
         }
     }
 
-}
+    /**
+     * 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);
+        }
+    }
 
-export default BookSort;
\ No newline at end of file
+    updateMoveActionStateForAll() {
+        const items = this.container.querySelectorAll('[data-type="chapter"],[data-type="page"]');
+        for (const item of items) {
+            this.updateMoveActionState(item);
+        }
+    }
+
+}