@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.cjs CHANGED
@@ -107,6 +107,10 @@ var BASE_CSS = `
107
107
  .us-picker-backdrop {
108
108
  --us-font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
109
109
  --us-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
110
+ /* Default to a light color-scheme so native controls never inherit the
111
+ visitor's OS dark mode; picker.ts overrides this to match the resolved
112
+ theme mode on mount. */
113
+ color-scheme: light;
110
114
  position: fixed; inset: 0; z-index: 2147483000;
111
115
  background: rgba(2, 6, 23, 0.4);
112
116
  -webkit-backdrop-filter: blur(8px);
@@ -279,6 +283,14 @@ var BASE_CSS = `
279
283
  color: var(--us-muted); font-size: 11px; flex-shrink: 0;
280
284
  font-family: var(--us-mono); letter-spacing: 0;
281
285
  }
286
+ /* Failure reason \u2014 full-width, danger-colored, wraps. Collapses entirely when
287
+ empty so non-failed rows keep their original height. */
288
+ .us-file-error {
289
+ color: var(--us-danger); font-size: 12px; line-height: 1.35;
290
+ margin-top: 4px; letter-spacing: -0.006em;
291
+ overflow-wrap: anywhere;
292
+ }
293
+ .us-file-error:empty { display: none; }
282
294
 
283
295
  .us-file-progress {
284
296
  height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
@@ -518,6 +530,26 @@ var BASE_CSS = `
518
530
  .us-tool svg { width: 13px; height: 13px; }
519
531
  .us-tool-spacer { flex: 1; }
520
532
 
533
+ /* Crop dimension inputs \u2014 live W\xD7H readout, editable for free-form crops. */
534
+ .us-crop-dims {
535
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
536
+ padding: 10px 16px 0;
537
+ }
538
+ .us-crop-dims[hidden] { display: none; }
539
+ .us-crop-dims-label {
540
+ font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
541
+ text-transform: uppercase; color: var(--us-muted);
542
+ }
543
+ .us-crop-dim-input {
544
+ width: 76px; appearance: none; text-align: center;
545
+ background: var(--us-overlay); border: 1px solid var(--us-border-strong);
546
+ border-radius: 8px; padding: 5px 8px; min-height: 30px;
547
+ font: inherit; font-size: 12px; color: var(--us-fg);
548
+ }
549
+ .us-crop-dim-input:focus { outline: none; border-color: var(--us-primary); }
550
+ .us-crop-dim-input:read-only { opacity: 0.55; cursor: default; }
551
+ .us-crop-dims-x, .us-crop-dims-unit { color: var(--us-muted); font-size: 12px; }
552
+
521
553
  .us-editor-footer {
522
554
  display: flex; gap: 8px; justify-content: flex-end;
523
555
  padding: 12px 16px;
@@ -763,6 +795,16 @@ var ImageEditor = class {
763
795
  this.displayCanvas.className = "us-editor-canvas";
764
796
  this.canvasWrap.appendChild(this.displayCanvas);
765
797
  this.root.appendChild(this.canvasWrap);
798
+ this.cropDims = el("div", "us-crop-dims");
799
+ this.cropDims.hidden = true;
800
+ this.widthInput = this.makeDimInput("Crop width in pixels", "w");
801
+ this.heightInput = this.makeDimInput("Crop height in pixels", "h");
802
+ this.cropDims.appendChild(el("span", "us-crop-dims-label", "Crop size"));
803
+ this.cropDims.appendChild(this.widthInput);
804
+ this.cropDims.appendChild(el("span", "us-crop-dims-x", "\xD7"));
805
+ this.cropDims.appendChild(this.heightInput);
806
+ this.cropDims.appendChild(el("span", "us-crop-dims-unit", "px"));
807
+ this.root.appendChild(this.cropDims);
766
808
  const toolbar = el("div", "us-editor-toolbar");
767
809
  this.cropTool = makeTool("Crop", ICON.crop);
768
810
  this.cropTool.onclick = () => this.toggleCropMode();
@@ -856,21 +898,30 @@ var ImageEditor = class {
856
898
  const dw = this.displayCanvas.width;
857
899
  const dh = this.displayCanvas.height;
858
900
  if (this.aspect) {
901
+ const a = this.aspect;
859
902
  let w = dw;
860
- let h = w / this.aspect;
903
+ let h = w / a;
861
904
  if (h > dh) {
862
905
  h = dh;
863
- w = h * this.aspect;
906
+ w = h * a;
907
+ }
908
+ w = w * 0.9;
909
+ h = w / a;
910
+ if (w < MIN_CROP) {
911
+ w = MIN_CROP;
912
+ h = w / a;
913
+ }
914
+ if (h < MIN_CROP) {
915
+ h = MIN_CROP;
916
+ w = h * a;
864
917
  }
865
- w = Math.max(MIN_CROP, Math.round(w * 0.9));
866
- h = Math.max(MIN_CROP, Math.round(w / this.aspect));
867
918
  if (w > dw) {
868
919
  w = dw;
869
- h = w / this.aspect;
920
+ h = w / a;
870
921
  }
871
922
  if (h > dh) {
872
923
  h = dh;
873
- w = h * this.aspect;
924
+ w = h * a;
874
925
  }
875
926
  this.cropRect = {
876
927
  x: Math.round((dw - w) / 2),
@@ -883,6 +934,12 @@ var ImageEditor = class {
883
934
  this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
884
935
  }
885
936
  this.renderCropBox();
937
+ const locked = this.aspect !== null;
938
+ this.widthInput.readOnly = locked;
939
+ this.heightInput.readOnly = locked;
940
+ this.cropDims.dataset.locked = locked ? "true" : "false";
941
+ this.cropDims.hidden = false;
942
+ this.syncCropDimsInputs();
886
943
  }
887
944
  exitCropMode() {
888
945
  this.mode = "none";
@@ -893,6 +950,46 @@ var ImageEditor = class {
893
950
  this.cropBox.remove();
894
951
  this.cropBox = null;
895
952
  }
953
+ this.cropDims.hidden = true;
954
+ }
955
+ // Build a numeric crop-dimension input. `axis` selects which side it edits.
956
+ makeDimInput(ariaLabel, axis) {
957
+ const input = document.createElement("input");
958
+ input.type = "number";
959
+ input.min = "1";
960
+ input.inputMode = "numeric";
961
+ input.className = "us-crop-dim-input";
962
+ input.setAttribute("aria-label", ariaLabel);
963
+ input.addEventListener("input", () => this.onDimInput(axis));
964
+ input.addEventListener("change", () => this.syncCropDimsInputs());
965
+ return input;
966
+ }
967
+ // Push the current crop frame size (in image/output pixels) into the inputs,
968
+ // skipping whichever the user is actively editing so we don't fight typing.
969
+ syncCropDimsInputs() {
970
+ if (!this.cropRect || !this.widthInput || !this.heightInput) return;
971
+ const s = this.displayScale || 1;
972
+ const wImg = Math.max(1, Math.round(this.cropRect.w / s));
973
+ const hImg = Math.max(1, Math.round(this.cropRect.h / s));
974
+ if (document.activeElement !== this.widthInput) this.widthInput.value = String(wImg);
975
+ if (document.activeElement !== this.heightInput) this.heightInput.value = String(hImg);
976
+ }
977
+ // Free-form only: resize the crop frame from a typed pixel dimension. Anchored
978
+ // at the frame's current top-left and clamped to the canvas + the min size.
979
+ onDimInput(axis) {
980
+ if (this.aspect !== null || !this.cropRect) return;
981
+ const s = this.displayScale || 1;
982
+ const input = axis === "w" ? this.widthInput : this.heightInput;
983
+ const valImg = parseInt(input.value, 10);
984
+ if (!Number.isFinite(valImg) || valImg <= 0) return;
985
+ if (axis === "w") {
986
+ const maxW = this.displayCanvas.width - this.cropRect.x;
987
+ this.cropRect = { ...this.cropRect, w: clamp(Math.round(valImg * s), MIN_CROP, maxW) };
988
+ } else {
989
+ const maxH = this.displayCanvas.height - this.cropRect.y;
990
+ this.cropRect = { ...this.cropRect, h: clamp(Math.round(valImg * s), MIN_CROP, maxH) };
991
+ }
992
+ this.updateCropBox();
896
993
  }
897
994
  renderCropBox() {
898
995
  if (!this.cropRect) return;
@@ -923,6 +1020,7 @@ var ImageEditor = class {
923
1020
  this.cropBox.style.top = `${top}px`;
924
1021
  this.cropBox.style.width = `${this.cropRect.w}px`;
925
1022
  this.cropBox.style.height = `${this.cropRect.h}px`;
1023
+ this.syncCropDimsInputs();
926
1024
  }
927
1025
  // ---- drag handlers (crop) ----------------------------------------------
928
1026
  beginDrag(e, kind) {
@@ -991,6 +1089,7 @@ var ImageEditor = class {
991
1089
  this.working = next;
992
1090
  this.exitCropMode();
993
1091
  this.markEdited(true);
1092
+ this.opts.onAspectReset?.();
994
1093
  this.draw();
995
1094
  }
996
1095
  applyCircle() {
@@ -1014,11 +1113,13 @@ var ImageEditor = class {
1014
1113
  this.hasAlpha = true;
1015
1114
  this.exitCropMode();
1016
1115
  this.markEdited(true);
1116
+ this.opts.onAspectReset?.();
1017
1117
  this.draw();
1018
1118
  }
1019
1119
  async revert() {
1020
1120
  await this.loadTrueOriginalIntoWorking();
1021
1121
  this.exitCropMode();
1122
+ this.opts.onAspectReset?.();
1022
1123
  this.draw();
1023
1124
  }
1024
1125
  markEdited(edited) {
@@ -1182,6 +1283,10 @@ var Picker = class {
1182
1283
  this.editor = null;
1183
1284
  this.abortCtrl = new AbortController();
1184
1285
  this.uploadStarted = false;
1286
+ // Monotonic counter so a stale image-dimension decode (e.g. one started before
1287
+ // the user edited the file) can't clobber the row's state — only the latest
1288
+ // validation per item is allowed to mutate it.
1289
+ this.validateSeqCounter = 0;
1185
1290
  this.resolved = false;
1186
1291
  // Element focused before the modal opened, restored on close (a11y).
1187
1292
  this.previouslyFocused = null;
@@ -1229,6 +1334,7 @@ var Picker = class {
1229
1334
  Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
1230
1335
  root.style.setProperty(k, v);
1231
1336
  });
1337
+ root.style.colorScheme = this.opts.theme?.mode === "dark" ? "dark" : "light";
1232
1338
  root.addEventListener("click", (e) => {
1233
1339
  if (e.target === root && !this.uploadStarted) this.cancel();
1234
1340
  });
@@ -1639,6 +1745,9 @@ var Picker = class {
1639
1745
  const bar = el2("div", "us-file-progress-bar");
1640
1746
  progress.appendChild(bar);
1641
1747
  main.appendChild(progress);
1748
+ const error = el2("div", "us-file-error");
1749
+ if (item.error) error.textContent = item.error;
1750
+ main.appendChild(error);
1642
1751
  row.appendChild(main);
1643
1752
  const isImage = item.file.type.startsWith("image/");
1644
1753
  const editingEnabled = this.opts.imageEditing !== false;
@@ -1662,6 +1771,7 @@ var Picker = class {
1662
1771
  item.$bar = bar;
1663
1772
  item.$status = status;
1664
1773
  item.$meta = meta;
1774
+ item.$error = error;
1665
1775
  item.$thumb = thumb;
1666
1776
  this.$list.appendChild(row);
1667
1777
  this.updateSummary();
@@ -1713,6 +1823,11 @@ var Picker = class {
1713
1823
  onCropApplied: () => {
1714
1824
  didRatioCrop = true;
1715
1825
  },
1826
+ // Rotating/circling/reverting after a crop changes the working aspect, so
1827
+ // the image is no longer guaranteed to match the locked ratio.
1828
+ onAspectReset: () => {
1829
+ didRatioCrop = false;
1830
+ },
1716
1831
  onApply: (edited) => {
1717
1832
  const wasEdited = edited !== item.originalFile;
1718
1833
  this.replaceItemFile(item, edited, wasEdited);
@@ -1777,16 +1892,13 @@ var Picker = class {
1777
1892
  case "uploading":
1778
1893
  item.$meta.textContent = `${size} \xB7 ${Math.round(item.progress)}%`;
1779
1894
  break;
1780
- case "done":
1781
- item.$meta.textContent = size;
1782
- break;
1783
- case "failed":
1784
- item.$meta.textContent = item.error || "Failed";
1785
- break;
1786
1895
  default:
1787
1896
  item.$meta.textContent = size;
1788
1897
  }
1789
1898
  }
1899
+ if (item.$error) {
1900
+ item.$error.textContent = state === "failed" ? item.error || "Upload failed" : "";
1901
+ }
1790
1902
  this.updateSummary();
1791
1903
  }
1792
1904
  updateSummary() {
@@ -1846,25 +1958,27 @@ var Picker = class {
1846
1958
  // Decode an image and fail the row if it's below the configured minimum.
1847
1959
  // Non-decodable inputs are left queued — we can't prove they're too small.
1848
1960
  async validateDimensions(item) {
1961
+ const seq = ++this.validateSeqCounter;
1962
+ item.validateSeq = seq;
1849
1963
  const minW = this.opts.minImageWidth ?? 0;
1850
1964
  const minH = this.opts.minImageHeight ?? 0;
1965
+ const file = item.file;
1851
1966
  let dim = null;
1852
1967
  try {
1853
- dim = await readImageSize(item.file);
1968
+ dim = await readImageSize(file);
1854
1969
  } catch {
1855
1970
  dim = null;
1856
1971
  }
1857
- if (item.state !== "queued") {
1858
- item.validating = false;
1859
- return;
1860
- }
1972
+ if (item.validateSeq !== seq) return;
1861
1973
  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);
1974
+ if (item.state === "queued") {
1975
+ if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
1976
+ const need = `${minW || "?"}\xD7${minH || "?"}`;
1977
+ item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
1978
+ this.setItemState(item, "failed");
1979
+ } else {
1980
+ this.updateItemBadge(item);
1981
+ }
1868
1982
  }
1869
1983
  this.refreshConfirm();
1870
1984
  this.maybeAutoUpload();