]> BookStack Code Mirror - bookstack/blob - resources/js/components/book-sort.js
Comments: Fixed display, added archive list support for editor toolbox
[bookstack] / resources / js / components / book-sort.js
1 import Sortable, {MultiDrag} from 'sortablejs';
2 import {Component} from './component';
3 import {htmlToDom} from '../services/dom.ts';
4
5 // Auto sort control
6 const sortOperations = {
7     name(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(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(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(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(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 /**
41  * The available move actions.
42  * The active function indicates if the action is possible for the given item.
43  * The run function performs the move.
44  * @type {{up: {active(Element, ?Element, Element): boolean, run(Element, ?Element, Element)}}}
45  */
46 const moveActions = {
47     up: {
48         active(elem, parent) {
49             return !(elem.previousElementSibling === null && !parent);
50         },
51         run(elem, parent) {
52             const newSibling = elem.previousElementSibling || parent;
53             newSibling.insertAdjacentElement('beforebegin', elem);
54         },
55     },
56     down: {
57         active(elem, parent) {
58             return !(elem.nextElementSibling === null && !parent);
59         },
60         run(elem, parent) {
61             const newSibling = elem.nextElementSibling || parent;
62             newSibling.insertAdjacentElement('afterend', elem);
63         },
64     },
65     next_book: {
66         active(elem, parent, book) {
67             return book.nextElementSibling !== null;
68         },
69         run(elem, parent, book) {
70             const newList = book.nextElementSibling.querySelector('ul');
71             newList.prepend(elem);
72         },
73     },
74     prev_book: {
75         active(elem, parent, book) {
76             return book.previousElementSibling !== null;
77         },
78         run(elem, parent, book) {
79             const newList = book.previousElementSibling.querySelector('ul');
80             newList.appendChild(elem);
81         },
82     },
83     next_chapter: {
84         active(elem, parent) {
85             return elem.dataset.type === 'page' && this.getNextChapter(elem, parent);
86         },
87         run(elem, parent) {
88             const nextChapter = this.getNextChapter(elem, parent);
89             nextChapter.querySelector('ul').prepend(elem);
90         },
91         getNextChapter(elem, parent) {
92             const topLevel = (parent || elem);
93             const topItems = Array.from(topLevel.parentElement.children);
94             const index = topItems.indexOf(topLevel);
95             return topItems.slice(index + 1).find(item => item.dataset.type === 'chapter');
96         },
97     },
98     prev_chapter: {
99         active(elem, parent) {
100             return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent);
101         },
102         run(elem, parent) {
103             const prevChapter = this.getPrevChapter(elem, parent);
104             prevChapter.querySelector('ul').append(elem);
105         },
106         getPrevChapter(elem, parent) {
107             const topLevel = (parent || elem);
108             const topItems = Array.from(topLevel.parentElement.children);
109             const index = topItems.indexOf(topLevel);
110             return topItems.slice(0, index).reverse().find(item => item.dataset.type === 'chapter');
111         },
112     },
113     book_end: {
114         active(elem, parent) {
115             return parent || (parent === null && elem.nextElementSibling);
116         },
117         run(elem, parent, book) {
118             book.querySelector('ul').append(elem);
119         },
120     },
121     book_start: {
122         active(elem, parent) {
123             return parent || (parent === null && elem.previousElementSibling);
124         },
125         run(elem, parent, book) {
126             book.querySelector('ul').prepend(elem);
127         },
128     },
129     before_chapter: {
130         active(elem, parent) {
131             return parent;
132         },
133         run(elem, parent) {
134             parent.insertAdjacentElement('beforebegin', elem);
135         },
136     },
137     after_chapter: {
138         active(elem, parent) {
139             return parent;
140         },
141         run(elem, parent) {
142             parent.insertAdjacentElement('afterend', elem);
143         },
144     },
145 };
146
147 export class BookSort extends Component {
148
149     setup() {
150         this.container = this.$el;
151         this.sortContainer = this.$refs.sortContainer;
152         this.input = this.$refs.input;
153
154         Sortable.mount(new MultiDrag());
155
156         const initialSortBox = this.container.querySelector('.sort-box');
157         this.setupBookSortable(initialSortBox);
158         this.setupSortPresets();
159         this.setupMoveActions();
160
161         window.$events.listen('entity-select-change', this.bookSelect.bind(this));
162     }
163
164     /**
165      * Set up the handlers for the item-level move buttons.
166      */
167     setupMoveActions() {
168         // Handle move button click
169         this.container.addEventListener('click', event => {
170             if (event.target.matches('[data-move]')) {
171                 const action = event.target.getAttribute('data-move');
172                 const sortItem = event.target.closest('[data-id]');
173                 this.runSortAction(sortItem, action);
174             }
175         });
176
177         this.updateMoveActionStateForAll();
178     }
179
180     /**
181      * Set up the handlers for the preset sort type buttons.
182      */
183     setupSortPresets() {
184         let lastSort = '';
185         let reverse = false;
186         const reversibleTypes = ['name', 'created', 'updated'];
187
188         this.sortContainer.addEventListener('click', event => {
189             const sortButton = event.target.closest('.sort-box-options [data-sort]');
190             if (!sortButton) return;
191
192             event.preventDefault();
193             const sortLists = sortButton.closest('.sort-box').querySelectorAll('ul');
194             const sort = sortButton.getAttribute('data-sort');
195
196             reverse = (lastSort === sort) ? !reverse : false;
197             let sortFunction = sortOperations[sort];
198             if (reverse && reversibleTypes.includes(sort)) {
199                 sortFunction = function reverseSortOperation(a, b) {
200                     return 0 - sortOperations[sort](a, b);
201                 };
202             }
203
204             for (const list of sortLists) {
205                 const directItems = Array.from(list.children).filter(child => child.matches('li'));
206                 directItems.sort(sortFunction).forEach(sortedItem => {
207                     list.appendChild(sortedItem);
208                 });
209             }
210
211             lastSort = sort;
212             this.updateMapInput();
213         });
214     }
215
216     /**
217      * Handle book selection from the entity selector.
218      * @param {Object} entityInfo
219      */
220     bookSelect(entityInfo) {
221         const alreadyAdded = this.container.querySelector(`[data-type="book"][data-id="${entityInfo.id}"]`) !== null;
222         if (alreadyAdded) return;
223
224         const entitySortItemUrl = `${entityInfo.link}/sort-item`;
225         window.$http.get(entitySortItemUrl).then(resp => {
226             const newBookContainer = htmlToDom(resp.data);
227             this.sortContainer.append(newBookContainer);
228             this.setupBookSortable(newBookContainer);
229             this.updateMoveActionStateForAll();
230
231             const summary = newBookContainer.querySelector('summary');
232             summary.focus();
233         });
234     }
235
236     /**
237      * Set up the given book container element to have sortable items.
238      * @param {Element} bookContainer
239      */
240     setupBookSortable(bookContainer) {
241         const sortElems = Array.from(bookContainer.querySelectorAll('.sort-list, .sortable-page-sublist'));
242
243         const bookGroupConfig = {
244             name: 'book',
245             pull: ['book', 'chapter'],
246             put: ['book', 'chapter'],
247         };
248
249         const chapterGroupConfig = {
250             name: 'chapter',
251             pull: ['book', 'chapter'],
252             put(toList, fromList, draggedElem) {
253                 return draggedElem.getAttribute('data-type') === 'page';
254             },
255         };
256
257         for (const sortElem of sortElems) {
258             Sortable.create(sortElem, {
259                 group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
260                 animation: 150,
261                 fallbackOnBody: true,
262                 swapThreshold: 0.65,
263                 onSort: () => {
264                     this.ensureNoNestedChapters();
265                     this.updateMapInput();
266                     this.updateMoveActionStateForAll();
267                 },
268                 dragClass: 'bg-white',
269                 ghostClass: 'primary-background-light',
270                 multiDrag: true,
271                 multiDragKey: 'Control',
272                 selectedClass: 'sortable-selected',
273             });
274         }
275     }
276
277     /**
278      * Handle nested chapters by moving them to the parent book.
279      * Needed since sorting with multi-sort only checks group rules based on the active item,
280      * not all in group, therefore need to manually check after a sort.
281      * Must be done before updating the map input.
282      */
283     ensureNoNestedChapters() {
284         const nestedChapters = this.container.querySelectorAll('[data-type="chapter"] [data-type="chapter"]');
285         for (const chapter of nestedChapters) {
286             const parentChapter = chapter.parentElement.closest('[data-type="chapter"]');
287             parentChapter.insertAdjacentElement('afterend', chapter);
288         }
289     }
290
291     /**
292      * Update the input with our sort data.
293      */
294     updateMapInput() {
295         const pageMap = this.buildEntityMap();
296         this.input.value = JSON.stringify(pageMap);
297     }
298
299     /**
300      * Build up a mapping of entities with their ordering and nesting.
301      * @returns {Array}
302      */
303     buildEntityMap() {
304         const entityMap = [];
305         const lists = this.container.querySelectorAll('.sort-list');
306
307         for (const list of lists) {
308             const bookId = list.closest('[data-type="book"]').getAttribute('data-id');
309             const directChildren = Array.from(list.children)
310                 .filter(elem => elem.matches('[data-type="page"], [data-type="chapter"]'));
311             for (let i = 0; i < directChildren.length; i++) {
312                 this.addBookChildToMap(directChildren[i], i, bookId, entityMap);
313             }
314         }
315
316         return entityMap;
317     }
318
319     /**
320      * Parse a sort item and add it to a data-map array.
321      * Parses sub0items if existing also.
322      * @param {Element} childElem
323      * @param {Number} index
324      * @param {Number} bookId
325      * @param {Array} entityMap
326      */
327     addBookChildToMap(childElem, index, bookId, entityMap) {
328         const type = childElem.getAttribute('data-type');
329         const parentChapter = false;
330         const childId = childElem.getAttribute('data-id');
331
332         entityMap.push({
333             id: childId,
334             sort: index,
335             parentChapter,
336             type,
337             book: bookId,
338         });
339
340         const subPages = childElem.querySelectorAll('[data-type="page"]');
341         for (let i = 0; i < subPages.length; i++) {
342             entityMap.push({
343                 id: subPages[i].getAttribute('data-id'),
344                 sort: i,
345                 parentChapter: childId,
346                 type: 'page',
347                 book: bookId,
348             });
349         }
350     }
351
352     /**
353      * Run the given sort action up the provided sort item.
354      * @param {Element} item
355      * @param {String} action
356      */
357     runSortAction(item, action) {
358         const parentItem = item.parentElement.closest('li[data-id]');
359         const parentBook = item.parentElement.closest('[data-type="book"]');
360         moveActions[action].run(item, parentItem, parentBook);
361         this.updateMapInput();
362         this.updateMoveActionStateForAll();
363         item.scrollIntoView({behavior: 'smooth', block: 'nearest'});
364         item.focus();
365     }
366
367     /**
368      * Update the state of the available move actions on this item.
369      * @param {Element} item
370      */
371     updateMoveActionState(item) {
372         const parentItem = item.parentElement.closest('li[data-id]');
373         const parentBook = item.parentElement.closest('[data-type="book"]');
374         for (const [action, functions] of Object.entries(moveActions)) {
375             const moveButton = item.querySelector(`[data-move="${action}"]`);
376             moveButton.disabled = !functions.active(item, parentItem, parentBook);
377         }
378     }
379
380     updateMoveActionStateForAll() {
381         const items = this.container.querySelectorAll('[data-type="chapter"],[data-type="page"]');
382         for (const item of items) {
383             this.updateMoveActionState(item);
384         }
385     }
386
387 }