Skip to content

Commit d7723b3

Browse files
authored
Merge pull request #3999 from BookStackApp/sort_ui_improvements
Improve Book Sorting User Experience
2 parents 03ad288 + 87e371f commit d7723b3

File tree

11 files changed

+314
-59
lines changed

11 files changed

+314
-59
lines changed

resources/js/components/book-sort.js

Lines changed: 191 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Sortable from "sortablejs";
1+
import Sortable, {MultiDrag} from "sortablejs";
22
import {Component} from "./component";
33
import {htmlToDom} from "../services/dom";
44

@@ -37,22 +37,148 @@ const sortOperations = {
3737
},
3838
};
3939

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, book) {
49+
return !(elem.previousElementSibling === null && !parent);
50+
},
51+
run(elem, parent, book) {
52+
const newSibling = elem.previousElementSibling || parent;
53+
newSibling.insertAdjacentElement('beforebegin', elem);
54+
}
55+
},
56+
down: {
57+
active(elem, parent, book) {
58+
return !(elem.nextElementSibling === null && !parent);
59+
},
60+
run(elem, parent, book) {
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, book) {
85+
return elem.dataset.type === 'page' && this.getNextChapter(elem, parent);
86+
},
87+
run(elem, parent, book) {
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(elem => elem.dataset.type === 'chapter');
96+
}
97+
},
98+
prev_chapter: {
99+
active(elem, parent, book) {
100+
return elem.dataset.type === 'page' && this.getPrevChapter(elem, parent);
101+
},
102+
run(elem, parent, book) {
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(elem => elem.dataset.type === 'chapter');
111+
}
112+
},
113+
book_end: {
114+
active(elem, parent, book) {
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, book) {
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, book) {
131+
return parent;
132+
},
133+
run(elem, parent, book) {
134+
parent.insertAdjacentElement('beforebegin', elem);
135+
}
136+
},
137+
after_chapter: {
138+
active(elem, parent, book) {
139+
return parent;
140+
},
141+
run(elem, parent, book) {
142+
parent.insertAdjacentElement('afterend', elem);
143+
}
144+
},
145+
};
146+
40147
export class BookSort extends Component {
41148

42149
setup() {
43150
this.container = this.$el;
44151
this.sortContainer = this.$refs.sortContainer;
45152
this.input = this.$refs.input;
46153

154+
Sortable.mount(new MultiDrag());
155+
47156
const initialSortBox = this.container.querySelector('.sort-box');
48157
this.setupBookSortable(initialSortBox);
49158
this.setupSortPresets();
159+
this.setupMoveActions();
50160

51-
window.$events.listen('entity-select-confirm', this.bookSelect.bind(this));
161+
window.$events.listen('entity-select-change', this.bookSelect.bind(this));
52162
}
53163

54164
/**
55-
* Setup the handlers for the preset sort type buttons.
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.
56182
*/
57183
setupSortPresets() {
58184
let lastSort = '';
@@ -100,16 +226,19 @@ export class BookSort extends Component {
100226
const newBookContainer = htmlToDom(resp.data);
101227
this.sortContainer.append(newBookContainer);
102228
this.setupBookSortable(newBookContainer);
229+
this.updateMoveActionStateForAll();
230+
231+
const summary = newBookContainer.querySelector('summary');
232+
summary.focus();
103233
});
104234
}
105235

106236
/**
107-
* Setup the given book container element to have sortable items.
237+
* Set up the given book container element to have sortable items.
108238
* @param {Element} bookContainer
109239
*/
110240
setupBookSortable(bookContainer) {
111-
const sortElems = [bookContainer.querySelector('.sort-list')];
112-
sortElems.push(...bookContainer.querySelectorAll('.entity-list-item + ul'));
241+
const sortElems = Array.from(bookContainer.querySelectorAll('.sort-list, .sortable-page-sublist'));
113242

114243
const bookGroupConfig = {
115244
name: 'book',
@@ -125,22 +254,40 @@ export class BookSort extends Component {
125254
}
126255
};
127256

128-
for (let sortElem of sortElems) {
129-
new Sortable(sortElem, {
257+
for (const sortElem of sortElems) {
258+
Sortable.create(sortElem, {
130259
group: sortElem.classList.contains('sort-list') ? bookGroupConfig : chapterGroupConfig,
131260
animation: 150,
132261
fallbackOnBody: true,
133262
swapThreshold: 0.65,
134-
onSort: this.updateMapInput.bind(this),
263+
onSort: (event) => {
264+
this.ensureNoNestedChapters()
265+
this.updateMapInput();
266+
this.updateMoveActionStateForAll();
267+
},
135268
dragClass: 'bg-white',
136269
ghostClass: 'primary-background-light',
137270
multiDrag: true,
138-
multiDragKey: 'CTRL',
271+
multiDragKey: 'Control',
139272
selectedClass: 'sortable-selected',
140273
});
141274
}
142275
}
143276

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+
144291
/**
145292
* Update the input with our sort data.
146293
*/
@@ -202,4 +349,38 @@ export class BookSort extends Component {
202349
}
203350
}
204351

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+
}
205386
}

resources/js/components/entity-selector.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export class EntitySelector extends Component {
1515
this.searchInput = this.$refs.search;
1616
this.loading = this.$refs.loading;
1717
this.resultsContainer = this.$refs.results;
18-
this.addButton = this.$refs.add;
1918

2019
this.search = '';
2120
this.lastClick = 0;
@@ -43,15 +42,6 @@ export class EntitySelector extends Component {
4342
if (event.keyCode === 13) event.preventDefault();
4443
});
4544

46-
if (this.addButton) {
47-
this.addButton.addEventListener('click', event => {
48-
if (this.selectedItemData) {
49-
this.confirmSelection(this.selectedItemData);
50-
this.unselectAll();
51-
}
52-
});
53-
}
54-
5545
// Keyboard navigation
5646
onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
5747
if (e.ctrlKey && e.code === 'Enter') {

resources/js/services/keyboard-navigation.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class KeyboardNavigationHandler {
8686
*/
8787
#getFocusable() {
8888
const focusable = [];
89-
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])';
89+
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
9090
for (const container of this.containers) {
9191
focusable.push(...container.querySelectorAll(selector))
9292
}

resources/lang/en/entities.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
'books_search_this' => 'Search this book',
142142
'books_navigation' => 'Book Navigation',
143143
'books_sort' => 'Sort Book Contents',
144+
'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.',
144145
'books_sort_named' => 'Sort Book :bookName',
145146
'books_sort_name' => 'Sort by Name',
146147
'books_sort_created' => 'Sort by Created Date',
@@ -149,6 +150,17 @@
149150
'books_sort_chapters_last' => 'Chapters Last',
150151
'books_sort_show_other' => 'Show Other Books',
151152
'books_sort_save' => 'Save New Order',
153+
'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.',
154+
'books_sort_move_up' => 'Move Up',
155+
'books_sort_move_down' => 'Move Down',
156+
'books_sort_move_prev_book' => 'Move to Previous Book',
157+
'books_sort_move_next_book' => 'Move to Next Book',
158+
'books_sort_move_prev_chapter' => 'Move Into Previous Chapter',
159+
'books_sort_move_next_chapter' => 'Move Into Next Chapter',
160+
'books_sort_move_book_start' => 'Move to Start of Book',
161+
'books_sort_move_book_end' => 'Move to End of Book',
162+
'books_sort_move_before_chapter' => 'Move to Before Chapter',
163+
'books_sort_move_after_chapter' => 'Move to After Chapter',
152164
'books_copy' => 'Copy Book',
153165
'books_copy_success' => 'Book successfully copied',
154166

resources/sass/_layout.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,11 @@ body.flexbox {
268268
}
269269
}
270270

271+
.sticky-top-m {
272+
position: sticky;
273+
top: $-m;
274+
}
275+
271276
/**
272277
* Visibility
273278
*/

0 commit comments

Comments
 (0)