@masters-union/union-stack 0.3.10 → 0.3.12

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
@@ -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;
package/dist/picker.d.ts CHANGED
@@ -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;
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);
@@ -277,6 +281,14 @@ var BASE_CSS = `
277
281
  color: var(--us-muted); font-size: 11px; flex-shrink: 0;
278
282
  font-family: var(--us-mono); letter-spacing: 0;
279
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; }
280
292
 
281
293
  .us-file-progress {
282
294
  height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
@@ -516,6 +528,26 @@ var BASE_CSS = `
516
528
  .us-tool svg { width: 13px; height: 13px; }
517
529
  .us-tool-spacer { flex: 1; }
518
530
 
531
+ /* Crop dimension inputs \u2014 live W\xD7H readout, editable for free-form crops. */
532
+ .us-crop-dims {
533
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
534
+ padding: 10px 16px 0;
535
+ }
536
+ .us-crop-dims[hidden] { display: none; }
537
+ .us-crop-dims-label {
538
+ font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
539
+ text-transform: uppercase; color: var(--us-muted);
540
+ }
541
+ .us-crop-dim-input {
542
+ width: 76px; appearance: none; text-align: center;
543
+ background: var(--us-overlay); border: 1px solid var(--us-border-strong);
544
+ border-radius: 8px; padding: 5px 8px; min-height: 30px;
545
+ font: inherit; font-size: 12px; color: var(--us-fg);
546
+ }
547
+ .us-crop-dim-input:focus { outline: none; border-color: var(--us-primary); }
548
+ .us-crop-dim-input:read-only { opacity: 0.55; cursor: default; }
549
+ .us-crop-dims-x, .us-crop-dims-unit { color: var(--us-muted); font-size: 12px; }
550
+
519
551
  .us-editor-footer {
520
552
  display: flex; gap: 8px; justify-content: flex-end;
521
553
  padding: 12px 16px;
@@ -761,6 +793,16 @@ var ImageEditor = class {
761
793
  this.displayCanvas.className = "us-editor-canvas";
762
794
  this.canvasWrap.appendChild(this.displayCanvas);
763
795
  this.root.appendChild(this.canvasWrap);
796
+ this.cropDims = el("div", "us-crop-dims");
797
+ this.cropDims.hidden = true;
798
+ this.widthInput = this.makeDimInput("Crop width in pixels", "w");
799
+ this.heightInput = this.makeDimInput("Crop height in pixels", "h");
800
+ this.cropDims.appendChild(el("span", "us-crop-dims-label", "Crop size"));
801
+ this.cropDims.appendChild(this.widthInput);
802
+ this.cropDims.appendChild(el("span", "us-crop-dims-x", "\xD7"));
803
+ this.cropDims.appendChild(this.heightInput);
804
+ this.cropDims.appendChild(el("span", "us-crop-dims-unit", "px"));
805
+ this.root.appendChild(this.cropDims);
764
806
  const toolbar = el("div", "us-editor-toolbar");
765
807
  this.cropTool = makeTool("Crop", ICON.crop);
766
808
  this.cropTool.onclick = () => this.toggleCropMode();
@@ -854,21 +896,30 @@ var ImageEditor = class {
854
896
  const dw = this.displayCanvas.width;
855
897
  const dh = this.displayCanvas.height;
856
898
  if (this.aspect) {
899
+ const a = this.aspect;
857
900
  let w = dw;
858
- let h = w / this.aspect;
901
+ let h = w / a;
859
902
  if (h > dh) {
860
903
  h = dh;
861
- w = h * this.aspect;
904
+ w = h * a;
905
+ }
906
+ w = w * 0.9;
907
+ h = w / a;
908
+ if (w < MIN_CROP) {
909
+ w = MIN_CROP;
910
+ h = w / a;
911
+ }
912
+ if (h < MIN_CROP) {
913
+ h = MIN_CROP;
914
+ w = h * a;
862
915
  }
863
- w = Math.max(MIN_CROP, Math.round(w * 0.9));
864
- h = Math.max(MIN_CROP, Math.round(w / this.aspect));
865
916
  if (w > dw) {
866
917
  w = dw;
867
- h = w / this.aspect;
918
+ h = w / a;
868
919
  }
869
920
  if (h > dh) {
870
921
  h = dh;
871
- w = h * this.aspect;
922
+ w = h * a;
872
923
  }
873
924
  this.cropRect = {
874
925
  x: Math.round((dw - w) / 2),
@@ -881,6 +932,12 @@ var ImageEditor = class {
881
932
  this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
882
933
  }
883
934
  this.renderCropBox();
935
+ const locked = this.aspect !== null;
936
+ this.widthInput.readOnly = locked;
937
+ this.heightInput.readOnly = locked;
938
+ this.cropDims.dataset.locked = locked ? "true" : "false";
939
+ this.cropDims.hidden = false;
940
+ this.syncCropDimsInputs();
884
941
  }
885
942
  exitCropMode() {
886
943
  this.mode = "none";
@@ -891,6 +948,46 @@ var ImageEditor = class {
891
948
  this.cropBox.remove();
892
949
  this.cropBox = null;
893
950
  }
951
+ this.cropDims.hidden = true;
952
+ }
953
+ // Build a numeric crop-dimension input. `axis` selects which side it edits.
954
+ makeDimInput(ariaLabel, axis) {
955
+ const input = document.createElement("input");
956
+ input.type = "number";
957
+ input.min = "1";
958
+ input.inputMode = "numeric";
959
+ input.className = "us-crop-dim-input";
960
+ input.setAttribute("aria-label", ariaLabel);
961
+ input.addEventListener("input", () => this.onDimInput(axis));
962
+ input.addEventListener("change", () => this.syncCropDimsInputs());
963
+ return input;
964
+ }
965
+ // Push the current crop frame size (in image/output pixels) into the inputs,
966
+ // skipping whichever the user is actively editing so we don't fight typing.
967
+ syncCropDimsInputs() {
968
+ if (!this.cropRect || !this.widthInput || !this.heightInput) return;
969
+ const s = this.displayScale || 1;
970
+ const wImg = Math.max(1, Math.round(this.cropRect.w / s));
971
+ const hImg = Math.max(1, Math.round(this.cropRect.h / s));
972
+ if (document.activeElement !== this.widthInput) this.widthInput.value = String(wImg);
973
+ if (document.activeElement !== this.heightInput) this.heightInput.value = String(hImg);
974
+ }
975
+ // Free-form only: resize the crop frame from a typed pixel dimension. Anchored
976
+ // at the frame's current top-left and clamped to the canvas + the min size.
977
+ onDimInput(axis) {
978
+ if (this.aspect !== null || !this.cropRect) return;
979
+ const s = this.displayScale || 1;
980
+ const input = axis === "w" ? this.widthInput : this.heightInput;
981
+ const valImg = parseInt(input.value, 10);
982
+ if (!Number.isFinite(valImg) || valImg <= 0) return;
983
+ if (axis === "w") {
984
+ const maxW = this.displayCanvas.width - this.cropRect.x;
985
+ this.cropRect = { ...this.cropRect, w: clamp(Math.round(valImg * s), MIN_CROP, maxW) };
986
+ } else {
987
+ const maxH = this.displayCanvas.height - this.cropRect.y;
988
+ this.cropRect = { ...this.cropRect, h: clamp(Math.round(valImg * s), MIN_CROP, maxH) };
989
+ }
990
+ this.updateCropBox();
894
991
  }
895
992
  renderCropBox() {
896
993
  if (!this.cropRect) return;
@@ -921,6 +1018,7 @@ var ImageEditor = class {
921
1018
  this.cropBox.style.top = `${top}px`;
922
1019
  this.cropBox.style.width = `${this.cropRect.w}px`;
923
1020
  this.cropBox.style.height = `${this.cropRect.h}px`;
1021
+ this.syncCropDimsInputs();
924
1022
  }
925
1023
  // ---- drag handlers (crop) ----------------------------------------------
926
1024
  beginDrag(e, kind) {
@@ -989,6 +1087,7 @@ var ImageEditor = class {
989
1087
  this.working = next;
990
1088
  this.exitCropMode();
991
1089
  this.markEdited(true);
1090
+ this.opts.onAspectReset?.();
992
1091
  this.draw();
993
1092
  }
994
1093
  applyCircle() {
@@ -1012,11 +1111,13 @@ var ImageEditor = class {
1012
1111
  this.hasAlpha = true;
1013
1112
  this.exitCropMode();
1014
1113
  this.markEdited(true);
1114
+ this.opts.onAspectReset?.();
1015
1115
  this.draw();
1016
1116
  }
1017
1117
  async revert() {
1018
1118
  await this.loadTrueOriginalIntoWorking();
1019
1119
  this.exitCropMode();
1120
+ this.opts.onAspectReset?.();
1020
1121
  this.draw();
1021
1122
  }
1022
1123
  markEdited(edited) {
@@ -1180,6 +1281,10 @@ var Picker = class {
1180
1281
  this.editor = null;
1181
1282
  this.abortCtrl = new AbortController();
1182
1283
  this.uploadStarted = false;
1284
+ // Monotonic counter so a stale image-dimension decode (e.g. one started before
1285
+ // the user edited the file) can't clobber the row's state — only the latest
1286
+ // validation per item is allowed to mutate it.
1287
+ this.validateSeqCounter = 0;
1183
1288
  this.resolved = false;
1184
1289
  // Element focused before the modal opened, restored on close (a11y).
1185
1290
  this.previouslyFocused = null;
@@ -1227,6 +1332,7 @@ var Picker = class {
1227
1332
  Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
1228
1333
  root.style.setProperty(k, v);
1229
1334
  });
1335
+ root.style.colorScheme = this.opts.theme?.mode === "dark" ? "dark" : "light";
1230
1336
  root.addEventListener("click", (e) => {
1231
1337
  if (e.target === root && !this.uploadStarted) this.cancel();
1232
1338
  });
@@ -1637,6 +1743,9 @@ var Picker = class {
1637
1743
  const bar = el2("div", "us-file-progress-bar");
1638
1744
  progress.appendChild(bar);
1639
1745
  main.appendChild(progress);
1746
+ const error = el2("div", "us-file-error");
1747
+ if (item.error) error.textContent = item.error;
1748
+ main.appendChild(error);
1640
1749
  row.appendChild(main);
1641
1750
  const isImage = item.file.type.startsWith("image/");
1642
1751
  const editingEnabled = this.opts.imageEditing !== false;
@@ -1660,6 +1769,7 @@ var Picker = class {
1660
1769
  item.$bar = bar;
1661
1770
  item.$status = status;
1662
1771
  item.$meta = meta;
1772
+ item.$error = error;
1663
1773
  item.$thumb = thumb;
1664
1774
  this.$list.appendChild(row);
1665
1775
  this.updateSummary();
@@ -1711,6 +1821,11 @@ var Picker = class {
1711
1821
  onCropApplied: () => {
1712
1822
  didRatioCrop = true;
1713
1823
  },
1824
+ // Rotating/circling/reverting after a crop changes the working aspect, so
1825
+ // the image is no longer guaranteed to match the locked ratio.
1826
+ onAspectReset: () => {
1827
+ didRatioCrop = false;
1828
+ },
1714
1829
  onApply: (edited) => {
1715
1830
  const wasEdited = edited !== item.originalFile;
1716
1831
  this.replaceItemFile(item, edited, wasEdited);
@@ -1775,16 +1890,13 @@ var Picker = class {
1775
1890
  case "uploading":
1776
1891
  item.$meta.textContent = `${size} \xB7 ${Math.round(item.progress)}%`;
1777
1892
  break;
1778
- case "done":
1779
- item.$meta.textContent = size;
1780
- break;
1781
- case "failed":
1782
- item.$meta.textContent = item.error || "Failed";
1783
- break;
1784
1893
  default:
1785
1894
  item.$meta.textContent = size;
1786
1895
  }
1787
1896
  }
1897
+ if (item.$error) {
1898
+ item.$error.textContent = state === "failed" ? item.error || "Upload failed" : "";
1899
+ }
1788
1900
  this.updateSummary();
1789
1901
  }
1790
1902
  updateSummary() {
@@ -1844,25 +1956,27 @@ var Picker = class {
1844
1956
  // Decode an image and fail the row if it's below the configured minimum.
1845
1957
  // Non-decodable inputs are left queued — we can't prove they're too small.
1846
1958
  async validateDimensions(item) {
1959
+ const seq = ++this.validateSeqCounter;
1960
+ item.validateSeq = seq;
1847
1961
  const minW = this.opts.minImageWidth ?? 0;
1848
1962
  const minH = this.opts.minImageHeight ?? 0;
1963
+ const file = item.file;
1849
1964
  let dim = null;
1850
1965
  try {
1851
- dim = await readImageSize(item.file);
1966
+ dim = await readImageSize(file);
1852
1967
  } catch {
1853
1968
  dim = null;
1854
1969
  }
1855
- if (item.state !== "queued") {
1856
- item.validating = false;
1857
- return;
1858
- }
1970
+ if (item.validateSeq !== seq) return;
1859
1971
  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);
1972
+ if (item.state === "queued") {
1973
+ if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
1974
+ const need = `${minW || "?"}\xD7${minH || "?"}`;
1975
+ item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
1976
+ this.setItemState(item, "failed");
1977
+ } else {
1978
+ this.updateItemBadge(item);
1979
+ }
1866
1980
  }
1867
1981
  this.refreshConfirm();
1868
1982
  this.maybeAutoUpload();