@masters-union/union-stack 0.3.11 → 0.3.13

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.js CHANGED
@@ -528,6 +528,26 @@ var BASE_CSS = `
528
528
  .us-tool svg { width: 13px; height: 13px; }
529
529
  .us-tool-spacer { flex: 1; }
530
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
+
531
551
  .us-editor-footer {
532
552
  display: flex; gap: 8px; justify-content: flex-end;
533
553
  padding: 12px 16px;
@@ -640,8 +660,6 @@ var ImageEditor = class {
640
660
  constructor(opts) {
641
661
  this.opts = opts;
642
662
  this.cropBox = null;
643
- // What's currently rendered to the user, sized to fit MAX_DISPLAY.
644
- this.displayScale = 1;
645
663
  // Tracks whether the working canvas has transparency (post-circle), so
646
664
  // export() can pick PNG vs JPEG correctly.
647
665
  this.hasAlpha = false;
@@ -653,12 +671,13 @@ var ImageEditor = class {
653
671
  this.dragStart = null;
654
672
  this.onPointerMove = (e) => {
655
673
  if (!this.dragKind || !this.dragStart || !this.cropRect) return;
656
- const dx = e.clientX - this.dragStart.px;
657
- const dy = e.clientY - this.dragStart.py;
674
+ const m = this.canvasMetrics();
675
+ const dx = (e.clientX - this.dragStart.px) / (m.scaleX || 1);
676
+ const dy = (e.clientY - this.dragStart.py) / (m.scaleY || 1);
658
677
  const start = this.dragStart.rect;
659
- const maxW = this.displayCanvas.width;
660
- const maxH = this.displayCanvas.height;
661
- const min = MIN_CROP;
678
+ const maxW = this.working.width;
679
+ const maxH = this.working.height;
680
+ const min = this.minCropImg();
662
681
  if (this.aspect && this.dragKind !== "move") {
663
682
  switch (this.dragKind) {
664
683
  case "se":
@@ -773,6 +792,16 @@ var ImageEditor = class {
773
792
  this.displayCanvas.className = "us-editor-canvas";
774
793
  this.canvasWrap.appendChild(this.displayCanvas);
775
794
  this.root.appendChild(this.canvasWrap);
795
+ this.cropDims = el("div", "us-crop-dims");
796
+ this.cropDims.hidden = true;
797
+ this.widthInput = this.makeDimInput("Crop width in pixels", "w");
798
+ this.heightInput = this.makeDimInput("Crop height in pixels", "h");
799
+ this.cropDims.appendChild(el("span", "us-crop-dims-label", "Crop size"));
800
+ this.cropDims.appendChild(this.widthInput);
801
+ this.cropDims.appendChild(el("span", "us-crop-dims-x", "\xD7"));
802
+ this.cropDims.appendChild(this.heightInput);
803
+ this.cropDims.appendChild(el("span", "us-crop-dims-unit", "px"));
804
+ this.root.appendChild(this.cropDims);
776
805
  const toolbar = el("div", "us-editor-toolbar");
777
806
  this.cropTool = makeTool("Crop", ICON.crop);
778
807
  this.cropTool.onclick = () => this.toggleCropMode();
@@ -844,7 +873,6 @@ var ImageEditor = class {
844
873
  const dh = Math.max(1, Math.round(wh * scale));
845
874
  this.displayCanvas.width = dw;
846
875
  this.displayCanvas.height = dh;
847
- this.displayScale = scale;
848
876
  const ctx = this.displayCanvas.getContext("2d");
849
877
  if (!ctx) return;
850
878
  ctx.clearRect(0, 0, dw, dh);
@@ -863,45 +891,52 @@ var ImageEditor = class {
863
891
  this.mode = "crop";
864
892
  this.cropTool.dataset.active = "true";
865
893
  this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
866
- const dw = this.displayCanvas.width;
867
- const dh = this.displayCanvas.height;
894
+ const iw = this.working.width;
895
+ const ih = this.working.height;
868
896
  if (this.aspect) {
869
897
  const a = this.aspect;
870
- let w = dw;
898
+ const minImg = this.minCropImg();
899
+ let w = iw;
871
900
  let h = w / a;
872
- if (h > dh) {
873
- h = dh;
901
+ if (h > ih) {
902
+ h = ih;
874
903
  w = h * a;
875
904
  }
876
905
  w = w * 0.9;
877
906
  h = w / a;
878
- if (w < MIN_CROP) {
879
- w = MIN_CROP;
907
+ if (w < minImg) {
908
+ w = minImg;
880
909
  h = w / a;
881
910
  }
882
- if (h < MIN_CROP) {
883
- h = MIN_CROP;
911
+ if (h < minImg) {
912
+ h = minImg;
884
913
  w = h * a;
885
914
  }
886
- if (w > dw) {
887
- w = dw;
915
+ if (w > iw) {
916
+ w = iw;
888
917
  h = w / a;
889
918
  }
890
- if (h > dh) {
891
- h = dh;
919
+ if (h > ih) {
920
+ h = ih;
892
921
  w = h * a;
893
922
  }
894
923
  this.cropRect = {
895
- x: Math.round((dw - w) / 2),
896
- y: Math.round((dh - h) / 2),
924
+ x: Math.round((iw - w) / 2),
925
+ y: Math.round((ih - h) / 2),
897
926
  w,
898
927
  h
899
928
  };
900
929
  } 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 };
930
+ const inset = Math.round(Math.min(iw, ih) * 0.1);
931
+ this.cropRect = { x: inset, y: inset, w: iw - inset * 2, h: ih - inset * 2 };
903
932
  }
904
933
  this.renderCropBox();
934
+ const locked = this.aspect !== null;
935
+ this.widthInput.readOnly = locked;
936
+ this.heightInput.readOnly = locked;
937
+ this.cropDims.dataset.locked = locked ? "true" : "false";
938
+ this.cropDims.hidden = false;
939
+ this.syncCropDimsInputs();
905
940
  }
906
941
  exitCropMode() {
907
942
  this.mode = "none";
@@ -912,6 +947,46 @@ var ImageEditor = class {
912
947
  this.cropBox.remove();
913
948
  this.cropBox = null;
914
949
  }
950
+ this.cropDims.hidden = true;
951
+ }
952
+ // Build a numeric crop-dimension input. `axis` selects which side it edits.
953
+ makeDimInput(ariaLabel, axis) {
954
+ const input = document.createElement("input");
955
+ input.type = "number";
956
+ input.min = "1";
957
+ input.inputMode = "numeric";
958
+ input.className = "us-crop-dim-input";
959
+ input.setAttribute("aria-label", ariaLabel);
960
+ input.addEventListener("input", () => this.onDimInput(axis));
961
+ input.addEventListener("change", () => this.syncCropDimsInputs());
962
+ return input;
963
+ }
964
+ // Push the current crop frame size (in image/output pixels) into the inputs,
965
+ // skipping whichever the user is actively editing so we don't fight typing.
966
+ // cropRect is already in image px, so this is a direct read.
967
+ syncCropDimsInputs() {
968
+ if (!this.cropRect || !this.widthInput || !this.heightInput) return;
969
+ const wImg = Math.max(1, Math.round(this.cropRect.w));
970
+ const hImg = Math.max(1, Math.round(this.cropRect.h));
971
+ if (document.activeElement !== this.widthInput) this.widthInput.value = String(wImg);
972
+ if (document.activeElement !== this.heightInput) this.heightInput.value = String(hImg);
973
+ }
974
+ // Free-form only: resize the crop frame from a typed output-pixel dimension.
975
+ // Anchored at the frame's current top-left and clamped to the image + min size.
976
+ onDimInput(axis) {
977
+ if (this.aspect !== null || !this.cropRect) return;
978
+ const input = axis === "w" ? this.widthInput : this.heightInput;
979
+ const valImg = parseInt(input.value, 10);
980
+ if (!Number.isFinite(valImg) || valImg <= 0) return;
981
+ const min = this.minCropImg();
982
+ if (axis === "w") {
983
+ const maxW = this.working.width - this.cropRect.x;
984
+ this.cropRect = { ...this.cropRect, w: clamp(valImg, min, maxW) };
985
+ } else {
986
+ const maxH = this.working.height - this.cropRect.y;
987
+ this.cropRect = { ...this.cropRect, h: clamp(valImg, min, maxH) };
988
+ }
989
+ this.updateCropBox();
915
990
  }
916
991
  renderCropBox() {
917
992
  if (!this.cropRect) return;
@@ -932,16 +1007,36 @@ var ImageEditor = class {
932
1007
  this.cropBox = box;
933
1008
  this.updateCropBox();
934
1009
  }
1010
+ // The crop rect is stored in IMAGE (working-canvas / output) pixels — the same
1011
+ // space we ultimately crop in — so the on-screen selection always matches the
1012
+ // pixels we extract, regardless of how CSS scales the displayed canvas. These
1013
+ // metrics convert between image px and the canvas's live on-screen size.
1014
+ canvasMetrics() {
1015
+ const c = this.displayCanvas.getBoundingClientRect();
1016
+ const w = this.canvasWrap.getBoundingClientRect();
1017
+ return {
1018
+ scaleX: c.width / (this.working.width || 1),
1019
+ // CSS px per image px
1020
+ scaleY: c.height / (this.working.height || 1),
1021
+ offX: c.left - w.left,
1022
+ // canvas offset inside wrap
1023
+ offY: c.top - w.top
1024
+ };
1025
+ }
1026
+ // Minimum crop size in IMAGE px, sized so the handles stay ~MIN_CROP px apart
1027
+ // on screen no matter how zoomed the canvas is.
1028
+ minCropImg() {
1029
+ const { scaleX } = this.canvasMetrics();
1030
+ return scaleX > 0 ? MIN_CROP / scaleX : MIN_CROP;
1031
+ }
935
1032
  updateCropBox() {
936
1033
  if (!this.cropBox || !this.cropRect) return;
937
- const canvasRect = this.displayCanvas.getBoundingClientRect();
938
- const wrapRect = this.canvasWrap.getBoundingClientRect();
939
- const left = canvasRect.left - wrapRect.left + this.cropRect.x;
940
- const top = canvasRect.top - wrapRect.top + this.cropRect.y;
941
- this.cropBox.style.left = `${left}px`;
942
- this.cropBox.style.top = `${top}px`;
943
- this.cropBox.style.width = `${this.cropRect.w}px`;
944
- this.cropBox.style.height = `${this.cropRect.h}px`;
1034
+ const m = this.canvasMetrics();
1035
+ this.cropBox.style.left = `${m.offX + this.cropRect.x * m.scaleX}px`;
1036
+ this.cropBox.style.top = `${m.offY + this.cropRect.y * m.scaleY}px`;
1037
+ this.cropBox.style.width = `${this.cropRect.w * m.scaleX}px`;
1038
+ this.cropBox.style.height = `${this.cropRect.h * m.scaleY}px`;
1039
+ this.syncCropDimsInputs();
945
1040
  }
946
1041
  // ---- drag handlers (crop) ----------------------------------------------
947
1042
  beginDrag(e, kind) {
@@ -958,12 +1053,13 @@ var ImageEditor = class {
958
1053
  // the ratio exact; both are clamped so the rect stays on-canvas.
959
1054
  lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
960
1055
  const a = this.aspect;
961
- const maxW = this.displayCanvas.width;
962
- const maxH = this.displayCanvas.height;
963
- let w = Math.max(MIN_CROP, desiredW);
1056
+ const maxW = this.working.width;
1057
+ const maxH = this.working.height;
1058
+ const min = this.minCropImg();
1059
+ let w = Math.max(min, desiredW);
964
1060
  let h = w / a;
965
- if (h < MIN_CROP) {
966
- h = MIN_CROP;
1061
+ if (h < min) {
1062
+ h = min;
967
1063
  w = h * a;
968
1064
  }
969
1065
  const roomX = signX > 0 ? maxW - anchorX : anchorX;
@@ -980,11 +1076,10 @@ var ImageEditor = class {
980
1076
  this.exitCropMode();
981
1077
  return;
982
1078
  }
983
- const s = this.displayScale;
984
- const sx = Math.round(this.cropRect.x / s);
985
- const sy = Math.round(this.cropRect.y / s);
986
- const sw = Math.round(this.cropRect.w / s);
987
- const sh = Math.round(this.cropRect.h / s);
1079
+ const sx = clamp(Math.round(this.cropRect.x), 0, this.working.width - 1);
1080
+ const sy = clamp(Math.round(this.cropRect.y), 0, this.working.height - 1);
1081
+ const sw = clamp(Math.round(this.cropRect.w), 1, this.working.width - sx);
1082
+ const sh = clamp(Math.round(this.cropRect.h), 1, this.working.height - sy);
988
1083
  const next = document.createElement("canvas");
989
1084
  next.width = sw;
990
1085
  next.height = sh;