Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * Javascript for customising the user's view of the question bank
18
 *
19
 * @module     qbank_columnsortorder/user_actions
20
 * @copyright  2021 Catalyst IT Australia Pty Ltd
21
 * @author     Ghaly Marc-Alexandre <marc-alexandreghaly@catalyst-ca.net>
22
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23
 */
24
 
25
import * as actions from 'qbank_columnsortorder/actions';
26
import * as repository from 'qbank_columnsortorder/repository';
27
import {get_string as getString} from 'core/str';
28
import ModalEvents from 'core/modal_events';
29
import ModalSaveCancel from 'core/modal_save_cancel';
30
import Notification from "core/notification";
31
import SortableList from 'core/sortable_list';
32
import Templates from "core/templates";
33
 
34
 
35
const SELECTORS = {
36
    uiRoot: '.questionbankwindow',
37
    moveAction: '.menu-action[data-action=move]',
38
    resizeAction: '.menu-action[data-action=resize]',
39
    resizeHandle: '.qbank_columnsortorder-action-handle.resize',
40
    handleContainer: '.handle-container',
41
    headerContainer: '.header-container',
42
    tableColumn: identifier => `td[data-columnid="${identifier.replace(/["\\]/g, '\\$&')}"]`,
43
};
44
 
45
/** To track mouse event on a table header */
46
let currentHeader;
47
 
48
/** Current mouse x postion, to track mouse event on a table header */
49
let currentX;
50
 
51
/** Minimum size for the column currently being resized. */
52
let currentMin;
53
 
54
/**
55
 * Flag to temporarily prevent move and resize handles from being shown or hidden.
56
 *
57
 * @type {boolean}
58
 */
59
let suspendShowHideHandles = false;
60
 
61
/**
62
 * Add handle containers for move and resize handles.
63
 *
64
 * @param {Element} uiRoot The root element of the quesiton bank UI.
65
 * @return {Promise} Resolved after the containers have been added to each column header.
66
 */
67
const addHandleContainers = uiRoot => {
68
    return new Promise((resolve) => {
69
        const headerContainers = uiRoot.querySelectorAll(SELECTORS.headerContainer);
70
        Templates.renderForPromise('qbank_columnsortorder/handle_container', {})
71
            .then(({html, js}) => {
72
                headerContainers.forEach(container => {
73
                    Templates.prependNodeContents(container, html, js);
74
                });
75
                resolve();
76
                return headerContainers;
77
            }).catch(Notification.exception);
78
    });
79
};
80
 
81
/**
82
 * Render move handles in each container.
83
 *
84
 * This takes a list of the move actions rendered in each column header, and creates a corresponding drag handle for each.
85
 *
86
 * @param {NodeList} moveActions Menu actions for moving columns.
87
 */
88
const setUpMoveHandles = moveActions => {
89
    moveActions.forEach(moveAction => {
90
        const header = moveAction.closest('th');
91
        header.classList.add('qbank-sortable-column');
92
        const handleContainer = header.querySelector(SELECTORS.handleContainer);
93
        const context = {
94
            action: "move",
95
            dragtype: "move",
96
            target: '',
97
            title: moveAction.title,
98
            pixicon: "i/dragdrop",
99
            pixcomponent: "core",
100
            popup: true
101
        };
102
        return Templates.renderForPromise('qbank_columnsortorder/action_handle', context)
103
            .then(({html, js}) => {
104
                Templates.prependNodeContents(handleContainer, html, js);
105
                return handleContainer;
106
            }).catch(Notification.exception);
107
    });
108
};
109
 
110
/**
111
 * Serialise the current column sizes.
112
 *
113
 * This finds the current width set in each column header's style property, and returns them encoded as a JSON string.
114
 *
115
 * @param {Element} uiRoot The root element of the quesiton bank UI.
116
 * @return {String} JSON array containing a list of objects with column and width properties.
117
 */
118
const serialiseColumnSizes = (uiRoot) => {
119
    const columnSizes = [];
120
    const tableHeaders = uiRoot.querySelectorAll('th');
121
    tableHeaders.forEach(header => {
122
        // Only get the width set via style attribute (set by move action).
123
        const width = parseInt(header.style.width);
124
        if (!width || isNaN(width)) {
125
            return;
126
        }
127
        columnSizes.push({
128
            column: header.dataset.columnid,
129
            width: width
130
        });
131
    });
132
    return JSON.stringify(columnSizes);
133
};
134
 
135
/**
136
 * Find the minimum width for a header, based on the width of its contents.
137
 *
138
 * This is to simulate `min-width: min-content;`, which doesn't work on Chrome because
139
 * min-width is ignored width `table-layout: fixed;`.
140
 *
141
 * @param {Element} header The table header
142
 * @return {Number} The minimum width in pixels
143
 */
144
const getMinWidth = (header) => {
145
    const contents = Array.from(header.querySelector('.header-text').children);
146
    const contentWidth = contents.reduce((width, contentElement) => width + contentElement.getBoundingClientRect().width, 0);
147
    return Math.ceil(contentWidth);
148
};
149
 
150
/**
151
 * Render resize handles in each container.
152
 *
153
 * This takes a list of the resize actions rendered in each column header, and creates a corresponding drag handle for each.
154
 * It also initialises the event handlers for the drag handles and resize modal.
155
 *
156
 * @param {Element} uiRoot Question bank UI root element.
157
 */
158
const setUpResizeHandles = (uiRoot) => {
159
    const resizeActions = uiRoot.querySelectorAll(SELECTORS.resizeAction);
160
    resizeActions.forEach(resizeAction => {
161
        const headerContainer = resizeAction.closest(SELECTORS.headerContainer);
162
        const header = resizeAction.closest(actions.SELECTORS.sortableColumn);
163
        const minWidth = getMinWidth(header);
164
        if (header.offsetWidth < minWidth) {
165
            header.style.width = minWidth + 'px';
166
        }
167
        const handleContainer = headerContainer.querySelector(SELECTORS.handleContainer);
168
        const context = {
169
            action: "resize",
170
            target: '',
171
            title: resizeAction.title,
172
            pixicon: 'i/twoway',
173
            pixcomponent: 'core',
174
            popup: true
175
        };
176
        return Templates.renderForPromise('qbank_columnsortorder/action_handle', context)
177
            .then(({html, js}) => {
178
                Templates.appendNodeContents(handleContainer, html, js);
179
                return handleContainer;
180
            }).catch(Notification.exception);
181
    });
182
 
183
    let moveTracker = false;
184
    let currentResizeHandle = null;
185
    // Start mouse event on headers.
186
    uiRoot.addEventListener('mousedown', e => {
187
        currentResizeHandle = e.target.closest(SELECTORS.resizeHandle);
188
        // Return if it is not ' resize' button.
189
        if (!currentResizeHandle) {
190
            return;
191
        }
192
        // Save current position.
193
        currentX = e.pageX;
194
        // Find the header.
195
        currentHeader = e.target.closest(actions.SELECTORS.sortableColumn);
196
        currentMin = getMinWidth(currentHeader);
197
        moveTracker = false;
198
        suspendShowHideHandles = true;
199
    });
200
 
201
    // Resize column as the mouse move.
202
    document.addEventListener('mousemove', e => {
203
        if (!currentHeader || !currentResizeHandle || currentX === 0) {
204
            return;
205
        }
206
 
207
        // Prevent text selection as the handle is dragged.
208
        document.getSelection().removeAllRanges();
209
 
210
        // Adjust the column width according the amount the handle was dragged.
211
        const offset = e.pageX - currentX;
212
        currentX = e.pageX;
213
        const newWidth = currentHeader.offsetWidth + offset;
214
        if (newWidth >= currentMin) {
215
            currentHeader.style.width = newWidth + 'px';
216
        }
217
        moveTracker = true;
218
    });
219
 
220
    // Set new size when mouse is up.
221
    document.addEventListener('mouseup', () => {
222
        if (!currentHeader || !currentResizeHandle || currentX === 0) {
223
            return;
224
        }
225
        if (moveTracker) {
226
            // If the mouse moved, we are changing the size by drag, so save the change.
227
            repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception);
228
        } else {
229
            // If the mouse didn't move, display a modal to change the size using a form.
230
            showResizeModal(currentHeader, uiRoot);
231
        }
232
        currentMin = null;
233
        currentHeader = null;
234
        currentResizeHandle = null;
235
        currentX = 0;
236
        moveTracker = false;
237
        suspendShowHideHandles = false;
238
    });
239
};
240
 
241
/**
242
 * Event handler for resize actions in each column header.
243
 *
244
 * This will listen for a click on any resize action, and activate the corresponding resize modal.
245
 *
246
 * @param {Element} uiRoot Question bank UI root element.
247
 */
248
const setUpResizeActions = uiRoot => {
249
    uiRoot.addEventListener('click', (e) => {
250
        const resizeAction = e.target.closest(SELECTORS.resizeAction);
251
        if (resizeAction) {
252
            e.preventDefault();
253
            const currentHeader = resizeAction.closest('th');
254
            showResizeModal(currentHeader, uiRoot);
255
        }
256
    });
257
};
258
 
259
/**
260
 * Show a modal containing a number input for changing a column width without click-and-drag.
261
 *
262
 * @param {Element} currentHeader The header element that is being resized.
263
 * @param {Element} uiRoot The question bank UI root element.
264
 * @returns {Promise<void>}
265
 */
266
const showResizeModal = async(currentHeader, uiRoot) => {
267
    const initialWidth = currentHeader.offsetWidth;
268
    const minWidth = getMinWidth(currentHeader);
269
 
270
    const modal = await ModalSaveCancel.create({
271
        title: getString('resizecolumn', 'qbank_columnsortorder', currentHeader.dataset.name),
272
        body: Templates.render('qbank_columnsortorder/resize_modal', {width: initialWidth, min: minWidth}),
273
        show: true,
274
    });
275
    const root = modal.getRoot();
276
    root.on(ModalEvents.cancel, () => {
277
        currentHeader.style.width = `${initialWidth}px`;
278
    });
279
    root.on(ModalEvents.save, () => {
280
        repository.setColumnSize(serialiseColumnSizes(uiRoot)).catch(Notification.exception);
281
    });
282
 
283
    const body = await modal.bodyPromise;
284
    const input = body.get(0).querySelector('input');
285
 
286
    input.addEventListener('change', e => {
287
        const valid = e.target.checkValidity();
288
        e.target.closest('.has-validation').classList.add('was-validated');
289
        if (valid) {
290
            const newWidth = e.target.value;
291
            currentHeader.style.width = `${newWidth}px`;
292
        }
293
    });
294
};
295
 
296
/**
297
 * Event handler for move actions in each column header.
298
 *
299
 * This will listen for a click on any move action, pass the click to the corresponding move handle, causing its modal to be shown.
300
 *
301
 * @param {Element} uiRoot Question bank UI root element.
302
 */
303
const setUpMoveActions = uiRoot => {
304
    uiRoot.addEventListener('click', e => {
305
        const moveAction = e.target.closest(SELECTORS.moveAction);
306
        if (moveAction) {
307
            e.preventDefault();
308
            const sortableColumn = moveAction.closest(actions.SELECTORS.sortableColumn);
309
            const moveHandle = sortableColumn.querySelector(actions.SELECTORS.moveHandler);
310
            moveHandle.click();
311
        }
312
    });
313
};
314
 
315
/**
316
 * Event handler for showing and hiding handles when the mouse is over a column header.
317
 *
318
 * Implementing this behaviour using the :hover CSS pseudoclass is not sufficient, as the mouse may move over the neighbouring
319
 * header while dragging, leading to some odd behaviour. This allows us to suspend the show/hide behaviour while a handle is being
320
 * dragged, and so keep the active handle visible until the drag is finished.
321
 *
322
 * @param {Element} uiRoot Question bank UI root element.
323
 */
324
const setupShowHideHandles = uiRoot => {
325
    let shownHeader = null;
326
    let tableHead = uiRoot.querySelector('thead');
327
    uiRoot.addEventListener('mouseover', e => {
328
        if (suspendShowHideHandles) {
329
            return;
330
        }
331
        const header = e.target.closest(actions.SELECTORS.sortableColumn);
332
        if (!header && !shownHeader) {
333
            return;
334
        }
335
        if (!header || header !== shownHeader) {
336
            tableHead.querySelector('.show-handles')?.classList.remove('show-handles');
337
            shownHeader = header;
338
            if (header) {
339
                header.classList.add('show-handles');
340
            }
341
        }
342
    });
343
};
344
 
345
/**
346
 * Event handler for sortable list DROP event.
347
 *
348
 * Find all table cells corresponding to the column of the dropped header, and move them to the new position.
349
 *
350
 * @param {Event} event
351
 */
352
const reorderColumns = event => {
353
    // Current header.
354
    const header = event.target;
355
    // Find the previous sibling of the header, which will be used when moving columns.
356
    const insertAfter = header.previousElementSibling;
357
    // Move columns.
358
    const uiRoot = document.querySelector(SELECTORS.uiRoot);
359
    const columns = uiRoot.querySelectorAll(SELECTORS.tableColumn(header.dataset.columnid));
360
    columns.forEach(column => {
361
        const row = column.parentElement;
362
        if (insertAfter) {
363
            // Find the column to insert after.
364
            const insertAfterColumn = row.querySelector(SELECTORS.tableColumn(insertAfter.dataset.columnid));
365
            // Insert the column.
366
            insertAfterColumn.after(column);
367
        } else {
368
            // Insert as the first child (first column in the table).
369
            row.insertBefore(column, row.firstChild);
370
        }
371
    });
372
};
373
 
374
/**
375
 * Initialize module
376
 *
377
 * Add containers for the drag handles to each column header, then render handles, enable show/hide behaviour, set up drag/drop
378
 * column sorting, then enable the move and resize modals to be triggered from menu actions.
379
 */
380
export const init = async() => {
381
    const uiRoot = document.getElementById('questionscontainer');
382
    await addHandleContainers(uiRoot);
383
    setUpMoveHandles(uiRoot.querySelectorAll(SELECTORS.moveAction));
384
    setUpResizeHandles(uiRoot);
385
    setupShowHideHandles(uiRoot);
386
    const sortableColumns = actions.setupSortableLists(uiRoot.querySelector(actions.SELECTORS.columnList));
387
    sortableColumns.on(SortableList.EVENTS.DROP, reorderColumns);
388
    sortableColumns.on(SortableList.EVENTS.DRAGSTART, () => {
389
        suspendShowHideHandles = true;
390
    });
391
    sortableColumns.on(SortableList.EVENTS.DRAGEND, () => {
392
        suspendShowHideHandles = false;
393
    });
394
    setUpMoveActions(uiRoot);
395
    setUpResizeActions(uiRoot);
396
    actions.setupActionButtons(uiRoot);
397
};