@masters-union/union-stack 0.1.7 → 0.1.8

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
@@ -3,10 +3,10 @@ var STYLE_ID = "unionstack-picker-styles";
3
3
  function ensureStyles() {
4
4
  if (typeof document === "undefined") return;
5
5
  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);
6
+ const el3 = document.createElement("style");
7
+ el3.id = STYLE_ID;
8
+ el3.textContent = BASE_CSS;
9
+ document.head.appendChild(el3);
10
10
  }
11
11
  function themeToCssVars(theme) {
12
12
  const mode = theme?.mode || "light";
@@ -80,6 +80,7 @@ var BASE_CSS = `
80
80
  width: 100%; max-width: 480px;
81
81
  max-height: min(calc(100dvh - 32px), 680px);
82
82
  display: flex; flex-direction: column;
83
+ position: relative;
83
84
  box-shadow:
84
85
  0 1px 1px rgba(0,0,0,0.04),
85
86
  0 18px 40px -8px rgba(0,0,0,0.18),
@@ -316,6 +317,178 @@ var BASE_CSS = `
316
317
  .us-footer a { color: var(--us-muted); text-decoration: none; font-weight: 500; }
317
318
  .us-footer a:hover { color: var(--us-fg); }
318
319
 
320
+ /* \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 */
321
+ .us-source-tabs {
322
+ display: inline-flex; gap: 2px; padding: 3px;
323
+ background: var(--us-subtle);
324
+ border: 1px solid var(--us-border);
325
+ border-radius: 10px;
326
+ margin-bottom: 14px;
327
+ }
328
+ .us-source-tab {
329
+ appearance: none; background: transparent; border: 0;
330
+ font: inherit; cursor: pointer;
331
+ padding: 6px 14px; min-height: 30px;
332
+ border-radius: 7px;
333
+ font-size: 12.5px; font-weight: 500; letter-spacing: -0.005em;
334
+ color: var(--us-muted);
335
+ transition: background 140ms, color 140ms;
336
+ display: inline-flex; align-items: center; gap: 6px;
337
+ }
338
+ .us-source-tab svg { width: 13px; height: 13px; }
339
+ .us-source-tab:hover { color: var(--us-fg); }
340
+ .us-source-tab[data-active="true"] {
341
+ background: var(--us-bg);
342
+ color: var(--us-fg);
343
+ box-shadow: 0 1px 2px rgba(0,0,0,0.06);
344
+ }
345
+ .us-source-tab:focus-visible { outline: 2px solid var(--us-primary); outline-offset: 2px; }
346
+
347
+ /* \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 */
348
+ .us-url-source { display: none; }
349
+ .us-url-source[data-active="true"] { display: block; }
350
+ .us-url-form {
351
+ display: flex; gap: 8px;
352
+ padding: 16px;
353
+ border: 1.5px dashed var(--us-border-strong);
354
+ border-radius: calc(var(--us-radius) - 2px);
355
+ background: color-mix(in srgb, var(--us-subtle) 60%, transparent);
356
+ }
357
+ .us-url-input {
358
+ appearance: none;
359
+ flex: 1; min-width: 0;
360
+ padding: 9px 12px; min-height: 36px;
361
+ border-radius: 8px; border: 1px solid var(--us-border);
362
+ background: var(--us-bg); color: var(--us-fg);
363
+ font: inherit; font-size: 13px;
364
+ transition: border-color 140ms, box-shadow 140ms;
365
+ }
366
+ .us-url-input::placeholder { color: var(--us-muted); }
367
+ .us-url-input:focus {
368
+ outline: none;
369
+ border-color: var(--us-primary);
370
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--us-primary) 18%, transparent);
371
+ }
372
+ .us-url-hint {
373
+ margin-top: 8px;
374
+ font-size: 11.5px; color: var(--us-muted);
375
+ }
376
+ .us-url-hint[data-error="true"] { color: var(--us-danger); }
377
+
378
+ /* \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 */
379
+ .us-file-actions { display: inline-flex; gap: 4px; }
380
+ .us-file-action {
381
+ appearance: none; background: transparent; border: 0; cursor: pointer;
382
+ width: 28px; height: 28px;
383
+ display: inline-flex; align-items: center; justify-content: center;
384
+ color: var(--us-muted); border-radius: 6px;
385
+ transition: background 140ms, color 140ms;
386
+ }
387
+ .us-file-action:hover { background: var(--us-subtle); color: var(--us-fg); }
388
+ .us-file-action:focus-visible { outline: 2px solid var(--us-primary); outline-offset: 1px; }
389
+ .us-file-action svg { width: 15px; height: 15px; }
390
+ .us-file-action[data-edited="true"] { color: var(--us-primary); }
391
+ .us-file:not([data-state="queued"]) .us-file-action { display: none; }
392
+
393
+ /* \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 */
394
+ .us-editor {
395
+ position: absolute; inset: 0; z-index: 2;
396
+ display: flex; flex-direction: column;
397
+ background: var(--us-bg);
398
+ animation: us-fade 140ms ease-out;
399
+ }
400
+ .us-editor-header {
401
+ display: flex; align-items: center; gap: 12px;
402
+ padding: 14px 16px;
403
+ border-bottom: 1px solid var(--us-border);
404
+ }
405
+ .us-editor-title { font-weight: 600; font-size: 14px; flex: 1; letter-spacing: -0.01em; }
406
+ .us-editor-back {
407
+ appearance: none; background: transparent; border: 0; cursor: pointer;
408
+ width: 32px; height: 32px; border-radius: 8px;
409
+ display: inline-flex; align-items: center; justify-content: center;
410
+ color: var(--us-muted);
411
+ transition: background 140ms, color 140ms;
412
+ }
413
+ .us-editor-back:hover { background: var(--us-subtle); color: var(--us-fg); }
414
+ .us-editor-back svg { width: 16px; height: 16px; }
415
+
416
+ .us-editor-canvas-wrap {
417
+ flex: 1; min-height: 0;
418
+ background: var(--us-subtle);
419
+ display: flex; align-items: center; justify-content: center;
420
+ padding: 16px;
421
+ position: relative;
422
+ overflow: hidden;
423
+ }
424
+ .us-editor-canvas {
425
+ max-width: 100%; max-height: 100%;
426
+ border-radius: 6px;
427
+ display: block;
428
+ /* Checkerboard for transparency awareness (visible only in circle mode). */
429
+ background-image:
430
+ linear-gradient(45deg, var(--us-border) 25%, transparent 25%, transparent 75%, var(--us-border) 75%),
431
+ linear-gradient(45deg, var(--us-border) 25%, transparent 25%, transparent 75%, var(--us-border) 75%);
432
+ background-size: 12px 12px;
433
+ background-position: 0 0, 6px 6px;
434
+ }
435
+ .us-editor-overlay {
436
+ position: absolute; inset: 0;
437
+ pointer-events: none;
438
+ }
439
+
440
+ .us-editor-toolbar {
441
+ display: flex; gap: 6px; flex-wrap: wrap;
442
+ padding: 10px 16px;
443
+ border-top: 1px solid var(--us-border);
444
+ }
445
+ .us-tool {
446
+ appearance: none; background: transparent; border: 1px solid var(--us-border);
447
+ font: inherit; cursor: pointer;
448
+ padding: 6px 11px; min-height: 32px;
449
+ border-radius: 8px;
450
+ font-size: 12.5px; font-weight: 500;
451
+ color: var(--us-fg);
452
+ display: inline-flex; align-items: center; gap: 6px;
453
+ transition: background 140ms, border-color 140ms, color 140ms;
454
+ }
455
+ .us-tool:hover { background: var(--us-subtle); border-color: var(--us-border-strong); }
456
+ .us-tool[data-active="true"] {
457
+ background: color-mix(in srgb, var(--us-primary) 10%, var(--us-bg));
458
+ border-color: var(--us-primary);
459
+ color: var(--us-primary);
460
+ }
461
+ .us-tool[disabled] { opacity: 0.45; cursor: not-allowed; }
462
+ .us-tool svg { width: 13px; height: 13px; }
463
+ .us-tool-spacer { flex: 1; }
464
+
465
+ .us-editor-footer {
466
+ display: flex; gap: 8px; justify-content: flex-end;
467
+ padding: 12px 16px;
468
+ border-top: 1px solid var(--us-border);
469
+ }
470
+
471
+ /* Crop drag handles. Drawn inside .us-editor-canvas-wrap, positioned over canvas. */
472
+ .us-crop-box {
473
+ position: absolute;
474
+ border: 1.5px solid var(--us-primary);
475
+ box-shadow: 0 0 0 9999px rgba(0,0,0,0.45);
476
+ pointer-events: auto;
477
+ cursor: move;
478
+ }
479
+ .us-crop-handle {
480
+ position: absolute;
481
+ width: 12px; height: 12px;
482
+ background: var(--us-bg);
483
+ border: 1.5px solid var(--us-primary);
484
+ border-radius: 2px;
485
+ pointer-events: auto;
486
+ }
487
+ .us-crop-handle[data-pos="nw"] { top: -6px; left: -6px; cursor: nwse-resize; }
488
+ .us-crop-handle[data-pos="ne"] { top: -6px; right: -6px; cursor: nesw-resize; }
489
+ .us-crop-handle[data-pos="sw"] { bottom: -6px; left: -6px; cursor: nesw-resize; }
490
+ .us-crop-handle[data-pos="se"] { bottom: -6px; right: -6px; cursor: nwse-resize; }
491
+
319
492
  /* \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
493
  @media (prefers-reduced-motion: reduce) {
321
494
  .us-picker-backdrop,
@@ -325,7 +498,10 @@ var BASE_CSS = `
325
498
  .us-dropzone-icon,
326
499
  .us-btn,
327
500
  .us-file-status,
328
- .us-file-progress-bar { animation: none !important; transition: none !important; }
501
+ .us-file-progress-bar,
502
+ .us-editor,
503
+ .us-source-tab,
504
+ .us-tool { animation: none !important; transition: none !important; }
329
505
  .us-file[data-state="uploading"] .us-file-progress-bar::after { animation: none; opacity: 0; }
330
506
  }
331
507
 
@@ -333,6 +509,409 @@ var BASE_CSS = `
333
509
  .us-file-list:empty { display: none; }
334
510
  `;
335
511
 
512
+ // src/picker/imageEditor.ts
513
+ var MAX_DISPLAY = 720;
514
+ var ICON = {
515
+ 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>`,
516
+ 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>`,
517
+ 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>`,
518
+ 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>`,
519
+ 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>`,
520
+ 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>`
521
+ };
522
+ var ImageEditor = class {
523
+ constructor(opts) {
524
+ this.opts = opts;
525
+ this.cropBox = null;
526
+ // What's currently rendered to the user, sized to fit MAX_DISPLAY.
527
+ this.displayScale = 1;
528
+ // Tracks whether the working canvas has transparency (post-circle), so
529
+ // export() can pick PNG vs JPEG correctly.
530
+ this.hasAlpha = false;
531
+ this.mode = "none";
532
+ this.cropRect = null;
533
+ // display-canvas coords
534
+ // Drag state for crop interaction.
535
+ this.dragKind = null;
536
+ this.dragStart = null;
537
+ this.onPointerMove = (e) => {
538
+ if (!this.dragKind || !this.dragStart || !this.cropRect) return;
539
+ const dx = e.clientX - this.dragStart.px;
540
+ const dy = e.clientY - this.dragStart.py;
541
+ const start = this.dragStart.rect;
542
+ const maxW = this.displayCanvas.width;
543
+ const maxH = this.displayCanvas.height;
544
+ const min = 24;
545
+ let { x, y, w, h } = start;
546
+ switch (this.dragKind) {
547
+ case "move":
548
+ x = clamp(start.x + dx, 0, maxW - start.w);
549
+ y = clamp(start.y + dy, 0, maxH - start.h);
550
+ break;
551
+ case "nw": {
552
+ const nx = clamp(start.x + dx, 0, start.x + start.w - min);
553
+ const ny = clamp(start.y + dy, 0, start.y + start.h - min);
554
+ w = start.w + (start.x - nx);
555
+ h = start.h + (start.y - ny);
556
+ x = nx;
557
+ y = ny;
558
+ break;
559
+ }
560
+ case "ne": {
561
+ const nw = clamp(start.w + dx, min, maxW - start.x);
562
+ const ny = clamp(start.y + dy, 0, start.y + start.h - min);
563
+ h = start.h + (start.y - ny);
564
+ w = nw;
565
+ y = ny;
566
+ break;
567
+ }
568
+ case "sw": {
569
+ const nx = clamp(start.x + dx, 0, start.x + start.w - min);
570
+ const nh = clamp(start.h + dy, min, maxH - start.y);
571
+ w = start.w + (start.x - nx);
572
+ h = nh;
573
+ x = nx;
574
+ break;
575
+ }
576
+ case "se":
577
+ w = clamp(start.w + dx, min, maxW - start.x);
578
+ h = clamp(start.h + dy, min, maxH - start.y);
579
+ break;
580
+ }
581
+ this.cropRect = { x, y, w, h };
582
+ this.updateCropBox();
583
+ };
584
+ this.onPointerUp = () => {
585
+ this.dragKind = null;
586
+ this.dragStart = null;
587
+ document.removeEventListener("pointermove", this.onPointerMove);
588
+ document.removeEventListener("pointerup", this.onPointerUp);
589
+ };
590
+ }
591
+ async open() {
592
+ this.mount();
593
+ try {
594
+ await this.loadOriginalIntoWorking();
595
+ if (this.opts.originalFile && this.opts.originalFile !== this.opts.file) {
596
+ this.markEdited(true);
597
+ }
598
+ this.draw();
599
+ } catch (err) {
600
+ this.canvasWrap.textContent = `Couldn't load image: ${err.message}`;
601
+ }
602
+ }
603
+ close() {
604
+ document.removeEventListener("pointermove", this.onPointerMove);
605
+ document.removeEventListener("pointerup", this.onPointerUp);
606
+ this.root.remove();
607
+ }
608
+ // ---- mount / dom --------------------------------------------------------
609
+ mount() {
610
+ this.root = el("div", "us-editor");
611
+ const header = el("div", "us-editor-header");
612
+ const back = document.createElement("button");
613
+ back.type = "button";
614
+ back.className = "us-editor-back";
615
+ back.setAttribute("aria-label", "Back");
616
+ back.innerHTML = ICON.back;
617
+ back.onclick = () => {
618
+ this.opts.onCancel();
619
+ this.close();
620
+ };
621
+ header.appendChild(back);
622
+ header.appendChild(el("div", "us-editor-title", this.opts.title));
623
+ this.root.appendChild(header);
624
+ this.canvasWrap = el("div", "us-editor-canvas-wrap");
625
+ this.displayCanvas = document.createElement("canvas");
626
+ this.displayCanvas.className = "us-editor-canvas";
627
+ this.canvasWrap.appendChild(this.displayCanvas);
628
+ this.root.appendChild(this.canvasWrap);
629
+ const toolbar = el("div", "us-editor-toolbar");
630
+ this.cropTool = makeTool("Crop", ICON.crop);
631
+ this.cropTool.onclick = () => this.toggleCropMode();
632
+ this.circleTool = makeTool("Circle", ICON.circle);
633
+ this.circleTool.onclick = () => this.applyCircle();
634
+ this.rotateTool = makeTool("Rotate 90\xB0", ICON.rotate);
635
+ this.rotateTool.onclick = () => this.applyRotate();
636
+ this.revertTool = makeTool("Revert", ICON.undo);
637
+ this.revertTool.onclick = () => this.revert();
638
+ this.revertTool.disabled = true;
639
+ toolbar.appendChild(this.cropTool);
640
+ toolbar.appendChild(this.circleTool);
641
+ toolbar.appendChild(this.rotateTool);
642
+ toolbar.appendChild(el("div", "us-tool-spacer"));
643
+ toolbar.appendChild(this.revertTool);
644
+ this.root.appendChild(toolbar);
645
+ const footer = el("div", "us-editor-footer");
646
+ const cancel = document.createElement("button");
647
+ cancel.type = "button";
648
+ cancel.className = "us-btn";
649
+ cancel.textContent = "Cancel";
650
+ cancel.onclick = () => {
651
+ this.opts.onCancel();
652
+ this.close();
653
+ };
654
+ this.applyBtn = document.createElement("button");
655
+ this.applyBtn.type = "button";
656
+ this.applyBtn.className = "us-btn us-btn-primary";
657
+ this.applyBtn.innerHTML = `${ICON.check} <span>Apply</span>`;
658
+ this.applyBtn.onclick = () => this.applyAndClose();
659
+ footer.appendChild(cancel);
660
+ footer.appendChild(this.applyBtn);
661
+ this.root.appendChild(footer);
662
+ this.opts.host.appendChild(this.root);
663
+ }
664
+ // ---- image loading ------------------------------------------------------
665
+ async loadOriginalIntoWorking() {
666
+ const img = await loadImage(this.opts.file);
667
+ const c = document.createElement("canvas");
668
+ c.width = img.naturalWidth;
669
+ c.height = img.naturalHeight;
670
+ const ctx = c.getContext("2d");
671
+ if (!ctx) throw new Error("Canvas 2D context unavailable");
672
+ ctx.drawImage(img, 0, 0);
673
+ this.working = c;
674
+ this.hasAlpha = looksLikePngMime(this.opts.file.type);
675
+ this.markEdited(false);
676
+ }
677
+ // For Revert. Loads the true original (passed-in `originalFile`, or `file`
678
+ // if the caller didn't distinguish).
679
+ async loadTrueOriginalIntoWorking() {
680
+ const source = this.opts.originalFile ?? this.opts.file;
681
+ const img = await loadImage(source);
682
+ const c = document.createElement("canvas");
683
+ c.width = img.naturalWidth;
684
+ c.height = img.naturalHeight;
685
+ const ctx = c.getContext("2d");
686
+ if (!ctx) throw new Error("Canvas 2D context unavailable");
687
+ ctx.drawImage(img, 0, 0);
688
+ this.working = c;
689
+ this.hasAlpha = looksLikePngMime(source.type);
690
+ this.markEdited(false);
691
+ }
692
+ // ---- rendering ----------------------------------------------------------
693
+ draw() {
694
+ const { width: ww, height: wh } = this.working;
695
+ const scale = Math.min(1, MAX_DISPLAY / Math.max(ww, wh));
696
+ const dw = Math.max(1, Math.round(ww * scale));
697
+ const dh = Math.max(1, Math.round(wh * scale));
698
+ this.displayCanvas.width = dw;
699
+ this.displayCanvas.height = dh;
700
+ this.displayScale = scale;
701
+ const ctx = this.displayCanvas.getContext("2d");
702
+ if (!ctx) return;
703
+ ctx.clearRect(0, 0, dw, dh);
704
+ ctx.drawImage(this.working, 0, 0, dw, dh);
705
+ this.updateCropBox();
706
+ }
707
+ // ---- mode: crop ---------------------------------------------------------
708
+ toggleCropMode() {
709
+ if (this.mode === "crop") {
710
+ this.applyCrop();
711
+ } else {
712
+ this.enterCropMode();
713
+ }
714
+ }
715
+ enterCropMode() {
716
+ this.mode = "crop";
717
+ this.cropTool.dataset.active = "true";
718
+ this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
719
+ const dw = this.displayCanvas.width;
720
+ const dh = this.displayCanvas.height;
721
+ const inset = Math.round(Math.min(dw, dh) * 0.1);
722
+ this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
723
+ this.renderCropBox();
724
+ }
725
+ exitCropMode() {
726
+ this.mode = "none";
727
+ this.cropTool.removeAttribute("data-active");
728
+ this.cropTool.innerHTML = `${ICON.crop} <span>Crop</span>`;
729
+ this.cropRect = null;
730
+ if (this.cropBox) {
731
+ this.cropBox.remove();
732
+ this.cropBox = null;
733
+ }
734
+ }
735
+ renderCropBox() {
736
+ if (!this.cropRect) return;
737
+ if (this.cropBox) this.cropBox.remove();
738
+ const box = el("div", "us-crop-box");
739
+ for (const pos of ["nw", "ne", "sw", "se"]) {
740
+ const h = el("div", "us-crop-handle");
741
+ h.dataset.pos = pos;
742
+ h.addEventListener("pointerdown", (e) => this.beginDrag(e, pos));
743
+ box.appendChild(h);
744
+ }
745
+ box.addEventListener("pointerdown", (e) => {
746
+ const target = e.target;
747
+ if (target.classList.contains("us-crop-handle")) return;
748
+ this.beginDrag(e, "move");
749
+ });
750
+ this.canvasWrap.appendChild(box);
751
+ this.cropBox = box;
752
+ this.updateCropBox();
753
+ }
754
+ updateCropBox() {
755
+ if (!this.cropBox || !this.cropRect) return;
756
+ const canvasRect = this.displayCanvas.getBoundingClientRect();
757
+ const wrapRect = this.canvasWrap.getBoundingClientRect();
758
+ const left = canvasRect.left - wrapRect.left + this.cropRect.x;
759
+ const top = canvasRect.top - wrapRect.top + this.cropRect.y;
760
+ this.cropBox.style.left = `${left}px`;
761
+ this.cropBox.style.top = `${top}px`;
762
+ this.cropBox.style.width = `${this.cropRect.w}px`;
763
+ this.cropBox.style.height = `${this.cropRect.h}px`;
764
+ }
765
+ // ---- drag handlers (crop) ----------------------------------------------
766
+ beginDrag(e, kind) {
767
+ if (!this.cropRect) return;
768
+ e.preventDefault();
769
+ this.dragKind = kind;
770
+ this.dragStart = { px: e.clientX, py: e.clientY, rect: { ...this.cropRect } };
771
+ document.addEventListener("pointermove", this.onPointerMove);
772
+ document.addEventListener("pointerup", this.onPointerUp);
773
+ }
774
+ // ---- operations (mutate working canvas) --------------------------------
775
+ applyCrop() {
776
+ if (!this.cropRect) {
777
+ this.exitCropMode();
778
+ return;
779
+ }
780
+ const s = this.displayScale;
781
+ const sx = Math.round(this.cropRect.x / s);
782
+ const sy = Math.round(this.cropRect.y / s);
783
+ const sw = Math.round(this.cropRect.w / s);
784
+ const sh = Math.round(this.cropRect.h / s);
785
+ const next = document.createElement("canvas");
786
+ next.width = sw;
787
+ next.height = sh;
788
+ const ctx = next.getContext("2d");
789
+ if (!ctx) return;
790
+ ctx.drawImage(this.working, sx, sy, sw, sh, 0, 0, sw, sh);
791
+ this.working = next;
792
+ this.exitCropMode();
793
+ this.markEdited(true);
794
+ this.draw();
795
+ }
796
+ applyRotate() {
797
+ const { width: w, height: h } = this.working;
798
+ const next = document.createElement("canvas");
799
+ next.width = h;
800
+ next.height = w;
801
+ const ctx = next.getContext("2d");
802
+ if (!ctx) return;
803
+ ctx.translate(h, 0);
804
+ ctx.rotate(Math.PI / 2);
805
+ ctx.drawImage(this.working, 0, 0);
806
+ this.working = next;
807
+ this.exitCropMode();
808
+ this.markEdited(true);
809
+ this.draw();
810
+ }
811
+ applyCircle() {
812
+ const { width: w, height: h } = this.working;
813
+ const size = Math.min(w, h);
814
+ const sx = Math.round((w - size) / 2);
815
+ const sy = Math.round((h - size) / 2);
816
+ const next = document.createElement("canvas");
817
+ next.width = size;
818
+ next.height = size;
819
+ const ctx = next.getContext("2d");
820
+ if (!ctx) return;
821
+ ctx.save();
822
+ ctx.beginPath();
823
+ ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
824
+ ctx.closePath();
825
+ ctx.clip();
826
+ ctx.drawImage(this.working, sx, sy, size, size, 0, 0, size, size);
827
+ ctx.restore();
828
+ this.working = next;
829
+ this.hasAlpha = true;
830
+ this.exitCropMode();
831
+ this.markEdited(true);
832
+ this.draw();
833
+ }
834
+ async revert() {
835
+ await this.loadTrueOriginalIntoWorking();
836
+ this.exitCropMode();
837
+ this.draw();
838
+ }
839
+ markEdited(edited) {
840
+ this.revertTool.disabled = !edited;
841
+ }
842
+ // ---- apply --------------------------------------------------------------
843
+ async applyAndClose() {
844
+ this.applyBtn.disabled = true;
845
+ try {
846
+ const file = await this.exportFile();
847
+ this.opts.onApply(file);
848
+ this.close();
849
+ } catch (err) {
850
+ this.applyBtn.disabled = false;
851
+ console.error("[union-stack] image export failed", err);
852
+ }
853
+ }
854
+ exportFile() {
855
+ return new Promise((resolve, reject) => {
856
+ const mime = this.hasAlpha ? "image/png" : "image/jpeg";
857
+ const quality = mime === "image/jpeg" ? 0.92 : void 0;
858
+ this.working.toBlob(
859
+ (blob) => {
860
+ if (!blob) return reject(new Error("toBlob returned null"));
861
+ const baseName = this.opts.file.name.replace(/\.[^.]+$/, "");
862
+ const ext = mime === "image/png" ? "png" : "jpg";
863
+ resolve(new File([blob], `${baseName}-edited.${ext}`, { type: mime }));
864
+ },
865
+ mime,
866
+ quality
867
+ );
868
+ });
869
+ }
870
+ };
871
+ function el(tag, className, text) {
872
+ const node = document.createElement(tag);
873
+ if (className) node.className = className;
874
+ if (text !== void 0) node.textContent = text;
875
+ return node;
876
+ }
877
+ function makeTool(label, iconHtml) {
878
+ const btn = document.createElement("button");
879
+ btn.type = "button";
880
+ btn.className = "us-tool";
881
+ btn.innerHTML = `${iconHtml} <span>${escapeText(label)}</span>`;
882
+ return btn;
883
+ }
884
+ function escapeText(s) {
885
+ return s.replace(/[&<>"']/g, (c) => ({
886
+ "&": "&amp;",
887
+ "<": "&lt;",
888
+ ">": "&gt;",
889
+ '"': "&quot;",
890
+ "'": "&#39;"
891
+ })[c]);
892
+ }
893
+ function clamp(v, lo, hi) {
894
+ return Math.max(lo, Math.min(hi, v));
895
+ }
896
+ function loadImage(file) {
897
+ return new Promise((resolve, reject) => {
898
+ const url = URL.createObjectURL(file);
899
+ const img = new Image();
900
+ img.onload = () => {
901
+ URL.revokeObjectURL(url);
902
+ resolve(img);
903
+ };
904
+ img.onerror = () => {
905
+ URL.revokeObjectURL(url);
906
+ reject(new Error("decode failed"));
907
+ };
908
+ img.src = url;
909
+ });
910
+ }
911
+ function looksLikePngMime(mime) {
912
+ return /^image\/(png|webp|gif)$/.test(mime);
913
+ }
914
+
336
915
  // src/picker/picker.ts
337
916
  function mergeConfig(server, runtime) {
338
917
  const merged = { ...runtime };
@@ -362,7 +941,7 @@ function mergeConfig(server, runtime) {
362
941
  }
363
942
  var DEFAULT_TITLE = "Upload files";
364
943
  var FOOTER_LINK = "https://unionstack.link";
365
- var ICON = {
944
+ var ICON2 = {
366
945
  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>`,
367
946
  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
947
  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>`,
@@ -374,28 +953,39 @@ var ICON = {
374
953
  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
954
  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
955
  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>`
956
+ 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>`,
957
+ 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>`,
958
+ 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>`,
959
+ 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
960
  };
379
961
  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;
962
+ if (!mime) return ICON2.file;
963
+ if (mime.startsWith("image/")) return ICON2.image;
964
+ if (mime.startsWith("video/")) return ICON2.video;
965
+ if (mime.startsWith("audio/")) return ICON2.audio;
966
+ if (mime === "application/pdf") return ICON2.pdf;
967
+ if (mime.startsWith("application/zip") || mime.includes("compressed") || mime === "application/x-tar" || mime === "application/gzip") return ICON2.archive;
968
+ return ICON2.file;
387
969
  }
388
970
  var Picker = class {
389
971
  constructor(client, opts) {
390
972
  this.client = client;
391
973
  this.opts = opts;
392
974
  this.$backdrop = null;
975
+ this.$panel = null;
393
976
  this.$list = null;
394
977
  this.$confirm = null;
395
978
  this.$cancel = null;
396
979
  this.$closeBtn = null;
397
980
  this.$input = null;
981
+ // Source-tab DOM (Device vs URL). Only created when both sources are enabled.
982
+ this.$deviceSource = null;
983
+ this.$urlSource = null;
984
+ this.$urlInput = null;
985
+ this.$urlHint = null;
986
+ this.$urlAddBtn = null;
398
987
  this.items = [];
988
+ this.editor = null;
399
989
  this.abortCtrl = new AbortController();
400
990
  this.uploadStarted = false;
401
991
  this.resolved = false;
@@ -438,38 +1028,50 @@ var Picker = class {
438
1028
  root.addEventListener("click", (e) => {
439
1029
  if (e.target === root && !this.uploadStarted) this.cancel();
440
1030
  });
441
- const panel = el("div", "us-picker");
442
- const header = el("div", "us-picker-header");
443
- const logoWrap = el("div", "us-picker-header-logo");
1031
+ const panel = el2("div", "us-picker");
1032
+ const header = el2("div", "us-picker-header");
1033
+ const logoWrap = el2("div", "us-picker-header-logo");
444
1034
  if (this.opts.branding?.logoUrl) {
445
1035
  const logo = document.createElement("img");
446
1036
  logo.src = this.opts.branding.logoUrl;
447
1037
  logo.alt = "";
448
1038
  logoWrap.appendChild(logo);
449
1039
  } else {
450
- logoWrap.innerHTML = ICON.zap;
1040
+ logoWrap.innerHTML = ICON2.zap;
451
1041
  }
452
1042
  header.appendChild(logoWrap);
453
- const title = el("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
1043
+ const title = el2("div", "us-picker-title", this.opts.branding?.title ?? DEFAULT_TITLE);
454
1044
  header.appendChild(title);
455
1045
  this.$closeBtn = document.createElement("button");
456
1046
  this.$closeBtn.type = "button";
457
1047
  this.$closeBtn.className = "us-picker-close";
458
1048
  this.$closeBtn.setAttribute("aria-label", "Close");
459
- this.$closeBtn.innerHTML = ICON.close;
1049
+ this.$closeBtn.innerHTML = ICON2.close;
460
1050
  this.$closeBtn.onclick = () => this.cancel();
461
1051
  header.appendChild(this.$closeBtn);
462
1052
  panel.appendChild(header);
463
- const body = el("div", "us-picker-body");
464
- body.appendChild(this.renderDropzone());
465
- this.$list = el("div", "us-file-list");
1053
+ const body = el2("div", "us-picker-body");
1054
+ const sources = this.resolvedSources();
1055
+ if (sources.length > 1) {
1056
+ body.appendChild(this.renderSourceTabs(sources));
1057
+ }
1058
+ if (sources.includes("device")) {
1059
+ this.$deviceSource = this.renderDropzone();
1060
+ body.appendChild(this.$deviceSource);
1061
+ }
1062
+ if (sources.includes("url")) {
1063
+ this.$urlSource = this.renderUrlSource();
1064
+ body.appendChild(this.$urlSource);
1065
+ }
1066
+ this.activateSource(sources[0] ?? "device");
1067
+ this.$list = el2("div", "us-file-list");
466
1068
  body.appendChild(this.$list);
467
1069
  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", "");
1070
+ const autoUpload = this.opts.autoUpload === true;
1071
+ const actions = el2("div", "us-actions");
1072
+ this.$summary = el2("div", "us-actions-summary", "");
471
1073
  actions.appendChild(this.$summary);
472
- const buttons = el("div", "us-actions-buttons");
1074
+ const buttons = el2("div", "us-actions-buttons");
473
1075
  this.$cancel = document.createElement("button");
474
1076
  this.$cancel.type = "button";
475
1077
  this.$cancel.className = "us-btn";
@@ -480,7 +1082,7 @@ var Picker = class {
480
1082
  this.$confirm = document.createElement("button");
481
1083
  this.$confirm.type = "button";
482
1084
  this.$confirm.className = "us-btn us-btn-primary";
483
- this.$confirm.innerHTML = `${ICON.upload} <span>Upload</span>`;
1085
+ this.$confirm.innerHTML = `${ICON2.upload} <span>Upload</span>`;
484
1086
  this.$confirm.disabled = true;
485
1087
  this.$confirm.onclick = () => this.startUpload();
486
1088
  buttons.appendChild(this.$confirm);
@@ -488,24 +1090,123 @@ var Picker = class {
488
1090
  actions.appendChild(buttons);
489
1091
  panel.appendChild(actions);
490
1092
  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>`;
1093
+ const footer = el2("div", "us-footer");
1094
+ footer.innerHTML = `${ICON2.zap} <span>Powered by <a href="${FOOTER_LINK}" target="_blank" rel="noopener">UnionStack</a></span>`;
493
1095
  panel.appendChild(footer);
494
1096
  }
495
1097
  root.appendChild(panel);
496
1098
  (this.opts.container ?? document.body).appendChild(root);
497
1099
  this.$backdrop = root;
1100
+ this.$panel = panel;
1101
+ }
1102
+ // Resolves which sources to show, honoring opts.fromSources, defaulting to
1103
+ // both. Empty arrays fall back to ['device'] — we never want to leave a
1104
+ // picker with no way to add files.
1105
+ resolvedSources() {
1106
+ const raw = this.opts.fromSources;
1107
+ if (!raw || raw.length === 0) return ["device", "url"];
1108
+ return raw;
1109
+ }
1110
+ renderSourceTabs(sources) {
1111
+ const tabs = el2("div", "us-source-tabs");
1112
+ tabs.setAttribute("role", "tablist");
1113
+ for (const src of sources) {
1114
+ const btn = document.createElement("button");
1115
+ btn.type = "button";
1116
+ btn.className = "us-source-tab";
1117
+ btn.setAttribute("role", "tab");
1118
+ btn.dataset.source = src;
1119
+ btn.innerHTML = src === "device" ? `${ICON2.device} <span>My Device</span>` : `${ICON2.link} <span>Link</span>`;
1120
+ btn.onclick = () => this.activateSource(src);
1121
+ tabs.appendChild(btn);
1122
+ }
1123
+ return tabs;
1124
+ }
1125
+ activateSource(source) {
1126
+ if (this.$deviceSource) {
1127
+ const active = source === "device";
1128
+ this.$deviceSource.style.display = active ? "" : "none";
1129
+ }
1130
+ if (this.$urlSource) {
1131
+ this.$urlSource.dataset.active = source === "url" ? "true" : "false";
1132
+ }
1133
+ const tabs = this.$panel?.querySelectorAll(".us-source-tab");
1134
+ tabs?.forEach((t) => {
1135
+ t.dataset.active = t.dataset.source === source ? "true" : "false";
1136
+ });
1137
+ }
1138
+ renderUrlSource() {
1139
+ const wrap = el2("div", "us-url-source");
1140
+ const form = el2("div", "us-url-form");
1141
+ const input = document.createElement("input");
1142
+ input.type = "url";
1143
+ input.className = "us-url-input";
1144
+ input.placeholder = "https://example.com/photo.jpg";
1145
+ input.setAttribute("aria-label", "File URL");
1146
+ input.onkeydown = (e) => {
1147
+ if (e.key === "Enter") {
1148
+ e.preventDefault();
1149
+ this.handleUrlAdd();
1150
+ }
1151
+ };
1152
+ this.$urlInput = input;
1153
+ const add = document.createElement("button");
1154
+ add.type = "button";
1155
+ add.className = "us-btn us-btn-primary";
1156
+ add.textContent = "Add file";
1157
+ add.onclick = () => this.handleUrlAdd();
1158
+ this.$urlAddBtn = add;
1159
+ form.appendChild(input);
1160
+ form.appendChild(add);
1161
+ wrap.appendChild(form);
1162
+ 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.");
1163
+ this.$urlHint = hint;
1164
+ wrap.appendChild(hint);
1165
+ return wrap;
1166
+ }
1167
+ async handleUrlAdd() {
1168
+ if (!this.$urlInput || !this.$urlAddBtn || !this.$urlHint) return;
1169
+ const url = this.$urlInput.value.trim();
1170
+ if (!url) {
1171
+ this.setUrlHint("Enter a URL first.", true);
1172
+ return;
1173
+ }
1174
+ if (!/^https?:\/\//i.test(url)) {
1175
+ this.setUrlHint("URL must start with http:// or https://", true);
1176
+ return;
1177
+ }
1178
+ this.$urlAddBtn.disabled = true;
1179
+ const prevLabel = this.$urlAddBtn.textContent;
1180
+ this.$urlAddBtn.textContent = "Fetching\u2026";
1181
+ this.setUrlHint("Downloading\u2026", false);
1182
+ try {
1183
+ const file = await fetchUrlAsFile(url);
1184
+ this.addFiles([file]);
1185
+ this.$urlInput.value = "";
1186
+ this.setUrlHint("Paste a direct file URL. The host must allow CORS so we can fetch it from the browser.", false);
1187
+ } catch (err) {
1188
+ const msg = err.message || "Failed to fetch URL";
1189
+ this.setUrlHint(msg, true);
1190
+ } finally {
1191
+ this.$urlAddBtn.disabled = false;
1192
+ this.$urlAddBtn.textContent = prevLabel || "Add file";
1193
+ }
1194
+ }
1195
+ setUrlHint(text, isError) {
1196
+ if (!this.$urlHint) return;
1197
+ this.$urlHint.textContent = text;
1198
+ this.$urlHint.dataset.error = isError ? "true" : "false";
498
1199
  }
499
1200
  renderDropzone() {
500
- const dz = el("div", "us-dropzone");
1201
+ const dz = el2("div", "us-dropzone");
501
1202
  dz.setAttribute("role", "button");
502
1203
  dz.setAttribute("tabindex", "0");
503
1204
  dz.setAttribute("aria-label", "Drop files here or click to browse");
504
- const icon = el("div", "us-dropzone-icon");
505
- icon.innerHTML = ICON.upload;
1205
+ const icon = el2("div", "us-dropzone-icon");
1206
+ icon.innerHTML = ICON2.upload;
506
1207
  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"));
1208
+ dz.appendChild(el2("div", "us-dropzone-title", "Drop files to upload"));
1209
+ dz.appendChild(el2("div", "us-dropzone-hint", "or click to browse from your device"));
509
1210
  const constraintBits = [];
510
1211
  if (this.opts.maxFileSize) constraintBits.push(`max ${formatBytes(this.opts.maxFileSize)}`);
511
1212
  if (this.opts.accept) {
@@ -515,7 +1216,7 @@ var Picker = class {
515
1216
  }
516
1217
  }
517
1218
  if (constraintBits.length > 0) {
518
- dz.appendChild(el("div", "us-dropzone-constraints", constraintBits.join(" \xB7 ")));
1219
+ dz.appendChild(el2("div", "us-dropzone-constraints", constraintBits.join(" \xB7 ")));
519
1220
  }
520
1221
  const input = document.createElement("input");
521
1222
  input.type = "file";
@@ -549,8 +1250,13 @@ var Picker = class {
549
1250
  return dz;
550
1251
  }
551
1252
  unmount() {
1253
+ if (this.editor) {
1254
+ this.editor.close();
1255
+ this.editor = null;
1256
+ }
552
1257
  if (this.$backdrop?.parentNode) this.$backdrop.parentNode.removeChild(this.$backdrop);
553
1258
  this.$backdrop = null;
1259
+ this.$panel = null;
554
1260
  for (const item of this.items) {
555
1261
  if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
556
1262
  }
@@ -567,6 +1273,8 @@ var Picker = class {
567
1273
  const item2 = {
568
1274
  uploadId: cryptoId(),
569
1275
  file,
1276
+ originalFile: file,
1277
+ edited: false,
570
1278
  state: "failed",
571
1279
  progress: 0,
572
1280
  error: `File exceeds ${formatBytes(this.opts.maxFileSize)} limit`
@@ -578,6 +1286,8 @@ var Picker = class {
578
1286
  const item = {
579
1287
  uploadId: cryptoId(),
580
1288
  file,
1289
+ originalFile: file,
1290
+ edited: false,
581
1291
  state: "queued",
582
1292
  progress: 0
583
1293
  };
@@ -585,7 +1295,7 @@ var Picker = class {
585
1295
  this.renderItem(item);
586
1296
  }
587
1297
  this.refreshConfirm();
588
- const autoUpload = this.opts.autoUpload !== false;
1298
+ const autoUpload = this.opts.autoUpload === true;
589
1299
  const hasQueued = this.items.some((i) => i.state === "queued");
590
1300
  if (autoUpload && hasQueued && !this.uploadStarted) {
591
1301
  requestAnimationFrame(() => {
@@ -595,12 +1305,12 @@ var Picker = class {
595
1305
  }
596
1306
  renderItem(item) {
597
1307
  if (!this.$list) return;
598
- const row = el("div", "us-file");
1308
+ const row = el2("div", "us-file");
599
1309
  row.dataset.state = item.state;
600
1310
  row.dataset.uploadId = item.uploadId;
601
1311
  const idx = Math.min(this.items.length - 1, 8);
602
1312
  row.style.animationDelay = `${idx * 35}ms`;
603
- const thumb = el("div", "us-file-thumb");
1313
+ const thumb = el2("div", "us-file-thumb");
604
1314
  if (item.file.type.startsWith("image/") && typeof URL !== "undefined" && URL.createObjectURL) {
605
1315
  try {
606
1316
  const objectUrl = URL.createObjectURL(item.file);
@@ -614,18 +1324,32 @@ var Picker = class {
614
1324
  thumb.innerHTML = iconForMime(item.file.type);
615
1325
  }
616
1326
  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));
1327
+ const main = el2("div", "us-file-main");
1328
+ const row1 = el2("div", "us-file-row1");
1329
+ row1.appendChild(el2("div", "us-file-name", item.file.name));
1330
+ const meta = el2("div", "us-file-meta", formatBytes(item.file.size));
621
1331
  row1.appendChild(meta);
622
1332
  main.appendChild(row1);
623
- const progress = el("div", "us-file-progress");
624
- const bar = el("div", "us-file-progress-bar");
1333
+ const progress = el2("div", "us-file-progress");
1334
+ const bar = el2("div", "us-file-progress-bar");
625
1335
  progress.appendChild(bar);
626
1336
  main.appendChild(progress);
627
1337
  row.appendChild(main);
628
- const status = el("div", "us-file-status");
1338
+ const isImage = item.file.type.startsWith("image/");
1339
+ const editingEnabled = this.opts.imageEditing !== false;
1340
+ if (isImage && editingEnabled && item.state === "queued") {
1341
+ const edit = document.createElement("button");
1342
+ edit.type = "button";
1343
+ edit.className = "us-file-action";
1344
+ edit.setAttribute("aria-label", "Edit image");
1345
+ edit.title = "Edit image";
1346
+ edit.innerHTML = ICON2.pencil;
1347
+ edit.dataset.edited = item.edited ? "true" : "false";
1348
+ edit.onclick = () => this.openEditor(item);
1349
+ row.appendChild(edit);
1350
+ item.$edit = edit;
1351
+ }
1352
+ const status = el2("div", "us-file-status");
629
1353
  status.setAttribute("aria-label", this.statusLabel(item));
630
1354
  status.innerHTML = this.statusIcon(item.state);
631
1355
  row.appendChild(status);
@@ -633,21 +1357,74 @@ var Picker = class {
633
1357
  item.$bar = bar;
634
1358
  item.$status = status;
635
1359
  item.$meta = meta;
1360
+ item.$thumb = thumb;
636
1361
  this.$list.appendChild(row);
637
1362
  this.updateSummary();
638
1363
  }
1364
+ // Swap a queued item's file with an edited version. Refreshes thumbnail and
1365
+ // meta in place so the row identity is preserved (uploadId, position).
1366
+ replaceItemFile(item, nextFile, edited) {
1367
+ item.file = nextFile;
1368
+ item.edited = edited;
1369
+ if (item.objectUrl) URL.revokeObjectURL(item.objectUrl);
1370
+ item.objectUrl = void 0;
1371
+ if (item.$thumb) {
1372
+ item.$thumb.style.backgroundImage = "";
1373
+ item.$thumb.removeAttribute("data-image");
1374
+ if (nextFile.type.startsWith("image/") && typeof URL !== "undefined" && URL.createObjectURL) {
1375
+ try {
1376
+ const objectUrl = URL.createObjectURL(nextFile);
1377
+ item.objectUrl = objectUrl;
1378
+ item.$thumb.style.backgroundImage = `url("${objectUrl}")`;
1379
+ item.$thumb.dataset.image = "true";
1380
+ item.$thumb.innerHTML = "";
1381
+ } catch {
1382
+ item.$thumb.innerHTML = iconForMime(nextFile.type);
1383
+ }
1384
+ } else {
1385
+ item.$thumb.innerHTML = iconForMime(nextFile.type);
1386
+ }
1387
+ }
1388
+ if (item.$meta) item.$meta.textContent = formatBytes(nextFile.size);
1389
+ if (item.$row) {
1390
+ const nameEl = item.$row.querySelector(".us-file-name");
1391
+ if (nameEl) nameEl.textContent = nextFile.name;
1392
+ }
1393
+ if (item.$edit) item.$edit.dataset.edited = edited ? "true" : "false";
1394
+ }
1395
+ // Mount the image editor overlay on top of the picker panel for the given
1396
+ // queued item. The editor calls back with either an edited File (Apply) or
1397
+ // nothing (Cancel/Back).
1398
+ openEditor(item) {
1399
+ if (!this.$panel || this.editor || item.state !== "queued") return;
1400
+ this.editor = new ImageEditor({
1401
+ host: this.$panel,
1402
+ file: item.file,
1403
+ originalFile: item.originalFile,
1404
+ title: item.originalFile.name,
1405
+ onApply: (edited) => {
1406
+ const wasEdited = edited !== item.originalFile;
1407
+ this.replaceItemFile(item, edited, wasEdited);
1408
+ this.editor = null;
1409
+ },
1410
+ onCancel: () => {
1411
+ this.editor = null;
1412
+ }
1413
+ });
1414
+ this.editor.open();
1415
+ }
639
1416
  statusIcon(state) {
640
1417
  switch (state) {
641
1418
  case "uploading":
642
- return ICON.spinner;
1419
+ return ICON2.spinner;
643
1420
  case "done":
644
- return ICON.check;
1421
+ return ICON2.check;
645
1422
  case "failed":
646
- return ICON.alert;
1423
+ return ICON2.alert;
647
1424
  case "cancelled":
648
- return ICON.alert;
1425
+ return ICON2.alert;
649
1426
  default:
650
- return ICON.spinner;
1427
+ return ICON2.spinner;
651
1428
  }
652
1429
  }
653
1430
  statusLabel(item) {
@@ -813,7 +1590,7 @@ var Picker = class {
813
1590
  this.resolvePromise(fallback);
814
1591
  }
815
1592
  };
816
- function el(tag, className, text) {
1593
+ function el2(tag, className, text) {
817
1594
  const node = document.createElement(tag);
818
1595
  if (className) node.className = className;
819
1596
  if (text !== void 0) node.textContent = text;
@@ -831,6 +1608,56 @@ function cryptoId() {
831
1608
  }
832
1609
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
833
1610
  }
1611
+ async function fetchUrlAsFile(url) {
1612
+ let res;
1613
+ try {
1614
+ res = await fetch(url, { mode: "cors", credentials: "omit" });
1615
+ } catch {
1616
+ throw new Error(`Could not reach ${shortHost(url)} \u2014 check the URL or CORS.`);
1617
+ }
1618
+ if (!res.ok) throw new Error(`Server returned ${res.status} \u2014 file unavailable.`);
1619
+ const blob = await res.blob();
1620
+ const filename = filenameFromUrl(url) || "download";
1621
+ const type = blob.type || guessMimeFromExt(filename);
1622
+ return new File([blob], filename, { type });
1623
+ }
1624
+ function shortHost(url) {
1625
+ try {
1626
+ return new URL(url).host;
1627
+ } catch {
1628
+ return "that host";
1629
+ }
1630
+ }
1631
+ function filenameFromUrl(url) {
1632
+ try {
1633
+ const u = new URL(url);
1634
+ const last = u.pathname.split("/").filter(Boolean).pop();
1635
+ return last ? decodeURIComponent(last) : "";
1636
+ } catch {
1637
+ return "";
1638
+ }
1639
+ }
1640
+ function guessMimeFromExt(filename) {
1641
+ const ext = filename.toLowerCase().split(".").pop() || "";
1642
+ const map = {
1643
+ jpg: "image/jpeg",
1644
+ jpeg: "image/jpeg",
1645
+ png: "image/png",
1646
+ gif: "image/gif",
1647
+ webp: "image/webp",
1648
+ svg: "image/svg+xml",
1649
+ pdf: "application/pdf",
1650
+ mp4: "video/mp4",
1651
+ mov: "video/quicktime",
1652
+ mp3: "audio/mpeg",
1653
+ wav: "audio/wav",
1654
+ zip: "application/zip",
1655
+ json: "application/json",
1656
+ txt: "text/plain",
1657
+ csv: "text/csv"
1658
+ };
1659
+ return map[ext] || "application/octet-stream";
1660
+ }
834
1661
  function openPicker(client, opts) {
835
1662
  const picker = new Picker(client, opts);
836
1663
  return {