X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/a6633642232efd164d4708967ab59e498fbff896..HEAD:/resources/js/components/book-sort.js diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 2b94ca4a7..48557141f 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -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,27 +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: '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. */ @@ -155,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"]')); @@ -185,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"]'); @@ -197,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); + } + } + +}