]> BookStack Code Mirror - bookstack/blob - resources/js/components/book-sort.js
Improves sortable ux
[bookstack] / resources / js / components / book-sort.js
1 import Sortable, {MultiDrag} from "sortablejs";
2 import {Component} from "./component";
3 import {htmlToDom} from "../services/dom";
4
5 // Auto sort control
6 const sortOperations = {
7     name: function(a, b) {
8         const aName = a.getAttribute('data-name').trim().toLowerCase();
9         const bName = b.getAttribute('data-name').trim().toLowerCase();
10         return aName.localeCompare(bName);
11     },
12     created: function(a, b) {
13         const aTime = Number(a.getAttribute('data-created'));
14         const bTime = Number(b.getAttribute('data-created'));
15         return bTime - aTime;
16     },
17     updated: function(a, b) {
18         const aTime = Number(a.getAttribute('data-updated'));
19         const bTime = Number(b.getAttribute('data-updated'));
20         return bTime - aTime;
21     },
22     chaptersFirst: function(a, b) {
23         const aType = a.getAttribute('data-type');
24         const bType = b.getAttribute('data-type');
25         if (aType === bType) {
26             return 0;
27         }
28         return (aType === 'chapter' ? -1 : 1);
29     },
30     chaptersLast: function(a, b) {
31         const aType = a.getAttribute('data-type');
32         const bType = b.getAttribute('data-type');
33         if (aType === bType) {
34             return 0;
35         }
36         return (aType === 'chapter' ? 1 : -1);
37     },
38 };
39
40 export class BookSort extends Component {
41
42     setup() {
43         this.container = this.$el;
44         this.sortContainer = this.$refs.sortContainer;
45         this.input = this.$refs.input;
46
47         Sortable.mount(new MultiDrag());
48
49         const initialSortBox = this.container.querySelector('.sort-box');
50         this.setupBookSortable(initialSortBox);
51         this.setupSortPresets();
52
53         window.$events.listen('entity-select-confirm', this.bookSelect.bind(this));
54     }
55
56     /**
57      * Setup the handlers for the preset sort type buttons.
58      */
59     setupSortPresets() {
60         let lastSort = '';
61         let reverse = false;
62         const reversibleTypes = ['name', 'created', 'updated'];
63
64         this.sortContainer.addEventListener('click', event => {
65             const sortButton = event.target.closest('.sort-box-options [data-sort]');
66             if (!sortButton) return;
67
68             event.preventDefault();
69             const sortLists = sortButton.closest('.sort-box').querySelectorAll('ul');
70             const sort = sortButton.getAttribute('data-sort');
71
72             reverse = (lastSort === sort) ? !reverse : false;
73             let sortFunction = sortOperations[sort];
74             if (reverse && reversibleTypes.includes(sort)) {
75                 sortFunction = function(a, b) {
76                     return 0 - sortOperations[sort](a, b)
77                 };
78             }
79
80             for (let list of sortLists) {
81                 const directItems = Array.from(list.children).filter(child => child.matches('li'));
82                 directItems.sort(sortFunction).forEach(sortedItem => {
83                     list.appendChild(sortedItem);
84                 });
85             }
86
87             lastSort = sort;
88             this.updateMapInput();
89         });
90     }
91
92     /**
93      * Handle book selection from the entity selector.
94      * @param {Object} entityInfo
95      */
96     bookSelect(entityInfo) {
97         const alreadyAdded = this.container.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
98         if (alreadyAdded) return;
99
100         const entitySortItemUrl = entityInfo.link + '/sort-item';
101         window.$http.get(entitySortItemUrl).then(resp => {
102             const newBookContainer = htmlToDom(resp.data);
103             this.sortContainer.append(newBookContainer);
104             this.setupBookSortable(newBookContainer);
105         });
106     }
107
108     /**
109      * Set up the given book container element to have sortable items.
110      * @param {Element} bookContainer
111      */
112     setupBookSortable(bookContainer) {
113         const sortElems = [bookContainer.querySelector('.sort-list')];
114         sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul'));
115
116         const bookGroupConfig = {
117             name: 'book',
118             pull: ['book', 'chapter'],
119             put: ['book', 'chapter'],
120         };
121
122         const chapterGroupConfig = {
123             name: 'chapter',
124             pull: ['book', 'chapter'],
125             put: function(toList, fromList, draggedElem) {
126                 return draggedElem.getAttribute('data-type') === 'page';
127             }
128         };
129
130         for (const sortElem of sortElems) {
131             Sortable.create(sortElem, {
132                 group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
133                 animation: 150,
134                 fallbackOnBody: true,
135                 swapThreshold: 0.65,
136                 onSort: this.updateMapInput.bind(this),
137                 dragClass: 'bg-white',
138                 ghostClass: 'primary-background-light',
139                 multiDrag: true,
140                 multiDragKey: 'Control',
141                 selectedClass: 'sortable-selected',
142             });
143         }
144     }
145
146     /**
147      * Update the input with our sort data.
148      */
149     updateMapInput() {
150         const pageMap = this.buildEntityMap();
151         this.input.value = JSON.stringify(pageMap);
152     }
153
154     /**
155      * Build up a mapping of entities with their ordering and nesting.
156      * @returns {Array}
157      */
158     buildEntityMap() {
159         const entityMap = [];
160         const lists = this.container.querySelectorAll('.sort-list');
161
162         for (let list of lists) {
163             const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
164             const directChildren = Array.from(list.children)
165                 .filter(elem => elem.matches('[data-type="page"], [data-type="chapter"]'));
166             for (let i = 0; i < directChildren.length; i++) {
167                 this.addBookChildToMap(directChildren[i], i, bookId, entityMap);
168             }
169         }
170
171         return entityMap;
172     }
173
174     /**
175      * Parse a sort item and add it to a data-map array.
176      * Parses sub0items if existing also.
177      * @param {Element} childElem
178      * @param {Number} index
179      * @param {Number} bookId
180      * @param {Array} entityMap
181      */
182     addBookChildToMap(childElem, index, bookId, entityMap) {
183         const type = childElem.getAttribute('data-type');
184         const parentChapter = false;
185         const childId = childElem.getAttribute('data-id');
186
187         entityMap.push({
188             id: childId,
189             sort: index,
190             parentChapter: parentChapter,
191             type: type,
192             book: bookId
193         });
194
195         const subPages = childElem.querySelectorAll('[data-type="page"]');
196         for (let i = 0; i < subPages.length; i++) {
197             entityMap.push({
198                 id: subPages[i].getAttribute('data-id'),
199                 sort: i,
200                 parentChapter: childId,
201                 type: 'page',
202                 book: bookId
203             });
204         }
205     }
206
207 }