oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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