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