@masters-union/union-stack 0.1.7 → 0.1.9

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/dist/picker.js CHANGED
@@ -1,18 +1,39 @@
1
1
  // src/picker/styles.ts
2
2
  var STYLE_ID = "unionstack-picker-styles";
3
+ var FONT_ID = "unionstack-picker-fonts";
4
+ var FONT_HREF = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";
5
+ function ensureFonts() {
6
+ if (document.getElementById(FONT_ID)) return;
7
+ const preconnect = (href, cross) => {
8
+ const l = document.createElement("link");
9
+ l.rel = "preconnect";
10
+ l.href = href;
11
+ if (cross) l.crossOrigin = "anonymous";
12
+ document.head.appendChild(l);
13
+ };
14
+ preconnect("https://fonts.googleapis.com");
15
+ preconnect("https://fonts.gstatic.com", true);
16
+ const link = document.createElement("link");
17
+ link.id = FONT_ID;
18
+ link.rel = "stylesheet";
19
+ link.href = FONT_HREF;
20
+ document.head.appendChild(link);
21
+ }
3
22
  function ensureStyles() {
4
23
  if (typeof document === "undefined") return;
24
+ ensureFonts();
5
25
  if (document.getElementById(STYLE_ID)) return;
6
- const el2 = document.createElement("style");
7
- el2.id = STYLE_ID;
8
- el2.textContent = BASE_CSS;
9
- document.head.appendChild(el2);
26
+ const el3 = document.createElement("style");
27
+ el3.id = STYLE_ID;
28
+ el3.textContent = BASE_CSS;
29
+ document.head.appendChild(el3);
10
30
  }
11
31
  function themeToCssVars(theme) {
12
32
  const mode = theme?.mode || "light";
13
33
  const defaults = mode === "dark" ? DARK_DEFAULTS : LIGHT_DEFAULTS;
14
34
  return {
15
35
  "--us-primary": theme?.primary ?? defaults.primary,
36
+ "--us-on-primary": defaults.onPrimary,
16
37
  "--us-bg": theme?.background ?? defaults.background,
17
38
  "--us-fg": theme?.foreground ?? defaults.foreground,
18
39
  "--us-muted": defaults.muted,
@@ -20,55 +41,84 @@ function themeToCssVars(theme) {
20
41
  "--us-border": theme?.border ?? defaults.border,
21
42
  "--us-border-strong": defaults.borderStrong,
22
43
  "--us-elevated": defaults.elevated,
44
+ "--us-overlay": defaults.overlay,
45
+ "--us-raised": defaults.raised,
46
+ "--us-accent": defaults.accent,
23
47
  "--us-success": defaults.success,
24
48
  "--us-danger": defaults.danger,
25
49
  "--us-radius": theme?.radius ?? "12px"
26
50
  };
27
51
  }
28
52
  var LIGHT_DEFAULTS = {
29
- primary: "#4f46e5",
30
- // indigo-600 confident, restrained
31
- background: "#ffffff",
32
- foreground: "#0f172a",
33
- // slate-90015.4:1 on white
34
- muted: "#64748b",
35
- // slate-500 — 4.7:1 on white
36
- subtle: "#f8fafc",
37
- // slate-50 — chips, icon wells
38
- border: "#e2e8f0",
39
- // slate-200
40
- borderStrong: "#cbd5e1",
41
- // slate-300 — drag-over emphasis
53
+ primary: "#494bd6",
54
+ // inverse-primary of the dark scheme
55
+ onPrimary: "#ffffff",
56
+ background: "#fdfbff",
57
+ // surfacenear-white with a violet hint
58
+ foreground: "#1b1b21",
59
+ // on-surface
60
+ muted: "#5e5c6e",
61
+ // on-surface-variant
62
+ subtle: "#f3f1fa",
63
+ // surface-container — chips, icon wells, tab rail
64
+ border: "#e4e1ee",
65
+ // outline-variant (soft)
66
+ borderStrong: "#c8c5d4",
67
+ // outline — drag-over emphasis
42
68
  elevated: "#ffffff",
43
- success: "#16a34a",
44
- danger: "#dc2626"
69
+ // cards, one step above the floor
70
+ overlay: "#eceaf4",
71
+ // instant hover state
72
+ raised: "#ffffff",
73
+ // active tab / popover layer
74
+ accent: "#b35a00",
75
+ // tertiary — "edited" markers
76
+ success: "#2c9a5b",
77
+ // desaturated green
78
+ danger: "#ba1a1a"
45
79
  };
46
80
  var DARK_DEFAULTS = {
47
- primary: "#818cf8",
48
- // indigo-400 — desaturated for dark mode
49
- background: "#0b0f1a",
50
- // near-black, not pure
51
- foreground: "#f1f5f9",
52
- // slate-100 — 15:1 on bg
53
- muted: "#94a3b8",
54
- // slate-400 — 6.4:1 on bg
55
- subtle: "#111827",
56
- // slightly elevated surface
57
- border: "#1f2937",
58
- borderStrong: "#334155",
59
- elevated: "#0f1625",
60
- success: "#4ade80",
61
- danger: "#f87171"
81
+ primary: "#c0c1ff",
82
+ // primary
83
+ onPrimary: "#1000a9",
84
+ // on-primary dark ink on periwinkle
85
+ background: "#0c1324",
86
+ // surface (floor)
87
+ foreground: "#dce1fb",
88
+ // on-surface
89
+ muted: "#908fa0",
90
+ // outline
91
+ subtle: "#191f31",
92
+ // surface-container
93
+ border: "#2e3447",
94
+ // surface-container-highest
95
+ borderStrong: "#464554",
96
+ // outline-variant
97
+ elevated: "#151b2d",
98
+ // surface-container-low — cards, one step up
99
+ overlay: "#23293c",
100
+ // surface-container-high — instant hover
101
+ raised: "#2e3447",
102
+ // active tab / popover layer
103
+ accent: "#ffb783",
104
+ // tertiary — "edited" markers
105
+ success: "#7ad08e",
106
+ // desaturated green
107
+ danger: "#ffb4ab"
108
+ // error
62
109
  };
63
110
  var BASE_CSS = `
64
111
  .us-picker-backdrop {
112
+ --us-font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
113
+ --us-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
65
114
  position: fixed; inset: 0; z-index: 2147483000;
66
- background: color-mix(in srgb, #02060f 55%, transparent);
115
+ background: rgba(2, 6, 23, 0.4);
67
116
  -webkit-backdrop-filter: blur(8px);
68
117
  backdrop-filter: blur(8px);
69
118
  display: flex; align-items: center; justify-content: center;
70
119
  padding: 16px;
71
- font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
120
+ font-family: var(--us-font);
121
+ font-feature-settings: "cv02", "cv11";
72
122
  animation: us-fade 140ms ease-out;
73
123
  }
74
124
  @keyframes us-fade { from { opacity: 0; } to { opacity: 1; } }
@@ -80,10 +130,8 @@ var BASE_CSS = `
80
130
  width: 100%; max-width: 480px;
81
131
  max-height: min(calc(100dvh - 32px), 680px);
82
132
  display: flex; flex-direction: column;
83
- box-shadow:
84
- 0 1px 1px rgba(0,0,0,0.04),
85
- 0 18px 40px -8px rgba(0,0,0,0.18),
86
- 0 32px 80px -16px rgba(0,0,0,0.22);
133
+ position: relative;
134
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
87
135
  overflow: hidden;
88
136
  animation: us-rise 240ms cubic-bezier(0.16, 1, 0.3, 1);
89
137
  }
@@ -104,19 +152,20 @@ var BASE_CSS = `
104
152
  width: 28px; height: 28px; flex-shrink: 0;
105
153
  border-radius: 8px;
106
154
  background: var(--us-subtle);
155
+ border: 1px solid var(--us-border);
107
156
  color: var(--us-primary);
108
157
  }
109
- .us-picker-header-logo svg { width: 16px; height: 16px; }
158
+ .us-picker-header-logo svg { width: 15px; height: 15px; }
110
159
  .us-picker-header-logo img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
111
- .us-picker-title { font-weight: 600; font-size: 14px; letter-spacing: -0.01em; flex: 1; }
160
+ .us-picker-title { font-weight: 600; font-size: 15px; letter-spacing: -0.02em; line-height: 1.2; flex: 1; }
112
161
  .us-picker-close {
113
162
  background: none; border: 0; cursor: pointer;
114
163
  width: 32px; height: 32px;
115
164
  display: inline-flex; align-items: center; justify-content: center;
116
165
  color: var(--us-muted); border-radius: 8px;
117
- transition: color 140ms, background 140ms;
166
+ transition: color 100ms, background 0ms;
118
167
  }
119
- .us-picker-close:hover { background: var(--us-subtle); color: var(--us-fg); }
168
+ .us-picker-close:hover { background: var(--us-overlay); color: var(--us-fg); }
120
169
  .us-picker-close:focus-visible { outline: 2px solid var(--us-primary); outline-offset: 1px; }
121
170
  .us-picker-close svg { width: 16px; height: 16px; }
122
171
 
@@ -125,17 +174,17 @@ var BASE_CSS = `
125
174
 
126
175
  .us-dropzone {
127
176
  position: relative;
128
- border: 1.5px dashed var(--us-border-strong);
129
- border-radius: calc(var(--us-radius) - 2px);
177
+ border: 1px dashed var(--us-border-strong);
178
+ border-radius: 8px;
130
179
  padding: 28px 20px;
131
180
  text-align: center;
132
181
  cursor: pointer;
133
- transition: border-color 160ms, background 160ms, transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
134
- background: color-mix(in srgb, var(--us-subtle) 60%, transparent);
182
+ transition: border-color 120ms, background 0ms;
183
+ background: var(--us-subtle);
135
184
  }
136
185
  .us-dropzone:hover {
137
186
  border-color: var(--us-primary);
138
- background: color-mix(in srgb, var(--us-primary) 4%, var(--us-bg));
187
+ background: color-mix(in srgb, var(--us-primary) 6%, var(--us-subtle));
139
188
  }
140
189
  .us-dropzone:focus-visible {
141
190
  outline: 2px solid var(--us-primary); outline-offset: 2px;
@@ -143,13 +192,13 @@ var BASE_CSS = `
143
192
  .us-dropzone[data-drag="over"] {
144
193
  border-style: solid;
145
194
  border-color: var(--us-primary);
146
- background: color-mix(in srgb, var(--us-primary) 8%, var(--us-bg));
147
- transform: scale(1.005);
195
+ background: color-mix(in srgb, var(--us-primary) 10%, var(--us-subtle));
148
196
  }
149
197
  .us-dropzone-icon {
150
198
  width: 44px; height: 44px;
151
- border-radius: 12px;
199
+ border-radius: 8px;
152
200
  background: color-mix(in srgb, var(--us-primary) 12%, var(--us-bg));
201
+ border: 1px solid color-mix(in srgb, var(--us-primary) 24%, transparent);
153
202
  color: var(--us-primary);
154
203
  display: inline-flex; align-items: center; justify-content: center;
155
204
  margin-bottom: 12px;
@@ -158,19 +207,18 @@ var BASE_CSS = `
158
207
  .us-dropzone:hover .us-dropzone-icon { transform: translateY(-2px); }
159
208
  .us-dropzone[data-drag="over"] .us-dropzone-icon {
160
209
  transform: translateY(-3px) scale(1.06);
161
- background: color-mix(in srgb, var(--us-primary) 18%, var(--us-bg));
162
210
  }
163
211
  .us-dropzone-icon svg { width: 22px; height: 22px; }
164
212
  .us-dropzone-title {
165
- font-size: 14px; font-weight: 600; letter-spacing: -0.01em;
213
+ font-size: 14px; font-weight: 600; letter-spacing: -0.011em;
166
214
  margin-bottom: 2px;
167
215
  }
168
- .us-dropzone-hint { color: var(--us-muted); font-size: 12.5px; }
216
+ .us-dropzone-hint { color: var(--us-muted); font-size: 12.5px; letter-spacing: -0.006em; }
169
217
  .us-dropzone-constraints {
170
- margin-top: 10px;
218
+ margin-top: 12px;
171
219
  font-size: 11px; color: var(--us-muted);
172
- font-family: ui-monospace, "SF Mono", Menlo, monospace;
173
- letter-spacing: 0.02em;
220
+ font-family: var(--us-mono);
221
+ letter-spacing: 0;
174
222
  }
175
223
 
176
224
  .us-dropzone--compact {
@@ -194,8 +242,8 @@ var BASE_CSS = `
194
242
  padding: 10px 12px;
195
243
  background: var(--us-elevated);
196
244
  border: 1px solid var(--us-border);
197
- border-radius: 10px;
198
- transition: border-color 140ms, background 140ms;
245
+ border-radius: 8px;
246
+ transition: border-color 120ms;
199
247
  animation: us-row-in 280ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
200
248
  position: relative;
201
249
  }
@@ -203,19 +251,20 @@ var BASE_CSS = `
203
251
  from { opacity: 0; transform: translateY(6px); }
204
252
  to { opacity: 1; transform: translateY(0); }
205
253
  }
206
- .us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 30%, var(--us-border)); }
207
- .us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 30%, var(--us-border)); }
254
+ .us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
255
+ .us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
208
256
 
209
257
  .us-file-thumb {
210
258
  width: 40px; height: 40px; flex-shrink: 0;
211
- border-radius: 8px;
259
+ border-radius: 4px;
212
260
  background: var(--us-subtle);
261
+ border: 1px solid var(--us-border);
213
262
  background-size: cover; background-position: center;
214
263
  display: inline-flex; align-items: center; justify-content: center;
215
264
  color: var(--us-muted);
216
265
  overflow: hidden;
217
266
  }
218
- .us-file-thumb[data-image="true"] { color: transparent; }
267
+ .us-file-thumb[data-image="true"] { color: transparent; border-color: transparent; }
219
268
  .us-file-thumb svg { width: 18px; height: 18px; }
220
269
 
221
270
  .us-file-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
@@ -224,9 +273,12 @@ var BASE_CSS = `
224
273
  font-size: 13px; font-weight: 500;
225
274
  flex: 1; min-width: 0;
226
275
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
227
- letter-spacing: -0.005em;
276
+ letter-spacing: -0.006em;
277
+ }
278
+ .us-file-meta {
279
+ color: var(--us-muted); font-size: 11px; flex-shrink: 0;
280
+ font-family: var(--us-mono); letter-spacing: 0;
228
281
  }
229
- .us-file-meta { color: var(--us-muted); font-size: 11.5px; flex-shrink: 0; font-variant-numeric: tabular-nums; }
230
282
 
231
283
  .us-file-progress {
232
284
  height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
@@ -282,27 +334,30 @@ var BASE_CSS = `
282
334
  padding: 12px 16px;
283
335
  border-top: 1px solid var(--us-border);
284
336
  }
285
- .us-actions-summary { font-size: 12px; color: var(--us-muted); font-variant-numeric: tabular-nums; }
337
+ .us-actions-summary {
338
+ font-size: 11px; color: var(--us-muted);
339
+ font-family: var(--us-mono); letter-spacing: 0;
340
+ }
286
341
  .us-actions-buttons { display: flex; gap: 8px; }
287
342
 
288
343
  .us-btn {
289
344
  appearance: none;
290
345
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
291
346
  padding: 8px 14px; min-height: 36px;
292
- border-radius: 8px; border: 1px solid var(--us-border);
347
+ border-radius: 8px; border: 1px solid var(--us-border-strong);
293
348
  background: transparent; color: var(--us-fg);
294
349
  cursor: pointer; font-size: 13px; font-weight: 500;
295
- font-family: inherit; letter-spacing: -0.005em;
296
- transition: background 140ms, border-color 140ms, transform 80ms ease-out;
350
+ font-family: inherit; letter-spacing: 0.01em;
351
+ transition: background 0ms, border-color 120ms, transform 80ms ease-out;
297
352
  }
298
- .us-btn:hover { background: var(--us-subtle); border-color: var(--us-border-strong); }
353
+ .us-btn:hover { background: var(--us-overlay); }
299
354
  .us-btn:active { transform: scale(0.98); }
300
355
  .us-btn:focus-visible { outline: 2px solid var(--us-primary); outline-offset: 2px; }
301
356
  .us-btn-primary {
302
- background: var(--us-primary); color: white;
357
+ background: var(--us-primary); color: var(--us-on-primary);
303
358
  border-color: var(--us-primary);
304
359
  }
305
- .us-btn-primary:hover { filter: brightness(0.95); background: var(--us-primary); }
360
+ .us-btn-primary:hover { filter: brightness(1.06); background: var(--us-primary); }
306
361
  .us-btn[disabled] { opacity: 0.5; cursor: not-allowed; }
307
362
  .us-btn[disabled]:hover { transform: none; }
308
363
  .us-btn svg { width: 14px; height: 14px; }
@@ -310,12 +365,235 @@ var BASE_CSS = `
310
365
  .us-footer {
311
366
  padding: 8px 16px 12px;
312
367
  font-size: 11px; color: var(--us-muted); text-align: center;
368
+ letter-spacing: 0.02em;
313
369
  display: flex; align-items: center; justify-content: center; gap: 6px;
314
370
  }
315
371
  .us-footer svg { width: 11px; height: 11px; opacity: 0.7; }
316
372
  .us-footer a { color: var(--us-muted); text-decoration: none; font-weight: 500; }
317
373
  .us-footer a:hover { color: var(--us-fg); }
318
374
 
375
+ /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 source tabs (Device / URL) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
376
+ .us-source-tabs {
377
+ display: inline-flex; gap: 2px; padding: 3px;
378
+ background: var(--us-subtle);
379
+ border: 1px solid var(--us-border);
380
+ border-radius: 8px;
381
+ margin-bottom: 14px;
382
+ }
383
+ .us-source-tab {
384
+ appearance: none; background: transparent; border: 1px solid transparent;
385
+ font: inherit; cursor: pointer;
386
+ padding: 6px 14px; min-height: 30px;
387
+ border-radius: 6px;
388
+ font-size: 12px; font-weight: 500; letter-spacing: 0.02em;
389
+ color: var(--us-muted);
390
+ transition: color 100ms, background 0ms;
391
+ display: inline-flex; align-items: center; gap: 6px;
392
+ }
393
+ .us-source-tab svg { width: 13px; height: 13px; }
394
+ .us-source-tab:hover { color: var(--us-fg); }
395
+ .us-source-tab[data-active="true"] {
396
+ background: var(--us-raised);
397
+ border-color: var(--us-border);
398
+ color: var(--us-fg);
399
+ }
400
+ .us-source-tab:focus-visible { outline: 2px solid var(--us-primary); outline-offset: 2px; }
401
+
402
+ /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 URL source \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
403
+ .us-url-source { display: none; }
404
+ .us-url-source[data-active="true"] { display: block; }
405
+ .us-url-form {
406
+ display: flex; gap: 8px;
407
+ padding: 16px;
408
+ border: 1px dashed var(--us-border-strong);
409
+ border-radius: 8px;
410
+ background: var(--us-subtle);
411
+ }
412
+ .us-url-input {
413
+ appearance: none;
414
+ flex: 1; min-width: 0;
415
+ padding: 9px 12px; min-height: 36px;
416
+ border-radius: 8px; border: 1px solid var(--us-border-strong);
417
+ background: var(--us-bg); color: var(--us-fg);
418
+ font: inherit; font-size: 13px; letter-spacing: -0.006em;
419
+ transition: border-color 120ms, box-shadow 120ms;
420
+ }
421
+ .us-url-input::placeholder { color: var(--us-muted); }
422
+ .us-url-input:focus {
423
+ outline: none;
424
+ border-color: var(--us-primary);
425
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--us-primary) 25%, transparent);
426
+ }
427
+ .us-url-hint {
428
+ margin-top: 8px;
429
+ font-size: 11.5px; color: var(--us-muted); letter-spacing: -0.006em;
430
+ }
431
+ .us-url-hint[data-error="true"] { color: var(--us-danger); }
432
+
433
+ /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 per-row edit button \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
434
+ .us-file-actions { display: inline-flex; gap: 4px; }
435
+ .us-file-action {
436
+ appearance: none; background: transparent; border: 0; cursor: pointer;
437
+ width: 28px; height: 28px;
438
+ display: inline-flex; align-items: center; justify-content: center;
439
+ color: var(--us-muted); border-radius: 6px;
440
+ transition: color 100ms, background 0ms;
441
+ }
442
+ .us-file-action:hover { background: var(--us-overlay); color: var(--us-fg); }
443
+ .us-file-action:focus-visible { outline: 2px solid var(--us-primary); outline-offset: 1px; }
444
+ .us-file-action svg { width: 15px; height: 15px; }
445
+ .us-file-action[data-edited="true"] { color: var(--us-accent); }
446
+ .us-file:not([data-state="queued"]) .us-file-action { display: none; }
447
+
448
+ /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 image editor overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
449
+ .us-editor {
450
+ position: absolute; inset: 0; z-index: 2;
451
+ display: flex; flex-direction: column;
452
+ background: var(--us-bg);
453
+ animation: us-fade 140ms ease-out;
454
+ }
455
+ .us-editor-header {
456
+ display: flex; align-items: center; gap: 12px;
457
+ padding: 14px 16px;
458
+ border-bottom: 1px solid var(--us-border);
459
+ }
460
+ .us-editor-title { font-weight: 600; font-size: 15px; flex: 1; letter-spacing: -0.02em; }
461
+ .us-editor-back {
462
+ appearance: none; background: transparent; border: 0; cursor: pointer;
463
+ width: 32px; height: 32px; border-radius: 8px;
464
+ display: inline-flex; align-items: center; justify-content: center;
465
+ color: var(--us-muted);
466
+ transition: color 100ms, background 0ms;
467
+ }
468
+ .us-editor-back:hover { background: var(--us-overlay); color: var(--us-fg); }
469
+ .us-editor-back svg { width: 16px; height: 16px; }
470
+
471
+ .us-editor-canvas-wrap {
472
+ flex: 1; min-height: 0;
473
+ background: var(--us-subtle);
474
+ display: flex; align-items: center; justify-content: center;
475
+ padding: 16px;
476
+ position: relative;
477
+ overflow: hidden;
478
+ }
479
+ .us-editor-canvas {
480
+ max-width: 100%; max-height: 100%;
481
+ border-radius: 4px;
482
+ display: block;
483
+ /* Checkerboard for transparency awareness (visible only in circle mode). */
484
+ background-image:
485
+ linear-gradient(45deg, var(--us-border) 25%, transparent 25%, transparent 75%, var(--us-border) 75%),
486
+ linear-gradient(45deg, var(--us-border) 25%, transparent 25%, transparent 75%, var(--us-border) 75%);
487
+ background-size: 12px 12px;
488
+ background-position: 0 0, 6px 6px;
489
+ }
490
+ .us-editor-overlay {
491
+ position: absolute; inset: 0;
492
+ pointer-events: none;
493
+ }
494
+
495
+ .us-editor-toolbar {
496
+ display: flex; gap: 6px; flex-wrap: wrap;
497
+ padding: 10px 16px;
498
+ border-top: 1px solid var(--us-border);
499
+ }
500
+ .us-tool {
501
+ appearance: none; background: transparent; border: 1px solid var(--us-border-strong);
502
+ font: inherit; cursor: pointer;
503
+ padding: 6px 11px; min-height: 32px;
504
+ border-radius: 8px;
505
+ font-size: 12px; font-weight: 500; letter-spacing: 0.02em;
506
+ color: var(--us-fg);
507
+ display: inline-flex; align-items: center; gap: 6px;
508
+ transition: border-color 120ms, color 100ms, background 0ms;
509
+ }
510
+ .us-tool:hover { background: var(--us-overlay); }
511
+ .us-tool[data-active="true"] {
512
+ background: color-mix(in srgb, var(--us-primary) 12%, var(--us-bg));
513
+ border-color: var(--us-primary);
514
+ color: var(--us-primary);
515
+ }
516
+ .us-tool[disabled] { opacity: 0.45; cursor: not-allowed; }
517
+ .us-tool svg { width: 13px; height: 13px; }
518
+ .us-tool-spacer { flex: 1; }
519
+
520
+ .us-editor-footer {
521
+ display: flex; gap: 8px; justify-content: flex-end;
522
+ padding: 12px 16px;
523
+ border-top: 1px solid var(--us-border);
524
+ }
525
+
526
+ /* Crop drag handles. Drawn inside .us-editor-canvas-wrap, positioned over canvas. */
527
+ .us-crop-box {
528
+ position: absolute;
529
+ border: 1.5px solid var(--us-primary);
530
+ box-shadow: 0 0 0 9999px rgba(0,0,0,0.45);
531
+ pointer-events: auto;
532
+ cursor: move;
533
+ }
534
+ .us-crop-handle {
535
+ position: absolute;
536
+ width: 12px; height: 12px;
537
+ background: var(--us-bg);
538
+ border: 1.5px solid var(--us-primary);
539
+ border-radius: 2px;
540
+ pointer-events: auto;
541
+ }
542
+ .us-crop-handle[data-pos="nw"] { top: -6px; left: -6px; cursor: nwse-resize; }
543
+ .us-crop-handle[data-pos="ne"] { top: -6px; right: -6px; cursor: nesw-resize; }
544
+ .us-crop-handle[data-pos="sw"] { bottom: -6px; left: -6px; cursor: nesw-resize; }
545
+ .us-crop-handle[data-pos="se"] { bottom: -6px; right: -6px; cursor: nwse-resize; }
546
+
547
+ /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 mobile (bottom sheet) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
548
+ @media (max-width: 600px) {
549
+ .us-picker-backdrop {
550
+ padding: 0;
551
+ align-items: flex-end;
552
+ }
553
+ .us-picker {
554
+ max-width: none;
555
+ max-height: calc(100dvh - 40px);
556
+ border-radius: 16px 16px 0 0;
557
+ border-left: 0; border-right: 0; border-bottom: 0;
558
+ animation: us-sheet-up 300ms cubic-bezier(0.32, 0.72, 0, 1);
559
+ }
560
+ /* Grab handle */
561
+ .us-picker::before {
562
+ content: "";
563
+ flex-shrink: 0;
564
+ width: 36px; height: 4px;
565
+ border-radius: 999px;
566
+ background: var(--us-border-strong);
567
+ margin: 8px auto 0;
568
+ }
569
+ .us-picker-header { padding: 10px 16px 14px; }
570
+ .us-dropzone { padding: 24px 16px; }
571
+ .us-btn { min-height: 44px; padding: 10px 16px; }
572
+ .us-actions {
573
+ flex-direction: column; align-items: stretch; gap: 10px;
574
+ padding-bottom: max(12px, env(safe-area-inset-bottom));
575
+ }
576
+ .us-actions-summary { text-align: center; order: 2; }
577
+ .us-actions-summary:empty { display: none; }
578
+ .us-actions-buttons { width: 100%; }
579
+ .us-actions-buttons .us-btn { flex: 1; }
580
+ .us-source-tabs { display: flex; width: 100%; }
581
+ .us-source-tab { flex: 1; justify-content: center; min-height: 38px; }
582
+ .us-url-form { flex-direction: column; }
583
+ .us-url-input { min-height: 44px; }
584
+ .us-file { padding: 12px; }
585
+ .us-file-action, .us-picker-close { width: 36px; height: 36px; }
586
+ .us-editor-toolbar { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
587
+ .us-tool { flex-shrink: 0; min-height: 40px; }
588
+ .us-editor-footer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }
589
+ .us-editor-footer .us-btn { flex: 1; }
590
+ .us-footer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }
591
+ }
592
+ @keyframes us-sheet-up {
593
+ from { opacity: 0.6; transform: translateY(32px); }
594
+ to { opacity: 1; transform: translateY(0); }
595
+ }
596
+
319
597
  /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 reduced motion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
320
598
  @media (prefers-reduced-motion: reduce) {
321
599
  .us-picker-backdrop,
@@ -325,7 +603,10 @@ var BASE_CSS = `
325
603
  .us-dropzone-icon,
326
604
  .us-btn,
327
605
  .us-file-status,
328
- .us-file-progress-bar { animation: none !important; transition: none !important; }
606
+ .us-file-progress-bar,
607
+ .us-editor,
608
+ .us-source-tab,
609
+ .us-tool { animation: none !important; transition: none !important; }
329
610
  .us-file[data-state="uploading"] .us-file-progress-bar::after { animation: none; opacity: 0; }
330
611
  }
331
612
 
@@ -333,6 +614,409 @@ var BASE_CSS = `
333
614
  .us-file-list:empty { display: none; }
334
615
  `;
335
616
 
617
+ // src/picker/imageEditor.ts
618
+ var MAX_DISPLAY = 720;
619
+ var ICON = {
620
+ back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
621
+ crop: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>`,
622
+ circle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>`,
623
+ rotate: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.82.93 6.58 2.46L21 8"/><path d="M21 3v5h-5"/></svg>`,
624
+ undo: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>`,
625
+ check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`
626
+ };
627
+ var ImageEditor = class {
628
+ constructor(opts) {
629
+ this.opts = opts;
630
+ this.cropBox = null;
631
+ // What's currently rendered to the user, sized to fit MAX_DISPLAY.
632
+ this.displayScale = 1;
633
+ // Tracks whether the working canvas has transparency (post-circle), so
634
+ // export() can pick PNG vs JPEG correctly.
635
+ this.hasAlpha = false;
636
+ this.mode = "none";
637
+ this.cropRect = null;
638
+ // display-canvas coords
639
+ // Drag state for crop interaction.
640
+ this.dragKind = null;
641
+ this.dragStart = null;
642
+ this.onPointerMove = (e) => {
643
+ if (!this.dragKind || !this.dragStart || !this.cropRect) return;
644
+ const dx = e.clientX - this.dragStart.px;
645
+ const dy = e.clientY - this.dragStart.py;
646
+ const start = this.dragStart.rect;
647
+ const maxW = this.displayCanvas.width;
648
+ const maxH = this.displayCanvas.height;
649
+ const min = 24;
650
+ let { x, y, w, h } = start;
651
+ switch (this.dragKind) {
652
+ case "move":
653
+ x = clamp(start.x + dx, 0, maxW - start.w);
654
+ y = clamp(start.y + dy, 0, maxH - start.h);
655
+ break;
656
+ case "nw": {
657
+ const nx = clamp(start.x + dx, 0, start.x + start.w - min);
658
+ const ny = clamp(start.y + dy, 0, start.y + start.h - min);
659
+ w = start.w + (start.x - nx);
660
+ h = start.h + (start.y - ny);
661
+ x = nx;
662
+ y = ny;
663
+ break;
664
+ }
665
+ case "ne": {
666
+ const nw = clamp(start.w + dx, min, maxW - start.x);
667
+ const ny = clamp(start.y + dy, 0, start.y + start.h - min);
668
+ h = start.h + (start.y - ny);
669
+ w = nw;
670
+ y = ny;
671
+ break;
672
+ }
673
+ case "sw": {
674
+ const nx = clamp(start.x + dx, 0, start.x + start.w - min);
675
+ const nh = clamp(start.h + dy, min, maxH - start.y);
676
+ w = start.w + (start.x - nx);
677
+ h = nh;
678
+ x = nx;
679
+ break;
680
+ }
681
+ case "se":
682
+ w = clamp(start.w + dx, min, maxW - start.x);
683
+ h = clamp(start.h + dy, min, maxH - start.y);
684
+ break;
685
+ }
686
+ this.cropRect = { x, y, w, h };
687
+ this.updateCropBox();
688
+ };
689
+ this.onPointerUp = () => {
690
+ this.dragKind = null;
691
+ this.dragStart = null;
692
+ document.removeEventListener("pointermove", this.onPointerMove);
693
+ document.removeEventListener("pointerup", this.onPointerUp);
694
+ };
695
+ }
696
+ async open() {
697
+ this.mount();
698
+ try {
699
+ await this.loadOriginalIntoWorking();
700
+ if (this.opts.originalFile && this.opts.originalFile !== this.opts.file) {
701
+ this.markEdited(true);
702
+ }
703
+ this.draw();
704
+ } catch (err) {
705
+ this.canvasWrap.textContent = `Couldn't load image: ${err.message}`;
706
+ }
707
+ }
708
+ close() {
709
+ document.removeEventListener("pointermove", this.onPointerMove);
710
+ document.removeEventListener("pointerup", this.onPointerUp);
711
+ this.root.remove();
712
+ }
713
+ // ---- mount / dom --------------------------------------------------------
714
+ mount() {
715
+ this.root = el("div", "us-editor");
716
+ const header = el("div", "us-editor-header");
717
+ const back = document.createElement("button");
718
+ back.type = "button";
719
+ back.className = "us-editor-back";
720
+ back.setAttribute("aria-label", "Back");
721
+ back.innerHTML = ICON.back;
722
+ back.onclick = () => {
723
+ this.opts.onCancel();
724
+ this.close();
725
+ };
726
+ header.appendChild(back);
727
+ header.appendChild(el("div", "us-editor-title", this.opts.title));
728
+ this.root.appendChild(header);
729
+ this.canvasWrap = el("div", "us-editor-canvas-wrap");
730
+ this.displayCanvas = document.createElement("canvas");
731
+ this.displayCanvas.className = "us-editor-canvas";
732
+ this.canvasWrap.appendChild(this.displayCanvas);
733
+ this.root.appendChild(this.canvasWrap);
734
+ const toolbar = el("div", "us-editor-toolbar");
735
+ this.cropTool = makeTool("Crop", ICON.crop);
736
+ this.cropTool.onclick = () => this.toggleCropMode();
737
+ this.circleTool = makeTool("Circle", ICON.circle);
738
+ this.circleTool.onclick = () => this.applyCircle();
739
+ this.rotateTool = makeTool("Rotate 90\xB0", ICON.rotate);
740
+ this.rotateTool.onclick = () => this.applyRotate();
741
+ this.revertTool = makeTool("Revert", ICON.undo);
742
+ this.revertTool.onclick = () => this.revert();
743
+ this.revertTool.disabled = true;
744
+ toolbar.appendChild(this.cropTool);
745
+ toolbar.appendChild(this.circleTool);
746
+ toolbar.appendChild(this.rotateTool);
747
+ toolbar.appendChild(el("div", "us-tool-spacer"));
748
+ toolbar.appendChild(this.revertTool);
749
+ this.root.appendChild(toolbar);
750
+ const footer = el("div", "us-editor-footer");
751
+ const cancel = document.createElement("button");
752
+ cancel.type = "button";
753
+ cancel.className = "us-btn";
754
+ cancel.textContent = "Cancel";
755
+ cancel.onclick = () => {
756
+ this.opts.onCancel();
757
+ this.close();
758
+ };
759
+ this.applyBtn = document.createElement("button");
760
+ this.applyBtn.type = "button";
761
+ this.applyBtn.className = "us-btn us-btn-primary";
762
+ this.applyBtn.innerHTML = `${ICON.check} <span>Apply</span>`;
763
+ this.applyBtn.onclick = () => this.applyAndClose();
764
+ footer.appendChild(cancel);
765
+ footer.appendChild(this.applyBtn);
766
+ this.root.appendChild(footer);
767
+ this.opts.host.appendChild(this.root);
768
+ }
769
+ // ---- image loading ------------------------------------------------------
770
+ async loadOriginalIntoWorking() {
771
+ const img = await loadImage(this.opts.file);
772
+ const c = document.createElement("canvas");
773
+ c.width = img.naturalWidth;
774
+ c.height = img.naturalHeight;
775
+ const ctx = c.getContext("2d");
776
+ if (!ctx) throw new Error("Canvas 2D context unavailable");
777
+ ctx.drawImage(img, 0, 0);
778
+ this.working = c;
779
+ this.hasAlpha = looksLikePngMime(this.opts.file.type);
780
+ this.markEdited(false);
781
+ }
782
+ // For Revert. Loads the true original (passed-in `originalFile`, or `file`
783
+ // if the caller didn't distinguish).
784
+ async loadTrueOriginalIntoWorking() {
785
+ const source = this.opts.originalFile ?? this.opts.file;
786
+ const img = await loadImage(source);
787
+ const c = document.createElement("canvas");
788
+ c.width = img.naturalWidth;
789
+ c.height = img.naturalHeight;
790
+ const ctx = c.getContext("2d");
791
+ if (!ctx) throw new Error("Canvas 2D context unavailable");
792
+ ctx.drawImage(img, 0, 0);
793
+ this.working = c;
794
+ this.hasAlpha = looksLikePngMime(source.type);
795
+ this.markEdited(false);
796
+ }
797
+ // ---- rendering ----------------------------------------------------------
798
+ draw() {
799
+ const { width: ww, height: wh } = this.working;
800
+ const scale = Math.min(1, MAX_DISPLAY / Math.max(ww, wh));
801
+ const dw = Math.max(1, Math.round(ww * scale));
802
+ const dh = Math.max(1, Math.round(wh * scale));
803
+ this.displayCanvas.width = dw;
804
+ this.displayCanvas.height = dh;
805
+ this.displayScale = scale;
806
+ const ctx = this.displayCanvas.getContext("2d");
807
+ if (!ctx) return;
808
+ ctx.clearRect(0, 0, dw, dh);
809
+ ctx.drawImage(this.working, 0, 0, dw, dh);
810
+ this.updateCropBox();
811
+ }
812
+ // ---- mode: crop ---------------------------------------------------------
813
+ toggleCropMode() {
814
+ if (this.mode === "crop") {
815
+ this.applyCrop();
816
+ } else {
817
+ this.enterCropMode();
818
+ }
819
+ }
820
+ enterCropMode() {
821
+ this.mode = "crop";
822
+ this.cropTool.dataset.active = "true";
823
+ this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
824
+ const dw = this.displayCanvas.width;
825
+ const dh = this.displayCanvas.height;
826
+ const inset = Math.round(Math.min(dw, dh) * 0.1);
827
+ this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
828
+ this.renderCropBox();
829
+ }
830
+ exitCropMode() {
831
+ this.mode = "none";
832
+ this.cropTool.removeAttribute("data-active");
833
+ this.cropTool.innerHTML = `${ICON.crop} <span>Crop</span>`;
834
+ this.cropRect = null;
835
+ if (this.cropBox) {
836
+ this.cropBox.remove();
837
+ this.cropBox = null;
838
+ }
839
+ }
840
+ renderCropBox() {
841
+ if (!this.cropRect) return;
842
+ if (this.cropBox) this.cropBox.remove();
843
+ const box = el("div", "us-crop-box");
844
+ for (const pos of ["nw", "ne", "sw", "se"]) {
845
+ const h = el("div", "us-crop-handle");
846
+ h.dataset.pos = pos;
847
+ h.addEventListener("pointerdown", (e) => this.beginDrag(e, pos));
848
+ box.appendChild(h);
849
+ }
850
+ box.addEventListener("pointerdown", (e) => {
851
+ const target = e.target;
852
+ if (target.classList.contains("us-crop-handle")) return;
853
+ this.beginDrag(e, "move");
854
+ });
855
+ this.canvasWrap.appendChild(box);
856
+ this.cropBox = box;
857
+ this.updateCropBox();
858
+ }
859
+ updateCropBox() {
860
+ if (!this.cropBox || !this.cropRect) return;
861
+ const canvasRect = this.displayCanvas.getBoundingClientRect();
862
+ const wrapRect = this.canvasWrap.getBoundingClientRect();
863
+ const left = canvasRect.left - wrapRect.left + this.cropRect.x;
864
+ const top = canvasRect.top - wrapRect.top + this.cropRect.y;
865
+ this.cropBox.style.left = `${left}px`;
866
+ this.cropBox.style.top = `${top}px`;
867
+ this.cropBox.style.width = `${this.cropRect.w}px`;
868
+ this.cropBox.style.height = `${this.cropRect.h}px`;
869
+ }
870
+ // ---- drag handlers (crop) ----------------------------------------------
871
+ beginDrag(e, kind) {
872
+ if (!this.cropRect) return;
873
+ e.preventDefault();
874
+ this.dragKind = kind;
875
+ this.dragStart = { px: e.clientX, py: e.clientY, rect: { ...this.cropRect } };
876
+ document.addEventListener("pointermove", this.onPointerMove);
877
+ document.addEventListener("pointerup", this.onPointerUp);
878
+ }
879
+ // ---- operations (mutate working canvas) --------------------------------
880
+ applyCrop() {
881
+ if (!this.cropRect) {
882
+ this.exitCropMode();
883
+ return;
884
+ }
885
+ const s = this.displayScale;
886
+ const sx = Math.round(this.cropRect.x / s);
887
+ const sy = Math.round(this.cropRect.y / s);
888
+ const sw = Math.round(this.cropRect.w / s);
889
+ const sh = Math.round(this.cropRect.h / s);
890
+ const next = document.createElement("canvas");
891
+ next.width = sw;
892
+ next.height = sh;
893
+ const ctx = next.getContext("2d");
894
+ if (!ctx) return;
895
+ ctx.drawImage(this.working, sx, sy, sw, sh, 0, 0, sw, sh);
896
+ this.working = next;
897
+ this.exitCropMode();
898
+ this.markEdited(true);
899
+ this.draw();
900
+ }
901
+ applyRotate() {
902
+ const { width: w, height: h } = this.working;
903
+ const next = document.createElement("canvas");
904
+ next.width = h;
905
+ next.height = w;
906
+ const ctx = next.getContext("2d");
907
+ if (!ctx) return;
908
+ ctx.translate(h, 0);
909
+ ctx.rotate(Math.PI / 2);
910
+ ctx.drawImage(this.working, 0, 0);
911
+ this.working = next;
912
+ this.exitCropMode();
913
+ this.markEdited(true);
914
+ this.draw();
915
+ }
916
+ applyCircle() {
917
+ const { width: w, height: h } = this.working;
918
+ const size = Math.min(w, h);
919
+ const sx = Math.round((w - size) / 2);
920
+ const sy = Math.round((h - size) / 2);
921
+ const next = document.createElement("canvas");
922
+ next.width = size;
923
+ next.height = size;
924
+ const ctx = next.getContext("2d");
925
+ if (!ctx) return;
926
+ ctx.save();
927
+ ctx.beginPath();
928
+ ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
929
+ ctx.closePath();
930
+ ctx.clip();
931
+ ctx.drawImage(this.working, sx, sy, size, size, 0, 0, size, size);
932
+ ctx.restore();
933
+ this.working = next;
934
+ this.hasAlpha = true;
935
+ this.exitCropMode();
936
+ this.markEdited(true);
937
+ this.draw();
938
+ }
939
+ async revert() {
940
+ await this.loadTrueOriginalIntoWorking();
941
+ this.exitCropMode();
942
+ this.draw();
943
+ }
944
+ markEdited(edited) {
945
+ this.revertTool.disabled = !edited;
946
+ }
947
+ // ---- apply --------------------------------------------------------------
948
+ async applyAndClose() {
949
+ this.applyBtn.disabled = true;
950
+ try {
951
+ const file = await this.exportFile();
952
+ this.opts.onApply(file);
953
+ this.close();
954
+ } catch (err) {
955
+ this.applyBtn.disabled = false;
956
+ console.error("[union-stack] image export failed", err);
957
+ }
958
+ }
959
+ exportFile() {
960
+ return new Promise((resolve, reject) => {
961
+ const mime = this.hasAlpha ? "image/png" : "image/jpeg";
962
+ const quality = mime === "image/jpeg" ? 0.92 : void 0;
963
+ this.working.toBlob(
964
+ (blob) => {
965
+ if (!blob) return reject(new Error("toBlob returned null"));
966
+ const baseName = this.opts.file.name.replace(/\.[^.]+$/, "");
967
+ const ext = mime === "image/png" ? "png" : "jpg";
968
+ resolve(new File([blob], `${baseName}-edited.${ext}`, { type: mime }));
969
+ },
970
+ mime,
971
+ quality
972
+ );
973
+ });
974
+ }
975
+ };
976
+ function el(tag, className, text) {
977
+ const node = document.createElement(tag);
978
+ if (className) node.className = className;
979
+ if (text !== void 0) node.textContent = text;
980
+ return node;
981
+ }
982
+ function makeTool(label, iconHtml) {
983
+ const btn = document.createElement("button");
984
+ btn.type = "button";
985
+ btn.className = "us-tool";
986
+ btn.innerHTML = `${iconHtml} <span>${escapeText(label)}</span>`;
987
+ return btn;
988
+ }
989
+ function escapeText(s) {
990
+ return s.replace(/[&<>"']/g, (c) => ({
991
+ "&": "&amp;",
992
+ "<": "&lt;",
993
+ ">": "&gt;",
994
+ '"': "&quot;",
995
+ "'": "&#39;"
996
+ })[c]);
997
+ }
998
+ function clamp(v, lo, hi) {
999
+ return Math.max(lo, Math.min(hi, v));
1000
+ }
1001
+ function loadImage(file) {
1002
+ return new Promise((resolve, reject) => {
1003
+ const url = URL.createObjectURL(file);
1004
+ const img = new Image();
1005
+ img.onload = () => {
1006
+ URL.revokeObjectURL(url);
1007
+ resolve(img);
1008
+ };
1009
+ img.onerror = () => {
1010
+ URL.revokeObjectURL(url);
1011
+ reject(new Error("decode failed"));
1012
+ };
1013
+ img.src = url;
1014
+ });
1015
+ }
1016
+ function looksLikePngMime(mime) {
1017
+ return /^image\/(png|webp|gif)$/.test(mime);
1018
+ }
1019
+
336
1020
  // src/picker/picker.ts
337
1021
  function mergeConfig(server, runtime) {
338
1022
  const merged = { ...runtime };
@@ -362,8 +1046,9 @@ function mergeConfig(server, runtime) {
362
1046
  }
363
1047
  var DEFAULT_TITLE = "Upload files";
364
1048
  var FOOTER_LINK = "https://unionstack.link";
365
- var ICON = {
1049
+ var ICON2 = {
366
1050
  upload: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
1051
+ fileUp: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M12 18v-6"/><path d="m9 15 3-3 3 3"/></svg>`,
367
1052
  close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
368
1053
  check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
369
1054
  alert: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>`,
@@ -374,28 +1059,39 @@ var ICON = {
374
1059
  pdf: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>`,
375
1060
  archive: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="2"/><path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>`,
376
1061
  file: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`,
377
- zap: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`
1062
+ zap: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
1063
+ device: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`,
1064
+ link: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.72"/></svg>`,
1065
+ pencil: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
378
1066
  };
379
1067
  function iconForMime(mime) {
380
- if (!mime) return ICON.file;
381
- if (mime.startsWith("image/")) return ICON.image;
382
- if (mime.startsWith("video/")) return ICON.video;
383
- if (mime.startsWith("audio/")) return ICON.audio;
384
- if (mime === "application/pdf") return ICON.pdf;
385
- if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON.archive;
386
- return ICON.file;
1068
+ if (!mime) return ICON2.file;
1069
+ if (mime.startsWith("image/")) return ICON2.image;
1070
+ if (mime.startsWith("video/")) return ICON2.video;
1071
+ if (mime.startsWith("audio/")) return ICON2.audio;
1072
+ if (mime === "application/pdf") return ICON2.pdf;
1073
+ if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
1074
+ return ICON2.file;
387
1075
  }
388
1076
  var Picker = class {
389
1077
  constructor(client, opts) {
390
1078
  this.client = client;
391
1079
  this.opts = opts;
392
1080
  this.$backdrop = null;
1081
+ this.$panel = null;
393
1082
  this.$list = null;
394
1083
  this.$confirm = null;
395
1084
  this.$cancel = null;
396
1085
  this.$closeBtn = null;
397
1086
  this.$input = null;
1087
+ // Source-tab DOM (Device vs URL). Only created when both sources are enabled.
1088
+ this.$deviceSource = null;
1089
+ this.$urlSource = null;
1090
+ this.$urlInput = null;
1091
+ this.$urlHint = null;
1092
+ this.$urlAddBtn = null;
398
1093
  this.items = [];
1094
+ this.editor = null;
399
1095
  this.abortCtrl = new AbortController();
400
1096
  this.uploadStarted = false;
401
1097
  this.resolved = false;
@@ -438,38 +1134,50 @@ var Picker = class {
438
1134
  root.addEventListener("click", (e) => {
439
1135
  if (e.target === root && !this.uploadStarted) this.cancel();
440
1136
  });
441
- const panel = el("div", "us-picker");
442
- const header = el("div", "us-picker-header");
443
- const logoWrap = el("div", "us-picker-header-logo");
1137
+ const panel = el2("div", "us-picker");
1138
+ const header = el2("div", "us-picker-header");
1139
+ const logoWrap = el2("div", "us-picker-header-logo");
444
1140
  if (this.opts.branding?.logoUrl) {
445
1141
  const logo = document.createElement("img");
446
1142
  logo.src = this.opts.branding.logoUrl;
447
1143
  logo.alt = "";
448
1144
  logoWrap.appendChild(logo);
449
1145
  } else {
450
- logoWrap.innerHTML = ICON.zap;
1146
+ logoWrap.innerHTML = ICON2.zap;
451
1147
  }
452
1148
  header.appendChild(logoWrap);
453
- const title = el("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
1149
+ const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
454
1150
  header.appendChild(title);
455
1151
  this.$closeBtn = document.createElement("button");
456
1152
  this.$closeBtn.type = "button";
457
1153
  this.$closeBtn.className = "us-picker-close";
458
1154
  this.$closeBtn.setAttribute("aria-label", "Close");
459
- this.$closeBtn.innerHTML = ICON.close;
1155
+ this.$closeBtn.innerHTML = ICON2.close;
460
1156
  this.$closeBtn.onclick = () => this.cancel();
461
1157
  header.appendChild(this.$closeBtn);
462
1158
  panel.appendChild(header);
463
- const body = el("div", "us-picker-body");
464
- body.appendChild(this.renderDropzone());
465
- this.$list = el("div", "us-file-list");
1159
+ const body = el2("div", "us-picker-body");
1160
+ const sources = this.resolvedSources();
1161
+ if (sources.length > 1) {
1162
+ body.appendChild(this.renderSourceTabs(sources));
1163
+ }
1164
+ if (sources.includes("device")) {
1165
+ this.$deviceSource = this.renderDropzone();
1166
+ body.appendChild(this.$deviceSource);
1167
+ }
1168
+ if (sources.includes("url")) {
1169
+ this.$urlSource = this.renderUrlSource();
1170
+ body.appendChild(this.$urlSource);
1171
+ }
1172
+ this.activateSource(sources[0] ?? "device");
1173
+ this.$list = el2("div", "us-file-list");
466
1174
  body.appendChild(this.$list);
467
1175
  panel.appendChild(body);
468
- const autoUpload = this.opts.autoUpload !== false;
469
- const actions = el("div", "us-actions");
470
- this.$summary = el("div", "us-actions-summary", "");
1176
+ const autoUpload = this.opts.autoUpload === true;
1177
+ const actions = el2("div", "us-actions");
1178
+ this.$summary = el2("div", "us-actions-summary", "");
471
1179
  actions.appendChild(this.$summary);
472
- const buttons = el("div", "us-actions-buttons");
1180
+ const buttons = el2("div", "us-actions-buttons");
473
1181
  this.$cancel = document.createElement("button");
474
1182
  this.$cancel.type = "button";
475
1183
  this.$cancel.className = "us-btn";
@@ -480,7 +1188,7 @@ var Picker = class {
480
1188
  this.$confirm = document.createElement("button");
481
1189
  this.$confirm.type = "button";
482
1190
  this.$confirm.className = "us-btn us-btn-primary";
483
- this.$confirm.innerHTML = `${ICON.upload} <span>Upload</span>`;
1191
+ this.$confirm.innerHTML = `${ICON2.upload} <span>Upload</span>`;
484
1192
  this.$confirm.disabled = true;
485
1193
  this.$confirm.onclick = () => this.startUpload();
486
1194
  buttons.appendChild(this.$confirm);
@@ -488,24 +1196,123 @@ var Picker = class {
488
1196
  actions.appendChild(buttons);
489
1197
  panel.appendChild(actions);
490
1198
  if (!this.opts.branding?.hideFooter) {
491
- const footer = el("div", "us-footer");
492
- footer.innerHTML = `${ICON.zap} <span>Powered by <a href="${FOOTER_LINK}" target="_blank" rel="noopener">UnionStack</a></span>`;
1199
+ const footer = el2("div", "us-footer");
1200
+ footer.innerHTML = `${ICON2.zap} <span>Powered by <a href="${FOOTER_LINK}" target="_blank" rel="noopener">UnionStack</a></span>`;
493
1201
  panel.appendChild(footer);
494
1202
  }
495
1203
  root.appendChild(panel);
496
1204
  (this.opts.container ?? document.body).appendChild(root);
497
1205
  this.$backdrop = root;
1206
+ this.$panel = panel;
1207
+ }
1208
+ // Resolves which sources to show, honoring opts.fromSources, defaulting to
1209
+ // both. Empty arrays fall back to ['device'] — we never want to leave a
1210
+ // picker with no way to add files.
1211
+ resolvedSources() {
1212
+ const raw = this.opts.fromSources;
1213
+ if (!raw || raw.length === 0) return ["device", "url"];
1214
+ return raw;
1215
+ }
1216
+ renderSourceTabs(sources) {
1217
+ const tabs = el2("div", "us-source-tabs");
1218
+ tabs.setAttribute("role", "tablist");
1219
+ for (const src of sources) {
1220
+ const btn = document.createElement("button");
1221
+ btn.type = "button";
1222
+ btn.className = "us-source-tab";
1223
+ btn.setAttribute("role", "tab");
1224
+ btn.dataset.source = src;
1225
+ btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
1226
+ btn.onclick = () => this.activateSource(src);
1227
+ tabs.appendChild(btn);
1228
+ }
1229
+ return tabs;
1230
+ }
1231
+ activateSource(source) {
1232
+ if (this.$deviceSource) {
1233
+ const active = source === "device";
1234
+ this.$deviceSource.style.display = active ? "" : "none";
1235
+ }
1236
+ if (this.$urlSource) {
1237
+ this.$urlSource.dataset.active = source === "url" ? "true" : "false";
1238
+ }
1239
+ const tabs = this.$panel?.querySelectorAll(".us-source-tab");
1240
+ tabs?.forEach((t) => {
1241
+ t.dataset.active = t.dataset.source === source ? "true" : "false";
1242
+ });
1243
+ }
1244
+ renderUrlSource() {
1245
+ const wrap = el2("div", "us-url-source");
1246
+ const form = el2("div", "us-url-form");
1247
+ const input = document.createElement("input");
1248
+ input.type = "url";
1249
+ input.className = "us-url-input";
1250
+ input.placeholder = "https://example.com/photo.jpg";
1251
+ input.setAttribute("aria-label", "File URL");
1252
+ input.onkeydown = (e) => {
1253
+ if (e.key === "Enter") {
1254
+ e.preventDefault();
1255
+ this.handleUrlAdd();
1256
+ }
1257
+ };
1258
+ this.$urlInput = input;
1259
+ const add = document.createElement("button");
1260
+ add.type = "button";
1261
+ add.className = "us-btn us-btn-primary";
1262
+ add.textContent = "Add file";
1263
+ add.onclick = () => this.handleUrlAdd();
1264
+ this.$urlAddBtn = add;
1265
+ form.appendChild(input);
1266
+ form.appendChild(add);
1267
+ wrap.appendChild(form);
1268
+ const hint = el2("div", "us-url-hint", "Paste a direct file URL. The host must allow CORS so we can fetch it from the browser.");
1269
+ this.$urlHint = hint;
1270
+ wrap.appendChild(hint);
1271
+ return wrap;
1272
+ }
1273
+ async handleUrlAdd() {
1274
+ if (!this.$urlInput || !this.$urlAddBtn || !this.$urlHint) return;
1275
+ const url = this.$urlInput.value.trim();
1276
+ if (!url) {
1277
+ this.setUrlHint("Enter a URL first.", true);
1278
+ return;
1279
+ }
1280
+ if (!/^https?:\/\//i.test(url)) {
1281
+ this.setUrlHint("URL must start with http:// or https://", true);
1282
+ return;
1283
+ }
1284
+ this.$urlAddBtn.disabled = true;
1285
+ const prevLabel = this.$urlAddBtn.textContent;
1286
+ this.$urlAddBtn.textContent = "Fetching\u2026";
1287
+ this.setUrlHint("Downloading\u2026", false);
1288
+ try {
1289
+ const file = await fetchUrlAsFile(url);
1290
+ this.addFiles([file]);
1291
+ this.$urlInput.value = "";
1292
+ this.setUrlHint("Paste a direct file URL. The host must allow CORS so we can fetch it from the browser.", false);
1293
+ } catch (err) {
1294
+ const msg = err.message || "Failed to fetch URL";
1295
+ this.setUrlHint(msg, true);
1296
+ } finally {
1297
+ this.$urlAddBtn.disabled = false;
1298
+ this.$urlAddBtn.textContent = prevLabel || "Add file";
1299
+ }
1300
+ }
1301
+ setUrlHint(text, isError) {
1302
+ if (!this.$urlHint) return;
1303
+ this.$urlHint.textContent = text;
1304
+ this.$urlHint.dataset.error = isError ? "true" : "false";
498
1305
  }
499
1306
  renderDropzone() {
500
- const dz = el("div", "us-dropzone");
1307
+ const dz = el2("div", "us-dropzone");
501
1308
  dz.setAttribute("role", "button");
502
1309
  dz.setAttribute("tabindex", "0");
503
1310
  dz.setAttribute("aria-label", "Drop files here or click to browse");
504
- const icon = el("div", "us-dropzone-icon");
505
- icon.innerHTML = ICON.upload;
1311
+ const icon = el2("div", "us-dropzone-icon");
1312
+ icon.innerHTML = ICON2.fileUp;
506
1313
  dz.appendChild(icon);
507
- dz.appendChild(el("div", "us-dropzone-title", "Drop files to upload"));
508
- dz.appendChild(el("div", "us-dropzone-hint", "or click to browse from your device"));
1314
+ dz.appendChild(el2("div", "us-dropzone-title", "Drop files to upload"));
1315
+ dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
509
1316
  const constraintBits = [];
510
1317
  if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
511
1318
  if (this.opts.accept) {
@@ -515,7 +1322,7 @@ var Picker = class {
515
1322
  }
516
1323
  }
517
1324
  if (constraintBits.length > 0) {
518
- dz.appendChild(el("div", "us-dropzone-constraints", constraintBits.join(" \xB7 ")));
1325
+ dz.appendChild(el2("div", "us-dropzone-constraints", constraintBits.join(" \xB7 ")));
519
1326
  }
520
1327
  const input = document.createElement("input");
521
1328
  input.type = "file";
@@ -549,8 +1356,13 @@ var Picker = class {
549
1356
  return dz;
550
1357
  }
551
1358
  unmount() {
1359
+ if (this.editor) {
1360
+ this.editor.close();
1361
+ this.editor = null;
1362
+ }
552
1363
  if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
553
1364
  this.$backdrop = null;
1365
+ this.$panel = null;
554
1366
  for (const item of this.items) {
555
1367
  if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
556
1368
  }
@@ -567,6 +1379,8 @@ var Picker = class {
567
1379
  const item2 = {
568
1380
  uploadId: cryptoId(),
569
1381
  file,
1382
+ originalFile: file,
1383
+ edited: false,
570
1384
  state: "failed",
571
1385
  progress: 0,
572
1386
  error: `File exceeds ${formatBytes(this.opts.maxFileSize)} limit`
@@ -578,6 +1392,8 @@ var Picker = class {
578
1392
  const item = {
579
1393
  uploadId: cryptoId(),
580
1394
  file,
1395
+ originalFile: file,
1396
+ edited: false,
581
1397
  state: "queued",
582
1398
  progress: 0
583
1399
  };
@@ -585,7 +1401,7 @@ var Picker = class {
585
1401
  this.renderItem(item);
586
1402
  }
587
1403
  this.refreshConfirm();
588
- const autoUpload = this.opts.autoUpload !== false;
1404
+ const autoUpload = this.opts.autoUpload === true;
589
1405
  const hasQueued = this.items.some((i) => i.state === "queued");
590
1406
  if (autoUpload && hasQueued && !this.uploadStarted) {
591
1407
  requestAnimationFrame(() => {
@@ -595,12 +1411,12 @@ var Picker = class {
595
1411
  }
596
1412
  renderItem(item) {
597
1413
  if (!this.$list) return;
598
- const row = el("div", "us-file");
1414
+ const row = el2("div", "us-file");
599
1415
  row.dataset.state = item.state;
600
1416
  row.dataset.uploadId = item.uploadId;
601
1417
  const idx = Math.min(this.items.length - 1, 8);
602
1418
  row.style.animationDelay = `${idx * 35}ms`;
603
- const thumb = el("div", "us-file-thumb");
1419
+ const thumb = el2("div", "us-file-thumb");
604
1420
  if (item.file.type.startsWith("image/") && typeof URL !== "undefined" && URL.createObjectURL) {
605
1421
  try {
606
1422
  const objectUrl = URL.createObjectURL(item.file);
@@ -614,18 +1430,32 @@ var Picker = class {
614
1430
  thumb.innerHTML = iconForMime(item.file.type);
615
1431
  }
616
1432
  row.appendChild(thumb);
617
- const main = el("div", "us-file-main");
618
- const row1 = el("div", "us-file-row1");
619
- row1.appendChild(el("div", "us-file-name", item.file.name));
620
- const meta = el("div", "us-file-meta", formatBytes(item.file.size));
1433
+ const main = el2("div", "us-file-main");
1434
+ const row1 = el2("div", "us-file-row1");
1435
+ row1.appendChild(el2("div", "us-file-name", item.file.name));
1436
+ const meta = el2("div", "us-file-meta", formatBytes(item.file.size));
621
1437
  row1.appendChild(meta);
622
1438
  main.appendChild(row1);
623
- const progress = el("div", "us-file-progress");
624
- const bar = el("div", "us-file-progress-bar");
1439
+ const progress = el2("div", "us-file-progress");
1440
+ const bar = el2("div", "us-file-progress-bar");
625
1441
  progress.appendChild(bar);
626
1442
  main.appendChild(progress);
627
1443
  row.appendChild(main);
628
- const status = el("div", "us-file-status");
1444
+ const isImage = item.file.type.startsWith("image/");
1445
+ const editingEnabled = this.opts.imageEditing !== false;
1446
+ if (isImage && editingEnabled && item.state === "queued") {
1447
+ const edit = document.createElement("button");
1448
+ edit.type = "button";
1449
+ edit.className = "us-file-action";
1450
+ edit.setAttribute("aria-label", "Edit image");
1451
+ edit.title = "Edit image";
1452
+ edit.innerHTML = ICON2.pencil;
1453
+ edit.dataset.edited = item.edited ? "true" : "false";
1454
+ edit.onclick = () => this.openEditor(item);
1455
+ row.appendChild(edit);
1456
+ item.$edit = edit;
1457
+ }
1458
+ const status = el2("div", "us-file-status");
629
1459
  status.setAttribute("aria-label", this.statusLabel(item));
630
1460
  status.innerHTML = this.statusIcon(item.state);
631
1461
  row.appendChild(status);
@@ -633,21 +1463,74 @@ var Picker = class {
633
1463
  item.$bar = bar;
634
1464
  item.$status = status;
635
1465
  item.$meta = meta;
1466
+ item.$thumb = thumb;
636
1467
  this.$list.appendChild(row);
637
1468
  this.updateSummary();
638
1469
  }
1470
+ // Swap a queued item's file with an edited version. Refreshes thumbnail and
1471
+ // meta in place so the row identity is preserved (uploadId, position).
1472
+ replaceItemFile(item, nextFile, edited) {
1473
+ item.file = nextFile;
1474
+ item.edited = edited;
1475
+ if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
1476
+ item.objectUrl = void 0;
1477
+ if (item.$thumb) {
1478
+ item.$thumb.style.backgroundImage = "";
1479
+ item.$thumb.removeAttribute("data-image");
1480
+ if (nextFile.type.startsWith("image/") && typeof URL !== "undefined" && URL.createObjectURL) {
1481
+ try {
1482
+ const objectUrl = URL.createObjectURL(nextFile);
1483
+ item.objectUrl = objectUrl;
1484
+ item.$thumb.style.backgroundImage = `url("${objectUrl}")`;
1485
+ item.$thumb.dataset.image = "true";
1486
+ item.$thumb.innerHTML = "";
1487
+ } catch {
1488
+ item.$thumb.innerHTML = iconForMime(nextFile.type);
1489
+ }
1490
+ } else {
1491
+ item.$thumb.innerHTML = iconForMime(nextFile.type);
1492
+ }
1493
+ }
1494
+ if (item.$meta) item.$meta.textContent = formatBytes(nextFile.size);
1495
+ if (item.$row) {
1496
+ const nameEl = item.$row.querySelector(".us-file-name");
1497
+ if (nameEl) nameEl.textContent = nextFile.name;
1498
+ }
1499
+ if (item.$edit) item.$edit.dataset.edited = edited ? "true" : "false";
1500
+ }
1501
+ // Mount the image editor overlay on top of the picker panel for the given
1502
+ // queued item. The editor calls back with either an edited File (Apply) or
1503
+ // nothing (Cancel/Back).
1504
+ openEditor(item) {
1505
+ if (!this.$panel || this.editor || item.state !== "queued") return;
1506
+ this.editor = new ImageEditor({
1507
+ host: this.$panel,
1508
+ file: item.file,
1509
+ originalFile: item.originalFile,
1510
+ title: item.originalFile.name,
1511
+ onApply: (edited) => {
1512
+ const wasEdited = edited !== item.originalFile;
1513
+ this.replaceItemFile(item, edited, wasEdited);
1514
+ this.editor = null;
1515
+ },
1516
+ onCancel: () => {
1517
+ this.editor = null;
1518
+ }
1519
+ });
1520
+ this.editor.open();
1521
+ }
639
1522
  statusIcon(state) {
640
1523
  switch (state) {
641
1524
  case "uploading":
642
- return ICON.spinner;
1525
+ return ICON2.spinner;
643
1526
  case "done":
644
- return ICON.check;
1527
+ return ICON2.check;
645
1528
  case "failed":
646
- return ICON.alert;
1529
+ return ICON2.alert;
647
1530
  case "cancelled":
648
- return ICON.alert;
1531
+ return ICON2.alert;
649
1532
  default:
650
- return ICON.spinner;
1533
+ return ICON2.spinner;
651
1534
  }
652
1535
  }
653
1536
  statusLabel(item) {
@@ -701,7 +1584,10 @@ var Picker = class {
701
1584
  const done = this.items.filter((i) => i.state === "done").length;
702
1585
  const failed = this.items.filter((i) => i.state === "failed").length;
703
1586
  const active = this.items.filter((i) => i.state === "uploading" || i.state === "queued").length;
704
- if (active > 0) {
1587
+ if (!this.uploadStarted && active > 0) {
1588
+ const ready = this.items.filter((i) => i.state === "queued").length;
1589
+ this.$summary.textContent = `${ready} file${ready === 1 ? "" : "s"} ready`;
1590
+ } else if (active > 0) {
705
1591
  this.$summary.textContent = `Uploading ${done + 1} of ${total}`;
706
1592
  } else if (failed > 0) {
707
1593
  this.$summary.textContent = `${done} of ${total} uploaded \xB7 ${failed} failed`;
@@ -813,7 +1699,7 @@ var Picker = class {
813
1699
  this.resolvePromise(fallback);
814
1700
  }
815
1701
  };
816
- function el(tag, className, text) {
1702
+ function el2(tag, className, text) {
817
1703
  const node = document.createElement(tag);
818
1704
  if (className) node.className = className;
819
1705
  if (text !== void 0) node.textContent = text;
@@ -831,6 +1717,56 @@ function cryptoId() {
831
1717
  }
832
1718
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
833
1719
  }
1720
+ async function fetchUrlAsFile(url) {
1721
+ let res;
1722
+ try {
1723
+ res = await fetch(url, { mode: "cors", credentials: "omit" });
1724
+ } catch {
1725
+ throw new Error(`Could not reach ${shortHost(url)} \u2014 check the URL or CORS.`);
1726
+ }
1727
+ if (!res.ok) throw new Error(`Server returned ${res.status} \u2014 file unavailable.`);
1728
+ const blob = await res.blob();
1729
+ const filename = filenameFromUrl(url) || "download";
1730
+ const type = blob.type || guessMimeFromExt(filename);
1731
+ return new File([blob], filename, { type });
1732
+ }
1733
+ function shortHost(url) {
1734
+ try {
1735
+ return new URL(url).host;
1736
+ } catch {
1737
+ return "that host";
1738
+ }
1739
+ }
1740
+ function filenameFromUrl(url) {
1741
+ try {
1742
+ const u = new URL(url);
1743
+ const last = u.pathname.split("/").filter(Boolean).pop();
1744
+ return last ? decodeURIComponent(last) : "";
1745
+ } catch {
1746
+ return "";
1747
+ }
1748
+ }
1749
+ function guessMimeFromExt(filename) {
1750
+ const ext = filename.toLowerCase().split(".").pop() || "";
1751
+ const map = {
1752
+ jpg: "image/jpeg",
1753
+ jpeg: "image/jpeg",
1754
+ png: "image/png",
1755
+ gif: "image/gif",
1756
+ webp: "image/webp",
1757
+ svg: "image/svg+xml",
1758
+ pdf: "application/pdf",
1759
+ mp4: "video/mp4",
1760
+ mov: "video/quicktime",
1761
+ mp3: "audio/mpeg",
1762
+ wav: "audio/wav",
1763
+ zip: "application/zip",
1764
+ json: "application/json",
1765
+ txt: "text/plain",
1766
+ csv: "text/csv"
1767
+ };
1768
+ return map[ext] || "application/octet-stream";
1769
+ }
834
1770
  function openPicker(client, opts) {
835
1771
  const picker = new Picker(client, opts);
836
1772
  return {