@sehv-oss/basketball-upload 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/NOTICE.md +23 -0
- package/README.md +435 -0
- package/dist/basketball-upload.d.ts +567 -0
- package/dist/basketball-upload.d.ts.map +1 -0
- package/dist/basketball-upload.js +2600 -0
- package/dist/basketball-upload.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,2600 @@
|
|
|
1
|
+
//#region src/game/config.ts
|
|
2
|
+
/**
|
|
3
|
+
* Physics constants. Lengths are in rim units (`u`, the rim width in pixels)
|
|
4
|
+
* and times in seconds, so a shot behaves the same at any rendered size.
|
|
5
|
+
*
|
|
6
|
+
* Calibrated against the reference design: its aiming dots are 1/15 s apart,
|
|
7
|
+
* advance ≈0.116 u horizontally and lose ≈0.095 u of rise per dot.
|
|
8
|
+
*/
|
|
9
|
+
const PHYSICS = {
|
|
10
|
+
/**
|
|
11
|
+
* Fixed simulation step. Preview and flight both advance by exactly this.
|
|
12
|
+
*/
|
|
13
|
+
step: 1 / 120,
|
|
14
|
+
/**
|
|
15
|
+
* Downward acceleration, u/s².
|
|
16
|
+
*/
|
|
17
|
+
gravity: 21,
|
|
18
|
+
/**
|
|
19
|
+
* Launch speed per unit of pull, 1/s. Raised per layout by `launchPower`.
|
|
20
|
+
*/
|
|
21
|
+
power: 12,
|
|
22
|
+
/**
|
|
23
|
+
* Headroom of a full pull over the speed of the assisted shot.
|
|
24
|
+
*/
|
|
25
|
+
powerHeadroom: 1.4,
|
|
26
|
+
/**
|
|
27
|
+
* Longest pull that still adds speed, u.
|
|
28
|
+
*/
|
|
29
|
+
maxPull: 1,
|
|
30
|
+
/**
|
|
31
|
+
* Shorter pulls cancel the shot instead of firing it, u.
|
|
32
|
+
*/
|
|
33
|
+
minPull: .12,
|
|
34
|
+
/**
|
|
35
|
+
* Pull beyond the maximum keeps moving the card, damped by this factor.
|
|
36
|
+
*/
|
|
37
|
+
pullStretch: .15,
|
|
38
|
+
/**
|
|
39
|
+
* Time between two aiming dots, s.
|
|
40
|
+
*/
|
|
41
|
+
dotInterval: 1 / 15,
|
|
42
|
+
/**
|
|
43
|
+
* The preview stops after this long when nothing is hit, s.
|
|
44
|
+
*/
|
|
45
|
+
maxPreviewTime: 2,
|
|
46
|
+
/**
|
|
47
|
+
* A shot still in the air after this long counts as a miss, s.
|
|
48
|
+
*/
|
|
49
|
+
maxFlightTime: 5,
|
|
50
|
+
/**
|
|
51
|
+
* Collision radius of the card around the rim, u.
|
|
52
|
+
*/
|
|
53
|
+
hitRadius: .17,
|
|
54
|
+
/**
|
|
55
|
+
* Half the thickness of the rim, u.
|
|
56
|
+
*/
|
|
57
|
+
rimRadius: .02,
|
|
58
|
+
/**
|
|
59
|
+
* Distance from the card center to the floor and walls, u.
|
|
60
|
+
*/
|
|
61
|
+
floorRadius: .35,
|
|
62
|
+
/**
|
|
63
|
+
* Card scale when it reaches the hoop: it travels away from the viewer.
|
|
64
|
+
*/
|
|
65
|
+
depthScale: .7,
|
|
66
|
+
/**
|
|
67
|
+
* A card rising faster than this (u/s) is still in front of the hoop:
|
|
68
|
+
* the board and the rim only stop cards around the top of their arc or falling.
|
|
69
|
+
*/
|
|
70
|
+
hoopDepthRise: 1.5,
|
|
71
|
+
/**
|
|
72
|
+
* Velocity kept after touching the board (the orange square).
|
|
73
|
+
*/
|
|
74
|
+
board: {
|
|
75
|
+
x: .25,
|
|
76
|
+
y: .3
|
|
77
|
+
},
|
|
78
|
+
restitution: {
|
|
79
|
+
rim: .45,
|
|
80
|
+
floor: .35,
|
|
81
|
+
wall: .5
|
|
82
|
+
},
|
|
83
|
+
/**
|
|
84
|
+
* Horizontal velocity kept on each floor bounce.
|
|
85
|
+
*/
|
|
86
|
+
floorFriction: .8,
|
|
87
|
+
/**
|
|
88
|
+
* A missed card stops bouncing after this many floor contacts.
|
|
89
|
+
*/
|
|
90
|
+
maxFloorBounces: 2,
|
|
91
|
+
/**
|
|
92
|
+
* Assisted shots peak this far above the top of the square, u.
|
|
93
|
+
*/
|
|
94
|
+
assistApex: .5,
|
|
95
|
+
/**
|
|
96
|
+
* Spin in degrees per second, per u/s of horizontal speed.
|
|
97
|
+
*/
|
|
98
|
+
spin: {
|
|
99
|
+
factor: 160,
|
|
100
|
+
min: 180,
|
|
101
|
+
max: 540
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/game/court.ts
|
|
106
|
+
function relative(rectangle, origin) {
|
|
107
|
+
return {
|
|
108
|
+
x: rectangle.x - origin.x,
|
|
109
|
+
y: rectangle.y - origin.y,
|
|
110
|
+
width: rectangle.width,
|
|
111
|
+
height: rectangle.height
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Builds a court from client rectangles (`getBoundingClientRect`).
|
|
116
|
+
*/
|
|
117
|
+
function courtFromRectangles(rectangles) {
|
|
118
|
+
const { host } = rectangles;
|
|
119
|
+
const rim = relative(rectangles.rim, host);
|
|
120
|
+
const net = relative(rectangles.net, host);
|
|
121
|
+
const spot = relative(rectangles.spot, host);
|
|
122
|
+
return {
|
|
123
|
+
unit: rim.width,
|
|
124
|
+
bounds: {
|
|
125
|
+
x: 0,
|
|
126
|
+
y: 0,
|
|
127
|
+
width: host.width,
|
|
128
|
+
height: host.height
|
|
129
|
+
},
|
|
130
|
+
board: relative(rectangles.board, host),
|
|
131
|
+
square: relative(rectangles.square, host),
|
|
132
|
+
rim: {
|
|
133
|
+
left: rim.x,
|
|
134
|
+
right: rim.x + rim.width,
|
|
135
|
+
y: rim.y + rim.height / 2
|
|
136
|
+
},
|
|
137
|
+
netBottom: net.y + net.height,
|
|
138
|
+
rest: {
|
|
139
|
+
x: spot.x + spot.width / 2,
|
|
140
|
+
y: spot.y + spot.height / 2
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Middle of the rim, on the rim plane.
|
|
146
|
+
*/
|
|
147
|
+
function rimCenter(court) {
|
|
148
|
+
return {
|
|
149
|
+
x: (court.rim.left + court.rim.right) / 2,
|
|
150
|
+
y: court.rim.y
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Whether `point` is inside `rectangle`, edges included.
|
|
155
|
+
*/
|
|
156
|
+
function contains(rectangle, point) {
|
|
157
|
+
return point.x >= rectangle.x && point.x <= rectangle.x + rectangle.width && point.y >= rectangle.y && point.y <= rectangle.y + rectangle.height;
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/upload/accept.ts
|
|
161
|
+
/**
|
|
162
|
+
* Whether a file matches an `accept` string, with the semantics of
|
|
163
|
+
* `<input type="file" accept>`: comma separated extensions (`.pdf`), MIME
|
|
164
|
+
* types (`application/pdf`) and wildcards (`image/*`). Empty accepts all.
|
|
165
|
+
*/
|
|
166
|
+
function matchesAccept(file, accept) {
|
|
167
|
+
const tokens = (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter(Boolean);
|
|
168
|
+
if (tokens.length === 0) return true;
|
|
169
|
+
const name = file.name.toLowerCase();
|
|
170
|
+
const type = file.type.toLowerCase();
|
|
171
|
+
return tokens.some((token) => {
|
|
172
|
+
if (token === "*" || token === "*/*") return true;
|
|
173
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
174
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
175
|
+
return type === token;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/file-types/defaults.ts
|
|
180
|
+
/**
|
|
181
|
+
* Built-in file types, checked after the ones you register.
|
|
182
|
+
*/
|
|
183
|
+
const defaultFileTypes = [
|
|
184
|
+
{
|
|
185
|
+
kind: "pdf",
|
|
186
|
+
match: ".pdf,application/pdf",
|
|
187
|
+
label: "PDF",
|
|
188
|
+
color: "#d8374e"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
kind: "image",
|
|
192
|
+
match: "image/*,.png,.jpg,.jpeg,.gif,.webp,.avif,.svg",
|
|
193
|
+
color: "#2f7cf6",
|
|
194
|
+
artwork: "thumbnail"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
kind: "video",
|
|
198
|
+
match: "video/*",
|
|
199
|
+
color: "#8b5cf6"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
kind: "audio",
|
|
203
|
+
match: "audio/*",
|
|
204
|
+
color: "#e39a0b"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
kind: "sheet",
|
|
208
|
+
match: ".csv,.tsv,.xls,.xlsx,.ods,.numbers,text/csv",
|
|
209
|
+
color: "#1f9d55"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
kind: "doc",
|
|
213
|
+
match: ".doc,.docx,.odt,.rtf,.md,.txt,.pages,text/plain,text/markdown",
|
|
214
|
+
color: "#2563eb"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
kind: "slides",
|
|
218
|
+
match: ".ppt,.pptx,.key,.odp",
|
|
219
|
+
color: "#ea580c"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
kind: "archive",
|
|
223
|
+
match: ".zip,.rar,.7z,.tar,.gz,.tgz,.bz2,application/zip",
|
|
224
|
+
label: (file) => file.name.toLowerCase().endsWith(".tar.gz") ? "TGZ" : extensionLabel(file.name, "ZIP"),
|
|
225
|
+
color: "#6b7280"
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
kind: "code",
|
|
229
|
+
match: ".json,.js,.mjs,.cjs,.ts,.tsx,.jsx,.html,.css,.xml,.yaml,.yml,.py,.go,.rs,.java,.c,.cpp,.sh",
|
|
230
|
+
color: "#475569"
|
|
231
|
+
}
|
|
232
|
+
];
|
|
233
|
+
/**
|
|
234
|
+
* The extension in capitals, at most 4 characters, or `fallback`.
|
|
235
|
+
*/
|
|
236
|
+
function extensionLabel(name, fallback) {
|
|
237
|
+
const dot = name.lastIndexOf(".");
|
|
238
|
+
return ((dot > 0 ? name.slice(dot + 1) : "") || fallback).slice(0, 4).toUpperCase();
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/file-types/registry.ts
|
|
242
|
+
const KIND = /^[a-z][a-z0-9-]*$/;
|
|
243
|
+
/**
|
|
244
|
+
* Registered globally, most recent first.
|
|
245
|
+
*/
|
|
246
|
+
const registered = [];
|
|
247
|
+
/**
|
|
248
|
+
* Throws when a file type cannot be used: its kind ends up in part names.
|
|
249
|
+
*/
|
|
250
|
+
function assertFileType(type) {
|
|
251
|
+
if (!KIND.test(type.kind)) throw new TypeError(`Invalid file type kind "${type.kind}": use lowercase letters, digits and dashes, starting with a letter.`);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Adds a file type for every `<basketball-upload>` on the page. It wins over the
|
|
255
|
+
* built-in types and over the ones registered before it; types set on an
|
|
256
|
+
* element (`fileTypes`) still win over it. Returns a function that removes it.
|
|
257
|
+
*/
|
|
258
|
+
function registerFileType(type) {
|
|
259
|
+
assertFileType(type);
|
|
260
|
+
registered.unshift(type);
|
|
261
|
+
return () => {
|
|
262
|
+
const index = registered.indexOf(type);
|
|
263
|
+
if (index !== -1) registered.splice(index, 1);
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function matches(type, file) {
|
|
267
|
+
return typeof type.match === "function" ? type.match(file) : matchesAccept(file, type.match);
|
|
268
|
+
}
|
|
269
|
+
function resolve$1(type, file) {
|
|
270
|
+
const label = typeof type.label === "function" ? type.label(file) : type.label ?? extensionLabel(file.name, type.kind);
|
|
271
|
+
return {
|
|
272
|
+
kind: type.kind,
|
|
273
|
+
label,
|
|
274
|
+
color: type.color ?? null,
|
|
275
|
+
artwork: type.artwork ?? "lines"
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The look of a file: the first match among `instanceTypes`, then the
|
|
280
|
+
* registered types, then the built-in ones, then a neutral `file`.
|
|
281
|
+
*/
|
|
282
|
+
function resolveFileType(file, instanceTypes = []) {
|
|
283
|
+
for (const types of [
|
|
284
|
+
instanceTypes,
|
|
285
|
+
registered,
|
|
286
|
+
defaultFileTypes
|
|
287
|
+
]) for (const type of types) if (matches(type, file)) return resolve$1(type, file);
|
|
288
|
+
return {
|
|
289
|
+
kind: "file",
|
|
290
|
+
label: extensionLabel(file.name, "FILE"),
|
|
291
|
+
color: null,
|
|
292
|
+
artwork: "lines"
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/upload/event-emitter.ts
|
|
297
|
+
/**
|
|
298
|
+
* Minimal typed emitter. Listeners run synchronously, in subscription order.
|
|
299
|
+
*/
|
|
300
|
+
var EventEmitter = class {
|
|
301
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
302
|
+
/**
|
|
303
|
+
* Subscribes to an event. Returns a function that unsubscribes.
|
|
304
|
+
*/
|
|
305
|
+
on(type, listener) {
|
|
306
|
+
let listeners = this.#listeners.get(type);
|
|
307
|
+
if (!listeners) {
|
|
308
|
+
listeners = /* @__PURE__ */ new Set();
|
|
309
|
+
this.#listeners.set(type, listeners);
|
|
310
|
+
}
|
|
311
|
+
listeners.add(listener);
|
|
312
|
+
return () => {
|
|
313
|
+
listeners.delete(listener);
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Calls the listeners of an event with its payload.
|
|
318
|
+
*/
|
|
319
|
+
emit(type, detail) {
|
|
320
|
+
const listeners = this.#listeners.get(type);
|
|
321
|
+
if (!listeners) return;
|
|
322
|
+
for (const listener of [...listeners]) listener(detail);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Files accepted by the hoop, and their uploads. Items are immutable
|
|
327
|
+
* snapshots; every change replaces the item and emits `change`.
|
|
328
|
+
*/
|
|
329
|
+
var UploadQueue = class extends EventEmitter {
|
|
330
|
+
#concurrency;
|
|
331
|
+
#items = [];
|
|
332
|
+
#controllers = /* @__PURE__ */ new Map();
|
|
333
|
+
#nextId = 0;
|
|
334
|
+
constructor(options = {}) {
|
|
335
|
+
super();
|
|
336
|
+
this.uploader = options.uploader ?? null;
|
|
337
|
+
this.#concurrency = normalizeConcurrency(options.concurrency);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Every item, in the order the files were added.
|
|
341
|
+
*/
|
|
342
|
+
get items() {
|
|
343
|
+
return this.#items;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Uploads running at the same time: a whole number from 1, otherwise
|
|
347
|
+
* `DEFAULT_CONCURRENCY`. Raising it starts queued uploads at once.
|
|
348
|
+
*/
|
|
349
|
+
get concurrency() {
|
|
350
|
+
return this.#concurrency;
|
|
351
|
+
}
|
|
352
|
+
set concurrency(value) {
|
|
353
|
+
this.#concurrency = normalizeConcurrency(value);
|
|
354
|
+
this.#pump();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The current snapshot of an item.
|
|
358
|
+
*/
|
|
359
|
+
get(id) {
|
|
360
|
+
return this.#items.find((item) => item.id === id);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Adds a file: `queued` with an uploader, which starts as soon as a slot is
|
|
364
|
+
* free, otherwise `ready`. Returns the item as it is once added, possibly
|
|
365
|
+
* already `uploading`.
|
|
366
|
+
*/
|
|
367
|
+
add(file) {
|
|
368
|
+
this.#nextId += 1;
|
|
369
|
+
const id = `upload-${this.#nextId}`;
|
|
370
|
+
const item = {
|
|
371
|
+
id,
|
|
372
|
+
file,
|
|
373
|
+
status: this.uploader ? "queued" : "ready",
|
|
374
|
+
progress: this.uploader ? 0 : 1,
|
|
375
|
+
response: void 0,
|
|
376
|
+
error: void 0
|
|
377
|
+
};
|
|
378
|
+
this.#items = [...this.#items, item];
|
|
379
|
+
this.emit("change", this.#items);
|
|
380
|
+
this.#pump();
|
|
381
|
+
return this.get(id) ?? item;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Uploads a failed item again. Returns whether there was one to retry.
|
|
385
|
+
*/
|
|
386
|
+
retry(id) {
|
|
387
|
+
const item = this.get(id);
|
|
388
|
+
if (!item || item.status !== "error" || !this.uploader) return false;
|
|
389
|
+
this.#update(id, {
|
|
390
|
+
status: "queued",
|
|
391
|
+
progress: 0,
|
|
392
|
+
error: void 0
|
|
393
|
+
});
|
|
394
|
+
this.#pump();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Removes an item, aborting its upload.
|
|
399
|
+
*/
|
|
400
|
+
remove(id) {
|
|
401
|
+
if (!this.get(id)) return false;
|
|
402
|
+
this.#controllers.get(id)?.abort();
|
|
403
|
+
this.#controllers.delete(id);
|
|
404
|
+
this.#items = this.#items.filter((item) => item.id !== id);
|
|
405
|
+
this.emit("change", this.#items);
|
|
406
|
+
this.#pump();
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Removes every item, aborting the uploads in flight.
|
|
411
|
+
*/
|
|
412
|
+
clear() {
|
|
413
|
+
for (const controller of this.#controllers.values()) controller.abort();
|
|
414
|
+
this.#controllers.clear();
|
|
415
|
+
if (this.#items.length === 0) return;
|
|
416
|
+
this.#items = [];
|
|
417
|
+
this.emit("change", this.#items);
|
|
418
|
+
}
|
|
419
|
+
#pump() {
|
|
420
|
+
if (!this.uploader) return;
|
|
421
|
+
let active = this.#items.filter((item) => item.status === "uploading").length;
|
|
422
|
+
for (const item of this.#items) {
|
|
423
|
+
if (active >= this.#concurrency) return;
|
|
424
|
+
if (item.status !== "queued") continue;
|
|
425
|
+
this.#start(item, this.uploader);
|
|
426
|
+
active += 1;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
#start(item, uploader) {
|
|
430
|
+
const { id, file } = item;
|
|
431
|
+
const controller = new AbortController();
|
|
432
|
+
const { signal } = controller;
|
|
433
|
+
this.#controllers.set(id, controller);
|
|
434
|
+
this.#update(id, {
|
|
435
|
+
status: "uploading",
|
|
436
|
+
progress: 0
|
|
437
|
+
});
|
|
438
|
+
this.#emitItem("start", id);
|
|
439
|
+
const onProgress = (loaded, total = file.size) => {
|
|
440
|
+
if (signal.aborted) return;
|
|
441
|
+
const progress = total > 0 ? Math.min(1, Math.max(0, loaded / total)) : 0;
|
|
442
|
+
this.#update(id, { progress });
|
|
443
|
+
this.#emitItem("progress", id);
|
|
444
|
+
};
|
|
445
|
+
let upload;
|
|
446
|
+
try {
|
|
447
|
+
upload = Promise.resolve(uploader(file, {
|
|
448
|
+
signal,
|
|
449
|
+
onProgress
|
|
450
|
+
}));
|
|
451
|
+
} catch (error) {
|
|
452
|
+
upload = Promise.reject(error);
|
|
453
|
+
}
|
|
454
|
+
upload.then((response) => {
|
|
455
|
+
if (signal.aborted) return;
|
|
456
|
+
this.#controllers.delete(id);
|
|
457
|
+
this.#update(id, {
|
|
458
|
+
status: "uploaded",
|
|
459
|
+
progress: 1,
|
|
460
|
+
response
|
|
461
|
+
});
|
|
462
|
+
this.#emitItem("success", id);
|
|
463
|
+
this.#pump();
|
|
464
|
+
}, (error) => {
|
|
465
|
+
if (signal.aborted) return;
|
|
466
|
+
this.#controllers.delete(id);
|
|
467
|
+
this.#update(id, {
|
|
468
|
+
status: "error",
|
|
469
|
+
error
|
|
470
|
+
});
|
|
471
|
+
this.#emitItem("error", id);
|
|
472
|
+
this.#pump();
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
#update(id, patch) {
|
|
476
|
+
this.#items = this.#items.map((item) => item.id === id ? {
|
|
477
|
+
...item,
|
|
478
|
+
...patch
|
|
479
|
+
} : item);
|
|
480
|
+
this.emit("change", this.#items);
|
|
481
|
+
}
|
|
482
|
+
#emitItem(type, id) {
|
|
483
|
+
const item = this.get(id);
|
|
484
|
+
if (item) this.emit(type, item);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
function normalizeConcurrency(value) {
|
|
488
|
+
return value !== void 0 && Number.isFinite(value) && value >= 1 ? Math.floor(value) : 3;
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/game/simulate.ts
|
|
492
|
+
/**
|
|
493
|
+
* A body at `position`, leaving at `velocity` (pixels per second).
|
|
494
|
+
*/
|
|
495
|
+
function launch(position, velocity) {
|
|
496
|
+
return {
|
|
497
|
+
x: position.x,
|
|
498
|
+
y: position.y,
|
|
499
|
+
velocityX: velocity.x,
|
|
500
|
+
velocityY: velocity.y,
|
|
501
|
+
steps: 0,
|
|
502
|
+
boarded: false
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Seconds since launch.
|
|
507
|
+
*/
|
|
508
|
+
function timeOf(body) {
|
|
509
|
+
return body.steps * PHYSICS.step;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Advances a body by one fixed step (`PHYSICS.step`).
|
|
513
|
+
*/
|
|
514
|
+
function step(body, court) {
|
|
515
|
+
const timeStep = PHYSICS.step;
|
|
516
|
+
const { unit } = court;
|
|
517
|
+
const events = [];
|
|
518
|
+
let velocityX = body.velocityX;
|
|
519
|
+
let velocityY = body.velocityY + PHYSICS.gravity * unit * timeStep;
|
|
520
|
+
let x = body.x + velocityX * timeStep;
|
|
521
|
+
let y = body.y + velocityY * timeStep;
|
|
522
|
+
let boarded = body.boarded;
|
|
523
|
+
const steps = body.steps + 1;
|
|
524
|
+
const { left, right, y: rimY } = court.rim;
|
|
525
|
+
const center = rimCenter(court).x;
|
|
526
|
+
const hit = PHYSICS.hitRadius * unit;
|
|
527
|
+
const atHoopDepth = body.velocityY > -PHYSICS.hoopDepthRise * unit;
|
|
528
|
+
if (body.y < rimY && y >= rimY) {
|
|
529
|
+
const fraction = (rimY - body.y) / (y - body.y);
|
|
530
|
+
const crossX = body.x + (x - body.x) * fraction;
|
|
531
|
+
if (Math.abs(crossX - center) < (right - left) / 2 - hit * .6) return {
|
|
532
|
+
body: {
|
|
533
|
+
x: crossX,
|
|
534
|
+
y: rimY,
|
|
535
|
+
velocityX,
|
|
536
|
+
velocityY,
|
|
537
|
+
steps,
|
|
538
|
+
boarded
|
|
539
|
+
},
|
|
540
|
+
events: ["score"]
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const reach = hit + PHYSICS.rimRadius * unit;
|
|
544
|
+
for (const end of atHoopDepth ? [left, right] : []) {
|
|
545
|
+
const offsetX = x - end;
|
|
546
|
+
const offsetY = y - rimY;
|
|
547
|
+
const distance = Math.hypot(offsetX, offsetY);
|
|
548
|
+
if (distance >= reach || distance === 0) continue;
|
|
549
|
+
const normalX = offsetX / distance;
|
|
550
|
+
const normalY = offsetY / distance;
|
|
551
|
+
x = end + normalX * reach;
|
|
552
|
+
y = rimY + normalY * reach;
|
|
553
|
+
const along = velocityX * normalX + velocityY * normalY;
|
|
554
|
+
if (along < 0) {
|
|
555
|
+
velocityX -= (1 + PHYSICS.restitution.rim) * along * normalX;
|
|
556
|
+
velocityY -= (1 + PHYSICS.restitution.rim) * along * normalY;
|
|
557
|
+
}
|
|
558
|
+
events.push("rim");
|
|
559
|
+
}
|
|
560
|
+
if (!boarded && atHoopDepth && contains(court.square, {
|
|
561
|
+
x,
|
|
562
|
+
y
|
|
563
|
+
})) {
|
|
564
|
+
boarded = true;
|
|
565
|
+
velocityX *= PHYSICS.board.x;
|
|
566
|
+
velocityY *= PHYSICS.board.y;
|
|
567
|
+
events.push("board");
|
|
568
|
+
}
|
|
569
|
+
const { bounds } = court;
|
|
570
|
+
const edge = PHYSICS.floorRadius * unit;
|
|
571
|
+
const floor = bounds.y + bounds.height - edge;
|
|
572
|
+
if (y > floor) {
|
|
573
|
+
y = floor;
|
|
574
|
+
if (velocityY > 0) velocityY = -velocityY * PHYSICS.restitution.floor;
|
|
575
|
+
velocityX *= PHYSICS.floorFriction;
|
|
576
|
+
events.push("floor");
|
|
577
|
+
}
|
|
578
|
+
if (x < bounds.x + edge) {
|
|
579
|
+
x = bounds.x + edge;
|
|
580
|
+
velocityX = Math.abs(velocityX) * PHYSICS.restitution.wall;
|
|
581
|
+
events.push("wall");
|
|
582
|
+
} else if (x > bounds.x + bounds.width - edge) {
|
|
583
|
+
x = bounds.x + bounds.width - edge;
|
|
584
|
+
velocityX = -Math.abs(velocityX) * PHYSICS.restitution.wall;
|
|
585
|
+
events.push("wall");
|
|
586
|
+
}
|
|
587
|
+
if (y < bounds.y + edge && velocityY < 0) {
|
|
588
|
+
y = bounds.y + edge;
|
|
589
|
+
velocityY = Math.abs(velocityY) * PHYSICS.restitution.wall;
|
|
590
|
+
events.push("wall");
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
body: {
|
|
594
|
+
x,
|
|
595
|
+
y,
|
|
596
|
+
velocityX,
|
|
597
|
+
velocityY,
|
|
598
|
+
steps,
|
|
599
|
+
boarded
|
|
600
|
+
},
|
|
601
|
+
events
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Runs the very simulation the flight will run, until its first event.
|
|
606
|
+
* The dots are therefore exactly where the card is going to be.
|
|
607
|
+
*/
|
|
608
|
+
function previewPath(start, court) {
|
|
609
|
+
const every = Math.round(PHYSICS.dotInterval / PHYSICS.step);
|
|
610
|
+
const limit = Math.round(PHYSICS.maxPreviewTime / PHYSICS.step);
|
|
611
|
+
const dots = [];
|
|
612
|
+
let body = start;
|
|
613
|
+
while (body.steps < limit) {
|
|
614
|
+
const result = step(body, court);
|
|
615
|
+
body = result.body;
|
|
616
|
+
const [event] = result.events;
|
|
617
|
+
if (event) {
|
|
618
|
+
dots.push({
|
|
619
|
+
x: body.x,
|
|
620
|
+
y: body.y,
|
|
621
|
+
steps: body.steps
|
|
622
|
+
});
|
|
623
|
+
return {
|
|
624
|
+
dots,
|
|
625
|
+
end: event,
|
|
626
|
+
steps: body.steps
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
if (body.steps % every === 0) dots.push({
|
|
630
|
+
x: body.x,
|
|
631
|
+
y: body.y,
|
|
632
|
+
steps: body.steps
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
dots,
|
|
637
|
+
end: null,
|
|
638
|
+
steps: body.steps
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/game/vector.ts
|
|
643
|
+
function scale(vector, factor) {
|
|
644
|
+
return {
|
|
645
|
+
x: vector.x * factor,
|
|
646
|
+
y: vector.y * factor
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function length(vector) {
|
|
650
|
+
return Math.hypot(vector.x, vector.y);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* `vector` turned by `degrees`, clockwise on screen (y points down).
|
|
654
|
+
*/
|
|
655
|
+
function rotate(vector, degrees) {
|
|
656
|
+
const radians = degrees * Math.PI / 180;
|
|
657
|
+
const cos = Math.cos(radians);
|
|
658
|
+
const sin = Math.sin(radians);
|
|
659
|
+
return {
|
|
660
|
+
x: vector.x * cos - vector.y * sin,
|
|
661
|
+
y: vector.x * sin + vector.y * cos
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* `vector` shortened to `max` when longer; unchanged otherwise.
|
|
666
|
+
*/
|
|
667
|
+
function limit(vector, max) {
|
|
668
|
+
const size = length(vector);
|
|
669
|
+
return size > max ? scale(vector, max / size) : vector;
|
|
670
|
+
}
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/game/slingshot.ts
|
|
673
|
+
/**
|
|
674
|
+
* Where the card is drawn while pulled: it follows the pointer up to the
|
|
675
|
+
* maximum pull, then only by a fraction of the extra distance.
|
|
676
|
+
*/
|
|
677
|
+
function stretchPull(pull, unit) {
|
|
678
|
+
const max = PHYSICS.maxPull * unit;
|
|
679
|
+
const size = length(pull);
|
|
680
|
+
if (size <= max) return pull;
|
|
681
|
+
return scale(pull, (max + (size - max) * PHYSICS.pullStretch) / size);
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Pulls shorter than this cancel the shot.
|
|
685
|
+
*/
|
|
686
|
+
function isPullEnough(pull, unit) {
|
|
687
|
+
return length(pull) >= PHYSICS.minPull * unit;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Launch velocity of a pull, in px/s: opposite to the pull, like a slingshot.
|
|
691
|
+
*/
|
|
692
|
+
function pullToVelocity(pull, unit, power) {
|
|
693
|
+
return scale(limit(pull, PHYSICS.maxPull * unit), -power);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Aiming with the keyboard: `pull` turned by `degrees` (clockwise on screen)
|
|
697
|
+
* and lengthened by `extra` rim units, between the shortest and the longest pull.
|
|
698
|
+
*/
|
|
699
|
+
function steerPull(pull, degrees, extra, unit) {
|
|
700
|
+
const size = length(pull);
|
|
701
|
+
const steered = Math.min(PHYSICS.maxPull * unit, Math.max(PHYSICS.minPull * unit, size + extra * unit));
|
|
702
|
+
return scale(rotate(pull, degrees), steered / size);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Velocity, in px/s, of a shot from `start` that peaks above the square and
|
|
706
|
+
* comes down on the center of the rim. Used for keyboard shots, and to size
|
|
707
|
+
* the slingshot to the layout.
|
|
708
|
+
*/
|
|
709
|
+
function solveAssistedShot(start, court) {
|
|
710
|
+
const gravity = PHYSICS.gravity * court.unit;
|
|
711
|
+
const target = rimCenter(court);
|
|
712
|
+
const apex = Math.min(start.y, court.square.y) - PHYSICS.assistApex * court.unit;
|
|
713
|
+
const rise = start.y - apex;
|
|
714
|
+
const fall = target.y - apex;
|
|
715
|
+
const time = Math.sqrt(2 * rise / gravity) + Math.sqrt(2 * fall / gravity);
|
|
716
|
+
return {
|
|
717
|
+
x: (target.x - start.x) / time,
|
|
718
|
+
y: -Math.sqrt(2 * gravity * rise)
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* The pull that launches the assisted shot from where cards rest.
|
|
723
|
+
*/
|
|
724
|
+
function assistedPull(court) {
|
|
725
|
+
return scale(solveAssistedShot(court.rest, court), -1 / launchPower(court));
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Speed per unit of pull for this layout: at least the default, and enough
|
|
729
|
+
* for a full pull to comfortably reach the hoop from where cards rest.
|
|
730
|
+
*/
|
|
731
|
+
function launchPower(court) {
|
|
732
|
+
const needed = length(solveAssistedShot(court.rest, court));
|
|
733
|
+
return Math.max(PHYSICS.power, needed * PHYSICS.powerHeadroom / (PHYSICS.maxPull * court.unit));
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Spin in degrees per second for a launch velocity in px/s.
|
|
737
|
+
*/
|
|
738
|
+
function spinFor(velocity, unit) {
|
|
739
|
+
const speed = Math.abs(velocity.x) / unit * PHYSICS.spin.factor;
|
|
740
|
+
const spin = Math.min(PHYSICS.spin.max, Math.max(PHYSICS.spin.min, speed));
|
|
741
|
+
return velocity.x < 0 ? -spin : spin;
|
|
742
|
+
}
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/element/motion.ts
|
|
745
|
+
/**
|
|
746
|
+
* Whether the user asked for reduced motion. `false` where `matchMedia` is
|
|
747
|
+
* missing.
|
|
748
|
+
*/
|
|
749
|
+
function prefersReducedMotion() {
|
|
750
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* The individual transform properties of a pose, for a box of the given size.
|
|
754
|
+
*/
|
|
755
|
+
function poseStyle(pose, width, height) {
|
|
756
|
+
return {
|
|
757
|
+
translate: `${pose.x - width / 2}px ${pose.y - height / 2}px`,
|
|
758
|
+
rotate: `${pose.rotation}deg`,
|
|
759
|
+
scale: String(pose.scale)
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Runs a Web Animation and resolves when it ends or is cancelled.
|
|
764
|
+
* Resolves at once where the Web Animations API is missing.
|
|
765
|
+
*/
|
|
766
|
+
async function play(element, keyframes, options = {}) {
|
|
767
|
+
if (typeof element.animate !== "function") return;
|
|
768
|
+
const { commit = false, ...timing } = options;
|
|
769
|
+
const animation = element.animate(keyframes, commit ? {
|
|
770
|
+
...timing,
|
|
771
|
+
fill: "forwards"
|
|
772
|
+
} : timing);
|
|
773
|
+
try {
|
|
774
|
+
await animation.finished;
|
|
775
|
+
} catch {
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (!commit) return;
|
|
779
|
+
try {
|
|
780
|
+
animation.commitStyles();
|
|
781
|
+
} catch {}
|
|
782
|
+
animation.cancel();
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* The nearest whole turn, so a spinning card settles upright without unwinding.
|
|
786
|
+
*/
|
|
787
|
+
function uprightOf(rotation) {
|
|
788
|
+
return Math.round(rotation / 360) * 360;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Cards lift a little while held.
|
|
792
|
+
*/
|
|
793
|
+
const GRAB_SCALE = 1.04;
|
|
794
|
+
/**
|
|
795
|
+
* Tilt while pulling, in degrees per rim unit of horizontal pull.
|
|
796
|
+
*/
|
|
797
|
+
const PULL_TILT = 14;
|
|
798
|
+
/**
|
|
799
|
+
* What each arrow does to the keyboard aim: degrees to turn it by, and rim
|
|
800
|
+
* units to lengthen its pull by.
|
|
801
|
+
*/
|
|
802
|
+
const STEERING = {
|
|
803
|
+
ArrowLeft: [-2, 0],
|
|
804
|
+
ArrowRight: [2, 0],
|
|
805
|
+
ArrowUp: [0, .05],
|
|
806
|
+
ArrowDown: [0, -.05]
|
|
807
|
+
};
|
|
808
|
+
/**
|
|
809
|
+
* Rotation of a card pulled by `pull`: it leans back against the pull.
|
|
810
|
+
*/
|
|
811
|
+
function tiltOf(pull, unit) {
|
|
812
|
+
return Math.max(-1, Math.min(1, pull.x / unit)) * PULL_TILT;
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Aiming with the pointer or the keyboard, assisted shots, and the flight of
|
|
816
|
+
* the card.
|
|
817
|
+
*/
|
|
818
|
+
var ShotController = class {
|
|
819
|
+
#host;
|
|
820
|
+
#card = null;
|
|
821
|
+
#frame = 0;
|
|
822
|
+
#endAim = null;
|
|
823
|
+
constructor(host) {
|
|
824
|
+
this.#host = host;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Starts aiming: the card follows the pointer like a slingshot pouch.
|
|
828
|
+
*/
|
|
829
|
+
grab(card, event) {
|
|
830
|
+
if (this.#card || event.button !== 0) return;
|
|
831
|
+
const court = this.#host.measure();
|
|
832
|
+
if (!court) return;
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
const { element } = card;
|
|
835
|
+
try {
|
|
836
|
+
element.setPointerCapture(event.pointerId);
|
|
837
|
+
} catch {}
|
|
838
|
+
const { signal, aim, end } = this.#takeAim(card, court, { follow: true });
|
|
839
|
+
signal.addEventListener("abort", () => {
|
|
840
|
+
if (element.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId);
|
|
841
|
+
});
|
|
842
|
+
const origin = {
|
|
843
|
+
x: event.clientX,
|
|
844
|
+
y: event.clientY
|
|
845
|
+
};
|
|
846
|
+
element.addEventListener("pointermove", (moved) => {
|
|
847
|
+
if (moved.pointerId !== event.pointerId) return;
|
|
848
|
+
aim({
|
|
849
|
+
x: moved.clientX - origin.x,
|
|
850
|
+
y: moved.clientY - origin.y
|
|
851
|
+
});
|
|
852
|
+
}, { signal });
|
|
853
|
+
element.addEventListener("pointerup", (released) => {
|
|
854
|
+
if (released.pointerId === event.pointerId) end(true);
|
|
855
|
+
}, { signal });
|
|
856
|
+
element.addEventListener("pointercancel", (cancelled) => {
|
|
857
|
+
if (cancelled.pointerId === event.pointerId) end(false);
|
|
858
|
+
}, { signal });
|
|
859
|
+
element.addEventListener("lostpointercapture", (lost) => {
|
|
860
|
+
if (lost.pointerId === event.pointerId) end(false);
|
|
861
|
+
}, { signal });
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Starts aiming from the keyboard, at the assisted shot: the arrows turn it
|
|
865
|
+
* and change its strength, and releasing the key that started it shoots.
|
|
866
|
+
*/
|
|
867
|
+
hold(card, event) {
|
|
868
|
+
if (this.#card || event.repeat) return;
|
|
869
|
+
const court = this.#host.measure();
|
|
870
|
+
if (!court) return;
|
|
871
|
+
const { element } = card;
|
|
872
|
+
const { signal, aim, end } = this.#takeAim(card, court, { follow: false });
|
|
873
|
+
let pull = assistedPull(court);
|
|
874
|
+
aim(pull);
|
|
875
|
+
element.addEventListener("keydown", (pressed) => {
|
|
876
|
+
const steering = STEERING[pressed.key];
|
|
877
|
+
if (!steering) return;
|
|
878
|
+
pressed.preventDefault();
|
|
879
|
+
pull = steerPull(pull, steering[0], steering[1], court.unit);
|
|
880
|
+
aim(pull);
|
|
881
|
+
}, { signal });
|
|
882
|
+
element.addEventListener("keyup", (released) => {
|
|
883
|
+
if (released.key === event.key) end(true);
|
|
884
|
+
}, { signal });
|
|
885
|
+
element.addEventListener("blur", () => end(false), { signal });
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Drops the current aim; the card goes back to the court.
|
|
889
|
+
*/
|
|
890
|
+
cancelAim() {
|
|
891
|
+
this.#endAim?.();
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* A perfect shot from the court, for `shoot()`.
|
|
895
|
+
*/
|
|
896
|
+
shoot(card) {
|
|
897
|
+
if (this.#card) return false;
|
|
898
|
+
const court = this.#host.measure();
|
|
899
|
+
if (!court) return false;
|
|
900
|
+
this.#card = card;
|
|
901
|
+
const pose = {
|
|
902
|
+
...court.rest,
|
|
903
|
+
rotation: -6,
|
|
904
|
+
scale: 1
|
|
905
|
+
};
|
|
906
|
+
card.place(pose);
|
|
907
|
+
const body = launch(pose, solveAssistedShot(court.rest, court));
|
|
908
|
+
this.#host.trajectory.show(previewPath(body, court).dots, court.unit);
|
|
909
|
+
this.#host.launched(card);
|
|
910
|
+
this.#fly(card, body, pose.rotation, court);
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Stops whatever is going on, leaving the card where it is.
|
|
915
|
+
*/
|
|
916
|
+
stop() {
|
|
917
|
+
this.#endAim?.();
|
|
918
|
+
cancelAnimationFrame(this.#frame);
|
|
919
|
+
this.#frame = 0;
|
|
920
|
+
const card = this.#card;
|
|
921
|
+
this.#card = null;
|
|
922
|
+
this.#host.trajectory.clear();
|
|
923
|
+
this.#host.setFlying(false);
|
|
924
|
+
this.#host.setOverBoard(false);
|
|
925
|
+
return card;
|
|
926
|
+
}
|
|
927
|
+
#fly(card, start, rotation, court) {
|
|
928
|
+
if (prefersReducedMotion()) {
|
|
929
|
+
this.#card = null;
|
|
930
|
+
this.#host.trajectory.clear();
|
|
931
|
+
this.#host.scored(card, {
|
|
932
|
+
x: start.x,
|
|
933
|
+
y: start.y,
|
|
934
|
+
rotation,
|
|
935
|
+
scale: 1
|
|
936
|
+
}, court);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
this.#host.setFlying(true);
|
|
940
|
+
const preview = previewPath(start, court);
|
|
941
|
+
const depthSteps = preview.end === "board" || preview.end === "rim" || preview.end === "score" ? Math.max(1, preview.steps) : Math.round(.6 / PHYSICS.step);
|
|
942
|
+
let body = start;
|
|
943
|
+
let angle = rotation;
|
|
944
|
+
let spin = spinFor({
|
|
945
|
+
x: start.velocityX,
|
|
946
|
+
y: start.velocityY
|
|
947
|
+
}, court.unit);
|
|
948
|
+
let bounces = 0;
|
|
949
|
+
let carry = 0;
|
|
950
|
+
let last = performance.now();
|
|
951
|
+
const pose = () => ({
|
|
952
|
+
x: body.x,
|
|
953
|
+
y: body.y,
|
|
954
|
+
rotation: angle,
|
|
955
|
+
scale: 1 - (1 - PHYSICS.depthScale) * Math.min(1, body.steps / depthSteps)
|
|
956
|
+
});
|
|
957
|
+
const finish = (scored) => {
|
|
958
|
+
this.#frame = 0;
|
|
959
|
+
this.#host.trajectory.clear();
|
|
960
|
+
this.#host.setFlying(false);
|
|
961
|
+
this.#host.setOverBoard(false);
|
|
962
|
+
const final = pose();
|
|
963
|
+
card.place(final);
|
|
964
|
+
if (scored) {
|
|
965
|
+
this.#card = null;
|
|
966
|
+
this.#host.scored(card, final, court);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
this.#returnToRest(card, final, court).then(() => {
|
|
970
|
+
this.#card = null;
|
|
971
|
+
this.#host.missed(card);
|
|
972
|
+
});
|
|
973
|
+
};
|
|
974
|
+
const frame = (now) => {
|
|
975
|
+
carry += Math.min(.1, Math.max(0, (now - last) / 1e3));
|
|
976
|
+
last = now;
|
|
977
|
+
while (carry >= PHYSICS.step) {
|
|
978
|
+
carry -= PHYSICS.step;
|
|
979
|
+
const result = step(body, court);
|
|
980
|
+
body = result.body;
|
|
981
|
+
angle += spin * PHYSICS.step;
|
|
982
|
+
if (result.events.includes("score")) {
|
|
983
|
+
finish(true);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (result.events.includes("board") || result.events.includes("rim")) spin *= .6;
|
|
987
|
+
if (result.events.includes("floor")) {
|
|
988
|
+
bounces += 1;
|
|
989
|
+
spin *= .5;
|
|
990
|
+
}
|
|
991
|
+
if (bounces > PHYSICS.maxFloorBounces || timeOf(body) > PHYSICS.maxFlightTime) {
|
|
992
|
+
finish(false);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
card.place(pose());
|
|
997
|
+
this.#host.trajectory.hideBefore(body.steps);
|
|
998
|
+
this.#host.setOverBoard(contains(court.board, body));
|
|
999
|
+
this.#frame = requestAnimationFrame(frame);
|
|
1000
|
+
};
|
|
1001
|
+
this.#frame = requestAnimationFrame(frame);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Holds `card` and draws the shot of each pull it is given, until the aim
|
|
1005
|
+
* ends. With `follow`, the card moves with the pull; otherwise it stays on
|
|
1006
|
+
* its spot.
|
|
1007
|
+
*/
|
|
1008
|
+
#takeAim(card, court, { follow }) {
|
|
1009
|
+
this.#card = card;
|
|
1010
|
+
this.#host.setAiming(true);
|
|
1011
|
+
const power = launchPower(court);
|
|
1012
|
+
const listeners = new AbortController();
|
|
1013
|
+
let pull = {
|
|
1014
|
+
x: 0,
|
|
1015
|
+
y: 0
|
|
1016
|
+
};
|
|
1017
|
+
let pose = {
|
|
1018
|
+
...court.rest,
|
|
1019
|
+
rotation: -6,
|
|
1020
|
+
scale: GRAB_SCALE
|
|
1021
|
+
};
|
|
1022
|
+
card.place(pose);
|
|
1023
|
+
const aim = (to) => {
|
|
1024
|
+
pull = to;
|
|
1025
|
+
const shown = stretchPull(pull, court.unit);
|
|
1026
|
+
pose = {
|
|
1027
|
+
x: court.rest.x + (follow ? shown.x : 0),
|
|
1028
|
+
y: court.rest.y + (follow ? shown.y : 0),
|
|
1029
|
+
rotation: -6 + tiltOf(shown, court.unit),
|
|
1030
|
+
scale: GRAB_SCALE
|
|
1031
|
+
};
|
|
1032
|
+
card.place(pose);
|
|
1033
|
+
if (isPullEnough(pull, court.unit)) {
|
|
1034
|
+
const body = launch(pose, pullToVelocity(pull, court.unit, power));
|
|
1035
|
+
this.#host.trajectory.show(previewPath(body, court).dots, court.unit);
|
|
1036
|
+
} else this.#host.trajectory.clear();
|
|
1037
|
+
};
|
|
1038
|
+
const end = (shoot) => {
|
|
1039
|
+
listeners.abort();
|
|
1040
|
+
this.#endAim = null;
|
|
1041
|
+
this.#host.setAiming(false);
|
|
1042
|
+
if (shoot && isPullEnough(pull, court.unit)) {
|
|
1043
|
+
const body = launch(pose, pullToVelocity(pull, court.unit, power));
|
|
1044
|
+
this.#host.launched(card);
|
|
1045
|
+
this.#fly(card, body, pose.rotation, court);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
this.#host.trajectory.clear();
|
|
1049
|
+
this.#returnToRest(card, pose, court).then(() => {
|
|
1050
|
+
this.#card = null;
|
|
1051
|
+
});
|
|
1052
|
+
};
|
|
1053
|
+
this.#endAim = () => end(false);
|
|
1054
|
+
return {
|
|
1055
|
+
signal: listeners.signal,
|
|
1056
|
+
aim,
|
|
1057
|
+
end
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
async #returnToRest(card, from, court) {
|
|
1061
|
+
const rest = {
|
|
1062
|
+
...court.rest,
|
|
1063
|
+
rotation: -6,
|
|
1064
|
+
scale: 1
|
|
1065
|
+
};
|
|
1066
|
+
const { width, height } = card.size();
|
|
1067
|
+
await play(card.element, [poseStyle(from, width, height), poseStyle(rest, width, height)], {
|
|
1068
|
+
duration: prefersReducedMotion() ? 0 : 520,
|
|
1069
|
+
easing: "cubic-bezier(0.34, 1.4, 0.64, 1)",
|
|
1070
|
+
commit: true
|
|
1071
|
+
});
|
|
1072
|
+
card.release();
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/element/dom.ts
|
|
1077
|
+
/**
|
|
1078
|
+
* An HTML element with its attributes and children.
|
|
1079
|
+
*/
|
|
1080
|
+
function createElement(tag, attributes = {}, children = []) {
|
|
1081
|
+
const element = document.createElement(tag);
|
|
1082
|
+
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
|
|
1083
|
+
element.append(...children);
|
|
1084
|
+
return element;
|
|
1085
|
+
}
|
|
1086
|
+
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
1087
|
+
/**
|
|
1088
|
+
* An SVG element with its attributes and children, in the SVG namespace.
|
|
1089
|
+
*/
|
|
1090
|
+
function createSvgElement(tag, attributes = {}, children = []) {
|
|
1091
|
+
const element = document.createElementNS(SVG_NAMESPACE, tag);
|
|
1092
|
+
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, String(value));
|
|
1093
|
+
element.append(...children);
|
|
1094
|
+
return element;
|
|
1095
|
+
}
|
|
1096
|
+
const icons = {
|
|
1097
|
+
upload: "M12 15V4.5m0 0L8 8.5m4-4 4 4M5 14v3.5A2.5 2.5 0 0 0 7.5 20h9a2.5 2.5 0 0 0 2.5-2.5V14",
|
|
1098
|
+
check: "M7.5 12.5l3 3 6-6.5",
|
|
1099
|
+
retry: "M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"
|
|
1100
|
+
};
|
|
1101
|
+
/**
|
|
1102
|
+
* A 24×24 stroked icon, hidden from assistive technology.
|
|
1103
|
+
*/
|
|
1104
|
+
function icon(name, attributes = {}) {
|
|
1105
|
+
return createSvgElement("svg", {
|
|
1106
|
+
viewBox: "0 0 24 24",
|
|
1107
|
+
"aria-hidden": "true",
|
|
1108
|
+
focusable: "false",
|
|
1109
|
+
class: "icon",
|
|
1110
|
+
...attributes
|
|
1111
|
+
}, [createSvgElement("path", { d: icons[name] })]);
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Replaces the children of a slot: its fallback content.
|
|
1115
|
+
*/
|
|
1116
|
+
function setFallback(slot, text) {
|
|
1117
|
+
if (slot.textContent !== text) slot.textContent = text;
|
|
1118
|
+
}
|
|
1119
|
+
//#endregion
|
|
1120
|
+
//#region src/element/messages.ts
|
|
1121
|
+
/**
|
|
1122
|
+
* The copy of the reference design, in English.
|
|
1123
|
+
*/
|
|
1124
|
+
const defaultMessages = {
|
|
1125
|
+
title: "Upload files",
|
|
1126
|
+
description: "Drag and drop, or take the shot.",
|
|
1127
|
+
prompt: "Drop files here",
|
|
1128
|
+
hint: "or take the shot",
|
|
1129
|
+
dropzone: "Choose files to upload",
|
|
1130
|
+
counter: "Uploaded",
|
|
1131
|
+
shoot: (name) => `Shoot ${name}`,
|
|
1132
|
+
ready: "Ready",
|
|
1133
|
+
queued: "Waiting…",
|
|
1134
|
+
uploading: "Uploading…",
|
|
1135
|
+
uploaded: "Uploaded",
|
|
1136
|
+
failed: "Upload failed",
|
|
1137
|
+
retry: (name) => `Retry ${name}`,
|
|
1138
|
+
progress: (name) => `${name} upload progress`,
|
|
1139
|
+
required: "Add at least one file.",
|
|
1140
|
+
scored: (name) => `${name}: nothing but net.`,
|
|
1141
|
+
missed: (name) => `Missed ${name}. Take the shot again.`,
|
|
1142
|
+
rejected: (name, reason) => reason === "type" ? `${name} is not an accepted file type.` : reason === "size" ? `${name} is too large.` : `${name} is one file too many.`,
|
|
1143
|
+
complete: (name) => `${name} uploaded.`,
|
|
1144
|
+
error: (name) => `${name} failed to upload.`
|
|
1145
|
+
};
|
|
1146
|
+
//#endregion
|
|
1147
|
+
//#region src/element/styles/styles.ts
|
|
1148
|
+
const SOURCES = [
|
|
1149
|
+
"@layer tokens, reset, layout, components, states, motion;\n",
|
|
1150
|
+
"@layer tokens {\n :host {\n color-scheme: light dark;\n\n --_background: var(\n --basketball-upload-background,\n light-dark(#eff1f5, #0f1115)\n );\n --_foreground: var(\n --basketball-upload-foreground,\n light-dark(#0f1115, #f4f5f7)\n );\n --_muted-foreground: var(\n --basketball-upload-muted-foreground,\n light-dark(#6b6f78, #a3a8b2)\n );\n --_subtle-foreground: var(\n --basketball-upload-subtle-foreground,\n light-dark(#989a9f, #7c818b)\n );\n --_surface: var(--basketball-upload-surface, light-dark(#ffffff, #181b21));\n --_border: var(--basketball-upload-border, light-dark(#cbccd1, #3a3f48));\n --_radius: var(--basketball-upload-radius, 1.125rem);\n\n --_accent: var(--basketball-upload-accent, light-dark(#f0612e, #f26a39));\n --_accent-strong: var(\n --basketball-upload-accent-strong,\n light-dark(#bd4d19, #c4521f)\n );\n --_accent-surface: var(\n --basketball-upload-accent-surface,\n light-dark(#fff4ef, color-mix(in oklab, #f26a39 14%, #181b21))\n );\n --_trajectory: var(\n --basketball-upload-trajectory,\n color-mix(in oklab, var(--_accent) 60%, transparent)\n );\n --_net: var(--basketball-upload-net, light-dark(#a9abb3, #6b717c));\n\n --_counter-background: var(\n --basketball-upload-counter-background,\n light-dark(#e3e7ed, #232730)\n );\n --_track: var(--basketball-upload-track, light-dark(#e6e8ec, #2a2e36));\n --_success: var(--basketball-upload-success, light-dark(#2fb06d, #34c07a));\n --_danger: var(--basketball-upload-danger, light-dark(#d8374e, #f0606f));\n\n --_file-surface: var(--basketball-upload-file-surface, #ffffff);\n --_file-line: var(--basketball-upload-file-line, #e4e5e9);\n --_file-fold: var(--basketball-upload-file-fold, #e9eaee);\n --_file-badge: var(--basketball-upload-file-badge, #6b7280);\n\n --_shadow: var(\n --basketball-upload-shadow,\n 0 1px 2px rgb(15 17 21 / 0.04),\n 0 0.5rem 1.5rem rgb(15 17 21 / 0.07)\n );\n --_focus-ring: var(\n --basketball-upload-focus-ring,\n 2px solid var(--_accent)\n );\n --_font-family: var(\n --basketball-upload-font-family,\n Inter,\n ui-sans-serif,\n system-ui,\n sans-serif\n );\n\n --_gutter: var(--basketball-upload-gutter, clamp(1.25rem, 8cqi, 3.75rem));\n --_hoop-size: var(\n --basketball-upload-hoop-size,\n min(25rem, 100cqi - 2 * var(--_gutter))\n );\n --_card-width: calc(var(--_hoop-size) * 0.2375);\n --_card-height: calc(var(--_hoop-size) * 0.2875);\n }\n\n :host([theme='light']) {\n color-scheme: light;\n }\n\n :host([theme='dark']) {\n color-scheme: dark;\n }\n}\n",
|
|
1151
|
+
"@layer reset {\n *,\n *::before,\n *::after {\n box-sizing: border-box;\n }\n\n [hidden] {\n display: none !important;\n }\n\n h2,\n p,\n ul {\n margin: 0;\n padding: 0;\n }\n\n ul {\n list-style: none;\n }\n\n button {\n margin: 0;\n padding: 0;\n font: inherit;\n color: inherit;\n background: none;\n border: 0;\n cursor: pointer;\n -webkit-tap-highlight-color: transparent;\n }\n\n img {\n display: block;\n max-inline-size: 100%;\n }\n\n svg {\n display: block;\n overflow: visible;\n }\n\n .icon {\n fill: none;\n stroke: currentColor;\n stroke-width: 1.75;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n .visually-hidden {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n }\n}\n",
|
|
1152
|
+
"@layer layout {\n :host {\n display: flex;\n flex-direction: column;\n position: relative;\n container-type: inline-size;\n min-block-size: 36rem;\n font-family: var(--_font-family);\n font-size: 1rem;\n line-height: 1.4;\n color: var(--_foreground);\n background: var(--_background);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n\n :host([hidden]) {\n display: none;\n }\n\n .frame {\n position: relative;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n padding: var(--_gutter);\n }\n\n .header {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-start;\n justify-content: space-between;\n gap: 0.75rem 1rem;\n }\n\n .hoop {\n position: relative;\n flex: none;\n align-self: center;\n inline-size: var(--_hoop-size);\n aspect-ratio: 400 / 268;\n margin-block-start: calc(var(--_hoop-size) * 0.1125);\n }\n\n .court {\n position: relative;\n flex: 1 1 auto;\n min-block-size: calc(var(--_hoop-size) * 0.96);\n }\n\n .frame:has(.list:not([hidden])) {\n padding-block-end: calc(var(--_gutter) * 0.65);\n\n & .court {\n min-block-size: calc(var(--_hoop-size) * 0.8);\n }\n }\n\n .spot {\n position: absolute;\n inset-inline-start: calc(var(--_hoop-size) * 0.13);\n inset-block-end: calc(var(--_hoop-size) * 0.3);\n inline-size: var(--_card-width);\n block-size: var(--_card-height);\n pointer-events: none;\n }\n\n .list {\n display: grid;\n flex: none;\n gap: 0.75rem;\n }\n\n .layer {\n position: absolute;\n inset: 0;\n z-index: 2;\n pointer-events: none;\n }\n}\n",
|
|
1153
|
+
"@layer components {\n .title {\n font-size: clamp(1.5rem, 3cqi + 1rem, 2rem);\n font-weight: 700;\n line-height: 1.25;\n letter-spacing: -0.015em;\n text-wrap: balance;\n }\n\n .description {\n margin-block-start: 0.1875rem;\n font-size: clamp(1rem, 1cqi + 0.85rem, 1.125rem);\n color: var(--_muted-foreground);\n text-wrap: balance;\n }\n\n .counter {\n display: inline-flex;\n flex: none;\n margin-block-start: 0.375rem;\n align-items: center;\n gap: 0.625rem;\n padding: 0.3125rem 0.875rem;\n border-radius: 999px;\n background: var(--_counter-background);\n white-space: nowrap;\n }\n\n .counter-label {\n font-size: 0.84375rem;\n color: var(--_muted-foreground);\n }\n\n .counter-value {\n display: inline-block;\n min-inline-size: 1ch;\n font-size: 1.0625rem;\n font-weight: 600;\n font-variant-numeric: tabular-nums;\n text-align: center;\n }\n\n .dropzone {\n position: absolute;\n inset: 0;\n z-index: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n padding-block-start: 7.5%;\n border-radius: var(--_radius);\n color: var(--_muted-foreground);\n background: var(--_surface);\n transition: background-color 180ms ease;\n\n &:focus-visible {\n outline: var(--_focus-ring);\n outline-offset: 4px;\n }\n\n &:disabled {\n cursor: not-allowed;\n }\n }\n\n .dropzone-outline {\n position: absolute;\n inset: 0;\n inline-size: 100%;\n block-size: 100%;\n pointer-events: none;\n\n & rect {\n fill: none;\n stroke: var(--_border);\n stroke-width: 1.5;\n stroke-dasharray: 8 6;\n transition: stroke 180ms ease;\n }\n }\n\n .dropzone-icon {\n inline-size: 2.125rem;\n block-size: 2.125rem;\n stroke-width: 1.6;\n }\n\n .prompt {\n margin-block-start: 0.6875rem;\n font-size: clamp(1.125rem, 2cqi + 0.8rem, 1.375rem);\n font-weight: 500;\n line-height: 1.25;\n letter-spacing: -0.01em;\n color: var(--_foreground);\n }\n\n .hint {\n margin-block-start: 0.25rem;\n font-size: 0.9375rem;\n color: var(--_subtle-foreground);\n }\n\n .square {\n position: absolute;\n inset-inline-start: 33%;\n inset-block-start: 64.6%;\n z-index: 0;\n inline-size: 34%;\n block-size: 29.9%;\n border: 3px solid var(--_accent);\n border-radius: 3px;\n pointer-events: none;\n }\n\n .rim {\n position: absolute;\n inset-inline-start: 29.5%;\n inset-block-start: 100%;\n inline-size: 41%;\n translate: 0 -55%;\n pointer-events: none;\n }\n\n .rim-arc {\n fill: none;\n stroke-width: 5.5;\n stroke-linecap: round;\n }\n\n .rim-back {\n z-index: 1;\n\n & .rim-arc {\n stroke: color-mix(in oklab, var(--_accent) 40%, var(--_accent-strong));\n }\n }\n\n .rim-bracket {\n fill: var(--_accent-strong);\n }\n\n .rim-front {\n z-index: 3;\n\n & .rim-arc {\n stroke: var(--_accent);\n }\n }\n\n .net {\n position: absolute;\n inset-inline-start: 29.5%;\n inset-block-start: 101.5%;\n z-index: 3;\n inline-size: 41%;\n aspect-ratio: 164 / 104;\n transform-origin: 50% 0;\n pointer-events: none;\n\n & line {\n stroke: var(--_net);\n stroke-width: 1.75;\n stroke-linecap: round;\n vector-effect: non-scaling-stroke;\n }\n }\n\n .score-pop {\n position: absolute;\n inset-inline-start: 68%;\n inset-block-start: 59.5%;\n z-index: 4;\n font-size: calc(var(--_hoop-size) * 0.08);\n font-weight: 800;\n line-height: 1;\n letter-spacing: -0.02em;\n color: var(--_accent);\n opacity: 0;\n pointer-events: none;\n }\n}\n",
|
|
1154
|
+
"@layer components {\n .card {\n --_depth: 0;\n --_fold: calc(var(--_card-width) * 0.2);\n\n position: absolute;\n top: 0;\n left: 0;\n inline-size: var(--_card-width);\n block-size: var(--_card-height);\n translate: calc(var(--_rest-x, 0px) - 50% - var(--_depth) * 0.45rem)\n calc(var(--_rest-y, 0px) - 50% + var(--_depth) * 0.2rem);\n /* Keep -6deg in sync with REST_ROTATION in shot-controller.ts. */\n rotate: calc(-6deg - var(--_depth) * 7deg);\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-drag: none;\n cursor: grab;\n filter: drop-shadow(0 0.5rem 0.75rem rgb(15 17 21 / 0.13));\n\n &[data-depth='1'] {\n --_depth: 1;\n }\n\n &[data-depth='2'] {\n --_depth: 2;\n }\n\n &[data-depth='hidden'] {\n visibility: hidden;\n }\n\n &:focus-visible {\n outline: var(--_focus-ring);\n outline-offset: 4px;\n border-radius: calc(var(--_card-width) * 0.1);\n }\n }\n\n .card-paper {\n position: absolute;\n inset: 0;\n border-radius: calc(var(--_card-width) * 0.1);\n background: var(--_file-surface);\n clip-path: polygon(\n 0 0,\n calc(100% - var(--_fold)) 0,\n 100% var(--_fold),\n 100% 100%,\n 0 100%\n );\n\n &::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n inline-size: var(--_fold);\n block-size: var(--_fold);\n border-end-start-radius: calc(var(--_fold) * 0.22);\n background: linear-gradient(\n to bottom left,\n transparent 50%,\n var(--_file-fold) 50%\n );\n }\n }\n\n .card-artwork {\n position: absolute;\n inset: 22% 18% 40% 14%;\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n gap: calc(var(--_card-height) * 0.06);\n\n &[data-artwork='thumbnail'] {\n inset: 12% 12% 34% 12%;\n }\n }\n\n .line {\n display: block;\n block-size: calc(var(--_card-height) * 0.05);\n border-radius: 999px;\n background: var(--_file-line);\n\n &:nth-child(2) {\n inline-size: 82%;\n }\n\n &:nth-child(3) {\n inline-size: 92%;\n }\n }\n\n .thumbnail {\n inline-size: 100%;\n block-size: 100%;\n object-fit: cover;\n border-radius: calc(var(--_card-width) * 0.05);\n }\n\n .card-badge {\n position: absolute;\n inset-inline-start: 12%;\n inset-block-end: 12.5%;\n padding: 0.18em 0.55em;\n border-radius: 0.32em;\n font-size: calc(var(--_card-width) * 0.12);\n font-weight: 700;\n line-height: 1.35;\n letter-spacing: 0.02em;\n color: #ffffff;\n background: var(--_file-color, var(--_file-badge));\n white-space: nowrap;\n }\n\n .trajectory {\n position: absolute;\n inset: 0;\n inline-size: 100%;\n block-size: 100%;\n z-index: 0;\n overflow: visible;\n }\n\n .dot {\n fill: var(--_trajectory);\n }\n}\n",
|
|
1155
|
+
"@layer components {\n .item {\n display: grid;\n grid-template-columns: auto minmax(0, 1fr) minmax(5rem, 11.25rem) 1.875rem;\n align-items: center;\n gap: 0.5rem 0.9375rem;\n padding: 1.0625rem 1.25rem 1.0625rem 1.375rem;\n border-radius: 1rem;\n background: var(--_surface);\n box-shadow: var(--_shadow);\n transition:\n opacity 320ms ease,\n translate 320ms cubic-bezier(0.22, 1, 0.36, 1);\n\n @starting-style {\n opacity: 0;\n translate: 0 0.75rem;\n }\n }\n\n @container (inline-size < 30rem) {\n .item {\n grid-template-columns: auto minmax(0, 1fr) 1.875rem;\n }\n\n .item-progress {\n grid-column: 1 / -1;\n grid-row: 2;\n }\n\n .item-end {\n grid-column: 3;\n grid-row: 1;\n }\n }\n\n .item-icon {\n --_card-width: 1.8125rem;\n --_card-height: 2.375rem;\n --_fold: 0.5625rem;\n\n position: relative;\n inline-size: var(--_card-width);\n block-size: var(--_card-height);\n filter: drop-shadow(0 1px 1px rgb(15 17 21 / 0.08));\n }\n\n .item-paper {\n position: absolute;\n inset: 0;\n border-radius: 0.1875rem;\n background: color-mix(\n in oklab,\n var(--_file-line) 45%,\n var(--_file-surface)\n );\n clip-path: polygon(\n 0 0,\n calc(100% - var(--_fold)) 0,\n 100% var(--_fold),\n 100% 100%,\n 0 100%\n );\n\n &::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n inline-size: var(--_fold);\n block-size: var(--_fold);\n background: linear-gradient(\n to bottom left,\n transparent 50%,\n var(--_file-line) 50%\n );\n }\n }\n\n .item-artwork {\n position: absolute;\n inset: 24% 16% 42% 16%;\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n\n & .line {\n block-size: 0.125rem;\n background: var(--_file-line);\n }\n\n &[data-artwork='thumbnail'] {\n inset: 8% 8% 36% 8%;\n }\n }\n\n .item-badge {\n position: absolute;\n inset-inline-start: 12%;\n inset-block-end: 12%;\n padding: 0 0.25em;\n border-radius: 0.2em;\n font-size: 0.375rem;\n font-weight: 700;\n line-height: 1.5;\n color: #ffffff;\n background: var(--_file-color, var(--_file-badge));\n }\n\n .item-meta {\n display: grid;\n gap: 0.125rem;\n min-inline-size: 0;\n }\n\n .item-name {\n overflow: hidden;\n font-size: clamp(1rem, 1.5cqi + 0.5rem, 1.1875rem);\n font-weight: 600;\n line-height: 1.3;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .item-size {\n font-size: 0.875rem;\n color: var(--_subtle-foreground);\n font-variant-numeric: tabular-nums;\n }\n\n .item-progress {\n display: grid;\n gap: 0.8125rem;\n }\n\n .item-status {\n font-size: 0.875rem;\n line-height: 1.2;\n color: var(--_subtle-foreground);\n }\n\n .progress {\n display: block;\n block-size: 0.3125rem;\n overflow: hidden;\n border-radius: 999px;\n background: var(--_track);\n }\n\n .progress-bar {\n display: block;\n block-size: 100%;\n border-radius: inherit;\n background: var(--_accent);\n transform-origin: 0 50%;\n scale: 0 1;\n transition:\n scale 240ms ease-out,\n background-color 240ms ease;\n }\n\n .item-end {\n display: grid;\n place-items: center;\n inline-size: 1.875rem;\n block-size: 1.875rem;\n }\n\n .item-check {\n display: grid;\n place-items: center;\n inline-size: 1.875rem;\n block-size: 1.875rem;\n border-radius: 50%;\n color: #ffffff;\n background: var(--_success);\n\n & .icon {\n inline-size: 1.3125rem;\n block-size: 1.3125rem;\n stroke-width: 2.75;\n }\n }\n\n .item-retry {\n display: grid;\n place-items: center;\n inline-size: 1.875rem;\n block-size: 1.875rem;\n border-radius: 50%;\n color: var(--_danger);\n\n &:hover {\n background: color-mix(in oklab, var(--_danger) 12%, transparent);\n }\n\n &:focus-visible {\n outline: var(--_focus-ring);\n outline-offset: 2px;\n }\n\n & .icon {\n inline-size: 1.25rem;\n block-size: 1.25rem;\n }\n }\n\n .item[data-status='ready'] .progress-bar {\n background: var(--_subtle-foreground);\n }\n\n .item[data-status='uploaded'] {\n & .item-status {\n color: var(--_success);\n }\n\n & .progress-bar {\n background: var(--_success);\n }\n }\n\n .item[data-status='error'] {\n & .item-status {\n color: var(--_danger);\n }\n\n & .progress-bar {\n background: var(--_danger);\n }\n }\n}\n",
|
|
1156
|
+
"@layer states {\n :host(:state(drop-target)) .dropzone {\n background: var(--_accent-surface);\n\n & .dropzone-outline rect {\n stroke: var(--_accent);\n }\n }\n\n :host(:state(aiming)) {\n cursor: grabbing;\n\n & .card {\n cursor: grabbing;\n }\n }\n\n :host(:state(aiming)),\n :host(:state(flying)) {\n user-select: none;\n }\n\n :host(:state(disabled)) {\n & .frame {\n cursor: not-allowed;\n }\n\n & .dropzone,\n & .card {\n filter: grayscale(1);\n opacity: 0.55;\n }\n }\n}\n",
|
|
1157
|
+
"@layer motion {\n @keyframes basketball-upload-shake {\n 0%,\n 100% {\n translate: 0 0;\n }\n 20% {\n translate: -0.375rem 0;\n }\n 40% {\n translate: 0.3125rem 0;\n }\n 60% {\n translate: -0.1875rem 0;\n }\n 80% {\n translate: 0.125rem 0;\n }\n }\n\n :host(:state(rejected)) .dropzone {\n animation: basketball-upload-shake 420ms ease-in-out;\n }\n\n @media (prefers-reduced-motion: reduce) {\n :host(:state(rejected)) .dropzone {\n animation: none;\n }\n\n .item,\n .progress-bar,\n .dropzone,\n .dropzone-outline rect {\n transition: none;\n }\n }\n}\n"
|
|
1158
|
+
];
|
|
1159
|
+
let sheet;
|
|
1160
|
+
/**
|
|
1161
|
+
* One constructed stylesheet shared by every element, created on first use rather than at module evaluation:
|
|
1162
|
+
* the module must be importable where `CSSStyleSheet` does not exist (SSR).
|
|
1163
|
+
*/
|
|
1164
|
+
function getStyleSheet() {
|
|
1165
|
+
if (!sheet) {
|
|
1166
|
+
sheet = new CSSStyleSheet();
|
|
1167
|
+
sheet.replaceSync(SOURCES.join("\n"));
|
|
1168
|
+
}
|
|
1169
|
+
return sheet;
|
|
1170
|
+
}
|
|
1171
|
+
//#endregion
|
|
1172
|
+
//#region src/element/views/card.ts
|
|
1173
|
+
/**
|
|
1174
|
+
* The CSS color of a file type: its token when set, its own color otherwise.
|
|
1175
|
+
*/
|
|
1176
|
+
function fileColor(type) {
|
|
1177
|
+
return `var(--basketball-upload-file-${type.kind}, ${type.color ?? "var(--_file-badge)"})`;
|
|
1178
|
+
}
|
|
1179
|
+
function lines() {
|
|
1180
|
+
return Array.from({ length: 3 }, () => createElement("span", { class: "line" }));
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* The body of a file card: text lines, a thumbnail, or a node of your own.
|
|
1184
|
+
*/
|
|
1185
|
+
function createArtwork(file, type, attributes) {
|
|
1186
|
+
const element = createElement("span", attributes);
|
|
1187
|
+
let url = null;
|
|
1188
|
+
const revoke = () => {
|
|
1189
|
+
if (url) URL.revokeObjectURL(url);
|
|
1190
|
+
url = null;
|
|
1191
|
+
};
|
|
1192
|
+
if (type.artwork === "thumbnail" && typeof URL.createObjectURL === "function") {
|
|
1193
|
+
url = URL.createObjectURL(file);
|
|
1194
|
+
const image = createElement("img", {
|
|
1195
|
+
class: "thumbnail",
|
|
1196
|
+
alt: "",
|
|
1197
|
+
src: url,
|
|
1198
|
+
draggable: "false",
|
|
1199
|
+
decoding: "async"
|
|
1200
|
+
});
|
|
1201
|
+
image.addEventListener("load", revoke, { once: true });
|
|
1202
|
+
image.addEventListener("error", () => {
|
|
1203
|
+
revoke();
|
|
1204
|
+
element.dataset.artwork = "lines";
|
|
1205
|
+
image.replaceWith(...lines());
|
|
1206
|
+
}, { once: true });
|
|
1207
|
+
element.dataset.artwork = "thumbnail";
|
|
1208
|
+
element.append(image);
|
|
1209
|
+
} else if (typeof type.artwork === "function") {
|
|
1210
|
+
element.dataset.artwork = "custom";
|
|
1211
|
+
element.append(type.artwork(file));
|
|
1212
|
+
} else {
|
|
1213
|
+
element.dataset.artwork = "lines";
|
|
1214
|
+
element.append(...lines());
|
|
1215
|
+
}
|
|
1216
|
+
return {
|
|
1217
|
+
element,
|
|
1218
|
+
dispose: revoke
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
function createCard(file, type, label) {
|
|
1222
|
+
const artwork = createArtwork(file, type, {
|
|
1223
|
+
class: "card-artwork",
|
|
1224
|
+
part: "file-artwork"
|
|
1225
|
+
});
|
|
1226
|
+
const element = createElement("button", {
|
|
1227
|
+
type: "button",
|
|
1228
|
+
class: "card",
|
|
1229
|
+
part: `file file-${type.kind}`,
|
|
1230
|
+
"data-kind": type.kind,
|
|
1231
|
+
"aria-label": label
|
|
1232
|
+
}, [createElement("span", { class: "card-paper" }, [artwork.element, createElement("span", {
|
|
1233
|
+
class: "card-badge",
|
|
1234
|
+
part: "file-badge"
|
|
1235
|
+
}, [type.label])])]);
|
|
1236
|
+
element.style.setProperty("--_file-color", fileColor(type));
|
|
1237
|
+
const size = () => ({
|
|
1238
|
+
width: element.offsetWidth,
|
|
1239
|
+
height: element.offsetHeight
|
|
1240
|
+
});
|
|
1241
|
+
return {
|
|
1242
|
+
element,
|
|
1243
|
+
file,
|
|
1244
|
+
type,
|
|
1245
|
+
size,
|
|
1246
|
+
place(pose) {
|
|
1247
|
+
const { width, height } = size();
|
|
1248
|
+
const style = poseStyle(pose, width, height);
|
|
1249
|
+
element.style.translate = style.translate;
|
|
1250
|
+
element.style.rotate = style.rotate;
|
|
1251
|
+
element.style.scale = style.scale;
|
|
1252
|
+
},
|
|
1253
|
+
release() {
|
|
1254
|
+
element.style.removeProperty("translate");
|
|
1255
|
+
element.style.removeProperty("rotate");
|
|
1256
|
+
element.style.removeProperty("scale");
|
|
1257
|
+
element.style.removeProperty("opacity");
|
|
1258
|
+
},
|
|
1259
|
+
dispose() {
|
|
1260
|
+
artwork.dispose();
|
|
1261
|
+
element.remove();
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
//#endregion
|
|
1266
|
+
//#region src/element/views/header.ts
|
|
1267
|
+
function createHeader() {
|
|
1268
|
+
const title = createElement("slot", { name: "title" });
|
|
1269
|
+
const description = createElement("slot", { name: "description" });
|
|
1270
|
+
const counterLabel = createElement("span", {
|
|
1271
|
+
class: "counter-label",
|
|
1272
|
+
part: "counter-label"
|
|
1273
|
+
});
|
|
1274
|
+
const counterValue = createElement("span", {
|
|
1275
|
+
class: "counter-value",
|
|
1276
|
+
part: "counter-value"
|
|
1277
|
+
}, ["0"]);
|
|
1278
|
+
const element = createElement("header", {
|
|
1279
|
+
class: "header",
|
|
1280
|
+
part: "header"
|
|
1281
|
+
}, [createElement("div", { class: "heading" }, [createElement("h2", {
|
|
1282
|
+
class: "title",
|
|
1283
|
+
part: "title"
|
|
1284
|
+
}, [title]), createElement("p", {
|
|
1285
|
+
class: "description",
|
|
1286
|
+
part: "description"
|
|
1287
|
+
}, [description])]), createElement("p", {
|
|
1288
|
+
class: "counter",
|
|
1289
|
+
part: "counter"
|
|
1290
|
+
}, [counterLabel, counterValue])]);
|
|
1291
|
+
let count = 0;
|
|
1292
|
+
return {
|
|
1293
|
+
element,
|
|
1294
|
+
update(messages) {
|
|
1295
|
+
setFallback(title, messages.title);
|
|
1296
|
+
setFallback(description, messages.description);
|
|
1297
|
+
counterLabel.textContent = messages.counter;
|
|
1298
|
+
},
|
|
1299
|
+
setCount(value, bump) {
|
|
1300
|
+
if (value === count) return;
|
|
1301
|
+
const increased = value > count;
|
|
1302
|
+
count = value;
|
|
1303
|
+
counterValue.textContent = String(value);
|
|
1304
|
+
if (bump && increased) play(counterValue, [
|
|
1305
|
+
{ scale: "1" },
|
|
1306
|
+
{ scale: "1.35" },
|
|
1307
|
+
{ scale: "1" }
|
|
1308
|
+
], {
|
|
1309
|
+
duration: 360,
|
|
1310
|
+
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)"
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
//#endregion
|
|
1316
|
+
//#region src/element/views/hoop.ts
|
|
1317
|
+
/**
|
|
1318
|
+
* Net in a 164×104 box: the rim width on top, narrowing towards the bottom.
|
|
1319
|
+
*/
|
|
1320
|
+
function netLines() {
|
|
1321
|
+
const top = [
|
|
1322
|
+
3,
|
|
1323
|
+
29.3,
|
|
1324
|
+
55.7,
|
|
1325
|
+
82,
|
|
1326
|
+
108.3,
|
|
1327
|
+
134.7,
|
|
1328
|
+
161
|
|
1329
|
+
];
|
|
1330
|
+
const bottom = [
|
|
1331
|
+
31,
|
|
1332
|
+
48,
|
|
1333
|
+
65,
|
|
1334
|
+
82,
|
|
1335
|
+
99,
|
|
1336
|
+
116,
|
|
1337
|
+
133
|
|
1338
|
+
];
|
|
1339
|
+
const [topY, bottomY] = [2, 102];
|
|
1340
|
+
const lines = [];
|
|
1341
|
+
const line = (fromX, fromY, toX, toY) => {
|
|
1342
|
+
lines.push(createSvgElement("line", {
|
|
1343
|
+
x1: fromX,
|
|
1344
|
+
y1: fromY,
|
|
1345
|
+
x2: toX,
|
|
1346
|
+
y2: toY
|
|
1347
|
+
}));
|
|
1348
|
+
};
|
|
1349
|
+
for (let index = 0; index < top.length; index += 1) {
|
|
1350
|
+
const x = top[index] ?? 0;
|
|
1351
|
+
const left = bottom[index - 1];
|
|
1352
|
+
const right = bottom[index + 1];
|
|
1353
|
+
if (left !== void 0) line(x, topY, left, bottomY);
|
|
1354
|
+
if (right !== void 0) line(x, topY, right, bottomY);
|
|
1355
|
+
}
|
|
1356
|
+
line(top[0] ?? 0, topY, bottom[0] ?? 0, bottomY);
|
|
1357
|
+
line(top[top.length - 1] ?? 0, topY, bottom[bottom.length - 1] ?? 0, bottomY);
|
|
1358
|
+
for (const y of [42, 70]) {
|
|
1359
|
+
const inset = 28 * ((y - topY) / (bottomY - topY));
|
|
1360
|
+
line(3 + inset, y, 161 - inset, y);
|
|
1361
|
+
}
|
|
1362
|
+
return lines;
|
|
1363
|
+
}
|
|
1364
|
+
function createHoop() {
|
|
1365
|
+
const prompt = createElement("slot", { name: "prompt" });
|
|
1366
|
+
const hint = createElement("slot", { name: "hint" });
|
|
1367
|
+
const dropzone = createElement("button", {
|
|
1368
|
+
type: "button",
|
|
1369
|
+
class: "dropzone",
|
|
1370
|
+
part: "dropzone"
|
|
1371
|
+
}, [
|
|
1372
|
+
createSvgElement("svg", {
|
|
1373
|
+
class: "dropzone-outline",
|
|
1374
|
+
"aria-hidden": "true"
|
|
1375
|
+
}, [createSvgElement("rect", {
|
|
1376
|
+
x: 0,
|
|
1377
|
+
y: 0,
|
|
1378
|
+
width: "100%",
|
|
1379
|
+
height: "100%",
|
|
1380
|
+
rx: "4.5%",
|
|
1381
|
+
ry: "6.7%"
|
|
1382
|
+
})]),
|
|
1383
|
+
icon("upload", {
|
|
1384
|
+
class: "icon dropzone-icon",
|
|
1385
|
+
part: "dropzone-icon"
|
|
1386
|
+
}),
|
|
1387
|
+
createElement("span", {
|
|
1388
|
+
class: "prompt",
|
|
1389
|
+
part: "prompt"
|
|
1390
|
+
}, [prompt]),
|
|
1391
|
+
createElement("span", {
|
|
1392
|
+
class: "hint",
|
|
1393
|
+
part: "hint"
|
|
1394
|
+
}, [hint])
|
|
1395
|
+
]);
|
|
1396
|
+
const square = createElement("div", {
|
|
1397
|
+
class: "square",
|
|
1398
|
+
part: "backboard-square"
|
|
1399
|
+
});
|
|
1400
|
+
const rimBack = createSvgElement("svg", {
|
|
1401
|
+
class: "rim rim-back",
|
|
1402
|
+
part: "rim",
|
|
1403
|
+
viewBox: "0 0 164 18",
|
|
1404
|
+
"aria-hidden": "true"
|
|
1405
|
+
}, [createSvgElement("path", {
|
|
1406
|
+
class: "rim-arc",
|
|
1407
|
+
d: "M3 10 A79 6.5 0 0 1 161 10"
|
|
1408
|
+
}), createSvgElement("rect", {
|
|
1409
|
+
class: "rim-bracket",
|
|
1410
|
+
x: 70,
|
|
1411
|
+
y: .5,
|
|
1412
|
+
width: 24,
|
|
1413
|
+
height: 10,
|
|
1414
|
+
rx: 1.5
|
|
1415
|
+
})]);
|
|
1416
|
+
const rim = createSvgElement("svg", {
|
|
1417
|
+
class: "rim rim-front",
|
|
1418
|
+
part: "rim",
|
|
1419
|
+
viewBox: "0 0 164 18",
|
|
1420
|
+
"aria-hidden": "true"
|
|
1421
|
+
}, [createSvgElement("path", {
|
|
1422
|
+
class: "rim-arc",
|
|
1423
|
+
d: "M3 10 A79 6.5 0 0 0 161 10"
|
|
1424
|
+
})]);
|
|
1425
|
+
const net = createSvgElement("svg", {
|
|
1426
|
+
class: "net",
|
|
1427
|
+
part: "net",
|
|
1428
|
+
viewBox: "0 0 164 104",
|
|
1429
|
+
preserveAspectRatio: "none",
|
|
1430
|
+
"aria-hidden": "true"
|
|
1431
|
+
}, netLines());
|
|
1432
|
+
const scorePop = createElement("span", {
|
|
1433
|
+
class: "score-pop",
|
|
1434
|
+
part: "score-pop",
|
|
1435
|
+
"aria-hidden": "true"
|
|
1436
|
+
}, ["+1"]);
|
|
1437
|
+
return {
|
|
1438
|
+
element: createElement("div", {
|
|
1439
|
+
class: "hoop",
|
|
1440
|
+
part: "hoop"
|
|
1441
|
+
}, [
|
|
1442
|
+
dropzone,
|
|
1443
|
+
square,
|
|
1444
|
+
rimBack,
|
|
1445
|
+
rim,
|
|
1446
|
+
net,
|
|
1447
|
+
scorePop
|
|
1448
|
+
]),
|
|
1449
|
+
dropzone,
|
|
1450
|
+
square,
|
|
1451
|
+
rim,
|
|
1452
|
+
net,
|
|
1453
|
+
update(messages) {
|
|
1454
|
+
setFallback(prompt, messages.prompt);
|
|
1455
|
+
setFallback(hint, messages.hint);
|
|
1456
|
+
dropzone.setAttribute("aria-label", messages.dropzone);
|
|
1457
|
+
},
|
|
1458
|
+
celebrate(reducedMotion) {
|
|
1459
|
+
play(scorePop, reducedMotion ? [
|
|
1460
|
+
{ opacity: 0 },
|
|
1461
|
+
{
|
|
1462
|
+
opacity: 1,
|
|
1463
|
+
offset: .2
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
opacity: 1,
|
|
1467
|
+
offset: .7
|
|
1468
|
+
},
|
|
1469
|
+
{ opacity: 0 }
|
|
1470
|
+
] : [
|
|
1471
|
+
{
|
|
1472
|
+
opacity: 0,
|
|
1473
|
+
translate: "0 0.3em",
|
|
1474
|
+
scale: "0.6"
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
opacity: 1,
|
|
1478
|
+
translate: "0 0",
|
|
1479
|
+
scale: "1.1",
|
|
1480
|
+
offset: .12
|
|
1481
|
+
},
|
|
1482
|
+
{
|
|
1483
|
+
opacity: 1,
|
|
1484
|
+
translate: "0 -0.1em",
|
|
1485
|
+
scale: "1",
|
|
1486
|
+
offset: .75
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
opacity: 0,
|
|
1490
|
+
translate: "0 -0.45em",
|
|
1491
|
+
scale: "1"
|
|
1492
|
+
}
|
|
1493
|
+
], {
|
|
1494
|
+
duration: 1700,
|
|
1495
|
+
easing: "ease-out"
|
|
1496
|
+
});
|
|
1497
|
+
},
|
|
1498
|
+
swish() {
|
|
1499
|
+
play(net, [
|
|
1500
|
+
{ scale: "1 1" },
|
|
1501
|
+
{
|
|
1502
|
+
scale: "0.94 1.22",
|
|
1503
|
+
offset: .45
|
|
1504
|
+
},
|
|
1505
|
+
{
|
|
1506
|
+
scale: "1.02 0.96",
|
|
1507
|
+
offset: .78
|
|
1508
|
+
},
|
|
1509
|
+
{ scale: "1 1" }
|
|
1510
|
+
], {
|
|
1511
|
+
duration: 700,
|
|
1512
|
+
easing: "ease-out"
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
//#endregion
|
|
1518
|
+
//#region src/element/views/live-region.ts
|
|
1519
|
+
/**
|
|
1520
|
+
* Polite announcements for what only shows as motion: scores, misses, uploads.
|
|
1521
|
+
*/
|
|
1522
|
+
function createLiveRegion() {
|
|
1523
|
+
const element = createElement("div", {
|
|
1524
|
+
class: "visually-hidden",
|
|
1525
|
+
role: "status",
|
|
1526
|
+
"aria-live": "polite"
|
|
1527
|
+
});
|
|
1528
|
+
let frame = 0;
|
|
1529
|
+
return {
|
|
1530
|
+
element,
|
|
1531
|
+
announce(message) {
|
|
1532
|
+
element.textContent = "";
|
|
1533
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
1534
|
+
element.textContent = message;
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
cancelAnimationFrame(frame);
|
|
1538
|
+
frame = requestAnimationFrame(() => {
|
|
1539
|
+
element.textContent = message;
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
//#endregion
|
|
1545
|
+
//#region src/element/views/trajectory.ts
|
|
1546
|
+
/**
|
|
1547
|
+
* Dot radius, in rim units: 3.6 px on the 164 px rim of the design.
|
|
1548
|
+
*/
|
|
1549
|
+
const DOT_RADIUS = .022;
|
|
1550
|
+
function createTrajectory() {
|
|
1551
|
+
const element = createSvgElement("svg", {
|
|
1552
|
+
class: "trajectory",
|
|
1553
|
+
part: "trajectory",
|
|
1554
|
+
"aria-hidden": "true"
|
|
1555
|
+
});
|
|
1556
|
+
const circles = [];
|
|
1557
|
+
let shown = [];
|
|
1558
|
+
return {
|
|
1559
|
+
element,
|
|
1560
|
+
show(dots, unit) {
|
|
1561
|
+
shown = dots;
|
|
1562
|
+
while (circles.length < dots.length) {
|
|
1563
|
+
const circle = createSvgElement("circle", { class: "dot" });
|
|
1564
|
+
circles.push(circle);
|
|
1565
|
+
element.append(circle);
|
|
1566
|
+
}
|
|
1567
|
+
const radius = Math.max(1.5, unit * DOT_RADIUS).toFixed(2);
|
|
1568
|
+
circles.forEach((circle, index) => {
|
|
1569
|
+
const dot = dots[index];
|
|
1570
|
+
circle.toggleAttribute("hidden", !dot);
|
|
1571
|
+
if (!dot) return;
|
|
1572
|
+
circle.setAttribute("cx", dot.x.toFixed(1));
|
|
1573
|
+
circle.setAttribute("cy", dot.y.toFixed(1));
|
|
1574
|
+
circle.setAttribute("r", radius);
|
|
1575
|
+
});
|
|
1576
|
+
},
|
|
1577
|
+
hideBefore(steps) {
|
|
1578
|
+
shown.forEach((dot, index) => {
|
|
1579
|
+
if (dot.steps <= steps) circles[index]?.setAttribute("hidden", "");
|
|
1580
|
+
});
|
|
1581
|
+
},
|
|
1582
|
+
clear() {
|
|
1583
|
+
shown = [];
|
|
1584
|
+
for (const circle of circles) circle.setAttribute("hidden", "");
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region src/upload/format.ts
|
|
1590
|
+
const UNITS = [
|
|
1591
|
+
"B",
|
|
1592
|
+
"KB",
|
|
1593
|
+
"MB",
|
|
1594
|
+
"GB",
|
|
1595
|
+
"TB"
|
|
1596
|
+
];
|
|
1597
|
+
/**
|
|
1598
|
+
* Human readable size in decimal units, the way file managers show it: `2_400_000` → `2.4 MB`.
|
|
1599
|
+
* `locale` formats the number (`2,4 MB` in `de`); it defaults to the runtime's.
|
|
1600
|
+
*/
|
|
1601
|
+
function formatBytes(bytes, locale) {
|
|
1602
|
+
let value = bytes > 0 ? bytes : 0;
|
|
1603
|
+
let unit = 0;
|
|
1604
|
+
while (value >= (unit === 0 ? 999.5 : 999.95) && unit < UNITS.length - 1) {
|
|
1605
|
+
value /= 1e3;
|
|
1606
|
+
unit += 1;
|
|
1607
|
+
}
|
|
1608
|
+
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: unit === 0 ? 0 : 1 }).format(value)} ${UNITS[unit]}`;
|
|
1609
|
+
}
|
|
1610
|
+
//#endregion
|
|
1611
|
+
//#region src/element/views/upload-list.ts
|
|
1612
|
+
function statusText(item, messages) {
|
|
1613
|
+
switch (item.status) {
|
|
1614
|
+
case "ready": return messages.ready;
|
|
1615
|
+
case "queued": return messages.queued;
|
|
1616
|
+
case "uploading": return messages.uploading;
|
|
1617
|
+
case "uploaded": return messages.uploaded;
|
|
1618
|
+
case "error": return messages.failed;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
function createRow(item, type, options) {
|
|
1622
|
+
const { file } = item;
|
|
1623
|
+
const artwork = createArtwork(file, type, { class: "item-artwork" });
|
|
1624
|
+
const thumbnail = createElement("span", {
|
|
1625
|
+
class: "item-icon",
|
|
1626
|
+
part: `item-icon item-icon-${type.kind}`,
|
|
1627
|
+
"aria-hidden": "true"
|
|
1628
|
+
}, [createElement("span", { class: "item-paper" }, [artwork.element, createElement("span", { class: "item-badge" }, [type.label])])]);
|
|
1629
|
+
thumbnail.style.setProperty("--_file-color", fileColor(type));
|
|
1630
|
+
const status = createElement("span", {
|
|
1631
|
+
class: "item-status",
|
|
1632
|
+
part: "item-status"
|
|
1633
|
+
});
|
|
1634
|
+
const bar = createElement("span", { class: "progress-bar" });
|
|
1635
|
+
const progress = createElement("span", {
|
|
1636
|
+
class: "progress",
|
|
1637
|
+
part: "progress",
|
|
1638
|
+
role: "progressbar",
|
|
1639
|
+
"aria-valuemin": "0",
|
|
1640
|
+
"aria-valuemax": "100"
|
|
1641
|
+
}, [bar]);
|
|
1642
|
+
const check = createElement("span", {
|
|
1643
|
+
class: "item-check",
|
|
1644
|
+
"aria-hidden": "true"
|
|
1645
|
+
}, [icon("check")]);
|
|
1646
|
+
const retry = createElement("button", {
|
|
1647
|
+
type: "button",
|
|
1648
|
+
class: "item-retry",
|
|
1649
|
+
part: "retry"
|
|
1650
|
+
}, [icon("retry")]);
|
|
1651
|
+
retry.addEventListener("click", () => options.onRetry(item.id));
|
|
1652
|
+
const element = createElement("li", {
|
|
1653
|
+
class: "item",
|
|
1654
|
+
part: "item",
|
|
1655
|
+
"data-kind": type.kind
|
|
1656
|
+
}, [
|
|
1657
|
+
thumbnail,
|
|
1658
|
+
createElement("span", { class: "item-meta" }, [createElement("span", {
|
|
1659
|
+
class: "item-name",
|
|
1660
|
+
part: "item-name"
|
|
1661
|
+
}, [file.name]), createElement("span", {
|
|
1662
|
+
class: "item-size",
|
|
1663
|
+
part: "item-size"
|
|
1664
|
+
}, [formatBytes(file.size, options.locale())])]),
|
|
1665
|
+
createElement("span", { class: "item-progress" }, [status, progress]),
|
|
1666
|
+
createElement("span", { class: "item-end" }, [check, retry])
|
|
1667
|
+
]);
|
|
1668
|
+
return {
|
|
1669
|
+
element,
|
|
1670
|
+
update(next, messages) {
|
|
1671
|
+
element.dataset.status = next.status;
|
|
1672
|
+
status.textContent = statusText(next, messages);
|
|
1673
|
+
const percent = Math.round(next.progress * 100);
|
|
1674
|
+
progress.setAttribute("aria-valuenow", String(percent));
|
|
1675
|
+
progress.setAttribute("aria-label", messages.progress(file.name));
|
|
1676
|
+
bar.style.scale = `${next.progress} 1`;
|
|
1677
|
+
check.hidden = next.status !== "uploaded";
|
|
1678
|
+
retry.hidden = next.status !== "error";
|
|
1679
|
+
retry.setAttribute("aria-label", messages.retry(file.name));
|
|
1680
|
+
retry.title = messages.retry(file.name);
|
|
1681
|
+
},
|
|
1682
|
+
dispose() {
|
|
1683
|
+
artwork.dispose();
|
|
1684
|
+
element.remove();
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* The list of files in the basket, with their upload progress.
|
|
1690
|
+
*/
|
|
1691
|
+
function createUploadList(options) {
|
|
1692
|
+
const element = createElement("ul", {
|
|
1693
|
+
class: "list",
|
|
1694
|
+
part: "list",
|
|
1695
|
+
role: "list"
|
|
1696
|
+
});
|
|
1697
|
+
element.hidden = true;
|
|
1698
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1699
|
+
return {
|
|
1700
|
+
element,
|
|
1701
|
+
render(items, messages) {
|
|
1702
|
+
const present = /* @__PURE__ */ new Set();
|
|
1703
|
+
for (const item of items) {
|
|
1704
|
+
present.add(item.id);
|
|
1705
|
+
let row = rows.get(item.id);
|
|
1706
|
+
if (!row) {
|
|
1707
|
+
row = createRow(item, options.resolveType(item.file), options);
|
|
1708
|
+
rows.set(item.id, row);
|
|
1709
|
+
element.append(row.element);
|
|
1710
|
+
}
|
|
1711
|
+
row.update(item, messages);
|
|
1712
|
+
}
|
|
1713
|
+
for (const [id, row] of rows) {
|
|
1714
|
+
if (present.has(id)) continue;
|
|
1715
|
+
row.dispose();
|
|
1716
|
+
rows.delete(id);
|
|
1717
|
+
}
|
|
1718
|
+
element.hidden = items.length === 0;
|
|
1719
|
+
},
|
|
1720
|
+
dispose() {
|
|
1721
|
+
for (const row of rows.values()) row.dispose();
|
|
1722
|
+
rows.clear();
|
|
1723
|
+
}
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
//#endregion
|
|
1727
|
+
//#region src/element/basketball-upload-element.ts
|
|
1728
|
+
/**
|
|
1729
|
+
* Cards drawn on the court: the top one and two underneath.
|
|
1730
|
+
*/
|
|
1731
|
+
const VISIBLE_STACK = 3;
|
|
1732
|
+
/**
|
|
1733
|
+
* Delay between the cards of a multi-file dunk, ms.
|
|
1734
|
+
*/
|
|
1735
|
+
const DUNK_STAGGER = 150;
|
|
1736
|
+
/**
|
|
1737
|
+
* `HTMLElement` is missing where there is no DOM (SSR). Extending a stand-in
|
|
1738
|
+
* keeps this module importable there; the class is only ever instantiated by
|
|
1739
|
+
* `customElements.define`, which those environments do not have either.
|
|
1740
|
+
*/
|
|
1741
|
+
const BaseElement = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
|
|
1742
|
+
function hasFiles(event) {
|
|
1743
|
+
return event.dataTransfer?.types.includes("Files") ?? false;
|
|
1744
|
+
}
|
|
1745
|
+
function numberAttribute(value) {
|
|
1746
|
+
if (value === null || value.trim() === "") return null;
|
|
1747
|
+
const number = Number(value);
|
|
1748
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* `<basketball-upload>`: a file upload where the dropzone is a backboard.
|
|
1752
|
+
* Drop files on it, or drop them on the court and take the shot.
|
|
1753
|
+
*/
|
|
1754
|
+
var BasketballUploadElement = class extends BaseElement {
|
|
1755
|
+
static {
|
|
1756
|
+
this.formAssociated = true;
|
|
1757
|
+
}
|
|
1758
|
+
static {
|
|
1759
|
+
this.observedAttributes = [
|
|
1760
|
+
"concurrency",
|
|
1761
|
+
"disabled",
|
|
1762
|
+
"name",
|
|
1763
|
+
"required"
|
|
1764
|
+
];
|
|
1765
|
+
}
|
|
1766
|
+
#internals;
|
|
1767
|
+
#queue = new UploadQueue();
|
|
1768
|
+
#header = createHeader();
|
|
1769
|
+
#hoop = createHoop();
|
|
1770
|
+
#trajectory = createTrajectory();
|
|
1771
|
+
#liveRegion = createLiveRegion();
|
|
1772
|
+
#list;
|
|
1773
|
+
#layer;
|
|
1774
|
+
#court;
|
|
1775
|
+
#spot;
|
|
1776
|
+
#input;
|
|
1777
|
+
#shots;
|
|
1778
|
+
/**
|
|
1779
|
+
* Cards waiting on the court, bottom of the stack first.
|
|
1780
|
+
*/
|
|
1781
|
+
#staged = [];
|
|
1782
|
+
/**
|
|
1783
|
+
* Cards on their way into the basket: dunks, shots in the air, scores.
|
|
1784
|
+
*/
|
|
1785
|
+
#inPlay = /* @__PURE__ */ new Set();
|
|
1786
|
+
/**
|
|
1787
|
+
* Scored cards fading out of the net: their files are already in the basket.
|
|
1788
|
+
*/
|
|
1789
|
+
#leaving = /* @__PURE__ */ new Set();
|
|
1790
|
+
#messages = defaultMessages;
|
|
1791
|
+
#fileTypes = [];
|
|
1792
|
+
#resizeObserver = null;
|
|
1793
|
+
#formDisabled = false;
|
|
1794
|
+
#changeSignature = "";
|
|
1795
|
+
#states = {
|
|
1796
|
+
dragging: false,
|
|
1797
|
+
dropOverBoard: false,
|
|
1798
|
+
flightOverBoard: false,
|
|
1799
|
+
scoring: 0,
|
|
1800
|
+
aiming: false,
|
|
1801
|
+
flying: false,
|
|
1802
|
+
rejected: false
|
|
1803
|
+
};
|
|
1804
|
+
#rejectedTimer;
|
|
1805
|
+
constructor() {
|
|
1806
|
+
super();
|
|
1807
|
+
this.#internals = typeof this.attachInternals === "function" ? this.attachInternals() : null;
|
|
1808
|
+
this.#list = createUploadList({
|
|
1809
|
+
resolveType: (file) => this.#resolveType(file),
|
|
1810
|
+
onRetry: (id) => this.retryItem(id),
|
|
1811
|
+
locale: () => this.closest("[lang]")?.getAttribute("lang") ?? void 0
|
|
1812
|
+
});
|
|
1813
|
+
this.#spot = createElement("div", {
|
|
1814
|
+
class: "spot",
|
|
1815
|
+
"aria-hidden": "true"
|
|
1816
|
+
});
|
|
1817
|
+
this.#court = createElement("div", { class: "court" }, [this.#spot]);
|
|
1818
|
+
this.#layer = createElement("div", { class: "layer" }, [this.#trajectory.element]);
|
|
1819
|
+
this.#input = createElement("input", {
|
|
1820
|
+
type: "file",
|
|
1821
|
+
hidden: "",
|
|
1822
|
+
tabindex: "-1",
|
|
1823
|
+
"aria-hidden": "true"
|
|
1824
|
+
});
|
|
1825
|
+
const root = this.attachShadow({ mode: "open" });
|
|
1826
|
+
root.adoptedStyleSheets = [getStyleSheet()];
|
|
1827
|
+
root.append(createElement("div", {
|
|
1828
|
+
class: "frame",
|
|
1829
|
+
part: "frame"
|
|
1830
|
+
}, [
|
|
1831
|
+
this.#header.element,
|
|
1832
|
+
this.#hoop.element,
|
|
1833
|
+
this.#court,
|
|
1834
|
+
this.#list.element,
|
|
1835
|
+
this.#layer
|
|
1836
|
+
]), this.#liveRegion.element, this.#input);
|
|
1837
|
+
this.#shots = new ShotController({
|
|
1838
|
+
trajectory: this.#trajectory,
|
|
1839
|
+
measure: () => this.#measure(),
|
|
1840
|
+
setAiming: (on) => this.#setState("aiming", on),
|
|
1841
|
+
setFlying: (on) => this.#setState("flying", on),
|
|
1842
|
+
setOverBoard: (on) => {
|
|
1843
|
+
this.#states.flightOverBoard = on;
|
|
1844
|
+
this.#syncStates();
|
|
1845
|
+
},
|
|
1846
|
+
launched: (card) => this.#launched(card),
|
|
1847
|
+
scored: (card, pose, court) => void this.#score(card, pose, court),
|
|
1848
|
+
missed: (card) => this.#missed(card)
|
|
1849
|
+
});
|
|
1850
|
+
this.#hoop.dropzone.addEventListener("click", () => this.openPicker());
|
|
1851
|
+
this.#input.addEventListener("change", () => {
|
|
1852
|
+
const files = [...this.#input.files ?? []];
|
|
1853
|
+
this.#input.value = "";
|
|
1854
|
+
if (this.instant) this.#dunk(files);
|
|
1855
|
+
else this.#stage(files);
|
|
1856
|
+
});
|
|
1857
|
+
this.addEventListener("dragenter", this.#handleDragOver);
|
|
1858
|
+
this.addEventListener("dragover", this.#handleDragOver);
|
|
1859
|
+
this.addEventListener("dragleave", this.#handleDragLeave);
|
|
1860
|
+
this.addEventListener("drop", this.#handleDrop);
|
|
1861
|
+
this.#queue.on("change", (items) => this.#handleItems(items));
|
|
1862
|
+
this.#queue.on("start", (item) => this.#emit("upload-start", { item }));
|
|
1863
|
+
this.#queue.on("progress", (item) => this.#emit("upload-progress", { item }));
|
|
1864
|
+
this.#queue.on("success", (item) => {
|
|
1865
|
+
this.#emit("upload-success", { item });
|
|
1866
|
+
this.#liveRegion.announce(this.#messages.complete(item.file.name));
|
|
1867
|
+
});
|
|
1868
|
+
this.#queue.on("error", (item) => {
|
|
1869
|
+
this.#emit("upload-error", { item });
|
|
1870
|
+
this.#liveRegion.announce(this.#messages.error(item.file.name));
|
|
1871
|
+
});
|
|
1872
|
+
this.#applyMessages();
|
|
1873
|
+
this.#syncForm();
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* `light`, `dark`, or `system` (the default) to follow `prefers-color-scheme`.
|
|
1877
|
+
*/
|
|
1878
|
+
get theme() {
|
|
1879
|
+
const value = this.getAttribute("theme");
|
|
1880
|
+
return value === "light" || value === "dark" ? value : "system";
|
|
1881
|
+
}
|
|
1882
|
+
set theme(value) {
|
|
1883
|
+
if (value === "light" || value === "dark") this.setAttribute("theme", value);
|
|
1884
|
+
else this.removeAttribute("theme");
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Accepted files, with the syntax of `<input type="file" accept>`.
|
|
1888
|
+
*/
|
|
1889
|
+
get accept() {
|
|
1890
|
+
return this.getAttribute("accept") ?? "";
|
|
1891
|
+
}
|
|
1892
|
+
set accept(value) {
|
|
1893
|
+
if (value) this.setAttribute("accept", value);
|
|
1894
|
+
else this.removeAttribute("accept");
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Several files at a time. Without it, a new file replaces the previous one.
|
|
1898
|
+
*/
|
|
1899
|
+
get multiple() {
|
|
1900
|
+
return this.hasAttribute("multiple");
|
|
1901
|
+
}
|
|
1902
|
+
set multiple(value) {
|
|
1903
|
+
this.toggleAttribute("multiple", Boolean(value));
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Largest accepted file, in bytes.
|
|
1907
|
+
*/
|
|
1908
|
+
get maxSize() {
|
|
1909
|
+
return numberAttribute(this.getAttribute("max-size"));
|
|
1910
|
+
}
|
|
1911
|
+
set maxSize(value) {
|
|
1912
|
+
if (value === null || value === void 0) this.removeAttribute("max-size");
|
|
1913
|
+
else this.setAttribute("max-size", String(value));
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Most files at once, staged and in the basket.
|
|
1917
|
+
*/
|
|
1918
|
+
get maxFiles() {
|
|
1919
|
+
return numberAttribute(this.getAttribute("max-files"));
|
|
1920
|
+
}
|
|
1921
|
+
set maxFiles(value) {
|
|
1922
|
+
if (value === null || value === void 0) this.removeAttribute("max-files");
|
|
1923
|
+
else this.setAttribute("max-files", String(value));
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Form field the files are submitted under.
|
|
1927
|
+
*/
|
|
1928
|
+
get name() {
|
|
1929
|
+
return this.getAttribute("name") ?? "";
|
|
1930
|
+
}
|
|
1931
|
+
set name(value) {
|
|
1932
|
+
if (value) this.setAttribute("name", value);
|
|
1933
|
+
else this.removeAttribute("name");
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* The form is invalid while the basket is empty.
|
|
1937
|
+
*/
|
|
1938
|
+
get required() {
|
|
1939
|
+
return this.hasAttribute("required");
|
|
1940
|
+
}
|
|
1941
|
+
set required(value) {
|
|
1942
|
+
this.toggleAttribute("required", Boolean(value));
|
|
1943
|
+
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Ignores files and shots. A disabled `<fieldset>` does the same, without
|
|
1946
|
+
* changing this property.
|
|
1947
|
+
*/
|
|
1948
|
+
get disabled() {
|
|
1949
|
+
return this.hasAttribute("disabled");
|
|
1950
|
+
}
|
|
1951
|
+
set disabled(value) {
|
|
1952
|
+
this.toggleAttribute("disabled", Boolean(value));
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* No shooting: every file goes straight into the basket.
|
|
1956
|
+
*/
|
|
1957
|
+
get instant() {
|
|
1958
|
+
return this.hasAttribute("instant");
|
|
1959
|
+
}
|
|
1960
|
+
set instant(value) {
|
|
1961
|
+
this.toggleAttribute("instant", Boolean(value));
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* Uploads running at the same time.
|
|
1965
|
+
*/
|
|
1966
|
+
get concurrency() {
|
|
1967
|
+
return this.#queue.concurrency;
|
|
1968
|
+
}
|
|
1969
|
+
set concurrency(value) {
|
|
1970
|
+
if (value === null || value === void 0) this.removeAttribute("concurrency");
|
|
1971
|
+
else this.setAttribute("concurrency", String(value));
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Sends each file in the basket. Without one, files only travel with their form.
|
|
1975
|
+
*/
|
|
1976
|
+
get uploader() {
|
|
1977
|
+
return this.#queue.uploader;
|
|
1978
|
+
}
|
|
1979
|
+
set uploader(value) {
|
|
1980
|
+
this.#queue.uploader = value ?? null;
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* Copy and accessible names. Assign a partial object to override some: it is
|
|
1984
|
+
* merged over the defaults, not over the previous value, and keys set to
|
|
1985
|
+
* `undefined` keep their default.
|
|
1986
|
+
*/
|
|
1987
|
+
get messages() {
|
|
1988
|
+
return this.#messages;
|
|
1989
|
+
}
|
|
1990
|
+
set messages(value) {
|
|
1991
|
+
const overrides = Object.fromEntries(Object.entries(value ?? {}).filter(([, message]) => message !== void 0));
|
|
1992
|
+
this.#messages = {
|
|
1993
|
+
...defaultMessages,
|
|
1994
|
+
...overrides
|
|
1995
|
+
};
|
|
1996
|
+
this.#applyMessages();
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* File types for this element only, checked before the registered ones.
|
|
2000
|
+
*/
|
|
2001
|
+
get fileTypes() {
|
|
2002
|
+
return this.#fileTypes;
|
|
2003
|
+
}
|
|
2004
|
+
set fileTypes(value) {
|
|
2005
|
+
const types = value ?? [];
|
|
2006
|
+
types.forEach(assertFileType);
|
|
2007
|
+
this.#fileTypes = types;
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* Files in the basket and their uploads.
|
|
2011
|
+
*/
|
|
2012
|
+
get items() {
|
|
2013
|
+
return this.#queue.items;
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Files in the basket: the value submitted with the form.
|
|
2017
|
+
*/
|
|
2018
|
+
get files() {
|
|
2019
|
+
return this.#queue.items.map((item) => item.file);
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* The `<form>` the element belongs to, or `null`.
|
|
2023
|
+
*/
|
|
2024
|
+
get form() {
|
|
2025
|
+
return this.#internals?.form ?? null;
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* As on native controls: `valueMissing` while `required` and empty.
|
|
2029
|
+
* `undefined` where `ElementInternals` is missing.
|
|
2030
|
+
*/
|
|
2031
|
+
get validity() {
|
|
2032
|
+
return this.#internals?.validity;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* `messages.required` while `required` and empty, otherwise empty.
|
|
2036
|
+
*/
|
|
2037
|
+
get validationMessage() {
|
|
2038
|
+
return this.#internals?.validationMessage ?? "";
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Whether the form validates the element, as on native controls: not while
|
|
2042
|
+
* disabled.
|
|
2043
|
+
*/
|
|
2044
|
+
get willValidate() {
|
|
2045
|
+
return this.#internals?.willValidate ?? false;
|
|
2046
|
+
}
|
|
2047
|
+
/**
|
|
2048
|
+
* Whether the element is valid, firing `invalid` when it is not. Always
|
|
2049
|
+
* valid where `ElementInternals` is missing.
|
|
2050
|
+
*/
|
|
2051
|
+
checkValidity() {
|
|
2052
|
+
return this.#internals?.checkValidity() ?? true;
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Like `checkValidity()`, and shows the message on the dropzone when invalid.
|
|
2056
|
+
*/
|
|
2057
|
+
reportValidity() {
|
|
2058
|
+
return this.#internals?.reportValidity() ?? true;
|
|
2059
|
+
}
|
|
2060
|
+
/**
|
|
2061
|
+
* Opens the file picker. The chosen files land on the court (or in the basket, with `instant`).
|
|
2062
|
+
*/
|
|
2063
|
+
openPicker() {
|
|
2064
|
+
if (this.#isDisabled) return;
|
|
2065
|
+
this.#input.accept = this.accept;
|
|
2066
|
+
this.#input.multiple = this.multiple;
|
|
2067
|
+
this.#input.click();
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Puts files on the court, ready to be shot (with `instant`, straight into
|
|
2071
|
+
* the basket). Files that do not pass `accept`, `max-size` or `max-files`
|
|
2072
|
+
* fire `file-reject` instead.
|
|
2073
|
+
*/
|
|
2074
|
+
stage(files) {
|
|
2075
|
+
if (this.instant) this.#dunk([...files]);
|
|
2076
|
+
else this.#stage([...files]);
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Puts files straight into the basket. Files that do not pass `accept`,
|
|
2080
|
+
* `max-size` or `max-files` fire `file-reject` instead.
|
|
2081
|
+
*/
|
|
2082
|
+
dunk(files) {
|
|
2083
|
+
this.#dunk([...files]);
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Shoots the card on top of the court with a perfect shot. Returns `false`
|
|
2087
|
+
* when it cannot: no card on the court, disabled, another card held or in
|
|
2088
|
+
* the air, or the element not laid out.
|
|
2089
|
+
*/
|
|
2090
|
+
shoot() {
|
|
2091
|
+
const card = this.#staged.at(-1);
|
|
2092
|
+
return card !== void 0 && !this.#isDisabled && this.#shots.shoot(card);
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Uploads a failed file again. Returns `false` when `id` is not an item in
|
|
2096
|
+
* error, or there is no `uploader`.
|
|
2097
|
+
*/
|
|
2098
|
+
retryItem(id) {
|
|
2099
|
+
return this.#queue.retry(id);
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Takes a file out of the basket, aborting its upload. Returns `false` when
|
|
2103
|
+
* there is no item with this `id`.
|
|
2104
|
+
*/
|
|
2105
|
+
removeItem(id) {
|
|
2106
|
+
return this.#queue.remove(id);
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Empties the court and the basket, aborting uploads.
|
|
2110
|
+
*/
|
|
2111
|
+
clear() {
|
|
2112
|
+
this.#shots.stop()?.dispose();
|
|
2113
|
+
for (const staged of this.#staged) staged.dispose();
|
|
2114
|
+
for (const playing of this.#inPlay) playing.dispose();
|
|
2115
|
+
for (const leaving of this.#leaving) leaving.dispose();
|
|
2116
|
+
this.#staged = [];
|
|
2117
|
+
this.#inPlay.clear();
|
|
2118
|
+
this.#leaving.clear();
|
|
2119
|
+
this.#states.scoring = 0;
|
|
2120
|
+
this.#syncStates();
|
|
2121
|
+
this.#queue.clear();
|
|
2122
|
+
}
|
|
2123
|
+
connectedCallback() {
|
|
2124
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
2125
|
+
this.#resizeObserver = new ResizeObserver(() => this.#syncRest());
|
|
2126
|
+
this.#resizeObserver.observe(this.#court);
|
|
2127
|
+
this.#resizeObserver.observe(this.#layer);
|
|
2128
|
+
}
|
|
2129
|
+
this.#syncRest();
|
|
2130
|
+
this.#applyDisabled();
|
|
2131
|
+
}
|
|
2132
|
+
disconnectedCallback() {
|
|
2133
|
+
this.#resizeObserver?.disconnect();
|
|
2134
|
+
this.#resizeObserver = null;
|
|
2135
|
+
const card = this.#shots.stop();
|
|
2136
|
+
if (card && !this.#staged.includes(card)) {
|
|
2137
|
+
this.#inPlay.delete(card);
|
|
2138
|
+
card.release();
|
|
2139
|
+
this.#staged.push(card);
|
|
2140
|
+
this.#renderStack();
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
attributeChangedCallback(name, _oldValue, value) {
|
|
2144
|
+
switch (name) {
|
|
2145
|
+
case "concurrency":
|
|
2146
|
+
this.#queue.concurrency = numberAttribute(value) ?? 3;
|
|
2147
|
+
break;
|
|
2148
|
+
case "disabled":
|
|
2149
|
+
this.#applyDisabled();
|
|
2150
|
+
break;
|
|
2151
|
+
case "name":
|
|
2152
|
+
case "required": this.#syncForm();
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
formResetCallback() {
|
|
2156
|
+
this.clear();
|
|
2157
|
+
}
|
|
2158
|
+
formDisabledCallback(disabled) {
|
|
2159
|
+
this.#formDisabled = disabled;
|
|
2160
|
+
this.#applyDisabled();
|
|
2161
|
+
}
|
|
2162
|
+
get #isDisabled() {
|
|
2163
|
+
return this.disabled || this.#formDisabled;
|
|
2164
|
+
}
|
|
2165
|
+
#emit(type, detail) {
|
|
2166
|
+
this.dispatchEvent(new CustomEvent(type, {
|
|
2167
|
+
detail,
|
|
2168
|
+
bubbles: true
|
|
2169
|
+
}));
|
|
2170
|
+
}
|
|
2171
|
+
#resolveType(file) {
|
|
2172
|
+
return resolveFileType(file, this.#fileTypes);
|
|
2173
|
+
}
|
|
2174
|
+
#setState(name, on) {
|
|
2175
|
+
this.#states[name] = on;
|
|
2176
|
+
this.#syncStates();
|
|
2177
|
+
}
|
|
2178
|
+
#toggleState(name, on) {
|
|
2179
|
+
const states = this.#internals?.states;
|
|
2180
|
+
if (!states) return;
|
|
2181
|
+
try {
|
|
2182
|
+
if (on) states.add(name);
|
|
2183
|
+
else states.delete(name);
|
|
2184
|
+
} catch {}
|
|
2185
|
+
}
|
|
2186
|
+
#syncStates() {
|
|
2187
|
+
const states = this.#states;
|
|
2188
|
+
this.#toggleState("dragging", states.dragging);
|
|
2189
|
+
this.#toggleState("drop-target", states.dragging && states.dropOverBoard || states.flightOverBoard || states.scoring > 0);
|
|
2190
|
+
this.#toggleState("aiming", states.aiming);
|
|
2191
|
+
this.#toggleState("flying", states.flying);
|
|
2192
|
+
this.#toggleState("scoring", states.scoring > 0);
|
|
2193
|
+
this.#toggleState("rejected", states.rejected);
|
|
2194
|
+
this.#toggleState("disabled", this.#isDisabled);
|
|
2195
|
+
}
|
|
2196
|
+
#applyMessages() {
|
|
2197
|
+
this.#header.update(this.#messages);
|
|
2198
|
+
this.#hoop.update(this.#messages);
|
|
2199
|
+
this.#list.render(this.#queue.items, this.#messages);
|
|
2200
|
+
for (const card of this.#staged) card.element.setAttribute("aria-label", this.#messages.shoot(card.file.name));
|
|
2201
|
+
this.#syncForm();
|
|
2202
|
+
}
|
|
2203
|
+
#applyDisabled() {
|
|
2204
|
+
const disabled = this.#isDisabled;
|
|
2205
|
+
this.#hoop.dropzone.disabled = disabled;
|
|
2206
|
+
if (this.#internals) this.#internals.ariaDisabled = disabled ? "true" : null;
|
|
2207
|
+
if (disabled) this.#shots.cancelAim();
|
|
2208
|
+
this.#renderStack();
|
|
2209
|
+
this.#syncStates();
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Validates incoming files; rejected ones are reported, accepted ones returned.
|
|
2213
|
+
*/
|
|
2214
|
+
#admit(files) {
|
|
2215
|
+
if (this.#isDisabled) return [];
|
|
2216
|
+
const max = this.multiple ? this.maxFiles ?? Infinity : 1;
|
|
2217
|
+
const used = this.multiple ? this.#queue.items.length + this.#staged.length + this.#inPlay.size : 0;
|
|
2218
|
+
const maxSize = this.maxSize;
|
|
2219
|
+
const accepted = [];
|
|
2220
|
+
for (const file of files) {
|
|
2221
|
+
let reason = null;
|
|
2222
|
+
if (!matchesAccept(file, this.accept)) reason = "type";
|
|
2223
|
+
else if (maxSize !== null && file.size > maxSize) reason = "size";
|
|
2224
|
+
else if (used + accepted.length >= max) reason = "count";
|
|
2225
|
+
if (reason) this.#reject(file, reason);
|
|
2226
|
+
else accepted.push(file);
|
|
2227
|
+
}
|
|
2228
|
+
if (!this.multiple && accepted.length > 0) this.clear();
|
|
2229
|
+
return accepted;
|
|
2230
|
+
}
|
|
2231
|
+
#reject(file, reason) {
|
|
2232
|
+
this.#emit("file-reject", {
|
|
2233
|
+
file,
|
|
2234
|
+
reason
|
|
2235
|
+
});
|
|
2236
|
+
this.#liveRegion.announce(this.#messages.rejected(file.name, reason));
|
|
2237
|
+
this.#states.rejected = true;
|
|
2238
|
+
this.#syncStates();
|
|
2239
|
+
clearTimeout(this.#rejectedTimer);
|
|
2240
|
+
this.#rejectedTimer = setTimeout(() => {
|
|
2241
|
+
this.#states.rejected = false;
|
|
2242
|
+
this.#syncStates();
|
|
2243
|
+
}, 450);
|
|
2244
|
+
}
|
|
2245
|
+
#createCard(file) {
|
|
2246
|
+
const card = createCard(file, this.#resolveType(file), this.#messages.shoot(file.name));
|
|
2247
|
+
const { element } = card;
|
|
2248
|
+
element.addEventListener("pointerdown", (event) => {
|
|
2249
|
+
if (this.#isDisabled || !this.#staged.includes(card)) return;
|
|
2250
|
+
this.#shots.grab(card, event);
|
|
2251
|
+
});
|
|
2252
|
+
element.addEventListener("keydown", (event) => {
|
|
2253
|
+
if (event.key === "Escape") {
|
|
2254
|
+
this.#shots.cancelAim();
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
2258
|
+
event.preventDefault();
|
|
2259
|
+
if (!this.#isDisabled && this.#staged.at(-1) === card) this.#shots.hold(card, event);
|
|
2260
|
+
});
|
|
2261
|
+
element.addEventListener("click", (event) => event.preventDefault());
|
|
2262
|
+
return card;
|
|
2263
|
+
}
|
|
2264
|
+
#stage(files, clientPoint) {
|
|
2265
|
+
const accepted = this.#admit(files);
|
|
2266
|
+
if (accepted.length === 0) return;
|
|
2267
|
+
const origin = clientPoint ? this.#toLayer(clientPoint) : null;
|
|
2268
|
+
for (const file of accepted) {
|
|
2269
|
+
const card = this.#createCard(file);
|
|
2270
|
+
this.#staged.push(card);
|
|
2271
|
+
this.#layer.append(card.element);
|
|
2272
|
+
}
|
|
2273
|
+
this.#renderStack();
|
|
2274
|
+
if (!origin || prefersReducedMotion()) return;
|
|
2275
|
+
for (const card of this.#staged.slice(-accepted.length)) {
|
|
2276
|
+
const rest = card.element.getBoundingClientRect();
|
|
2277
|
+
const layer = this.#layer.getBoundingClientRect();
|
|
2278
|
+
const offsetX = origin.x - (rest.left - layer.left + rest.width / 2);
|
|
2279
|
+
const offsetY = origin.y - (rest.top - layer.top + rest.height / 2);
|
|
2280
|
+
play(card.element, [{ transform: `translate(${offsetX}px, ${offsetY}px)` }, { transform: "none" }], {
|
|
2281
|
+
duration: 380,
|
|
2282
|
+
easing: "cubic-bezier(0.22, 1, 0.36, 1)"
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
#dunk(files, clientPoint) {
|
|
2287
|
+
this.#admit(files).forEach((file, index) => {
|
|
2288
|
+
const card = this.#createCard(file);
|
|
2289
|
+
card.element.tabIndex = -1;
|
|
2290
|
+
card.element.inert = true;
|
|
2291
|
+
this.#inPlay.add(card);
|
|
2292
|
+
this.#layer.append(card.element);
|
|
2293
|
+
setTimeout(() => void this.#runDunk(card, clientPoint), index * DUNK_STAGGER);
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
async #runDunk(card, clientPoint) {
|
|
2297
|
+
if (!this.#inPlay.has(card)) return;
|
|
2298
|
+
const court = this.#measure();
|
|
2299
|
+
if (!court) {
|
|
2300
|
+
this.#inPlay.delete(card);
|
|
2301
|
+
card.dispose();
|
|
2302
|
+
this.#queue.add(card.file);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
const center = rimCenter(court);
|
|
2306
|
+
const from = {
|
|
2307
|
+
...clientPoint ? this.#toLayer(clientPoint) : {
|
|
2308
|
+
x: center.x,
|
|
2309
|
+
y: court.board.y + court.board.height * .3
|
|
2310
|
+
},
|
|
2311
|
+
rotation: -10,
|
|
2312
|
+
scale: .9
|
|
2313
|
+
};
|
|
2314
|
+
const above = {
|
|
2315
|
+
x: center.x,
|
|
2316
|
+
y: court.square.y + court.square.height * .35,
|
|
2317
|
+
rotation: 6,
|
|
2318
|
+
scale: PHYSICS.depthScale
|
|
2319
|
+
};
|
|
2320
|
+
card.place(from);
|
|
2321
|
+
this.#states.scoring += 1;
|
|
2322
|
+
this.#syncStates();
|
|
2323
|
+
if (!prefersReducedMotion()) {
|
|
2324
|
+
const { width, height } = card.size();
|
|
2325
|
+
await play(card.element, [poseStyle(from, width, height), poseStyle(above, width, height)], {
|
|
2326
|
+
duration: 320,
|
|
2327
|
+
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
2328
|
+
commit: true
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
if (!this.#inPlay.has(card)) return;
|
|
2332
|
+
this.#states.scoring -= 1;
|
|
2333
|
+
await this.#score(card, above, court);
|
|
2334
|
+
}
|
|
2335
|
+
#launched(card) {
|
|
2336
|
+
const hadFocus = this.shadowRoot?.activeElement === card.element;
|
|
2337
|
+
this.#staged = this.#staged.filter((staged) => staged !== card);
|
|
2338
|
+
this.#inPlay.add(card);
|
|
2339
|
+
card.element.inert = true;
|
|
2340
|
+
this.#renderStack();
|
|
2341
|
+
if (hadFocus) (this.#staged.at(-1)?.element ?? this.#hoop.dropzone).focus();
|
|
2342
|
+
}
|
|
2343
|
+
#missed(card) {
|
|
2344
|
+
if (!this.#inPlay.delete(card)) return;
|
|
2345
|
+
card.element.inert = false;
|
|
2346
|
+
this.#staged.push(card);
|
|
2347
|
+
this.#renderStack();
|
|
2348
|
+
this.#emit("shot", {
|
|
2349
|
+
file: card.file,
|
|
2350
|
+
result: "miss"
|
|
2351
|
+
});
|
|
2352
|
+
this.#liveRegion.announce(this.#messages.missed(card.file.name));
|
|
2353
|
+
}
|
|
2354
|
+
/**
|
|
2355
|
+
* Through the net, then into the basket: the "+1", the counter and the
|
|
2356
|
+
* upload only come once the card is out of the net, as in the design.
|
|
2357
|
+
*/
|
|
2358
|
+
async #score(card, from, court) {
|
|
2359
|
+
if (!this.#inPlay.has(card)) return;
|
|
2360
|
+
const reduced = prefersReducedMotion();
|
|
2361
|
+
this.#states.scoring += 1;
|
|
2362
|
+
this.#syncStates();
|
|
2363
|
+
if (!reduced) {
|
|
2364
|
+
const { width, height } = card.size();
|
|
2365
|
+
const below = {
|
|
2366
|
+
x: rimCenter(court).x,
|
|
2367
|
+
y: court.netBottom + height * PHYSICS.depthScale / 2 - height * .06,
|
|
2368
|
+
rotation: uprightOf(from.rotation),
|
|
2369
|
+
scale: PHYSICS.depthScale
|
|
2370
|
+
};
|
|
2371
|
+
this.#hoop.swish();
|
|
2372
|
+
await play(card.element, [poseStyle(from, width, height), poseStyle(below, width, height)], {
|
|
2373
|
+
duration: 600,
|
|
2374
|
+
easing: "cubic-bezier(0.45, 0, 0.7, 0.6)",
|
|
2375
|
+
commit: true
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
if (!this.#inPlay.has(card)) return;
|
|
2379
|
+
this.#states.scoring -= 1;
|
|
2380
|
+
this.#syncStates();
|
|
2381
|
+
this.#hoop.celebrate(reduced);
|
|
2382
|
+
this.#inPlay.delete(card);
|
|
2383
|
+
this.#leaving.add(card);
|
|
2384
|
+
this.#queue.add(card.file);
|
|
2385
|
+
this.#emit("shot", {
|
|
2386
|
+
file: card.file,
|
|
2387
|
+
result: "score"
|
|
2388
|
+
});
|
|
2389
|
+
this.#liveRegion.announce(this.#messages.scored(card.file.name));
|
|
2390
|
+
await play(card.element, [
|
|
2391
|
+
{ opacity: 1 },
|
|
2392
|
+
{
|
|
2393
|
+
opacity: .6,
|
|
2394
|
+
offset: .2
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
opacity: .6,
|
|
2398
|
+
offset: .6
|
|
2399
|
+
},
|
|
2400
|
+
{ opacity: 0 }
|
|
2401
|
+
], {
|
|
2402
|
+
duration: reduced ? 240 : 1200,
|
|
2403
|
+
commit: true
|
|
2404
|
+
});
|
|
2405
|
+
this.#leaving.delete(card);
|
|
2406
|
+
card.dispose();
|
|
2407
|
+
}
|
|
2408
|
+
#renderStack() {
|
|
2409
|
+
const top = this.#staged.length - 1;
|
|
2410
|
+
this.#staged.forEach((card, index) => {
|
|
2411
|
+
const depth = top - index;
|
|
2412
|
+
const { element } = card;
|
|
2413
|
+
element.dataset.depth = depth < VISIBLE_STACK ? String(depth) : "hidden";
|
|
2414
|
+
element.style.zIndex = String(index + 1);
|
|
2415
|
+
const interactive = depth === 0 && !this.#isDisabled;
|
|
2416
|
+
element.inert = !interactive;
|
|
2417
|
+
element.tabIndex = interactive ? 0 : -1;
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
#handleItems(items) {
|
|
2421
|
+
this.#list.render(items, this.#messages);
|
|
2422
|
+
this.#header.setCount(items.length, !prefersReducedMotion());
|
|
2423
|
+
this.#syncForm();
|
|
2424
|
+
const signature = items.map((item) => `${item.id}:${item.status}`).join();
|
|
2425
|
+
if (signature === this.#changeSignature) return;
|
|
2426
|
+
this.#changeSignature = signature;
|
|
2427
|
+
this.#emit("change", { items });
|
|
2428
|
+
}
|
|
2429
|
+
#syncForm() {
|
|
2430
|
+
const internals = this.#internals;
|
|
2431
|
+
if (!internals) return;
|
|
2432
|
+
const files = this.files;
|
|
2433
|
+
const name = this.name;
|
|
2434
|
+
if (name && files.length > 0) {
|
|
2435
|
+
const data = new FormData();
|
|
2436
|
+
for (const file of files) data.append(name, file);
|
|
2437
|
+
internals.setFormValue(data);
|
|
2438
|
+
} else internals.setFormValue(null);
|
|
2439
|
+
if (this.required && files.length === 0) internals.setValidity({ valueMissing: true }, this.#messages.required, this.#hoop.dropzone);
|
|
2440
|
+
else internals.setValidity({});
|
|
2441
|
+
}
|
|
2442
|
+
/**
|
|
2443
|
+
* Converts client coordinates to the card layer's.
|
|
2444
|
+
*/
|
|
2445
|
+
#toLayer(point) {
|
|
2446
|
+
const layer = this.#layer.getBoundingClientRect();
|
|
2447
|
+
return {
|
|
2448
|
+
x: point.x - layer.left,
|
|
2449
|
+
y: point.y - layer.top
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
#measure() {
|
|
2453
|
+
const host = this.#layer.getBoundingClientRect();
|
|
2454
|
+
if (host.width === 0 || host.height === 0) return null;
|
|
2455
|
+
const court = courtFromRectangles({
|
|
2456
|
+
host,
|
|
2457
|
+
board: this.#hoop.dropzone.getBoundingClientRect(),
|
|
2458
|
+
square: this.#hoop.square.getBoundingClientRect(),
|
|
2459
|
+
rim: this.#hoop.rim.getBoundingClientRect(),
|
|
2460
|
+
net: this.#hoop.net.getBoundingClientRect(),
|
|
2461
|
+
spot: this.#spot.getBoundingClientRect()
|
|
2462
|
+
});
|
|
2463
|
+
return court.unit > 0 ? court : null;
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Publishes where staged cards rest, for the CSS that draws them there.
|
|
2467
|
+
*/
|
|
2468
|
+
#syncRest() {
|
|
2469
|
+
const layer = this.#layer.getBoundingClientRect();
|
|
2470
|
+
const spot = this.#spot.getBoundingClientRect();
|
|
2471
|
+
if (layer.width === 0) return;
|
|
2472
|
+
const x = spot.left - layer.left + spot.width / 2;
|
|
2473
|
+
const y = spot.top - layer.top + spot.height / 2;
|
|
2474
|
+
this.#layer.style.setProperty("--_rest-x", `${x}px`);
|
|
2475
|
+
this.#layer.style.setProperty("--_rest-y", `${y}px`);
|
|
2476
|
+
}
|
|
2477
|
+
#isOverBoard(point) {
|
|
2478
|
+
const board = this.#hoop.dropzone.getBoundingClientRect();
|
|
2479
|
+
return point.x >= board.left && point.x <= board.right && point.y >= board.top && point.y <= board.bottom;
|
|
2480
|
+
}
|
|
2481
|
+
#handleDragOver = (dragEvent) => {
|
|
2482
|
+
const event = dragEvent;
|
|
2483
|
+
if (!hasFiles(event)) return;
|
|
2484
|
+
event.preventDefault();
|
|
2485
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = this.#isDisabled ? "none" : "copy";
|
|
2486
|
+
if (this.#isDisabled) return;
|
|
2487
|
+
this.#states.dragging = true;
|
|
2488
|
+
this.#states.dropOverBoard = this.#isOverBoard({
|
|
2489
|
+
x: event.clientX,
|
|
2490
|
+
y: event.clientY
|
|
2491
|
+
});
|
|
2492
|
+
this.#syncStates();
|
|
2493
|
+
};
|
|
2494
|
+
#handleDragLeave = (dragEvent) => {
|
|
2495
|
+
const event = dragEvent;
|
|
2496
|
+
const box = this.getBoundingClientRect();
|
|
2497
|
+
if (event.clientX > box.left && event.clientX < box.right && event.clientY > box.top && event.clientY < box.bottom) return;
|
|
2498
|
+
this.#states.dragging = false;
|
|
2499
|
+
this.#states.dropOverBoard = false;
|
|
2500
|
+
this.#syncStates();
|
|
2501
|
+
};
|
|
2502
|
+
#handleDrop = (dragEvent) => {
|
|
2503
|
+
const event = dragEvent;
|
|
2504
|
+
if (!hasFiles(event)) return;
|
|
2505
|
+
event.preventDefault();
|
|
2506
|
+
const point = {
|
|
2507
|
+
x: event.clientX,
|
|
2508
|
+
y: event.clientY
|
|
2509
|
+
};
|
|
2510
|
+
const overBoard = this.#isOverBoard(point);
|
|
2511
|
+
this.#states.dragging = false;
|
|
2512
|
+
this.#states.dropOverBoard = false;
|
|
2513
|
+
this.#syncStates();
|
|
2514
|
+
const files = [...event.dataTransfer?.files ?? []];
|
|
2515
|
+
if (overBoard || this.instant) this.#dunk(files, point);
|
|
2516
|
+
else this.#stage(files, point);
|
|
2517
|
+
};
|
|
2518
|
+
};
|
|
2519
|
+
//#endregion
|
|
2520
|
+
//#region src/element/register.ts
|
|
2521
|
+
/**
|
|
2522
|
+
* Name the element is registered with when no `tagName` is given.
|
|
2523
|
+
*/
|
|
2524
|
+
const DEFAULT_TAG_NAME = "basketball-upload";
|
|
2525
|
+
/**
|
|
2526
|
+
* Defines the custom element. Idempotent, and a no-op where custom elements
|
|
2527
|
+
* do not exist (SSR), so it is safe to call from any module.
|
|
2528
|
+
*/
|
|
2529
|
+
function registerBasketballUpload(options = {}) {
|
|
2530
|
+
const tagName = options.tagName ?? "basketball-upload";
|
|
2531
|
+
if (typeof customElements === "undefined") return;
|
|
2532
|
+
if (customElements.get(tagName)) return;
|
|
2533
|
+
customElements.define(tagName, tagName === "basketball-upload" ? BasketballUploadElement : class extends BasketballUploadElement {});
|
|
2534
|
+
}
|
|
2535
|
+
//#endregion
|
|
2536
|
+
//#region src/upload/xhr.ts
|
|
2537
|
+
/**
|
|
2538
|
+
* Rejection reason of `createXhrUploader` for a non-2xx response or a network error.
|
|
2539
|
+
*/
|
|
2540
|
+
var UploadError = class extends Error {
|
|
2541
|
+
constructor(status, body) {
|
|
2542
|
+
super(status === 0 ? "Network error" : `Upload failed with status ${status}`);
|
|
2543
|
+
this.name = "UploadError";
|
|
2544
|
+
this.status = status;
|
|
2545
|
+
this.body = body;
|
|
2546
|
+
}
|
|
2547
|
+
};
|
|
2548
|
+
function resolve(value, file) {
|
|
2549
|
+
return typeof value === "function" ? value(file) : value;
|
|
2550
|
+
}
|
|
2551
|
+
function parseResponse(request) {
|
|
2552
|
+
if (!(request.getResponseHeader("content-type") ?? "").includes("json")) return request.responseText;
|
|
2553
|
+
try {
|
|
2554
|
+
return JSON.parse(request.responseText);
|
|
2555
|
+
} catch {
|
|
2556
|
+
return request.responseText;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* An `Uploader` built on `XMLHttpRequest`, the one browser API that reports
|
|
2561
|
+
* the progress of a request body. Resolves with the parsed JSON response (or
|
|
2562
|
+
* its text), rejects with an `UploadError`, or with the abort reason.
|
|
2563
|
+
*/
|
|
2564
|
+
function createXhrUploader(options) {
|
|
2565
|
+
return (file, { signal, onProgress }) => new Promise((resolvePromise, reject) => {
|
|
2566
|
+
if (signal.aborted) {
|
|
2567
|
+
reject(signal.reason);
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
const request = new XMLHttpRequest();
|
|
2571
|
+
request.open(options.method ?? "POST", String(resolve(options.url, file)));
|
|
2572
|
+
request.withCredentials = options.withCredentials ?? false;
|
|
2573
|
+
for (const [name, value] of Object.entries(resolve(options.headers ?? {}, file))) request.setRequestHeader(name, value);
|
|
2574
|
+
request.upload.addEventListener("progress", (event) => {
|
|
2575
|
+
onProgress(event.loaded, event.lengthComputable ? event.total : void 0);
|
|
2576
|
+
});
|
|
2577
|
+
request.addEventListener("load", () => {
|
|
2578
|
+
if (request.status >= 200 && request.status < 300) resolvePromise(parseResponse(request));
|
|
2579
|
+
else reject(new UploadError(request.status, request.responseText));
|
|
2580
|
+
});
|
|
2581
|
+
request.addEventListener("error", () => reject(new UploadError(0, "")));
|
|
2582
|
+
request.addEventListener("abort", () => reject(signal.reason));
|
|
2583
|
+
const abort = () => request.abort();
|
|
2584
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
2585
|
+
request.addEventListener("loadend", () => signal.removeEventListener("abort", abort));
|
|
2586
|
+
const fieldName = options.fieldName === void 0 ? "file" : options.fieldName;
|
|
2587
|
+
if (fieldName === null) {
|
|
2588
|
+
request.send(file);
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
const body = new FormData();
|
|
2592
|
+
for (const [name, value] of Object.entries(resolve(options.fields ?? {}, file))) body.append(name, value);
|
|
2593
|
+
body.append(fieldName, file, file.name);
|
|
2594
|
+
request.send(body);
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
//#endregion
|
|
2598
|
+
export { BasketballUploadElement, DEFAULT_TAG_NAME, UploadError, createXhrUploader, defaultFileTypes, defaultMessages, formatBytes, matchesAccept, registerBasketballUpload, registerFileType, resolveFileType };
|
|
2599
|
+
|
|
2600
|
+
//# sourceMappingURL=basketball-upload.js.map
|