|
42 | 42 | } |
43 | 43 | }; |
44 | 44 |
|
| 45 | + const getSourceFilePath = (id) => |
| 46 | + document.getElementById(`problem.${id}.source_file_id`)?.value || |
| 47 | + document.getElementById(`problem_${id}_default_source_file`)?.value || |
| 48 | + ''; |
| 49 | + |
| 50 | + // Compute the "same source file" alerts for every problem based on the current order of the list. |
| 51 | + const updateRepeatFileAlerts = () => { |
| 52 | + if (!container) return; |
| 53 | + const repeatFileText = container.dataset.repeatFileText; |
| 54 | + if (!repeatFileText) return; |
| 55 | + |
| 56 | + const shownYet = new Map(); |
| 57 | + for (const item of container.querySelectorAll('.psd_list_item')) { |
| 58 | + const row = item.querySelector(':scope > .problem_detail_row'); |
| 59 | + const alertContainer = row?.querySelector('.pdr_repeat_file_alert'); |
| 60 | + if (!alertContainer) return; |
| 61 | + |
| 62 | + alertContainer.innerHTML = ''; |
| 63 | + |
| 64 | + const problemID = item.id.replace('psd_list_item_', ''); |
| 65 | + const sourceFile = getSourceFilePath(problemID).replace(/^\//, '').replace(/\.\./g, ''); |
| 66 | + if (!sourceFile || sourceFile.startsWith('group:')) return; |
| 67 | + |
| 68 | + if (shownYet.has(sourceFile)) { |
| 69 | + const alert = document.createElement('div'); |
| 70 | + alert.className = 'alert alert-danger p-1 mb-2 fw-bold'; |
| 71 | + alert.textContent = repeatFileText.replace('[_1]', shownYet.get(sourceFile)); |
| 72 | + alertContainer.append(alert); |
| 73 | + } else { |
| 74 | + shownYet.set(sourceFile, row.querySelector('.pdr_problem_number')?.textContent ?? ''); |
| 75 | + } |
| 76 | + } |
| 77 | + }; |
| 78 | + |
45 | 79 | const setProblemNumberFields = () => { |
46 | 80 | container.querySelectorAll('.psd_list_item .pdr_problem_number').forEach((num) => (num.textContent = '')); |
47 | 81 | recursiveRenumber(Sortable.get(container).toArray()); |
| 82 | + updateRepeatFileAlerts(); |
| 83 | + }; |
| 84 | + |
| 85 | + // Buttons to rearrange problems. Either up/down for rearranging order, or buttons for nestin/de-nesting JITAR |
| 86 | + // problems. Buttons are disabled when the corresponding action isn't possible. |
| 87 | + const updateReorderButtonStates = () => { |
| 88 | + if (!container) return; |
| 89 | + for (const list of [container, ...container.querySelectorAll('.sortable-branch')]) { |
| 90 | + const items = list.querySelectorAll(':scope > .psd_list_item'); |
| 91 | + for (const [index, item] of items.entries()) { |
| 92 | + const upButton = item.querySelector(':scope > .problem_detail_row .psd_move_up'); |
| 93 | + const downButton = item.querySelector(':scope > .problem_detail_row .psd_move_down'); |
| 94 | + if (upButton) upButton.disabled = index === 0; |
| 95 | + if (downButton) downButton.disabled = index === items.length - 1; |
| 96 | + |
| 97 | + // Nesting nests the item under its previous sibling, so it needs a previous sibling to nest under. |
| 98 | + // Denesting moves the item up to its parent's list, so it isn't possible at the top level. |
| 99 | + const nestButton = item.querySelector(':scope > .problem_detail_row .psd_nest'); |
| 100 | + const denestButton = item.querySelector(':scope > .problem_detail_row .psd_denest'); |
| 101 | + if (nestButton) nestButton.disabled = index === 0; |
| 102 | + if (denestButton) denestButton.disabled = list === container; |
| 103 | + } |
| 104 | + } |
| 105 | + }; |
| 106 | + |
| 107 | + // Recompute the nestDepth property (used by the drag-and-drop "put" rule) for a sub-list and all of its |
| 108 | + // descendant sub-lists after the sub-list has moved to a new position in the tree. |
| 109 | + const recomputeNestDepth = (list) => { |
| 110 | + if (!list) return; |
| 111 | + list.nestDepth = list.parentNode.closest('.sortable-branch').nestDepth + 1; |
| 112 | + list.querySelectorAll(':scope > .psd_list_item > .sortable-branch').forEach(recomputeNestDepth); |
| 113 | + }; |
| 114 | + |
| 115 | + // Show or hide each row's expand/collapse button depending on whether its sub-list currently has any children. |
| 116 | + const updateCollapseButtonVisibility = () => { |
| 117 | + if (!container) return; |
| 118 | + for (const list of container.querySelectorAll('.sortable-branch')) { |
| 119 | + if (list.firstElementChild) list.parentNode.collapseButton?.classList.remove('d-none'); |
| 120 | + else list.parentNode.collapseButton?.classList.add('d-none'); |
| 121 | + } |
| 122 | + }; |
| 123 | + |
| 124 | + // Bookkeeping after any keyboard-driven reorder/nest/denest: update the hidden problem number/parent fields, |
| 125 | + // re-enable/disable fields whose relevance depends on tree position, update this row's button states, and |
| 126 | + // manage focus. |
| 127 | + const finishReorder = (item, button, fallbackSelectors) => { |
| 128 | + setProblemNumberFields(); |
| 129 | + disableFields(); |
| 130 | + updateReorderButtonStates(); |
| 131 | + updateCollapseButtonVisibility(); |
| 132 | + |
| 133 | + if (!button.disabled) { |
| 134 | + button.focus(); |
| 135 | + return; |
| 136 | + } |
| 137 | + for (const selector of fallbackSelectors) { |
| 138 | + const fallback = item.querySelector(selector); |
| 139 | + if (fallback && !fallback.disabled) { |
| 140 | + fallback.focus(); |
| 141 | + return; |
| 142 | + } |
| 143 | + } |
| 144 | + }; |
| 145 | + |
| 146 | + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 147 | + |
| 148 | + // Animate elements that just moved from where they used to be to where they ended up (the "FLIP" technique), |
| 149 | + // so a move up/down reads as the two rows swapping places instead of an unexplained instant jump. Only the |
| 150 | + // vertical position can change here, since these are stacked list items. |
| 151 | + const animateReorder = (elementsAndFirstRects) => { |
| 152 | + if (prefersReducedMotion) return; |
| 153 | + for (const [el, firstRect] of elementsAndFirstRects) { |
| 154 | + const deltaY = firstRect.top - el.getBoundingClientRect().top; |
| 155 | + if (!deltaY) continue; |
| 156 | + el.style.transition = 'none'; |
| 157 | + el.style.transform = `translateY(${deltaY}px)`; |
| 158 | + el.getBoundingClientRect(); // Force a reflow so the starting position above is rendered before animating. |
| 159 | + requestAnimationFrame(() => { |
| 160 | + el.style.transition = 'transform 150ms ease-in-out'; |
| 161 | + el.style.transform = ''; |
| 162 | + }); |
| 163 | + el.addEventListener('transitionend', () => (el.style.transition = ''), { once: true }); |
| 164 | + } |
48 | 165 | }; |
49 | 166 |
|
| 167 | + const moveItem = (button, direction) => { |
| 168 | + const item = button.closest('.psd_list_item'); |
| 169 | + const sibling = direction === 'up' ? item.previousElementSibling : item.nextElementSibling; |
| 170 | + if (!sibling?.classList.contains('psd_list_item')) return; |
| 171 | + |
| 172 | + const firstRects = [item, sibling].map((el) => [el, el.getBoundingClientRect()]); |
| 173 | + |
| 174 | + if (direction === 'up') item.parentNode.insertBefore(item, sibling); |
| 175 | + else item.parentNode.insertBefore(sibling, item); |
| 176 | + |
| 177 | + animateReorder(firstRects); |
| 178 | + |
| 179 | + finishReorder(item, button, [ |
| 180 | + direction === 'up' ? '.psd_move_down' : '.psd_move_up', |
| 181 | + '.psd_denest', |
| 182 | + '.psd_nest' |
| 183 | + ]); |
| 184 | + }; |
| 185 | + |
| 186 | + const nestItem = (button) => { |
| 187 | + const item = button.closest('.psd_list_item'); |
| 188 | + const prevSibling = item.previousElementSibling; |
| 189 | + if (!prevSibling?.classList.contains('psd_list_item')) return; |
| 190 | + |
| 191 | + const targetList = prevSibling.querySelector(':scope > .sortable-branch'); |
| 192 | + if (!targetList) return; |
| 193 | + |
| 194 | + targetList.append(item); |
| 195 | + recomputeNestDepth(item.querySelector(':scope > .sortable-branch')); |
| 196 | + |
| 197 | + // Make sure the item's new position is actually visible, in case its new parent's children were collapsed. |
| 198 | + bootstrap.Collapse.getInstance(targetList)?.show(); |
| 199 | + |
| 200 | + finishReorder(item, button, ['.psd_denest', '.psd_move_up', '.psd_move_down']); |
| 201 | + }; |
| 202 | + |
| 203 | + const denestItem = (button) => { |
| 204 | + const item = button.closest('.psd_list_item'); |
| 205 | + const currentList = item.parentNode; |
| 206 | + if (currentList === container) return; |
| 207 | + |
| 208 | + const parentItem = currentList.closest('.psd_list_item'); |
| 209 | + const grandParentList = parentItem.parentNode; |
| 210 | + |
| 211 | + grandParentList.insertBefore(item, parentItem.nextSibling); |
| 212 | + recomputeNestDepth(item.querySelector(':scope > .sortable-branch')); |
| 213 | + |
| 214 | + finishReorder(item, button, ['.psd_nest', '.psd_move_up', '.psd_move_down']); |
| 215 | + }; |
| 216 | + |
| 217 | + container?.addEventListener('click', (e) => { |
| 218 | + const button = e.target.closest('.psd_move_up, .psd_move_down, .psd_nest, .psd_denest'); |
| 219 | + if (!button || button.disabled) return; |
| 220 | + |
| 221 | + // Since focus stays on (or moves to another) reorder button after the click is handled below, the |
| 222 | + // tooltip's own focus/hover triggers won't naturally hide it. Hide it explicitly so it doesn't get stuck. |
| 223 | + bootstrap.Tooltip.getInstance(button)?.hide(); |
| 224 | + |
| 225 | + if (button.classList.contains('psd_move_up')) moveItem(button, 'up'); |
| 226 | + else if (button.classList.contains('psd_move_down')) moveItem(button, 'down'); |
| 227 | + else if (button.classList.contains('psd_nest')) nestItem(button); |
| 228 | + else denestItem(button); |
| 229 | + }); |
| 230 | + |
50 | 231 | const setSortable = (list) => { |
51 | 232 | // Set up the bootstrap collapses. Note that if a list is empty, then the collapse is shown. Since it is empty |
52 | 233 | // you still see nothing, but dragging into the list is smoother because it doesn't need to be expanded first. |
|
112 | 293 | onSort() { |
113 | 294 | setProblemNumberFields(); |
114 | 295 | disableFields(); |
| 296 | + updateReorderButtonStates(); |
115 | 297 | }, |
116 | 298 | onRemove() { |
117 | 299 | if (hiddenCollapse?._isTransitioning) |
|
121 | 303 | else hiddenCollapse?.show(); |
122 | 304 | }, |
123 | 305 | onChange(evt) { |
124 | | - container.querySelectorAll('.sortable-branch').forEach((list) => { |
125 | | - if (list.firstElementChild) list.parentNode.collapseButton?.classList.remove('d-none'); |
126 | | - else list.parentNode.collapseButton?.classList.add('d-none'); |
127 | | - }); |
| 306 | + updateCollapseButtonVisibility(); |
128 | 307 | if (evt.from.querySelectorAll('.psd_list_item').length < 2) |
129 | 308 | evt.from.parentNode.collapseButton?.classList.add('d-none'); |
130 | 309 | } |
|
182 | 361 |
|
183 | 362 | if (container) setSortable(container); |
184 | 363 | container?.querySelectorAll('.sortable-branch').forEach(setSortable); |
| 364 | + updateReorderButtonStates(); |
| 365 | + updateRepeatFileAlerts(); |
185 | 366 |
|
186 | 367 | // Recursively convert the sortable list to a tree. Each entry of the tree has the problem id, the parent id if it |
187 | 368 | // has a parent, and a list of the child problems (each of which is again an entry of the tree). |
|
211 | 392 |
|
212 | 393 | // Initialize tooltips. |
213 | 394 | document |
214 | | - .querySelectorAll('.psd_view,.psd_edit,.pdr_render,.pdr_grader,.pdr_handle > i') |
| 395 | + .querySelectorAll( |
| 396 | + '.psd_view,.psd_edit,.pdr_render,.pdr_grader,.pdr_handle > i,.psd_move_up,.psd_move_down,.psd_nest,.psd_denest' |
| 397 | + ) |
215 | 398 | .forEach((el) => new bootstrap.Tooltip(el)); |
216 | 399 |
|
217 | 400 | // If editing for user(s), then disable drag and drop re-ordering of problems. |
|
345 | 528 |
|
346 | 529 | const ro = { |
347 | 530 | problemSeed: document.getElementById(`problem.${id}.problem_seed_id`)?.value ?? 1, |
348 | | - sourceFilePath: |
349 | | - document.getElementById(`problem.${id}.source_file_id`)?.value || |
350 | | - document.getElementById(`problem_${id}_default_source_file`)?.value |
| 531 | + sourceFilePath: getSourceFilePath(id) |
351 | 532 | }; |
352 | 533 |
|
353 | 534 | if (ro.sourceFilePath.startsWith('group')) { |
|
0 commit comments