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