| 1 | /* Drag and drop for the import form, in both places it appears. |
| 2 | * |
| 3 | * On /sheets/import the zone is a box in the page. On the sheet list it |
| 4 | * is an overlay that stays `hidden` until a drag carrying files is |
| 5 | * actually over the window — the list carries no visible drop box, |
| 6 | * because a list of sheets should be a list of sheets. Which shape it |
| 7 | * is comes from data-drop-page on the zone, and it changes only where |
| 8 | * the events are listened for and whether the zone is revealed. |
| 9 | * |
| 10 | * This file adds no capability. The form it decorates already imports a |
| 11 | * spreadsheet with scripting off, and every drop here ends as that same |
| 12 | * form's own submit — same route, same CSRF check, same 303 to the same |
| 13 | * job. Nothing is fetched, nothing is uploaded by hand, and there is no |
| 14 | * second code path on the server to keep in step with the first. That |
| 15 | * is the point: AGENTS.md wants a menu equivalent for every drag-only |
| 16 | * affordance, and the cheapest way to have one is for the drag to BE |
| 17 | * the equivalent. |
| 18 | * |
| 19 | * It therefore removes itself from the picture whenever it cannot do |
| 20 | * that faithfully — no zone, no form, no file input, no DataTransfer, |
| 21 | * or an input that refuses assignment — and the person clicks the same |
| 22 | * button they would have clicked anyway. |
| 23 | */ |
| 24 | (function () { |
| 25 | 'use strict'; |
| 26 | |
| 27 | var zone = document.querySelector('[data-drop]'); |
| 28 | if (!zone) return; |
| 29 | |
| 30 | var form = zone.closest('form'); |
| 31 | var input = form && form.querySelector('input[type="file"]'); |
| 32 | if (!form || !input) return; |
| 33 | |
| 34 | /* Assigning a FileList to input.files needs DataTransfer. Without it |
| 35 | * the drop cannot become a normal form submission, and a drop that |
| 36 | * silently does nothing is worse than a drop zone that was never |
| 37 | * advertised — so we leave the hint text alone too (see below) and |
| 38 | * the box stays an ordinary field. */ |
| 39 | if (typeof window.DataTransfer !== 'function') return; |
| 40 | |
| 41 | var hint = zone.querySelector('[data-drop-hint]'); |
| 42 | var hintText = hint ? hint.textContent : ''; |
| 43 | var depth = 0; |
| 44 | |
| 45 | /* Two shapes, one behaviour. |
| 46 | * |
| 47 | * On /sheets/import the zone is a box in the page and the events |
| 48 | * belong to it. On the sheet list there is no box — a list of sheets |
| 49 | * should be a list of sheets — so the zone is an overlay that is |
| 50 | * `hidden` until a drag carrying files enters the window, and the |
| 51 | * events belong to the document because a hidden element receives |
| 52 | * none. Everything after this line is the same either way. */ |
| 53 | var page = zone.hasAttribute('data-drop-page'); |
| 54 | var source = page ? document : zone; |
| 55 | |
| 56 | function reveal() { if (page) zone.hidden = false; } |
| 57 | |
| 58 | /* dismiss puts the hint back as well. Otherwise a refusal ("drop one |
| 59 | * file at a time") is still sitting there the next time the overlay |
| 60 | * comes up, answering a drag that has not happened yet. */ |
| 61 | function dismiss() { |
| 62 | if (!page) return; |
| 63 | zone.hidden = true; |
| 64 | zone.removeAttribute('data-drop-over'); |
| 65 | say(hintText); |
| 66 | } |
| 67 | |
| 68 | /* dragenter/dragleave fire for every child element the pointer |
| 69 | * crosses, so a plain enter/leave pair flickers the whole time a |
| 70 | * file is moved across the box. Counting depth is what makes the |
| 71 | * highlight track the ZONE rather than its descendants. */ |
| 72 | function enter(e) { |
| 73 | if (!hasFiles(e)) return; |
| 74 | e.preventDefault(); |
| 75 | depth++; |
| 76 | reveal(); |
| 77 | zone.setAttribute('data-drop-over', ''); |
| 78 | } |
| 79 | |
| 80 | function leave(e) { |
| 81 | if (!hasFiles(e)) return; |
| 82 | e.preventDefault(); |
| 83 | depth = Math.max(0, depth - 1); |
| 84 | if (depth === 0) { |
| 85 | zone.removeAttribute('data-drop-over'); |
| 86 | dismiss(); |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | /* dragover must preventDefault on every tick or the browser refuses |
| 91 | * the drop and navigates to the file instead — which, on a page |
| 92 | * behind a session, looks like the app losing your work. */ |
| 93 | function over(e) { |
| 94 | if (!hasFiles(e)) return; |
| 95 | e.preventDefault(); |
| 96 | if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; |
| 97 | } |
| 98 | |
| 99 | function drop(e) { |
| 100 | if (!hasFiles(e)) return; |
| 101 | e.preventDefault(); |
| 102 | depth = 0; |
| 103 | zone.removeAttribute('data-drop-over'); |
| 104 | |
| 105 | var files = e.dataTransfer.files; |
| 106 | /* One file, one sheet. A multi-file drop is ambiguous rather than |
| 107 | * obviously wrong — is it several sheets or several tabs? — so it |
| 108 | * is refused here in the client's own words instead of guessed at, |
| 109 | * and the person can still import them one at a time. */ |
| 110 | if (files.length !== 1) { |
| 111 | say('Drop one file at a time.'); |
| 112 | return; |
| 113 | } |
| 114 | var carrier = new DataTransfer(); |
| 115 | carrier.items.add(files[0]); |
| 116 | try { |
| 117 | input.files = carrier.files; |
| 118 | } catch (err) { |
| 119 | /* Some browsers refuse the assignment. Say so rather than |
| 120 | * submitting an empty form and letting the server answer |
| 121 | * "choose a spreadsheet" to somebody who plainly did. */ |
| 122 | say('Use the button to choose that file.'); |
| 123 | return; |
| 124 | } |
| 125 | /* requestSubmit fires the submit event, which is what the busy |
| 126 | * rule listens for; passing the form's button is what makes that |
| 127 | * button show the spinner. form.submit() skips the event, so it is |
| 128 | * the fallback and not the first choice. */ |
| 129 | var button = form.querySelector('button[type=submit]'); |
| 130 | if (typeof form.requestSubmit === 'function') form.requestSubmit(button || undefined); |
| 131 | else form.submit(); |
| 132 | } |
| 133 | |
| 134 | /* Only react to a drag actually carrying files. Dragging selected |
| 135 | * text across the page also fires these events, and highlighting a |
| 136 | * file drop zone for a text selection promises something that will |
| 137 | * not work. */ |
| 138 | function hasFiles(e) { |
| 139 | var dt = e.dataTransfer; |
| 140 | if (!dt) return false; |
| 141 | if (dt.types) { |
| 142 | for (var i = 0; i < dt.types.length; i++) { |
| 143 | if (dt.types[i] === 'Files') return true; |
| 144 | } |
| 145 | return false; |
| 146 | } |
| 147 | return true; |
| 148 | } |
| 149 | |
| 150 | /* say replaces the hint text. The hint is a plain paragraph beside |
| 151 | * the field rather than an alert, because this runs while somebody |
| 152 | * is looking straight at the box they just dropped on. */ |
| 153 | function say(text) { |
| 154 | if (hint) hint.textContent = text; |
| 155 | } |
| 156 | |
| 157 | source.addEventListener('dragenter', enter); |
| 158 | source.addEventListener('dragleave', leave); |
| 159 | source.addEventListener('dragover', over); |
| 160 | source.addEventListener('drop', drop); |
| 161 | |
| 162 | /* A refused drop leaves the overlay up on purpose — the message is |
| 163 | * the whole point of refusing in the client's own words, and the |
| 164 | * chooser beside it is how the person recovers. So the overlay needs |
| 165 | * a way out that is not the back button: Escape, or a click on the |
| 166 | * backdrop outside the panel. Both are what every other dismissible |
| 167 | * layer in this app answers to, so neither has to be taught. */ |
| 168 | if (page) { |
| 169 | document.addEventListener('keydown', function (e) { |
| 170 | if (e.key === 'Escape' && !zone.hidden) dismiss(); |
| 171 | }); |
| 172 | zone.addEventListener('click', function (e) { |
| 173 | if (e.target === zone) dismiss(); |
| 174 | }); |
| 175 | } |
| 176 | })(); |
| 177 | |