@masters-union/union-stack 0.3.8 → 0.3.10
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/README.md +42 -16
- package/dist/cdn/loader.v1.global.js +4 -2
- package/dist/cdn/loader.v1.global.js.map +1 -1
- package/dist/{chunk-TNWAUSLA.js → chunk-F4EVNUJZ.cjs} +10 -140
- package/dist/chunk-F4EVNUJZ.cjs.map +1 -0
- package/dist/chunk-IIVRPVNX.js +140 -0
- package/dist/chunk-IIVRPVNX.js.map +1 -0
- package/dist/chunk-IV76YPDA.cjs +142 -0
- package/dist/chunk-IV76YPDA.cjs.map +1 -0
- package/dist/{chunk-ACQDEKCW.cjs → chunk-MFZ2JGLP.js} +6 -142
- package/dist/chunk-MFZ2JGLP.js.map +1 -0
- package/dist/client-C0qMOfb3.d.ts +112 -0
- package/dist/client-DOTa3Mai.d.cts +112 -0
- package/dist/index.cjs +4 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/node.cjs +244 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +56 -0
- package/dist/node.d.ts +56 -0
- package/dist/node.js +221 -0
- package/dist/node.js.map +1 -0
- package/dist/picker.cjs +213 -11
- package/dist/picker.cjs.map +1 -1
- package/dist/picker.d.cts +10 -2
- package/dist/picker.d.ts +10 -2
- package/dist/picker.js +213 -11
- package/dist/picker.js.map +1 -1
- package/dist/react.cjs +3 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +4 -2
- package/dist/react.d.ts +4 -2
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/{client-I6TadQ-m.d.cts → types-ZCfjNZls.d.cts} +20 -93
- package/dist/{client-I6TadQ-m.d.ts → types-ZCfjNZls.d.ts} +20 -93
- package/package.json +7 -1
- package/dist/chunk-ACQDEKCW.cjs.map +0 -1
- package/dist/chunk-TNWAUSLA.js.map +0 -1
package/dist/picker.cjs
CHANGED
|
@@ -251,6 +251,8 @@ var BASE_CSS = `
|
|
|
251
251
|
}
|
|
252
252
|
.us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
|
|
253
253
|
.us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
|
|
254
|
+
/* Force-crop: image queued but not yet cropped to the required ratio. */
|
|
255
|
+
.us-file[data-crop-required="true"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
|
|
254
256
|
|
|
255
257
|
.us-file-thumb {
|
|
256
258
|
width: 40px; height: 40px; flex-shrink: 0;
|
|
@@ -615,6 +617,7 @@ var BASE_CSS = `
|
|
|
615
617
|
|
|
616
618
|
// src/picker/imageEditor.ts
|
|
617
619
|
var MAX_DISPLAY = 720;
|
|
620
|
+
var MIN_CROP = 24;
|
|
618
621
|
var ICON = {
|
|
619
622
|
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>`,
|
|
620
623
|
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>`,
|
|
@@ -645,7 +648,31 @@ var ImageEditor = class {
|
|
|
645
648
|
const start = this.dragStart.rect;
|
|
646
649
|
const maxW = this.displayCanvas.width;
|
|
647
650
|
const maxH = this.displayCanvas.height;
|
|
648
|
-
const min =
|
|
651
|
+
const min = MIN_CROP;
|
|
652
|
+
if (this.aspect && this.dragKind !== "move") {
|
|
653
|
+
switch (this.dragKind) {
|
|
654
|
+
case "se":
|
|
655
|
+
this.cropRect = this.lockedRectFromDrag(start.x, start.y, start.w + dx, 1, 1);
|
|
656
|
+
break;
|
|
657
|
+
case "nw": {
|
|
658
|
+
const ax = start.x + start.w, ay = start.y + start.h;
|
|
659
|
+
this.cropRect = this.lockedRectFromDrag(ax, ay, ax - (start.x + dx), -1, -1);
|
|
660
|
+
break;
|
|
661
|
+
}
|
|
662
|
+
case "ne": {
|
|
663
|
+
const ay = start.y + start.h;
|
|
664
|
+
this.cropRect = this.lockedRectFromDrag(start.x, ay, start.w + dx, 1, -1);
|
|
665
|
+
break;
|
|
666
|
+
}
|
|
667
|
+
case "sw": {
|
|
668
|
+
const ax = start.x + start.w;
|
|
669
|
+
this.cropRect = this.lockedRectFromDrag(ax, start.y, ax - (start.x + dx), -1, 1);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
this.updateCropBox();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
649
676
|
let { x, y, w, h } = start;
|
|
650
677
|
switch (this.dragKind) {
|
|
651
678
|
case "move":
|
|
@@ -692,6 +719,12 @@ var ImageEditor = class {
|
|
|
692
719
|
document.removeEventListener("pointerup", this.onPointerUp);
|
|
693
720
|
};
|
|
694
721
|
}
|
|
722
|
+
// Locked crop ratio (width / height), or null for free-form. Derived from
|
|
723
|
+
// opts so there's a single source of truth.
|
|
724
|
+
get aspect() {
|
|
725
|
+
const a = this.opts.lockedAspect;
|
|
726
|
+
return a && a.width > 0 && a.height > 0 ? a.width / a.height : null;
|
|
727
|
+
}
|
|
695
728
|
async open() {
|
|
696
729
|
this.mount();
|
|
697
730
|
try {
|
|
@@ -822,8 +855,33 @@ var ImageEditor = class {
|
|
|
822
855
|
this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
|
|
823
856
|
const dw = this.displayCanvas.width;
|
|
824
857
|
const dh = this.displayCanvas.height;
|
|
825
|
-
|
|
826
|
-
|
|
858
|
+
if (this.aspect) {
|
|
859
|
+
let w = dw;
|
|
860
|
+
let h = w / this.aspect;
|
|
861
|
+
if (h > dh) {
|
|
862
|
+
h = dh;
|
|
863
|
+
w = h * this.aspect;
|
|
864
|
+
}
|
|
865
|
+
w = Math.max(MIN_CROP, Math.round(w * 0.9));
|
|
866
|
+
h = Math.max(MIN_CROP, Math.round(w / this.aspect));
|
|
867
|
+
if (w > dw) {
|
|
868
|
+
w = dw;
|
|
869
|
+
h = w / this.aspect;
|
|
870
|
+
}
|
|
871
|
+
if (h > dh) {
|
|
872
|
+
h = dh;
|
|
873
|
+
w = h * this.aspect;
|
|
874
|
+
}
|
|
875
|
+
this.cropRect = {
|
|
876
|
+
x: Math.round((dw - w) / 2),
|
|
877
|
+
y: Math.round((dh - h) / 2),
|
|
878
|
+
w,
|
|
879
|
+
h
|
|
880
|
+
};
|
|
881
|
+
} else {
|
|
882
|
+
const inset = Math.round(Math.min(dw, dh) * 0.1);
|
|
883
|
+
this.cropRect = { x: inset, y: inset, w: dw - inset * 2, h: dh - inset * 2 };
|
|
884
|
+
}
|
|
827
885
|
this.renderCropBox();
|
|
828
886
|
}
|
|
829
887
|
exitCropMode() {
|
|
@@ -875,6 +933,28 @@ var ImageEditor = class {
|
|
|
875
933
|
document.addEventListener("pointermove", this.onPointerMove);
|
|
876
934
|
document.addEventListener("pointerup", this.onPointerUp);
|
|
877
935
|
}
|
|
936
|
+
// Build a ratio-locked rect from a fixed anchor corner and a desired width.
|
|
937
|
+
// signX/signY say whether the dragged corner sits to the right/below the
|
|
938
|
+
// anchor (+1) or left/above (-1). Height is always derived from width to keep
|
|
939
|
+
// the ratio exact; both are clamped so the rect stays on-canvas.
|
|
940
|
+
lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
|
|
941
|
+
const a = this.aspect;
|
|
942
|
+
const maxW = this.displayCanvas.width;
|
|
943
|
+
const maxH = this.displayCanvas.height;
|
|
944
|
+
let w = Math.max(MIN_CROP, desiredW);
|
|
945
|
+
let h = w / a;
|
|
946
|
+
if (h < MIN_CROP) {
|
|
947
|
+
h = MIN_CROP;
|
|
948
|
+
w = h * a;
|
|
949
|
+
}
|
|
950
|
+
const roomX = signX > 0 ? maxW - anchorX : anchorX;
|
|
951
|
+
const roomY = signY > 0 ? maxH - anchorY : anchorY;
|
|
952
|
+
w = Math.min(w, roomX, roomY * a);
|
|
953
|
+
h = w / a;
|
|
954
|
+
const x = signX > 0 ? anchorX : anchorX - w;
|
|
955
|
+
const y = signY > 0 ? anchorY : anchorY - h;
|
|
956
|
+
return { x, y, w, h };
|
|
957
|
+
}
|
|
878
958
|
// ---- operations (mutate working canvas) --------------------------------
|
|
879
959
|
applyCrop() {
|
|
880
960
|
if (!this.cropRect) {
|
|
@@ -895,6 +975,7 @@ var ImageEditor = class {
|
|
|
895
975
|
this.working = next;
|
|
896
976
|
this.exitCropMode();
|
|
897
977
|
this.markEdited(true);
|
|
978
|
+
this.opts.onCropApplied?.();
|
|
898
979
|
this.draw();
|
|
899
980
|
}
|
|
900
981
|
applyRotate() {
|
|
@@ -1041,6 +1122,12 @@ function mergeConfig(server, runtime) {
|
|
|
1041
1122
|
const types = server.constraints.allowedMimeTypes.filter((t) => t !== "*/*");
|
|
1042
1123
|
if (types.length > 0) merged.accept = types.join(",");
|
|
1043
1124
|
}
|
|
1125
|
+
const srvImg = server.image ?? null;
|
|
1126
|
+
const ratio = runtime.cropAspectRatio ?? (srvImg && srvImg.cropRatioW && srvImg.cropRatioH ? { width: srvImg.cropRatioW, height: srvImg.cropRatioH } : void 0);
|
|
1127
|
+
merged.cropAspectRatio = ratio;
|
|
1128
|
+
merged.forceCrop = (runtime.forceCrop ?? srvImg?.forceCrop ?? false) && !!ratio && runtime.imageEditing !== false;
|
|
1129
|
+
merged.minImageWidth = runtime.minImageWidth ?? srvImg?.minWidth ?? void 0;
|
|
1130
|
+
merged.minImageHeight = runtime.minImageHeight ?? srvImg?.minHeight ?? void 0;
|
|
1044
1131
|
return merged;
|
|
1045
1132
|
}
|
|
1046
1133
|
var DEFAULT_TITLE = "Upload files";
|
|
@@ -1513,15 +1600,13 @@ var Picker = class {
|
|
|
1513
1600
|
};
|
|
1514
1601
|
this.items.push(item);
|
|
1515
1602
|
this.renderItem(item);
|
|
1603
|
+
if (this.shouldCheckDimensions(file)) {
|
|
1604
|
+
item.validating = true;
|
|
1605
|
+
this.validateDimensions(item);
|
|
1606
|
+
}
|
|
1516
1607
|
}
|
|
1517
1608
|
this.refreshConfirm();
|
|
1518
|
-
|
|
1519
|
-
const hasQueued = this.items.some((i) => i.state === "queued");
|
|
1520
|
-
if (autoUpload && hasQueued && !this.uploadStarted) {
|
|
1521
|
-
requestAnimationFrame(() => {
|
|
1522
|
-
if (!this.uploadStarted) this.startUpload();
|
|
1523
|
-
});
|
|
1524
|
-
}
|
|
1609
|
+
this.maybeAutoUpload();
|
|
1525
1610
|
}
|
|
1526
1611
|
renderItem(item) {
|
|
1527
1612
|
if (!this.$list) return;
|
|
@@ -1580,6 +1665,7 @@ var Picker = class {
|
|
|
1580
1665
|
item.$thumb = thumb;
|
|
1581
1666
|
this.$list.appendChild(row);
|
|
1582
1667
|
this.updateSummary();
|
|
1668
|
+
this.updateItemBadge(item);
|
|
1583
1669
|
}
|
|
1584
1670
|
// Swap a queued item's file with an edited version. Refreshes thumbnail and
|
|
1585
1671
|
// meta in place so the row identity is preserved (uploadId, position).
|
|
@@ -1617,15 +1703,27 @@ var Picker = class {
|
|
|
1617
1703
|
// nothing (Cancel/Back).
|
|
1618
1704
|
openEditor(item) {
|
|
1619
1705
|
if (!this.$panel || this.editor || item.state !== "queued") return;
|
|
1706
|
+
let didRatioCrop = false;
|
|
1620
1707
|
this.editor = new ImageEditor({
|
|
1621
1708
|
host: this.$panel,
|
|
1622
1709
|
file: item.file,
|
|
1623
1710
|
originalFile: item.originalFile,
|
|
1624
1711
|
title: item.originalFile.name,
|
|
1712
|
+
lockedAspect: this.opts.cropAspectRatio,
|
|
1713
|
+
onCropApplied: () => {
|
|
1714
|
+
didRatioCrop = true;
|
|
1715
|
+
},
|
|
1625
1716
|
onApply: (edited) => {
|
|
1626
1717
|
const wasEdited = edited !== item.originalFile;
|
|
1627
1718
|
this.replaceItemFile(item, edited, wasEdited);
|
|
1719
|
+
if (didRatioCrop) item.ratioCropped = true;
|
|
1628
1720
|
this.editor = null;
|
|
1721
|
+
if (this.shouldCheckDimensions(item.file)) {
|
|
1722
|
+
item.validating = true;
|
|
1723
|
+
this.validateDimensions(item);
|
|
1724
|
+
}
|
|
1725
|
+
this.updateItemBadge(item);
|
|
1726
|
+
this.refreshConfirm();
|
|
1629
1727
|
},
|
|
1630
1728
|
onCancel: () => {
|
|
1631
1729
|
this.editor = null;
|
|
@@ -1714,10 +1812,77 @@ var Picker = class {
|
|
|
1714
1812
|
this.$summary.textContent = `${total} file${total === 1 ? "" : "s"} ready`;
|
|
1715
1813
|
}
|
|
1716
1814
|
}
|
|
1815
|
+
// ---- image rules (force-crop + min dimensions) --------------------------
|
|
1816
|
+
// True when force-crop is on and this queued raster image hasn't been cropped
|
|
1817
|
+
// to the locked ratio yet. SVGs are exempt (vector→raster crop is lossy).
|
|
1818
|
+
needsCrop(item) {
|
|
1819
|
+
return this.opts.forceCrop === true && !!this.opts.cropAspectRatio && item.state === "queued" && item.file.type.startsWith("image/") && item.file.type !== "image/svg+xml" && !item.ratioCropped;
|
|
1820
|
+
}
|
|
1821
|
+
hasUncroppedImages() {
|
|
1822
|
+
return this.items.some((i) => this.needsCrop(i));
|
|
1823
|
+
}
|
|
1824
|
+
anyValidating() {
|
|
1825
|
+
return this.items.some((i) => i.validating === true);
|
|
1826
|
+
}
|
|
1827
|
+
// Whether a min-dimension rule applies to this file (image, non-SVG, rule set).
|
|
1828
|
+
shouldCheckDimensions(file) {
|
|
1829
|
+
const minConfigured = !!(this.opts.minImageWidth || this.opts.minImageHeight);
|
|
1830
|
+
return minConfigured && file.type.startsWith("image/") && file.type !== "image/svg+xml";
|
|
1831
|
+
}
|
|
1832
|
+
// Toggle the per-row "crop required" cue. Only touches the meta line for
|
|
1833
|
+
// queued rows (other states own their meta via setItemState).
|
|
1834
|
+
updateItemBadge(item) {
|
|
1835
|
+
if (!item.$row) return;
|
|
1836
|
+
if (this.needsCrop(item)) {
|
|
1837
|
+
item.$row.dataset.cropRequired = "true";
|
|
1838
|
+
if (item.$meta) item.$meta.textContent = "Crop to ratio required";
|
|
1839
|
+
} else {
|
|
1840
|
+
delete item.$row.dataset.cropRequired;
|
|
1841
|
+
if (item.state === "queued" && item.$meta && !item.validating) {
|
|
1842
|
+
item.$meta.textContent = formatBytes(item.file.size);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
// Decode an image and fail the row if it's below the configured minimum.
|
|
1847
|
+
// Non-decodable inputs are left queued — we can't prove they're too small.
|
|
1848
|
+
async validateDimensions(item) {
|
|
1849
|
+
const minW = this.opts.minImageWidth ?? 0;
|
|
1850
|
+
const minH = this.opts.minImageHeight ?? 0;
|
|
1851
|
+
let dim = null;
|
|
1852
|
+
try {
|
|
1853
|
+
dim = await readImageSize(item.file);
|
|
1854
|
+
} catch {
|
|
1855
|
+
dim = null;
|
|
1856
|
+
}
|
|
1857
|
+
if (item.state !== "queued") {
|
|
1858
|
+
item.validating = false;
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
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);
|
|
1868
|
+
}
|
|
1869
|
+
this.refreshConfirm();
|
|
1870
|
+
this.maybeAutoUpload();
|
|
1871
|
+
}
|
|
1872
|
+
// Fire auto-upload once nothing is blocking it (no pending validation, no
|
|
1873
|
+
// pending force-crops). Re-checked whenever a validation settles.
|
|
1874
|
+
maybeAutoUpload() {
|
|
1875
|
+
if (this.opts.autoUpload !== true || this.uploadStarted) return;
|
|
1876
|
+
if (this.anyValidating() || this.hasUncroppedImages()) return;
|
|
1877
|
+
if (!this.items.some((i) => i.state === "queued")) return;
|
|
1878
|
+
requestAnimationFrame(() => {
|
|
1879
|
+
if (!this.uploadStarted) this.startUpload();
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1717
1882
|
refreshConfirm() {
|
|
1718
1883
|
if (!this.$confirm) return;
|
|
1719
1884
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1720
|
-
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1885
|
+
this.$confirm.disabled = queued === 0 || this.uploadStarted || this.hasUncroppedImages() || this.anyValidating();
|
|
1721
1886
|
}
|
|
1722
1887
|
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1723
1888
|
flashNotice(text) {
|
|
@@ -1736,6 +1901,15 @@ var Picker = class {
|
|
|
1736
1901
|
// ---- upload -------------------------------------------------------------
|
|
1737
1902
|
async startUpload() {
|
|
1738
1903
|
if (this.uploadStarted) return;
|
|
1904
|
+
if (this.anyValidating()) {
|
|
1905
|
+
this.flashNotice("Checking image dimensions\u2026");
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
if (this.hasUncroppedImages()) {
|
|
1909
|
+
this.items.forEach((i) => this.updateItemBadge(i));
|
|
1910
|
+
this.flashNotice("Crop required \u2014 crop every image to the set ratio before uploading.");
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1739
1913
|
const queued = this.items.filter((i) => i.state === "queued");
|
|
1740
1914
|
if (queued.length === 0) return;
|
|
1741
1915
|
this.uploadStarted = true;
|
|
@@ -1891,6 +2065,34 @@ function cryptoId() {
|
|
|
1891
2065
|
}
|
|
1892
2066
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1893
2067
|
}
|
|
2068
|
+
async function readImageSize(file) {
|
|
2069
|
+
if (typeof createImageBitmap === "function") {
|
|
2070
|
+
try {
|
|
2071
|
+
const bmp = await createImageBitmap(file);
|
|
2072
|
+
const w = bmp.width, h = bmp.height;
|
|
2073
|
+
bmp.close?.();
|
|
2074
|
+
return { w, h };
|
|
2075
|
+
} catch {
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
return new Promise((resolve, reject) => {
|
|
2079
|
+
if (typeof URL === "undefined" || !URL.createObjectURL) {
|
|
2080
|
+
reject(new Error("no object URL support"));
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
const url = URL.createObjectURL(file);
|
|
2084
|
+
const img = new Image();
|
|
2085
|
+
img.onload = () => {
|
|
2086
|
+
URL.revokeObjectURL(url);
|
|
2087
|
+
resolve({ w: img.naturalWidth, h: img.naturalHeight });
|
|
2088
|
+
};
|
|
2089
|
+
img.onerror = () => {
|
|
2090
|
+
URL.revokeObjectURL(url);
|
|
2091
|
+
reject(new Error("decode failed"));
|
|
2092
|
+
};
|
|
2093
|
+
img.src = url;
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
1894
2096
|
async function fetchUrlAsFile(url) {
|
|
1895
2097
|
let res;
|
|
1896
2098
|
try {
|