@masters-union/union-stack 0.3.8 → 0.3.10

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.
Files changed (42) hide show
  1. package/README.md +42 -16
  2. package/dist/cdn/loader.v1.global.js +4 -2
  3. package/dist/cdn/loader.v1.global.js.map +1 -1
  4. package/dist/{chunk-TNWAUSLA.js → chunk-F4EVNUJZ.cjs} +10 -140
  5. package/dist/chunk-F4EVNUJZ.cjs.map +1 -0
  6. package/dist/chunk-IIVRPVNX.js +140 -0
  7. package/dist/chunk-IIVRPVNX.js.map +1 -0
  8. package/dist/chunk-IV76YPDA.cjs +142 -0
  9. package/dist/chunk-IV76YPDA.cjs.map +1 -0
  10. package/dist/{chunk-ACQDEKCW.cjs → chunk-MFZ2JGLP.js} +6 -142
  11. package/dist/chunk-MFZ2JGLP.js.map +1 -0
  12. package/dist/client-C0qMOfb3.d.ts +112 -0
  13. package/dist/client-DOTa3Mai.d.cts +112 -0
  14. package/dist/index.cjs +4 -3
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +3 -2
  17. package/dist/index.d.ts +3 -2
  18. package/dist/index.js +3 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/node.cjs +244 -0
  21. package/dist/node.cjs.map +1 -0
  22. package/dist/node.d.cts +56 -0
  23. package/dist/node.d.ts +56 -0
  24. package/dist/node.js +221 -0
  25. package/dist/node.js.map +1 -0
  26. package/dist/picker.cjs +213 -11
  27. package/dist/picker.cjs.map +1 -1
  28. package/dist/picker.d.cts +10 -2
  29. package/dist/picker.d.ts +10 -2
  30. package/dist/picker.js +213 -11
  31. package/dist/picker.js.map +1 -1
  32. package/dist/react.cjs +3 -2
  33. package/dist/react.cjs.map +1 -1
  34. package/dist/react.d.cts +4 -2
  35. package/dist/react.d.ts +4 -2
  36. package/dist/react.js +2 -1
  37. package/dist/react.js.map +1 -1
  38. package/dist/{client-I6TadQ-m.d.cts → types-ZCfjNZls.d.cts} +20 -93
  39. package/dist/{client-I6TadQ-m.d.ts → types-ZCfjNZls.d.ts} +20 -93
  40. package/package.json +7 -1
  41. package/dist/chunk-ACQDEKCW.cjs.map +0 -1
  42. package/dist/chunk-TNWAUSLA.js.map +0 -1
package/dist/picker.cjs CHANGED
@@ -251,6 +251,8 @@ var BASE_CSS = `
251
251
  }
252
252
  .us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
253
253
  .us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
254
+ /* Force-crop: image queued but not yet cropped to the required ratio. */
255
+ .us-file[data-crop-required="true"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
254
256
 
255
257
  .us-file-thumb {
256
258
  width: 40px; height: 40px; flex-shrink: 0;
@@ -615,6 +617,7 @@ var BASE_CSS = `
615
617
 
616
618
  // src/picker/imageEditor.ts
617
619
  var MAX_DISPLAY = 720;
620
+ var MIN_CROP = 24;
618
621
  var ICON = {
619
622
  back: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>`,
620
623
  crop: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>`,
@@ -645,7 +648,31 @@ var ImageEditor = class {
645
648
  const start = this.dragStart.rect;
646
649
  const maxW = this.displayCanvas.width;
647
650
  const maxH = this.displayCanvas.height;
648
- const min = 24;
651
+ const min = MIN_CROP;
652
+ if (this.aspect && this.dragKind !== "move") {
653
+ switch (this.dragKind) {
654
+ case "se":
655
+ this.cropRect = this.lockedRectFromDrag(start.x, start.y, start.w + dx, 1, 1);
656
+ break;
657
+ case "nw": {
658
+ const ax = start.x + start.w, ay = start.y + start.h;
659
+ this.cropRect = this.lockedRectFromDrag(ax, ay, ax - (start.x + dx), -1, -1);
660
+ break;
661
+ }
662
+ case "ne": {
663
+ const ay = start.y + start.h;
664
+ this.cropRect = this.lockedRectFromDrag(start.x, ay, start.w + dx, 1, -1);
665
+ break;
666
+ }
667
+ case "sw": {
668
+ const ax = start.x + start.w;
669
+ this.cropRect = this.lockedRectFromDrag(ax, start.y, ax - (start.x + dx), -1, 1);
670
+ break;
671
+ }
672
+ }
673
+ this.updateCropBox();
674
+ return;
675
+ }
649
676
  let { x, y, w, h } = start;
650
677
  switch (this.dragKind) {
651
678
  case "move":
@@ -692,6 +719,12 @@ var ImageEditor = class {
692
719
  document.removeEventListener("pointerup", this.onPointerUp);
693
720
  };
694
721
  }
722
+ // Locked crop ratio (width / height), or null for free-form. Derived from
723
+ // opts so there's a single source of truth.
724
+ get aspect() {
725
+ const a = this.opts.lockedAspect;
726
+ return a && a.width > 0 && a.height > 0 ? a.width / a.height : null;
727
+ }
695
728
  async open() {
696
729
  this.mount();
697
730
  try {
@@ -822,8 +855,33 @@ var ImageEditor = class {
822
855
  this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
823
856
  const dw = this.displayCanvas.width;
824
857
  const dh = this.displayCanvas.height;
825
- const inset = Math.round(Math.min(dw, dh) * 0.1);
826
- this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
858
+ if (this.aspect) {
859
+ let w = dw;
860
+ let h = w / this.aspect;
861
+ if (h > dh) {
862
+ h = dh;
863
+ w = h * this.aspect;
864
+ }
865
+ w = Math.max(MIN_CROP, Math.round(w * 0.9));
866
+ h = Math.max(MIN_CROP, Math.round(w / this.aspect));
867
+ if (w > dw) {
868
+ w = dw;
869
+ h = w / this.aspect;
870
+ }
871
+ if (h > dh) {
872
+ h = dh;
873
+ w = h * this.aspect;
874
+ }
875
+ this.cropRect = {
876
+ x: Math.round((dw - w) / 2),
877
+ y: Math.round((dh - h) / 2),
878
+ w,
879
+ h
880
+ };
881
+ } else {
882
+ const inset = Math.round(Math.min(dw, dh) * 0.1);
883
+ this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
884
+ }
827
885
  this.renderCropBox();
828
886
  }
829
887
  exitCropMode() {
@@ -875,6 +933,28 @@ var ImageEditor = class {
875
933
  document.addEventListener("pointermove", this.onPointerMove);
876
934
  document.addEventListener("pointerup", this.onPointerUp);
877
935
  }
936
+ // Build a ratio-locked rect from a fixed anchor corner and a desired width.
937
+ // signX/signY say whether the dragged corner sits to the right/below the
938
+ // anchor (+1) or left/above (-1). Height is always derived from width to keep
939
+ // the ratio exact; both are clamped so the rect stays on-canvas.
940
+ lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
941
+ const a = this.aspect;
942
+ const maxW = this.displayCanvas.width;
943
+ const maxH = this.displayCanvas.height;
944
+ let w = Math.max(MIN_CROP, desiredW);
945
+ let h = w / a;
946
+ if (h < MIN_CROP) {
947
+ h = MIN_CROP;
948
+ w = h * a;
949
+ }
950
+ const roomX = signX > 0 ? maxW - anchorX : anchorX;
951
+ const roomY = signY > 0 ? maxH - anchorY : anchorY;
952
+ w = Math.min(w, roomX, roomY * a);
953
+ h = w / a;
954
+ const x = signX > 0 ? anchorX : anchorX - w;
955
+ const y = signY > 0 ? anchorY : anchorY - h;
956
+ return { x, y, w, h };
957
+ }
878
958
  // ---- operations (mutate working canvas) --------------------------------
879
959
  applyCrop() {
880
960
  if (!this.cropRect) {
@@ -895,6 +975,7 @@ var ImageEditor = class {
895
975
  this.working = next;
896
976
  this.exitCropMode();
897
977
  this.markEdited(true);
978
+ this.opts.onCropApplied?.();
898
979
  this.draw();
899
980
  }
900
981
  applyRotate() {
@@ -1041,6 +1122,12 @@ function mergeConfig(server, runtime) {
1041
1122
  const types = server.constraints.allowedMimeTypes.filter((t) => t !== "*/*");
1042
1123
  if (types.length > 0) merged.accept = types.join(",");
1043
1124
  }
1125
+ const srvImg = server.image ?? null;
1126
+ const ratio = runtime.cropAspectRatio ?? (srvImg && srvImg.cropRatioW && srvImg.cropRatioH ? { width: srvImg.cropRatioW, height: srvImg.cropRatioH } : void 0);
1127
+ merged.cropAspectRatio = ratio;
1128
+ merged.forceCrop = (runtime.forceCrop ?? srvImg?.forceCrop ?? false) && !!ratio && runtime.imageEditing !== false;
1129
+ merged.minImageWidth = runtime.minImageWidth ?? srvImg?.minWidth ?? void 0;
1130
+ merged.minImageHeight = runtime.minImageHeight ?? srvImg?.minHeight ?? void 0;
1044
1131
  return merged;
1045
1132
  }
1046
1133
  var DEFAULT_TITLE = "Upload files";
@@ -1513,15 +1600,13 @@ var Picker = class {
1513
1600
  };
1514
1601
  this.items.push(item);
1515
1602
  this.renderItem(item);
1603
+ if (this.shouldCheckDimensions(file)) {
1604
+ item.validating = true;
1605
+ this.validateDimensions(item);
1606
+ }
1516
1607
  }
1517
1608
  this.refreshConfirm();
1518
- const autoUpload = this.opts.autoUpload === true;
1519
- const hasQueued = this.items.some((i) => i.state === "queued");
1520
- if (autoUpload && hasQueued && !this.uploadStarted) {
1521
- requestAnimationFrame(() => {
1522
- if (!this.uploadStarted) this.startUpload();
1523
- });
1524
- }
1609
+ this.maybeAutoUpload();
1525
1610
  }
1526
1611
  renderItem(item) {
1527
1612
  if (!this.$list) return;
@@ -1580,6 +1665,7 @@ var Picker = class {
1580
1665
  item.$thumb = thumb;
1581
1666
  this.$list.appendChild(row);
1582
1667
  this.updateSummary();
1668
+ this.updateItemBadge(item);
1583
1669
  }
1584
1670
  // Swap a queued item's file with an edited version. Refreshes thumbnail and
1585
1671
  // meta in place so the row identity is preserved (uploadId, position).
@@ -1617,15 +1703,27 @@ var Picker = class {
1617
1703
  // nothing (Cancel/Back).
1618
1704
  openEditor(item) {
1619
1705
  if (!this.$panel || this.editor || item.state !== "queued") return;
1706
+ let didRatioCrop = false;
1620
1707
  this.editor = new ImageEditor({
1621
1708
  host: this.$panel,
1622
1709
  file: item.file,
1623
1710
  originalFile: item.originalFile,
1624
1711
  title: item.originalFile.name,
1712
+ lockedAspect: this.opts.cropAspectRatio,
1713
+ onCropApplied: () => {
1714
+ didRatioCrop = true;
1715
+ },
1625
1716
  onApply: (edited) => {
1626
1717
  const wasEdited = edited !== item.originalFile;
1627
1718
  this.replaceItemFile(item, edited, wasEdited);
1719
+ if (didRatioCrop) item.ratioCropped = true;
1628
1720
  this.editor = null;
1721
+ if (this.shouldCheckDimensions(item.file)) {
1722
+ item.validating = true;
1723
+ this.validateDimensions(item);
1724
+ }
1725
+ this.updateItemBadge(item);
1726
+ this.refreshConfirm();
1629
1727
  },
1630
1728
  onCancel: () => {
1631
1729
  this.editor = null;
@@ -1714,10 +1812,77 @@ var Picker = class {
1714
1812
  this.$summary.textContent = `${total} file${total === 1 ? "" : "s"} ready`;
1715
1813
  }
1716
1814
  }
1815
+ // ---- image rules (force-crop + min dimensions) --------------------------
1816
+ // True when force-crop is on and this queued raster image hasn't been cropped
1817
+ // to the locked ratio yet. SVGs are exempt (vector→raster crop is lossy).
1818
+ needsCrop(item) {
1819
+ return this.opts.forceCrop === true && !!this.opts.cropAspectRatio && item.state === "queued" && item.file.type.startsWith("image/") && item.file.type !== "image/svg+xml" && !item.ratioCropped;
1820
+ }
1821
+ hasUncroppedImages() {
1822
+ return this.items.some((i) => this.needsCrop(i));
1823
+ }
1824
+ anyValidating() {
1825
+ return this.items.some((i) => i.validating === true);
1826
+ }
1827
+ // Whether a min-dimension rule applies to this file (image, non-SVG, rule set).
1828
+ shouldCheckDimensions(file) {
1829
+ const minConfigured = !!(this.opts.minImageWidth || this.opts.minImageHeight);
1830
+ return minConfigured && file.type.startsWith("image/") && file.type !== "image/svg+xml";
1831
+ }
1832
+ // Toggle the per-row "crop required" cue. Only touches the meta line for
1833
+ // queued rows (other states own their meta via setItemState).
1834
+ updateItemBadge(item) {
1835
+ if (!item.$row) return;
1836
+ if (this.needsCrop(item)) {
1837
+ item.$row.dataset.cropRequired = "true";
1838
+ if (item.$meta) item.$meta.textContent = "Crop to ratio required";
1839
+ } else {
1840
+ delete item.$row.dataset.cropRequired;
1841
+ if (item.state === "queued" && item.$meta && !item.validating) {
1842
+ item.$meta.textContent = formatBytes(item.file.size);
1843
+ }
1844
+ }
1845
+ }
1846
+ // Decode an image and fail the row if it's below the configured minimum.
1847
+ // Non-decodable inputs are left queued — we can't prove they're too small.
1848
+ async validateDimensions(item) {
1849
+ const minW = this.opts.minImageWidth ?? 0;
1850
+ const minH = this.opts.minImageHeight ?? 0;
1851
+ let dim = null;
1852
+ try {
1853
+ dim = await readImageSize(item.file);
1854
+ } catch {
1855
+ dim = null;
1856
+ }
1857
+ if (item.state !== "queued") {
1858
+ item.validating = false;
1859
+ return;
1860
+ }
1861
+ item.validating = false;
1862
+ if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
1863
+ const need = `${minW || "?"}\xD7${minH || "?"}`;
1864
+ item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
1865
+ this.setItemState(item, "failed");
1866
+ } else {
1867
+ this.updateItemBadge(item);
1868
+ }
1869
+ this.refreshConfirm();
1870
+ this.maybeAutoUpload();
1871
+ }
1872
+ // Fire auto-upload once nothing is blocking it (no pending validation, no
1873
+ // pending force-crops). Re-checked whenever a validation settles.
1874
+ maybeAutoUpload() {
1875
+ if (this.opts.autoUpload !== true || this.uploadStarted) return;
1876
+ if (this.anyValidating() || this.hasUncroppedImages()) return;
1877
+ if (!this.items.some((i) => i.state === "queued")) return;
1878
+ requestAnimationFrame(() => {
1879
+ if (!this.uploadStarted) this.startUpload();
1880
+ });
1881
+ }
1717
1882
  refreshConfirm() {
1718
1883
  if (!this.$confirm) return;
1719
1884
  const queued = this.items.filter((i) => i.state === "queued").length;
1720
- this.$confirm.disabled = queued === 0 || this.uploadStarted;
1885
+ this.$confirm.disabled = queued === 0 || this.uploadStarted || this.hasUncroppedImages() || this.anyValidating();
1721
1886
  }
1722
1887
  // Transient message in the actions bar (e.g. "2 files skipped — too large").
1723
1888
  flashNotice(text) {
@@ -1736,6 +1901,15 @@ var Picker = class {
1736
1901
  // ---- upload -------------------------------------------------------------
1737
1902
  async startUpload() {
1738
1903
  if (this.uploadStarted) return;
1904
+ if (this.anyValidating()) {
1905
+ this.flashNotice("Checking image dimensions\u2026");
1906
+ return;
1907
+ }
1908
+ if (this.hasUncroppedImages()) {
1909
+ this.items.forEach((i) => this.updateItemBadge(i));
1910
+ this.flashNotice("Crop required \u2014 crop every image to the set ratio before uploading.");
1911
+ return;
1912
+ }
1739
1913
  const queued = this.items.filter((i) => i.state === "queued");
1740
1914
  if (queued.length === 0) return;
1741
1915
  this.uploadStarted = true;
@@ -1891,6 +2065,34 @@ function cryptoId() {
1891
2065
  }
1892
2066
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
1893
2067
  }
2068
+ async function readImageSize(file) {
2069
+ if (typeof createImageBitmap === "function") {
2070
+ try {
2071
+ const bmp = await createImageBitmap(file);
2072
+ const w = bmp.width, h = bmp.height;
2073
+ bmp.close?.();
2074
+ return { w, h };
2075
+ } catch {
2076
+ }
2077
+ }
2078
+ return new Promise((resolve, reject) => {
2079
+ if (typeof URL === "undefined" || !URL.createObjectURL) {
2080
+ reject(new Error("no object URL support"));
2081
+ return;
2082
+ }
2083
+ const url = URL.createObjectURL(file);
2084
+ const img = new Image();
2085
+ img.onload = () => {
2086
+ URL.revokeObjectURL(url);
2087
+ resolve({ w: img.naturalWidth, h: img.naturalHeight });
2088
+ };
2089
+ img.onerror = () => {
2090
+ URL.revokeObjectURL(url);
2091
+ reject(new Error("decode failed"));
2092
+ };
2093
+ img.src = url;
2094
+ });
2095
+ }
1894
2096
  async function fetchUrlAsFile(url) {
1895
2097
  let res;
1896
2098
  try {