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