@masters-union/union-stack 0.3.9 → 0.3.11
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/cdn/loader.v1.global.js +16 -2
- package/dist/cdn/loader.v1.global.js.map +1 -1
- package/dist/{chunk-27RCQJAE.cjs → chunk-NIO443GV.cjs} +7 -7
- package/dist/{chunk-27RCQJAE.cjs.map → chunk-NIO443GV.cjs.map} +1 -1
- package/dist/{chunk-ZOOUTUWU.cjs → chunk-NOAB2Q4B.cjs} +3 -3
- package/dist/{chunk-ZOOUTUWU.cjs.map → chunk-NOAB2Q4B.cjs.map} +1 -1
- package/dist/{chunk-DV5YSBNS.js → chunk-SP4HHLZB.js} +3 -3
- package/dist/{chunk-DV5YSBNS.js.map → chunk-SP4HHLZB.js.map} +1 -1
- package/dist/{chunk-YNWENXR2.js → chunk-Z5PZQVST.js} +3 -3
- package/dist/{chunk-YNWENXR2.js.map → chunk-Z5PZQVST.js.map} +1 -1
- package/dist/{client-B1KPVLJR.d.ts → client-C0qMOfb3.d.ts} +18 -1
- package/dist/{client-DP2jyZzm.d.cts → client-DOTa3Mai.d.cts} +18 -1
- package/dist/index.cjs +4 -4
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/node.cjs +7 -7
- package/dist/node.d.cts +2 -2
- package/dist/node.d.ts +2 -2
- package/dist/node.js +1 -1
- package/dist/picker.cjs +256 -17
- package/dist/picker.cjs.map +1 -1
- package/dist/picker.d.cts +11 -3
- package/dist/picker.d.ts +11 -3
- package/dist/picker.js +256 -17
- package/dist/picker.js.map +1 -1
- package/dist/react.cjs +3 -3
- package/dist/react.d.cts +4 -4
- package/dist/react.d.ts +4 -4
- package/dist/react.js +2 -2
- package/dist/{types-50o0pbff.d.ts → types-ZCfjNZls.d.cts} +13 -0
- package/dist/{types-50o0pbff.d.cts → types-ZCfjNZls.d.ts} +13 -0
- package/package.json +1 -1
package/dist/picker.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { U as UnionStackClient, P as PickerOptions, a as PickerHandle } from './client-
|
|
2
|
-
export { b as PickerBranding, c as PickerTheme } from './client-
|
|
3
|
-
import { P as PickResponse } from './types-
|
|
1
|
+
import { U as UnionStackClient, P as PickerOptions, a as PickerHandle } from './client-DOTa3Mai.cjs';
|
|
2
|
+
export { b as PickerBranding, c as PickerTheme } from './client-DOTa3Mai.cjs';
|
|
3
|
+
import { P as PickResponse } from './types-ZCfjNZls.cjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Single-shot file picker modal. Built imperatively (no framework) so it can
|
|
@@ -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;
|
|
@@ -56,6 +57,13 @@ declare class Picker {
|
|
|
56
57
|
private statusLabel;
|
|
57
58
|
private setItemState;
|
|
58
59
|
private updateSummary;
|
|
60
|
+
private needsCrop;
|
|
61
|
+
private hasUncroppedImages;
|
|
62
|
+
private anyValidating;
|
|
63
|
+
private shouldCheckDimensions;
|
|
64
|
+
private updateItemBadge;
|
|
65
|
+
private validateDimensions;
|
|
66
|
+
private maybeAutoUpload;
|
|
59
67
|
private refreshConfirm;
|
|
60
68
|
private flashNotice;
|
|
61
69
|
private startUpload;
|
package/dist/picker.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { U as UnionStackClient, P as PickerOptions, a as PickerHandle } from './client-
|
|
2
|
-
export { b as PickerBranding, c as PickerTheme } from './client-
|
|
3
|
-
import { P as PickResponse } from './types-
|
|
1
|
+
import { U as UnionStackClient, P as PickerOptions, a as PickerHandle } from './client-C0qMOfb3.js';
|
|
2
|
+
export { b as PickerBranding, c as PickerTheme } from './client-C0qMOfb3.js';
|
|
3
|
+
import { P as PickResponse } from './types-ZCfjNZls.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Single-shot file picker modal. Built imperatively (no framework) so it can
|
|
@@ -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;
|
|
@@ -56,6 +57,13 @@ declare class Picker {
|
|
|
56
57
|
private statusLabel;
|
|
57
58
|
private setItemState;
|
|
58
59
|
private updateSummary;
|
|
60
|
+
private needsCrop;
|
|
61
|
+
private hasUncroppedImages;
|
|
62
|
+
private anyValidating;
|
|
63
|
+
private shouldCheckDimensions;
|
|
64
|
+
private updateItemBadge;
|
|
65
|
+
private validateDimensions;
|
|
66
|
+
private maybeAutoUpload;
|
|
59
67
|
private refreshConfirm;
|
|
60
68
|
private flashNotice;
|
|
61
69
|
private startUpload;
|
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);
|
|
@@ -249,6 +253,8 @@ var BASE_CSS = `
|
|
|
249
253
|
}
|
|
250
254
|
.us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
|
|
251
255
|
.us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
|
|
256
|
+
/* Force-crop: image queued but not yet cropped to the required ratio. */
|
|
257
|
+
.us-file[data-crop-required="true"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
|
|
252
258
|
|
|
253
259
|
.us-file-thumb {
|
|
254
260
|
width: 40px; height: 40px; flex-shrink: 0;
|
|
@@ -275,6 +281,14 @@ var BASE_CSS = `
|
|
|
275
281
|
color: var(--us-muted); font-size: 11px; flex-shrink: 0;
|
|
276
282
|
font-family: var(--us-mono); letter-spacing: 0;
|
|
277
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; }
|
|
278
292
|
|
|
279
293
|
.us-file-progress {
|
|
280
294
|
height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
|
|
@@ -613,6 +627,7 @@ var BASE_CSS = `
|
|
|
613
627
|
|
|
614
628
|
// src/picker/imageEditor.ts
|
|
615
629
|
var MAX_DISPLAY = 720;
|
|
630
|
+
var MIN_CROP = 24;
|
|
616
631
|
var ICON = {
|
|
617
632
|
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>`,
|
|
618
633
|
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>`,
|
|
@@ -643,7 +658,31 @@ var ImageEditor = class {
|
|
|
643
658
|
const start = this.dragStart.rect;
|
|
644
659
|
const maxW = this.displayCanvas.width;
|
|
645
660
|
const maxH = this.displayCanvas.height;
|
|
646
|
-
const min =
|
|
661
|
+
const min = MIN_CROP;
|
|
662
|
+
if (this.aspect && this.dragKind !== "move") {
|
|
663
|
+
switch (this.dragKind) {
|
|
664
|
+
case "se":
|
|
665
|
+
this.cropRect = this.lockedRectFromDrag(start.x, start.y, start.w + dx, 1, 1);
|
|
666
|
+
break;
|
|
667
|
+
case "nw": {
|
|
668
|
+
const ax = start.x + start.w, ay = start.y + start.h;
|
|
669
|
+
this.cropRect = this.lockedRectFromDrag(ax, ay, ax - (start.x + dx), -1, -1);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case "ne": {
|
|
673
|
+
const ay = start.y + start.h;
|
|
674
|
+
this.cropRect = this.lockedRectFromDrag(start.x, ay, start.w + dx, 1, -1);
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case "sw": {
|
|
678
|
+
const ax = start.x + start.w;
|
|
679
|
+
this.cropRect = this.lockedRectFromDrag(ax, start.y, ax - (start.x + dx), -1, 1);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
this.updateCropBox();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
647
686
|
let { x, y, w, h } = start;
|
|
648
687
|
switch (this.dragKind) {
|
|
649
688
|
case "move":
|
|
@@ -690,6 +729,12 @@ var ImageEditor = class {
|
|
|
690
729
|
document.removeEventListener("pointerup", this.onPointerUp);
|
|
691
730
|
};
|
|
692
731
|
}
|
|
732
|
+
// Locked crop ratio (width / height), or null for free-form. Derived from
|
|
733
|
+
// opts so there's a single source of truth.
|
|
734
|
+
get aspect() {
|
|
735
|
+
const a = this.opts.lockedAspect;
|
|
736
|
+
return a && a.width > 0 && a.height > 0 ? a.width / a.height : null;
|
|
737
|
+
}
|
|
693
738
|
async open() {
|
|
694
739
|
this.mount();
|
|
695
740
|
try {
|
|
@@ -820,8 +865,42 @@ var ImageEditor = class {
|
|
|
820
865
|
this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
|
|
821
866
|
const dw = this.displayCanvas.width;
|
|
822
867
|
const dh = this.displayCanvas.height;
|
|
823
|
-
|
|
824
|
-
|
|
868
|
+
if (this.aspect) {
|
|
869
|
+
const a = this.aspect;
|
|
870
|
+
let w = dw;
|
|
871
|
+
let h = w / a;
|
|
872
|
+
if (h > dh) {
|
|
873
|
+
h = dh;
|
|
874
|
+
w = h * a;
|
|
875
|
+
}
|
|
876
|
+
w = w * 0.9;
|
|
877
|
+
h = w / a;
|
|
878
|
+
if (w < MIN_CROP) {
|
|
879
|
+
w = MIN_CROP;
|
|
880
|
+
h = w / a;
|
|
881
|
+
}
|
|
882
|
+
if (h < MIN_CROP) {
|
|
883
|
+
h = MIN_CROP;
|
|
884
|
+
w = h * a;
|
|
885
|
+
}
|
|
886
|
+
if (w > dw) {
|
|
887
|
+
w = dw;
|
|
888
|
+
h = w / a;
|
|
889
|
+
}
|
|
890
|
+
if (h > dh) {
|
|
891
|
+
h = dh;
|
|
892
|
+
w = h * a;
|
|
893
|
+
}
|
|
894
|
+
this.cropRect = {
|
|
895
|
+
x: Math.round((dw - w) / 2),
|
|
896
|
+
y: Math.round((dh - h) / 2),
|
|
897
|
+
w,
|
|
898
|
+
h
|
|
899
|
+
};
|
|
900
|
+
} 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 };
|
|
903
|
+
}
|
|
825
904
|
this.renderCropBox();
|
|
826
905
|
}
|
|
827
906
|
exitCropMode() {
|
|
@@ -873,6 +952,28 @@ var ImageEditor = class {
|
|
|
873
952
|
document.addEventListener("pointermove", this.onPointerMove);
|
|
874
953
|
document.addEventListener("pointerup", this.onPointerUp);
|
|
875
954
|
}
|
|
955
|
+
// Build a ratio-locked rect from a fixed anchor corner and a desired width.
|
|
956
|
+
// signX/signY say whether the dragged corner sits to the right/below the
|
|
957
|
+
// anchor (+1) or left/above (-1). Height is always derived from width to keep
|
|
958
|
+
// the ratio exact; both are clamped so the rect stays on-canvas.
|
|
959
|
+
lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
|
|
960
|
+
const a = this.aspect;
|
|
961
|
+
const maxW = this.displayCanvas.width;
|
|
962
|
+
const maxH = this.displayCanvas.height;
|
|
963
|
+
let w = Math.max(MIN_CROP, desiredW);
|
|
964
|
+
let h = w / a;
|
|
965
|
+
if (h < MIN_CROP) {
|
|
966
|
+
h = MIN_CROP;
|
|
967
|
+
w = h * a;
|
|
968
|
+
}
|
|
969
|
+
const roomX = signX > 0 ? maxW - anchorX : anchorX;
|
|
970
|
+
const roomY = signY > 0 ? maxH - anchorY : anchorY;
|
|
971
|
+
w = Math.min(w, roomX, roomY * a);
|
|
972
|
+
h = w / a;
|
|
973
|
+
const x = signX > 0 ? anchorX : anchorX - w;
|
|
974
|
+
const y = signY > 0 ? anchorY : anchorY - h;
|
|
975
|
+
return { x, y, w, h };
|
|
976
|
+
}
|
|
876
977
|
// ---- operations (mutate working canvas) --------------------------------
|
|
877
978
|
applyCrop() {
|
|
878
979
|
if (!this.cropRect) {
|
|
@@ -893,6 +994,7 @@ var ImageEditor = class {
|
|
|
893
994
|
this.working = next;
|
|
894
995
|
this.exitCropMode();
|
|
895
996
|
this.markEdited(true);
|
|
997
|
+
this.opts.onCropApplied?.();
|
|
896
998
|
this.draw();
|
|
897
999
|
}
|
|
898
1000
|
applyRotate() {
|
|
@@ -908,6 +1010,7 @@ var ImageEditor = class {
|
|
|
908
1010
|
this.working = next;
|
|
909
1011
|
this.exitCropMode();
|
|
910
1012
|
this.markEdited(true);
|
|
1013
|
+
this.opts.onAspectReset?.();
|
|
911
1014
|
this.draw();
|
|
912
1015
|
}
|
|
913
1016
|
applyCircle() {
|
|
@@ -931,11 +1034,13 @@ var ImageEditor = class {
|
|
|
931
1034
|
this.hasAlpha = true;
|
|
932
1035
|
this.exitCropMode();
|
|
933
1036
|
this.markEdited(true);
|
|
1037
|
+
this.opts.onAspectReset?.();
|
|
934
1038
|
this.draw();
|
|
935
1039
|
}
|
|
936
1040
|
async revert() {
|
|
937
1041
|
await this.loadTrueOriginalIntoWorking();
|
|
938
1042
|
this.exitCropMode();
|
|
1043
|
+
this.opts.onAspectReset?.();
|
|
939
1044
|
this.draw();
|
|
940
1045
|
}
|
|
941
1046
|
markEdited(edited) {
|
|
@@ -1039,6 +1144,12 @@ function mergeConfig(server, runtime) {
|
|
|
1039
1144
|
const types = server.constraints.allowedMimeTypes.filter((t) => t !== "*/*");
|
|
1040
1145
|
if (types.length > 0) merged.accept = types.join(",");
|
|
1041
1146
|
}
|
|
1147
|
+
const srvImg = server.image ?? null;
|
|
1148
|
+
const ratio = runtime.cropAspectRatio ?? (srvImg && srvImg.cropRatioW && srvImg.cropRatioH ? { width: srvImg.cropRatioW, height: srvImg.cropRatioH } : void 0);
|
|
1149
|
+
merged.cropAspectRatio = ratio;
|
|
1150
|
+
merged.forceCrop = (runtime.forceCrop ?? srvImg?.forceCrop ?? false) && !!ratio && runtime.imageEditing !== false;
|
|
1151
|
+
merged.minImageWidth = runtime.minImageWidth ?? srvImg?.minWidth ?? void 0;
|
|
1152
|
+
merged.minImageHeight = runtime.minImageHeight ?? srvImg?.minHeight ?? void 0;
|
|
1042
1153
|
return merged;
|
|
1043
1154
|
}
|
|
1044
1155
|
var DEFAULT_TITLE = "Upload files";
|
|
@@ -1093,6 +1204,10 @@ var Picker = class {
|
|
|
1093
1204
|
this.editor = null;
|
|
1094
1205
|
this.abortCtrl = new AbortController();
|
|
1095
1206
|
this.uploadStarted = false;
|
|
1207
|
+
// Monotonic counter so a stale image-dimension decode (e.g. one started before
|
|
1208
|
+
// the user edited the file) can't clobber the row's state — only the latest
|
|
1209
|
+
// validation per item is allowed to mutate it.
|
|
1210
|
+
this.validateSeqCounter = 0;
|
|
1096
1211
|
this.resolved = false;
|
|
1097
1212
|
// Element focused before the modal opened, restored on close (a11y).
|
|
1098
1213
|
this.previouslyFocused = null;
|
|
@@ -1140,6 +1255,7 @@ var Picker = class {
|
|
|
1140
1255
|
Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
|
|
1141
1256
|
root.style.setProperty(k, v);
|
|
1142
1257
|
});
|
|
1258
|
+
root.style.colorScheme = this.opts.theme?.mode === "dark" ? "dark" : "light";
|
|
1143
1259
|
root.addEventListener("click", (e) => {
|
|
1144
1260
|
if (e.target === root && !this.uploadStarted) this.cancel();
|
|
1145
1261
|
});
|
|
@@ -1511,15 +1627,13 @@ var Picker = class {
|
|
|
1511
1627
|
};
|
|
1512
1628
|
this.items.push(item);
|
|
1513
1629
|
this.renderItem(item);
|
|
1630
|
+
if (this.shouldCheckDimensions(file)) {
|
|
1631
|
+
item.validating = true;
|
|
1632
|
+
this.validateDimensions(item);
|
|
1633
|
+
}
|
|
1514
1634
|
}
|
|
1515
1635
|
this.refreshConfirm();
|
|
1516
|
-
|
|
1517
|
-
const hasQueued = this.items.some((i) => i.state === "queued");
|
|
1518
|
-
if (autoUpload && hasQueued && !this.uploadStarted) {
|
|
1519
|
-
requestAnimationFrame(() => {
|
|
1520
|
-
if (!this.uploadStarted) this.startUpload();
|
|
1521
|
-
});
|
|
1522
|
-
}
|
|
1636
|
+
this.maybeAutoUpload();
|
|
1523
1637
|
}
|
|
1524
1638
|
renderItem(item) {
|
|
1525
1639
|
if (!this.$list) return;
|
|
@@ -1552,6 +1666,9 @@ var Picker = class {
|
|
|
1552
1666
|
const bar = el2("div", "us-file-progress-bar");
|
|
1553
1667
|
progress.appendChild(bar);
|
|
1554
1668
|
main.appendChild(progress);
|
|
1669
|
+
const error = el2("div", "us-file-error");
|
|
1670
|
+
if (item.error) error.textContent = item.error;
|
|
1671
|
+
main.appendChild(error);
|
|
1555
1672
|
row.appendChild(main);
|
|
1556
1673
|
const isImage = item.file.type.startsWith("image/");
|
|
1557
1674
|
const editingEnabled = this.opts.imageEditing !== false;
|
|
@@ -1575,9 +1692,11 @@ var Picker = class {
|
|
|
1575
1692
|
item.$bar = bar;
|
|
1576
1693
|
item.$status = status;
|
|
1577
1694
|
item.$meta = meta;
|
|
1695
|
+
item.$error = error;
|
|
1578
1696
|
item.$thumb = thumb;
|
|
1579
1697
|
this.$list.appendChild(row);
|
|
1580
1698
|
this.updateSummary();
|
|
1699
|
+
this.updateItemBadge(item);
|
|
1581
1700
|
}
|
|
1582
1701
|
// Swap a queued item's file with an edited version. Refreshes thumbnail and
|
|
1583
1702
|
// meta in place so the row identity is preserved (uploadId, position).
|
|
@@ -1615,15 +1734,32 @@ var Picker = class {
|
|
|
1615
1734
|
// nothing (Cancel/Back).
|
|
1616
1735
|
openEditor(item) {
|
|
1617
1736
|
if (!this.$panel || this.editor || item.state !== "queued") return;
|
|
1737
|
+
let didRatioCrop = false;
|
|
1618
1738
|
this.editor = new ImageEditor({
|
|
1619
1739
|
host: this.$panel,
|
|
1620
1740
|
file: item.file,
|
|
1621
1741
|
originalFile: item.originalFile,
|
|
1622
1742
|
title: item.originalFile.name,
|
|
1743
|
+
lockedAspect: this.opts.cropAspectRatio,
|
|
1744
|
+
onCropApplied: () => {
|
|
1745
|
+
didRatioCrop = true;
|
|
1746
|
+
},
|
|
1747
|
+
// Rotating/circling/reverting after a crop changes the working aspect, so
|
|
1748
|
+
// the image is no longer guaranteed to match the locked ratio.
|
|
1749
|
+
onAspectReset: () => {
|
|
1750
|
+
didRatioCrop = false;
|
|
1751
|
+
},
|
|
1623
1752
|
onApply: (edited) => {
|
|
1624
1753
|
const wasEdited = edited !== item.originalFile;
|
|
1625
1754
|
this.replaceItemFile(item, edited, wasEdited);
|
|
1755
|
+
if (didRatioCrop) item.ratioCropped = true;
|
|
1626
1756
|
this.editor = null;
|
|
1757
|
+
if (this.shouldCheckDimensions(item.file)) {
|
|
1758
|
+
item.validating = true;
|
|
1759
|
+
this.validateDimensions(item);
|
|
1760
|
+
}
|
|
1761
|
+
this.updateItemBadge(item);
|
|
1762
|
+
this.refreshConfirm();
|
|
1627
1763
|
},
|
|
1628
1764
|
onCancel: () => {
|
|
1629
1765
|
this.editor = null;
|
|
@@ -1677,16 +1813,13 @@ var Picker = class {
|
|
|
1677
1813
|
case "uploading":
|
|
1678
1814
|
item.$meta.textContent = `${size} \xB7 ${Math.round(item.progress)}%`;
|
|
1679
1815
|
break;
|
|
1680
|
-
case "done":
|
|
1681
|
-
item.$meta.textContent = size;
|
|
1682
|
-
break;
|
|
1683
|
-
case "failed":
|
|
1684
|
-
item.$meta.textContent = item.error || "Failed";
|
|
1685
|
-
break;
|
|
1686
1816
|
default:
|
|
1687
1817
|
item.$meta.textContent = size;
|
|
1688
1818
|
}
|
|
1689
1819
|
}
|
|
1820
|
+
if (item.$error) {
|
|
1821
|
+
item.$error.textContent = state === "failed" ? item.error || "Upload failed" : "";
|
|
1822
|
+
}
|
|
1690
1823
|
this.updateSummary();
|
|
1691
1824
|
}
|
|
1692
1825
|
updateSummary() {
|
|
@@ -1712,10 +1845,79 @@ var Picker = class {
|
|
|
1712
1845
|
this.$summary.textContent = `${total} file${total === 1 ? "" : "s"} ready`;
|
|
1713
1846
|
}
|
|
1714
1847
|
}
|
|
1848
|
+
// ---- image rules (force-crop + min dimensions) --------------------------
|
|
1849
|
+
// True when force-crop is on and this queued raster image hasn't been cropped
|
|
1850
|
+
// to the locked ratio yet. SVGs are exempt (vector→raster crop is lossy).
|
|
1851
|
+
needsCrop(item) {
|
|
1852
|
+
return this.opts.forceCrop === true && !!this.opts.cropAspectRatio && item.state === "queued" && item.file.type.startsWith("image/") && item.file.type !== "image/svg+xml" && !item.ratioCropped;
|
|
1853
|
+
}
|
|
1854
|
+
hasUncroppedImages() {
|
|
1855
|
+
return this.items.some((i) => this.needsCrop(i));
|
|
1856
|
+
}
|
|
1857
|
+
anyValidating() {
|
|
1858
|
+
return this.items.some((i) => i.validating === true);
|
|
1859
|
+
}
|
|
1860
|
+
// Whether a min-dimension rule applies to this file (image, non-SVG, rule set).
|
|
1861
|
+
shouldCheckDimensions(file) {
|
|
1862
|
+
const minConfigured = !!(this.opts.minImageWidth || this.opts.minImageHeight);
|
|
1863
|
+
return minConfigured && file.type.startsWith("image/") && file.type !== "image/svg+xml";
|
|
1864
|
+
}
|
|
1865
|
+
// Toggle the per-row "crop required" cue. Only touches the meta line for
|
|
1866
|
+
// queued rows (other states own their meta via setItemState).
|
|
1867
|
+
updateItemBadge(item) {
|
|
1868
|
+
if (!item.$row) return;
|
|
1869
|
+
if (this.needsCrop(item)) {
|
|
1870
|
+
item.$row.dataset.cropRequired = "true";
|
|
1871
|
+
if (item.$meta) item.$meta.textContent = "Crop to ratio required";
|
|
1872
|
+
} else {
|
|
1873
|
+
delete item.$row.dataset.cropRequired;
|
|
1874
|
+
if (item.state === "queued" && item.$meta && !item.validating) {
|
|
1875
|
+
item.$meta.textContent = formatBytes(item.file.size);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
// Decode an image and fail the row if it's below the configured minimum.
|
|
1880
|
+
// Non-decodable inputs are left queued — we can't prove they're too small.
|
|
1881
|
+
async validateDimensions(item) {
|
|
1882
|
+
const seq = ++this.validateSeqCounter;
|
|
1883
|
+
item.validateSeq = seq;
|
|
1884
|
+
const minW = this.opts.minImageWidth ?? 0;
|
|
1885
|
+
const minH = this.opts.minImageHeight ?? 0;
|
|
1886
|
+
const file = item.file;
|
|
1887
|
+
let dim = null;
|
|
1888
|
+
try {
|
|
1889
|
+
dim = await readImageSize(file);
|
|
1890
|
+
} catch {
|
|
1891
|
+
dim = null;
|
|
1892
|
+
}
|
|
1893
|
+
if (item.validateSeq !== seq) return;
|
|
1894
|
+
item.validating = false;
|
|
1895
|
+
if (item.state === "queued") {
|
|
1896
|
+
if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
|
|
1897
|
+
const need = `${minW || "?"}\xD7${minH || "?"}`;
|
|
1898
|
+
item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
|
|
1899
|
+
this.setItemState(item, "failed");
|
|
1900
|
+
} else {
|
|
1901
|
+
this.updateItemBadge(item);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
this.refreshConfirm();
|
|
1905
|
+
this.maybeAutoUpload();
|
|
1906
|
+
}
|
|
1907
|
+
// Fire auto-upload once nothing is blocking it (no pending validation, no
|
|
1908
|
+
// pending force-crops). Re-checked whenever a validation settles.
|
|
1909
|
+
maybeAutoUpload() {
|
|
1910
|
+
if (this.opts.autoUpload !== true || this.uploadStarted) return;
|
|
1911
|
+
if (this.anyValidating() || this.hasUncroppedImages()) return;
|
|
1912
|
+
if (!this.items.some((i) => i.state === "queued")) return;
|
|
1913
|
+
requestAnimationFrame(() => {
|
|
1914
|
+
if (!this.uploadStarted) this.startUpload();
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1715
1917
|
refreshConfirm() {
|
|
1716
1918
|
if (!this.$confirm) return;
|
|
1717
1919
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1718
|
-
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1920
|
+
this.$confirm.disabled = queued === 0 || this.uploadStarted || this.hasUncroppedImages() || this.anyValidating();
|
|
1719
1921
|
}
|
|
1720
1922
|
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1721
1923
|
flashNotice(text) {
|
|
@@ -1734,6 +1936,15 @@ var Picker = class {
|
|
|
1734
1936
|
// ---- upload -------------------------------------------------------------
|
|
1735
1937
|
async startUpload() {
|
|
1736
1938
|
if (this.uploadStarted) return;
|
|
1939
|
+
if (this.anyValidating()) {
|
|
1940
|
+
this.flashNotice("Checking image dimensions\u2026");
|
|
1941
|
+
return;
|
|
1942
|
+
}
|
|
1943
|
+
if (this.hasUncroppedImages()) {
|
|
1944
|
+
this.items.forEach((i) => this.updateItemBadge(i));
|
|
1945
|
+
this.flashNotice("Crop required \u2014 crop every image to the set ratio before uploading.");
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1737
1948
|
const queued = this.items.filter((i) => i.state === "queued");
|
|
1738
1949
|
if (queued.length === 0) return;
|
|
1739
1950
|
this.uploadStarted = true;
|
|
@@ -1889,6 +2100,34 @@ function cryptoId() {
|
|
|
1889
2100
|
}
|
|
1890
2101
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1891
2102
|
}
|
|
2103
|
+
async function readImageSize(file) {
|
|
2104
|
+
if (typeof createImageBitmap === "function") {
|
|
2105
|
+
try {
|
|
2106
|
+
const bmp = await createImageBitmap(file);
|
|
2107
|
+
const w = bmp.width, h = bmp.height;
|
|
2108
|
+
bmp.close?.();
|
|
2109
|
+
return { w, h };
|
|
2110
|
+
} catch {
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
return new Promise((resolve, reject) => {
|
|
2114
|
+
if (typeof URL === "undefined" || !URL.createObjectURL) {
|
|
2115
|
+
reject(new Error("no object URL support"));
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
const url = URL.createObjectURL(file);
|
|
2119
|
+
const img = new Image();
|
|
2120
|
+
img.onload = () => {
|
|
2121
|
+
URL.revokeObjectURL(url);
|
|
2122
|
+
resolve({ w: img.naturalWidth, h: img.naturalHeight });
|
|
2123
|
+
};
|
|
2124
|
+
img.onerror = () => {
|
|
2125
|
+
URL.revokeObjectURL(url);
|
|
2126
|
+
reject(new Error("decode failed"));
|
|
2127
|
+
};
|
|
2128
|
+
img.src = url;
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
1892
2131
|
async function fetchUrlAsFile(url) {
|
|
1893
2132
|
let res;
|
|
1894
2133
|
try {
|