@masters-union/union-stack 0.3.9 → 0.3.11

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
@@ -25,6 +25,7 @@ declare class Picker {
25
25
  private editor;
26
26
  private abortCtrl;
27
27
  private uploadStarted;
28
+ private validateSeqCounter;
28
29
  private resolved;
29
30
  private resolvePromise;
30
31
  private donePromise;
@@ -56,6 +57,13 @@ declare class Picker {
56
57
  private statusLabel;
57
58
  private setItemState;
58
59
  private updateSummary;
60
+ private needsCrop;
61
+ private hasUncroppedImages;
62
+ private anyValidating;
63
+ private shouldCheckDimensions;
64
+ private updateItemBadge;
65
+ private validateDimensions;
66
+ private maybeAutoUpload;
59
67
  private refreshConfirm;
60
68
  private flashNotice;
61
69
  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
@@ -25,6 +25,7 @@ declare class Picker {
25
25
  private editor;
26
26
  private abortCtrl;
27
27
  private uploadStarted;
28
+ private validateSeqCounter;
28
29
  private resolved;
29
30
  private resolvePromise;
30
31
  private donePromise;
@@ -56,6 +57,13 @@ declare class Picker {
56
57
  private statusLabel;
57
58
  private setItemState;
58
59
  private updateSummary;
60
+ private needsCrop;
61
+ private hasUncroppedImages;
62
+ private anyValidating;
63
+ private shouldCheckDimensions;
64
+ private updateItemBadge;
65
+ private validateDimensions;
66
+ private maybeAutoUpload;
59
67
  private refreshConfirm;
60
68
  private flashNotice;
61
69
  private startUpload;
package/dist/picker.js CHANGED
@@ -105,6 +105,10 @@ var BASE_CSS = `
105
105
  .us-picker-backdrop {
106
106
  --us-font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
107
107
  --us-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
108
+ /* Default to a light color-scheme so native controls never inherit the
109
+ visitor's OS dark mode; picker.ts overrides this to match the resolved
110
+ theme mode on mount. */
111
+ color-scheme: light;
108
112
  position: fixed; inset: 0; z-index: 2147483000;
109
113
  background: rgba(2, 6, 23, 0.4);
110
114
  -webkit-backdrop-filter: blur(8px);
@@ -249,6 +253,8 @@ var BASE_CSS = `
249
253
  }
250
254
  .us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
251
255
  .us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
256
+ /* Force-crop: image queued but not yet cropped to the required ratio. */
257
+ .us-file[data-crop-required="true"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
252
258
 
253
259
  .us-file-thumb {
254
260
  width: 40px; height: 40px; flex-shrink: 0;
@@ -275,6 +281,14 @@ var BASE_CSS = `
275
281
  color: var(--us-muted); font-size: 11px; flex-shrink: 0;
276
282
  font-family: var(--us-mono); letter-spacing: 0;
277
283
  }
284
+ /* Failure reason \u2014 full-width, danger-colored, wraps. Collapses entirely when
285
+ empty so non-failed rows keep their original height. */
286
+ .us-file-error {
287
+ color: var(--us-danger); font-size: 12px; line-height: 1.35;
288
+ margin-top: 4px; letter-spacing: -0.006em;
289
+ overflow-wrap: anywhere;
290
+ }
291
+ .us-file-error:empty { display: none; }
278
292
 
279
293
  .us-file-progress {
280
294
  height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
@@ -613,6 +627,7 @@ var BASE_CSS = `
613
627
 
614
628
  // src/picker/imageEditor.ts
615
629
  var MAX_DISPLAY = 720;
630
+ var MIN_CROP = 24;
616
631
  var ICON = {
617
632
  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
633
  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 +658,31 @@ var ImageEditor = class {
643
658
  const start = this.dragStart.rect;
644
659
  const maxW = this.displayCanvas.width;
645
660
  const maxH = this.displayCanvas.height;
646
- const min = 24;
661
+ const min = MIN_CROP;
662
+ if (this.aspect && this.dragKind !== "move") {
663
+ switch (this.dragKind) {
664
+ case "se":
665
+ this.cropRect = this.lockedRectFromDrag(start.x, start.y, start.w + dx, 1, 1);
666
+ break;
667
+ case "nw": {
668
+ const ax = start.x + start.w, ay = start.y + start.h;
669
+ this.cropRect = this.lockedRectFromDrag(ax, ay, ax - (start.x + dx), -1, -1);
670
+ break;
671
+ }
672
+ case "ne": {
673
+ const ay = start.y + start.h;
674
+ this.cropRect = this.lockedRectFromDrag(start.x, ay, start.w + dx, 1, -1);
675
+ break;
676
+ }
677
+ case "sw": {
678
+ const ax = start.x + start.w;
679
+ this.cropRect = this.lockedRectFromDrag(ax, start.y, ax - (start.x + dx), -1, 1);
680
+ break;
681
+ }
682
+ }
683
+ this.updateCropBox();
684
+ return;
685
+ }
647
686
  let { x, y, w, h } = start;
648
687
  switch (this.dragKind) {
649
688
  case "move":
@@ -690,6 +729,12 @@ var ImageEditor = class {
690
729
  document.removeEventListener("pointerup", this.onPointerUp);
691
730
  };
692
731
  }
732
+ // Locked crop ratio (width / height), or null for free-form. Derived from
733
+ // opts so there's a single source of truth.
734
+ get aspect() {
735
+ const a = this.opts.lockedAspect;
736
+ return a && a.width > 0 && a.height > 0 ? a.width / a.height : null;
737
+ }
693
738
  async open() {
694
739
  this.mount();
695
740
  try {
@@ -820,8 +865,42 @@ var ImageEditor = class {
820
865
  this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
821
866
  const dw = this.displayCanvas.width;
822
867
  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 };
868
+ if (this.aspect) {
869
+ const a = this.aspect;
870
+ let w = dw;
871
+ let h = w / a;
872
+ if (h > dh) {
873
+ h = dh;
874
+ w = h * a;
875
+ }
876
+ w = w * 0.9;
877
+ h = w / a;
878
+ if (w < MIN_CROP) {
879
+ w = MIN_CROP;
880
+ h = w / a;
881
+ }
882
+ if (h < MIN_CROP) {
883
+ h = MIN_CROP;
884
+ w = h * a;
885
+ }
886
+ if (w > dw) {
887
+ w = dw;
888
+ h = w / a;
889
+ }
890
+ if (h > dh) {
891
+ h = dh;
892
+ w = h * a;
893
+ }
894
+ this.cropRect = {
895
+ x: Math.round((dw - w) / 2),
896
+ y: Math.round((dh - h) / 2),
897
+ w,
898
+ h
899
+ };
900
+ } else {
901
+ const inset = Math.round(Math.min(dw, dh) * 0.1);
902
+ this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
903
+ }
825
904
  this.renderCropBox();
826
905
  }
827
906
  exitCropMode() {
@@ -873,6 +952,28 @@ var ImageEditor = class {
873
952
  document.addEventListener("pointermove", this.onPointerMove);
874
953
  document.addEventListener("pointerup", this.onPointerUp);
875
954
  }
955
+ // Build a ratio-locked rect from a fixed anchor corner and a desired width.
956
+ // signX/signY say whether the dragged corner sits to the right/below the
957
+ // anchor (+1) or left/above (-1). Height is always derived from width to keep
958
+ // the ratio exact; both are clamped so the rect stays on-canvas.
959
+ lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
960
+ const a = this.aspect;
961
+ const maxW = this.displayCanvas.width;
962
+ const maxH = this.displayCanvas.height;
963
+ let w = Math.max(MIN_CROP, desiredW);
964
+ let h = w / a;
965
+ if (h < MIN_CROP) {
966
+ h = MIN_CROP;
967
+ w = h * a;
968
+ }
969
+ const roomX = signX > 0 ? maxW - anchorX : anchorX;
970
+ const roomY = signY > 0 ? maxH - anchorY : anchorY;
971
+ w = Math.min(w, roomX, roomY * a);
972
+ h = w / a;
973
+ const x = signX > 0 ? anchorX : anchorX - w;
974
+ const y = signY > 0 ? anchorY : anchorY - h;
975
+ return { x, y, w, h };
976
+ }
876
977
  // ---- operations (mutate working canvas) --------------------------------
877
978
  applyCrop() {
878
979
  if (!this.cropRect) {
@@ -893,6 +994,7 @@ var ImageEditor = class {
893
994
  this.working = next;
894
995
  this.exitCropMode();
895
996
  this.markEdited(true);
997
+ this.opts.onCropApplied?.();
896
998
  this.draw();
897
999
  }
898
1000
  applyRotate() {
@@ -908,6 +1010,7 @@ var ImageEditor = class {
908
1010
  this.working = next;
909
1011
  this.exitCropMode();
910
1012
  this.markEdited(true);
1013
+ this.opts.onAspectReset?.();
911
1014
  this.draw();
912
1015
  }
913
1016
  applyCircle() {
@@ -931,11 +1034,13 @@ var ImageEditor = class {
931
1034
  this.hasAlpha = true;
932
1035
  this.exitCropMode();
933
1036
  this.markEdited(true);
1037
+ this.opts.onAspectReset?.();
934
1038
  this.draw();
935
1039
  }
936
1040
  async revert() {
937
1041
  await this.loadTrueOriginalIntoWorking();
938
1042
  this.exitCropMode();
1043
+ this.opts.onAspectReset?.();
939
1044
  this.draw();
940
1045
  }
941
1046
  markEdited(edited) {
@@ -1039,6 +1144,12 @@ function mergeConfig(server, runtime) {
1039
1144
  const types = server.constraints.allowedMimeTypes.filter((t) => t !== "*/*");
1040
1145
  if (types.length > 0) merged.accept = types.join(",");
1041
1146
  }
1147
+ const srvImg = server.image ?? null;
1148
+ const ratio = runtime.cropAspectRatio ?? (srvImg && srvImg.cropRatioW && srvImg.cropRatioH ? { width: srvImg.cropRatioW, height: srvImg.cropRatioH } : void 0);
1149
+ merged.cropAspectRatio = ratio;
1150
+ merged.forceCrop = (runtime.forceCrop ?? srvImg?.forceCrop ?? false) && !!ratio && runtime.imageEditing !== false;
1151
+ merged.minImageWidth = runtime.minImageWidth ?? srvImg?.minWidth ?? void 0;
1152
+ merged.minImageHeight = runtime.minImageHeight ?? srvImg?.minHeight ?? void 0;
1042
1153
  return merged;
1043
1154
  }
1044
1155
  var DEFAULT_TITLE = "Upload files";
@@ -1093,6 +1204,10 @@ var Picker = class {
1093
1204
  this.editor = null;
1094
1205
  this.abortCtrl = new AbortController();
1095
1206
  this.uploadStarted = false;
1207
+ // Monotonic counter so a stale image-dimension decode (e.g. one started before
1208
+ // the user edited the file) can't clobber the row's state — only the latest
1209
+ // validation per item is allowed to mutate it.
1210
+ this.validateSeqCounter = 0;
1096
1211
  this.resolved = false;
1097
1212
  // Element focused before the modal opened, restored on close (a11y).
1098
1213
  this.previouslyFocused = null;
@@ -1140,6 +1255,7 @@ var Picker = class {
1140
1255
  Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
1141
1256
  root.style.setProperty(k, v);
1142
1257
  });
1258
+ root.style.colorScheme = this.opts.theme?.mode === "dark" ? "dark" : "light";
1143
1259
  root.addEventListener("click", (e) => {
1144
1260
  if (e.target === root && !this.uploadStarted) this.cancel();
1145
1261
  });
@@ -1511,15 +1627,13 @@ var Picker = class {
1511
1627
  };
1512
1628
  this.items.push(item);
1513
1629
  this.renderItem(item);
1630
+ if (this.shouldCheckDimensions(file)) {
1631
+ item.validating = true;
1632
+ this.validateDimensions(item);
1633
+ }
1514
1634
  }
1515
1635
  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
- }
1636
+ this.maybeAutoUpload();
1523
1637
  }
1524
1638
  renderItem(item) {
1525
1639
  if (!this.$list) return;
@@ -1552,6 +1666,9 @@ var Picker = class {
1552
1666
  const bar = el2("div", "us-file-progress-bar");
1553
1667
  progress.appendChild(bar);
1554
1668
  main.appendChild(progress);
1669
+ const error = el2("div", "us-file-error");
1670
+ if (item.error) error.textContent = item.error;
1671
+ main.appendChild(error);
1555
1672
  row.appendChild(main);
1556
1673
  const isImage = item.file.type.startsWith("image/");
1557
1674
  const editingEnabled = this.opts.imageEditing !== false;
@@ -1575,9 +1692,11 @@ var Picker = class {
1575
1692
  item.$bar = bar;
1576
1693
  item.$status = status;
1577
1694
  item.$meta = meta;
1695
+ item.$error = error;
1578
1696
  item.$thumb = thumb;
1579
1697
  this.$list.appendChild(row);
1580
1698
  this.updateSummary();
1699
+ this.updateItemBadge(item);
1581
1700
  }
1582
1701
  // Swap a queued item's file with an edited version. Refreshes thumbnail and
1583
1702
  // meta in place so the row identity is preserved (uploadId, position).
@@ -1615,15 +1734,32 @@ var Picker = class {
1615
1734
  // nothing (Cancel/Back).
1616
1735
  openEditor(item) {
1617
1736
  if (!this.$panel || this.editor || item.state !== "queued") return;
1737
+ let didRatioCrop = false;
1618
1738
  this.editor = new ImageEditor({
1619
1739
  host: this.$panel,
1620
1740
  file: item.file,
1621
1741
  originalFile: item.originalFile,
1622
1742
  title: item.originalFile.name,
1743
+ lockedAspect: this.opts.cropAspectRatio,
1744
+ onCropApplied: () => {
1745
+ didRatioCrop = true;
1746
+ },
1747
+ // Rotating/circling/reverting after a crop changes the working aspect, so
1748
+ // the image is no longer guaranteed to match the locked ratio.
1749
+ onAspectReset: () => {
1750
+ didRatioCrop = false;
1751
+ },
1623
1752
  onApply: (edited) => {
1624
1753
  const wasEdited = edited !== item.originalFile;
1625
1754
  this.replaceItemFile(item, edited, wasEdited);
1755
+ if (didRatioCrop) item.ratioCropped = true;
1626
1756
  this.editor = null;
1757
+ if (this.shouldCheckDimensions(item.file)) {
1758
+ item.validating = true;
1759
+ this.validateDimensions(item);
1760
+ }
1761
+ this.updateItemBadge(item);
1762
+ this.refreshConfirm();
1627
1763
  },
1628
1764
  onCancel: () => {
1629
1765
  this.editor = null;
@@ -1677,16 +1813,13 @@ var Picker = class {
1677
1813
  case "uploading":
1678
1814
  item.$meta.textContent = `${size} \xB7 ${Math.round(item.progress)}%`;
1679
1815
  break;
1680
- case "done":
1681
- item.$meta.textContent = size;
1682
- break;
1683
- case "failed":
1684
- item.$meta.textContent = item.error || "Failed";
1685
- break;
1686
1816
  default:
1687
1817
  item.$meta.textContent = size;
1688
1818
  }
1689
1819
  }
1820
+ if (item.$error) {
1821
+ item.$error.textContent = state === "failed" ? item.error || "Upload failed" : "";
1822
+ }
1690
1823
  this.updateSummary();
1691
1824
  }
1692
1825
  updateSummary() {
@@ -1712,10 +1845,79 @@ var Picker = class {
1712
1845
  this.$summary.textContent = `${total} file${total === 1 ? "" : "s"} ready`;
1713
1846
  }
1714
1847
  }
1848
+ // ---- image rules (force-crop + min dimensions) --------------------------
1849
+ // True when force-crop is on and this queued raster image hasn't been cropped
1850
+ // to the locked ratio yet. SVGs are exempt (vector→raster crop is lossy).
1851
+ needsCrop(item) {
1852
+ return this.opts.forceCrop === true && !!this.opts.cropAspectRatio && item.state === "queued" && item.file.type.startsWith("image/") && item.file.type !== "image/svg+xml" && !item.ratioCropped;
1853
+ }
1854
+ hasUncroppedImages() {
1855
+ return this.items.some((i) => this.needsCrop(i));
1856
+ }
1857
+ anyValidating() {
1858
+ return this.items.some((i) => i.validating === true);
1859
+ }
1860
+ // Whether a min-dimension rule applies to this file (image, non-SVG, rule set).
1861
+ shouldCheckDimensions(file) {
1862
+ const minConfigured = !!(this.opts.minImageWidth || this.opts.minImageHeight);
1863
+ return minConfigured && file.type.startsWith("image/") && file.type !== "image/svg+xml";
1864
+ }
1865
+ // Toggle the per-row "crop required" cue. Only touches the meta line for
1866
+ // queued rows (other states own their meta via setItemState).
1867
+ updateItemBadge(item) {
1868
+ if (!item.$row) return;
1869
+ if (this.needsCrop(item)) {
1870
+ item.$row.dataset.cropRequired = "true";
1871
+ if (item.$meta) item.$meta.textContent = "Crop to ratio required";
1872
+ } else {
1873
+ delete item.$row.dataset.cropRequired;
1874
+ if (item.state === "queued" && item.$meta && !item.validating) {
1875
+ item.$meta.textContent = formatBytes(item.file.size);
1876
+ }
1877
+ }
1878
+ }
1879
+ // Decode an image and fail the row if it's below the configured minimum.
1880
+ // Non-decodable inputs are left queued — we can't prove they're too small.
1881
+ async validateDimensions(item) {
1882
+ const seq = ++this.validateSeqCounter;
1883
+ item.validateSeq = seq;
1884
+ const minW = this.opts.minImageWidth ?? 0;
1885
+ const minH = this.opts.minImageHeight ?? 0;
1886
+ const file = item.file;
1887
+ let dim = null;
1888
+ try {
1889
+ dim = await readImageSize(file);
1890
+ } catch {
1891
+ dim = null;
1892
+ }
1893
+ if (item.validateSeq !== seq) return;
1894
+ item.validating = false;
1895
+ if (item.state === "queued") {
1896
+ if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
1897
+ const need = `${minW || "?"}\xD7${minH || "?"}`;
1898
+ item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
1899
+ this.setItemState(item, "failed");
1900
+ } else {
1901
+ this.updateItemBadge(item);
1902
+ }
1903
+ }
1904
+ this.refreshConfirm();
1905
+ this.maybeAutoUpload();
1906
+ }
1907
+ // Fire auto-upload once nothing is blocking it (no pending validation, no
1908
+ // pending force-crops). Re-checked whenever a validation settles.
1909
+ maybeAutoUpload() {
1910
+ if (this.opts.autoUpload !== true || this.uploadStarted) return;
1911
+ if (this.anyValidating() || this.hasUncroppedImages()) return;
1912
+ if (!this.items.some((i) => i.state === "queued")) return;
1913
+ requestAnimationFrame(() => {
1914
+ if (!this.uploadStarted) this.startUpload();
1915
+ });
1916
+ }
1715
1917
  refreshConfirm() {
1716
1918
  if (!this.$confirm) return;
1717
1919
  const queued = this.items.filter((i) => i.state === "queued").length;
1718
- this.$confirm.disabled = queued === 0 || this.uploadStarted;
1920
+ this.$confirm.disabled = queued === 0 || this.uploadStarted || this.hasUncroppedImages() || this.anyValidating();
1719
1921
  }
1720
1922
  // Transient message in the actions bar (e.g. "2 files skipped — too large").
1721
1923
  flashNotice(text) {
@@ -1734,6 +1936,15 @@ var Picker = class {
1734
1936
  // ---- upload -------------------------------------------------------------
1735
1937
  async startUpload() {
1736
1938
  if (this.uploadStarted) return;
1939
+ if (this.anyValidating()) {
1940
+ this.flashNotice("Checking image dimensions\u2026");
1941
+ return;
1942
+ }
1943
+ if (this.hasUncroppedImages()) {
1944
+ this.items.forEach((i) => this.updateItemBadge(i));
1945
+ this.flashNotice("Crop required \u2014 crop every image to the set ratio before uploading.");
1946
+ return;
1947
+ }
1737
1948
  const queued = this.items.filter((i) => i.state === "queued");
1738
1949
  if (queued.length === 0) return;
1739
1950
  this.uploadStarted = true;
@@ -1889,6 +2100,34 @@ function cryptoId() {
1889
2100
  }
1890
2101
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
1891
2102
  }
2103
+ async function readImageSize(file) {
2104
+ if (typeof createImageBitmap === "function") {
2105
+ try {
2106
+ const bmp = await createImageBitmap(file);
2107
+ const w = bmp.width, h = bmp.height;
2108
+ bmp.close?.();
2109
+ return { w, h };
2110
+ } catch {
2111
+ }
2112
+ }
2113
+ return new Promise((resolve, reject) => {
2114
+ if (typeof URL === "undefined" || !URL.createObjectURL) {
2115
+ reject(new Error("no object URL support"));
2116
+ return;
2117
+ }
2118
+ const url = URL.createObjectURL(file);
2119
+ const img = new Image();
2120
+ img.onload = () => {
2121
+ URL.revokeObjectURL(url);
2122
+ resolve({ w: img.naturalWidth, h: img.naturalHeight });
2123
+ };
2124
+ img.onerror = () => {
2125
+ URL.revokeObjectURL(url);
2126
+ reject(new Error("decode failed"));
2127
+ };
2128
+ img.src = url;
2129
+ });
2130
+ }
1892
2131
  async function fetchUrlAsFile(url) {
1893
2132
  let res;
1894
2133
  try {