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