@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/node.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var chunkNOAB2Q4B_cjs = require('./chunk-NOAB2Q4B.cjs');
|
|
4
4
|
var path = require('path');
|
|
5
5
|
var promises = require('fs/promises');
|
|
6
6
|
var fs = require('fs');
|
|
@@ -97,7 +97,7 @@ async function resolveSource(source, opts = {}) {
|
|
|
97
97
|
const blob = openAsBlob2 ? await openAsBlob2(source, { type: mimetype }) : new Blob([toBlobPart(await promises.readFile(source))], { type: mimetype });
|
|
98
98
|
return { blob, filename, mimetype };
|
|
99
99
|
} catch (cause) {
|
|
100
|
-
throw
|
|
100
|
+
throw chunkNOAB2Q4B_cjs.makeError("VALIDATION", `Could not read file at "${source}".`, {
|
|
101
101
|
retryable: false,
|
|
102
102
|
cause
|
|
103
103
|
});
|
|
@@ -120,7 +120,7 @@ async function resolveSource(source, opts = {}) {
|
|
|
120
120
|
const mimetype = opts.mimeType || mimeFromName(filename);
|
|
121
121
|
return { blob: new Blob([toBlobPart(source)], { type: mimetype }), filename, mimetype };
|
|
122
122
|
}
|
|
123
|
-
throw
|
|
123
|
+
throw chunkNOAB2Q4B_cjs.makeError(
|
|
124
124
|
"VALIDATION",
|
|
125
125
|
"Unsupported upload source. Pass a file path, Buffer, Uint8Array, ArrayBuffer, Blob, or Readable stream.",
|
|
126
126
|
{ retryable: false }
|
|
@@ -133,7 +133,7 @@ var DEFAULT_FILE_CONCURRENCY = 5;
|
|
|
133
133
|
var UnionStackNodeClient = class {
|
|
134
134
|
constructor(cfg) {
|
|
135
135
|
if (!cfg?.apiKey) {
|
|
136
|
-
throw
|
|
136
|
+
throw chunkNOAB2Q4B_cjs.makeError("CONFIG", 'apiKey is required. Pass { apiKey: "unionstack_live_\u2026" }.', {
|
|
137
137
|
retryable: false
|
|
138
138
|
});
|
|
139
139
|
}
|
|
@@ -144,9 +144,9 @@ var UnionStackNodeClient = class {
|
|
|
144
144
|
skipConfigPrefetch: true,
|
|
145
145
|
// Tag the traffic as server-to-server ("node/…") so cdn-be can tell it
|
|
146
146
|
// apart from browser SDK calls ("js/…").
|
|
147
|
-
sdkHeader:
|
|
147
|
+
sdkHeader: chunkNOAB2Q4B_cjs.NODE_SDK_VERSION_HEADER
|
|
148
148
|
};
|
|
149
|
-
this.uploader = new
|
|
149
|
+
this.uploader = new chunkNOAB2Q4B_cjs.Uploader(this.cfg);
|
|
150
150
|
}
|
|
151
151
|
/**
|
|
152
152
|
* Upload one file. Accepts a path on disk, a Buffer/Uint8Array/ArrayBuffer, a
|
|
@@ -168,7 +168,7 @@ var UnionStackNodeClient = class {
|
|
|
168
168
|
*/
|
|
169
169
|
async uploadMany(sources, opts = {}) {
|
|
170
170
|
if (!Array.isArray(sources) || sources.length === 0) {
|
|
171
|
-
const err =
|
|
171
|
+
const err = chunkNOAB2Q4B_cjs.makeError("VALIDATION", "uploadMany requires a non-empty array of sources.", {
|
|
172
172
|
retryable: false
|
|
173
173
|
});
|
|
174
174
|
opts.onError?.(err);
|
package/dist/node.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
|
-
import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-
|
|
3
|
-
export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-
|
|
2
|
+
import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-ZCfjNZls.cjs';
|
|
3
|
+
export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-ZCfjNZls.cjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Anything the Node client knows how to turn into an upload.
|
package/dist/node.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
|
-
import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-
|
|
3
|
-
export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-
|
|
2
|
+
import { C as ClientConfig, d as UploadOptions, e as UploadedFile, B as BatchUploadOptions, P as PickResponse, R as ResolvedClientConfig } from './types-ZCfjNZls.js';
|
|
3
|
+
export { a as PickedFile, b as ProgressEvent, S as Source, U as UploadError, c as UploadErrorCode } from './types-ZCfjNZls.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Anything the Node client knows how to turn into an upload.
|
package/dist/node.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { makeError, NODE_SDK_VERSION_HEADER, Uploader } from './chunk-
|
|
1
|
+
import { makeError, NODE_SDK_VERSION_HEADER, Uploader } from './chunk-Z5PZQVST.js';
|
|
2
2
|
import { basename, extname } from 'path';
|
|
3
3
|
import { readFile } from 'fs/promises';
|
|
4
4
|
import * as fs from 'fs';
|
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);
|
|
@@ -251,6 +255,8 @@ var BASE_CSS = `
|
|
|
251
255
|
}
|
|
252
256
|
.us-file[data-state="done"] { border-color: color-mix(in srgb, var(--us-success) 35%, var(--us-border)); }
|
|
253
257
|
.us-file[data-state="failed"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
|
|
258
|
+
/* Force-crop: image queued but not yet cropped to the required ratio. */
|
|
259
|
+
.us-file[data-crop-required="true"] { border-color: color-mix(in srgb, var(--us-danger) 35%, var(--us-border)); }
|
|
254
260
|
|
|
255
261
|
.us-file-thumb {
|
|
256
262
|
width: 40px; height: 40px; flex-shrink: 0;
|
|
@@ -277,6 +283,14 @@ var BASE_CSS = `
|
|
|
277
283
|
color: var(--us-muted); font-size: 11px; flex-shrink: 0;
|
|
278
284
|
font-family: var(--us-mono); letter-spacing: 0;
|
|
279
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; }
|
|
280
294
|
|
|
281
295
|
.us-file-progress {
|
|
282
296
|
height: 3px; background: var(--us-border); border-radius: 999px; overflow: hidden;
|
|
@@ -615,6 +629,7 @@ var BASE_CSS = `
|
|
|
615
629
|
|
|
616
630
|
// src/picker/imageEditor.ts
|
|
617
631
|
var MAX_DISPLAY = 720;
|
|
632
|
+
var MIN_CROP = 24;
|
|
618
633
|
var ICON = {
|
|
619
634
|
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
635
|
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 +660,31 @@ var ImageEditor = class {
|
|
|
645
660
|
const start = this.dragStart.rect;
|
|
646
661
|
const maxW = this.displayCanvas.width;
|
|
647
662
|
const maxH = this.displayCanvas.height;
|
|
648
|
-
const min =
|
|
663
|
+
const min = MIN_CROP;
|
|
664
|
+
if (this.aspect && this.dragKind !== "move") {
|
|
665
|
+
switch (this.dragKind) {
|
|
666
|
+
case "se":
|
|
667
|
+
this.cropRect = this.lockedRectFromDrag(start.x, start.y, start.w + dx, 1, 1);
|
|
668
|
+
break;
|
|
669
|
+
case "nw": {
|
|
670
|
+
const ax = start.x + start.w, ay = start.y + start.h;
|
|
671
|
+
this.cropRect = this.lockedRectFromDrag(ax, ay, ax - (start.x + dx), -1, -1);
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
case "ne": {
|
|
675
|
+
const ay = start.y + start.h;
|
|
676
|
+
this.cropRect = this.lockedRectFromDrag(start.x, ay, start.w + dx, 1, -1);
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
case "sw": {
|
|
680
|
+
const ax = start.x + start.w;
|
|
681
|
+
this.cropRect = this.lockedRectFromDrag(ax, start.y, ax - (start.x + dx), -1, 1);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
this.updateCropBox();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
649
688
|
let { x, y, w, h } = start;
|
|
650
689
|
switch (this.dragKind) {
|
|
651
690
|
case "move":
|
|
@@ -692,6 +731,12 @@ var ImageEditor = class {
|
|
|
692
731
|
document.removeEventListener("pointerup", this.onPointerUp);
|
|
693
732
|
};
|
|
694
733
|
}
|
|
734
|
+
// Locked crop ratio (width / height), or null for free-form. Derived from
|
|
735
|
+
// opts so there's a single source of truth.
|
|
736
|
+
get aspect() {
|
|
737
|
+
const a = this.opts.lockedAspect;
|
|
738
|
+
return a && a.width > 0 && a.height > 0 ? a.width / a.height : null;
|
|
739
|
+
}
|
|
695
740
|
async open() {
|
|
696
741
|
this.mount();
|
|
697
742
|
try {
|
|
@@ -822,8 +867,42 @@ var ImageEditor = class {
|
|
|
822
867
|
this.cropTool.innerHTML = `${ICON.check} <span>Apply crop</span>`;
|
|
823
868
|
const dw = this.displayCanvas.width;
|
|
824
869
|
const dh = this.displayCanvas.height;
|
|
825
|
-
|
|
826
|
-
|
|
870
|
+
if (this.aspect) {
|
|
871
|
+
const a = this.aspect;
|
|
872
|
+
let w = dw;
|
|
873
|
+
let h = w / a;
|
|
874
|
+
if (h > dh) {
|
|
875
|
+
h = dh;
|
|
876
|
+
w = h * a;
|
|
877
|
+
}
|
|
878
|
+
w = w * 0.9;
|
|
879
|
+
h = w / a;
|
|
880
|
+
if (w < MIN_CROP) {
|
|
881
|
+
w = MIN_CROP;
|
|
882
|
+
h = w / a;
|
|
883
|
+
}
|
|
884
|
+
if (h < MIN_CROP) {
|
|
885
|
+
h = MIN_CROP;
|
|
886
|
+
w = h * a;
|
|
887
|
+
}
|
|
888
|
+
if (w > dw) {
|
|
889
|
+
w = dw;
|
|
890
|
+
h = w / a;
|
|
891
|
+
}
|
|
892
|
+
if (h > dh) {
|
|
893
|
+
h = dh;
|
|
894
|
+
w = h * a;
|
|
895
|
+
}
|
|
896
|
+
this.cropRect = {
|
|
897
|
+
x: Math.round((dw - w) / 2),
|
|
898
|
+
y: Math.round((dh - h) / 2),
|
|
899
|
+
w,
|
|
900
|
+
h
|
|
901
|
+
};
|
|
902
|
+
} 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 };
|
|
905
|
+
}
|
|
827
906
|
this.renderCropBox();
|
|
828
907
|
}
|
|
829
908
|
exitCropMode() {
|
|
@@ -875,6 +954,28 @@ var ImageEditor = class {
|
|
|
875
954
|
document.addEventListener("pointermove", this.onPointerMove);
|
|
876
955
|
document.addEventListener("pointerup", this.onPointerUp);
|
|
877
956
|
}
|
|
957
|
+
// Build a ratio-locked rect from a fixed anchor corner and a desired width.
|
|
958
|
+
// signX/signY say whether the dragged corner sits to the right/below the
|
|
959
|
+
// anchor (+1) or left/above (-1). Height is always derived from width to keep
|
|
960
|
+
// the ratio exact; both are clamped so the rect stays on-canvas.
|
|
961
|
+
lockedRectFromDrag(anchorX, anchorY, desiredW, signX, signY) {
|
|
962
|
+
const a = this.aspect;
|
|
963
|
+
const maxW = this.displayCanvas.width;
|
|
964
|
+
const maxH = this.displayCanvas.height;
|
|
965
|
+
let w = Math.max(MIN_CROP, desiredW);
|
|
966
|
+
let h = w / a;
|
|
967
|
+
if (h < MIN_CROP) {
|
|
968
|
+
h = MIN_CROP;
|
|
969
|
+
w = h * a;
|
|
970
|
+
}
|
|
971
|
+
const roomX = signX > 0 ? maxW - anchorX : anchorX;
|
|
972
|
+
const roomY = signY > 0 ? maxH - anchorY : anchorY;
|
|
973
|
+
w = Math.min(w, roomX, roomY * a);
|
|
974
|
+
h = w / a;
|
|
975
|
+
const x = signX > 0 ? anchorX : anchorX - w;
|
|
976
|
+
const y = signY > 0 ? anchorY : anchorY - h;
|
|
977
|
+
return { x, y, w, h };
|
|
978
|
+
}
|
|
878
979
|
// ---- operations (mutate working canvas) --------------------------------
|
|
879
980
|
applyCrop() {
|
|
880
981
|
if (!this.cropRect) {
|
|
@@ -895,6 +996,7 @@ var ImageEditor = class {
|
|
|
895
996
|
this.working = next;
|
|
896
997
|
this.exitCropMode();
|
|
897
998
|
this.markEdited(true);
|
|
999
|
+
this.opts.onCropApplied?.();
|
|
898
1000
|
this.draw();
|
|
899
1001
|
}
|
|
900
1002
|
applyRotate() {
|
|
@@ -910,6 +1012,7 @@ var ImageEditor = class {
|
|
|
910
1012
|
this.working = next;
|
|
911
1013
|
this.exitCropMode();
|
|
912
1014
|
this.markEdited(true);
|
|
1015
|
+
this.opts.onAspectReset?.();
|
|
913
1016
|
this.draw();
|
|
914
1017
|
}
|
|
915
1018
|
applyCircle() {
|
|
@@ -933,11 +1036,13 @@ var ImageEditor = class {
|
|
|
933
1036
|
this.hasAlpha = true;
|
|
934
1037
|
this.exitCropMode();
|
|
935
1038
|
this.markEdited(true);
|
|
1039
|
+
this.opts.onAspectReset?.();
|
|
936
1040
|
this.draw();
|
|
937
1041
|
}
|
|
938
1042
|
async revert() {
|
|
939
1043
|
await this.loadTrueOriginalIntoWorking();
|
|
940
1044
|
this.exitCropMode();
|
|
1045
|
+
this.opts.onAspectReset?.();
|
|
941
1046
|
this.draw();
|
|
942
1047
|
}
|
|
943
1048
|
markEdited(edited) {
|
|
@@ -1041,6 +1146,12 @@ function mergeConfig(server, runtime) {
|
|
|
1041
1146
|
const types = server.constraints.allowedMimeTypes.filter((t) => t !== "*/*");
|
|
1042
1147
|
if (types.length > 0) merged.accept = types.join(",");
|
|
1043
1148
|
}
|
|
1149
|
+
const srvImg = server.image ?? null;
|
|
1150
|
+
const ratio = runtime.cropAspectRatio ?? (srvImg && srvImg.cropRatioW && srvImg.cropRatioH ? { width: srvImg.cropRatioW, height: srvImg.cropRatioH } : void 0);
|
|
1151
|
+
merged.cropAspectRatio = ratio;
|
|
1152
|
+
merged.forceCrop = (runtime.forceCrop ?? srvImg?.forceCrop ?? false) && !!ratio && runtime.imageEditing !== false;
|
|
1153
|
+
merged.minImageWidth = runtime.minImageWidth ?? srvImg?.minWidth ?? void 0;
|
|
1154
|
+
merged.minImageHeight = runtime.minImageHeight ?? srvImg?.minHeight ?? void 0;
|
|
1044
1155
|
return merged;
|
|
1045
1156
|
}
|
|
1046
1157
|
var DEFAULT_TITLE = "Upload files";
|
|
@@ -1095,6 +1206,10 @@ var Picker = class {
|
|
|
1095
1206
|
this.editor = null;
|
|
1096
1207
|
this.abortCtrl = new AbortController();
|
|
1097
1208
|
this.uploadStarted = false;
|
|
1209
|
+
// Monotonic counter so a stale image-dimension decode (e.g. one started before
|
|
1210
|
+
// the user edited the file) can't clobber the row's state — only the latest
|
|
1211
|
+
// validation per item is allowed to mutate it.
|
|
1212
|
+
this.validateSeqCounter = 0;
|
|
1098
1213
|
this.resolved = false;
|
|
1099
1214
|
// Element focused before the modal opened, restored on close (a11y).
|
|
1100
1215
|
this.previouslyFocused = null;
|
|
@@ -1142,6 +1257,7 @@ var Picker = class {
|
|
|
1142
1257
|
Object.entries(themeToCssVars(this.opts.theme)).forEach(([k, v]) => {
|
|
1143
1258
|
root.style.setProperty(k, v);
|
|
1144
1259
|
});
|
|
1260
|
+
root.style.colorScheme = this.opts.theme?.mode === "dark" ? "dark" : "light";
|
|
1145
1261
|
root.addEventListener("click", (e) => {
|
|
1146
1262
|
if (e.target === root && !this.uploadStarted) this.cancel();
|
|
1147
1263
|
});
|
|
@@ -1513,15 +1629,13 @@ var Picker = class {
|
|
|
1513
1629
|
};
|
|
1514
1630
|
this.items.push(item);
|
|
1515
1631
|
this.renderItem(item);
|
|
1632
|
+
if (this.shouldCheckDimensions(file)) {
|
|
1633
|
+
item.validating = true;
|
|
1634
|
+
this.validateDimensions(item);
|
|
1635
|
+
}
|
|
1516
1636
|
}
|
|
1517
1637
|
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
|
-
}
|
|
1638
|
+
this.maybeAutoUpload();
|
|
1525
1639
|
}
|
|
1526
1640
|
renderItem(item) {
|
|
1527
1641
|
if (!this.$list) return;
|
|
@@ -1554,6 +1668,9 @@ var Picker = class {
|
|
|
1554
1668
|
const bar = el2("div", "us-file-progress-bar");
|
|
1555
1669
|
progress.appendChild(bar);
|
|
1556
1670
|
main.appendChild(progress);
|
|
1671
|
+
const error = el2("div", "us-file-error");
|
|
1672
|
+
if (item.error) error.textContent = item.error;
|
|
1673
|
+
main.appendChild(error);
|
|
1557
1674
|
row.appendChild(main);
|
|
1558
1675
|
const isImage = item.file.type.startsWith("image/");
|
|
1559
1676
|
const editingEnabled = this.opts.imageEditing !== false;
|
|
@@ -1577,9 +1694,11 @@ var Picker = class {
|
|
|
1577
1694
|
item.$bar = bar;
|
|
1578
1695
|
item.$status = status;
|
|
1579
1696
|
item.$meta = meta;
|
|
1697
|
+
item.$error = error;
|
|
1580
1698
|
item.$thumb = thumb;
|
|
1581
1699
|
this.$list.appendChild(row);
|
|
1582
1700
|
this.updateSummary();
|
|
1701
|
+
this.updateItemBadge(item);
|
|
1583
1702
|
}
|
|
1584
1703
|
// Swap a queued item's file with an edited version. Refreshes thumbnail and
|
|
1585
1704
|
// meta in place so the row identity is preserved (uploadId, position).
|
|
@@ -1617,15 +1736,32 @@ var Picker = class {
|
|
|
1617
1736
|
// nothing (Cancel/Back).
|
|
1618
1737
|
openEditor(item) {
|
|
1619
1738
|
if (!this.$panel || this.editor || item.state !== "queued") return;
|
|
1739
|
+
let didRatioCrop = false;
|
|
1620
1740
|
this.editor = new ImageEditor({
|
|
1621
1741
|
host: this.$panel,
|
|
1622
1742
|
file: item.file,
|
|
1623
1743
|
originalFile: item.originalFile,
|
|
1624
1744
|
title: item.originalFile.name,
|
|
1745
|
+
lockedAspect: this.opts.cropAspectRatio,
|
|
1746
|
+
onCropApplied: () => {
|
|
1747
|
+
didRatioCrop = true;
|
|
1748
|
+
},
|
|
1749
|
+
// Rotating/circling/reverting after a crop changes the working aspect, so
|
|
1750
|
+
// the image is no longer guaranteed to match the locked ratio.
|
|
1751
|
+
onAspectReset: () => {
|
|
1752
|
+
didRatioCrop = false;
|
|
1753
|
+
},
|
|
1625
1754
|
onApply: (edited) => {
|
|
1626
1755
|
const wasEdited = edited !== item.originalFile;
|
|
1627
1756
|
this.replaceItemFile(item, edited, wasEdited);
|
|
1757
|
+
if (didRatioCrop) item.ratioCropped = true;
|
|
1628
1758
|
this.editor = null;
|
|
1759
|
+
if (this.shouldCheckDimensions(item.file)) {
|
|
1760
|
+
item.validating = true;
|
|
1761
|
+
this.validateDimensions(item);
|
|
1762
|
+
}
|
|
1763
|
+
this.updateItemBadge(item);
|
|
1764
|
+
this.refreshConfirm();
|
|
1629
1765
|
},
|
|
1630
1766
|
onCancel: () => {
|
|
1631
1767
|
this.editor = null;
|
|
@@ -1679,16 +1815,13 @@ var Picker = class {
|
|
|
1679
1815
|
case "uploading":
|
|
1680
1816
|
item.$meta.textContent = `${size} \xB7 ${Math.round(item.progress)}%`;
|
|
1681
1817
|
break;
|
|
1682
|
-
case "done":
|
|
1683
|
-
item.$meta.textContent = size;
|
|
1684
|
-
break;
|
|
1685
|
-
case "failed":
|
|
1686
|
-
item.$meta.textContent = item.error || "Failed";
|
|
1687
|
-
break;
|
|
1688
1818
|
default:
|
|
1689
1819
|
item.$meta.textContent = size;
|
|
1690
1820
|
}
|
|
1691
1821
|
}
|
|
1822
|
+
if (item.$error) {
|
|
1823
|
+
item.$error.textContent = state === "failed" ? item.error || "Upload failed" : "";
|
|
1824
|
+
}
|
|
1692
1825
|
this.updateSummary();
|
|
1693
1826
|
}
|
|
1694
1827
|
updateSummary() {
|
|
@@ -1714,10 +1847,79 @@ var Picker = class {
|
|
|
1714
1847
|
this.$summary.textContent = `${total} file${total === 1 ? "" : "s"} ready`;
|
|
1715
1848
|
}
|
|
1716
1849
|
}
|
|
1850
|
+
// ---- image rules (force-crop + min dimensions) --------------------------
|
|
1851
|
+
// True when force-crop is on and this queued raster image hasn't been cropped
|
|
1852
|
+
// to the locked ratio yet. SVGs are exempt (vector→raster crop is lossy).
|
|
1853
|
+
needsCrop(item) {
|
|
1854
|
+
return this.opts.forceCrop === true && !!this.opts.cropAspectRatio && item.state === "queued" && item.file.type.startsWith("image/") && item.file.type !== "image/svg+xml" && !item.ratioCropped;
|
|
1855
|
+
}
|
|
1856
|
+
hasUncroppedImages() {
|
|
1857
|
+
return this.items.some((i) => this.needsCrop(i));
|
|
1858
|
+
}
|
|
1859
|
+
anyValidating() {
|
|
1860
|
+
return this.items.some((i) => i.validating === true);
|
|
1861
|
+
}
|
|
1862
|
+
// Whether a min-dimension rule applies to this file (image, non-SVG, rule set).
|
|
1863
|
+
shouldCheckDimensions(file) {
|
|
1864
|
+
const minConfigured = !!(this.opts.minImageWidth || this.opts.minImageHeight);
|
|
1865
|
+
return minConfigured && file.type.startsWith("image/") && file.type !== "image/svg+xml";
|
|
1866
|
+
}
|
|
1867
|
+
// Toggle the per-row "crop required" cue. Only touches the meta line for
|
|
1868
|
+
// queued rows (other states own their meta via setItemState).
|
|
1869
|
+
updateItemBadge(item) {
|
|
1870
|
+
if (!item.$row) return;
|
|
1871
|
+
if (this.needsCrop(item)) {
|
|
1872
|
+
item.$row.dataset.cropRequired = "true";
|
|
1873
|
+
if (item.$meta) item.$meta.textContent = "Crop to ratio required";
|
|
1874
|
+
} else {
|
|
1875
|
+
delete item.$row.dataset.cropRequired;
|
|
1876
|
+
if (item.state === "queued" && item.$meta && !item.validating) {
|
|
1877
|
+
item.$meta.textContent = formatBytes(item.file.size);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
// Decode an image and fail the row if it's below the configured minimum.
|
|
1882
|
+
// Non-decodable inputs are left queued — we can't prove they're too small.
|
|
1883
|
+
async validateDimensions(item) {
|
|
1884
|
+
const seq = ++this.validateSeqCounter;
|
|
1885
|
+
item.validateSeq = seq;
|
|
1886
|
+
const minW = this.opts.minImageWidth ?? 0;
|
|
1887
|
+
const minH = this.opts.minImageHeight ?? 0;
|
|
1888
|
+
const file = item.file;
|
|
1889
|
+
let dim = null;
|
|
1890
|
+
try {
|
|
1891
|
+
dim = await readImageSize(file);
|
|
1892
|
+
} catch {
|
|
1893
|
+
dim = null;
|
|
1894
|
+
}
|
|
1895
|
+
if (item.validateSeq !== seq) return;
|
|
1896
|
+
item.validating = false;
|
|
1897
|
+
if (item.state === "queued") {
|
|
1898
|
+
if (dim && dim.w > 0 && dim.h > 0 && (dim.w < minW || dim.h < minH)) {
|
|
1899
|
+
const need = `${minW || "?"}\xD7${minH || "?"}`;
|
|
1900
|
+
item.error = `Image too small \u2014 needs at least ${need}px (got ${dim.w}\xD7${dim.h})`;
|
|
1901
|
+
this.setItemState(item, "failed");
|
|
1902
|
+
} else {
|
|
1903
|
+
this.updateItemBadge(item);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
this.refreshConfirm();
|
|
1907
|
+
this.maybeAutoUpload();
|
|
1908
|
+
}
|
|
1909
|
+
// Fire auto-upload once nothing is blocking it (no pending validation, no
|
|
1910
|
+
// pending force-crops). Re-checked whenever a validation settles.
|
|
1911
|
+
maybeAutoUpload() {
|
|
1912
|
+
if (this.opts.autoUpload !== true || this.uploadStarted) return;
|
|
1913
|
+
if (this.anyValidating() || this.hasUncroppedImages()) return;
|
|
1914
|
+
if (!this.items.some((i) => i.state === "queued")) return;
|
|
1915
|
+
requestAnimationFrame(() => {
|
|
1916
|
+
if (!this.uploadStarted) this.startUpload();
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1717
1919
|
refreshConfirm() {
|
|
1718
1920
|
if (!this.$confirm) return;
|
|
1719
1921
|
const queued = this.items.filter((i) => i.state === "queued").length;
|
|
1720
|
-
this.$confirm.disabled = queued === 0 || this.uploadStarted;
|
|
1922
|
+
this.$confirm.disabled = queued === 0 || this.uploadStarted || this.hasUncroppedImages() || this.anyValidating();
|
|
1721
1923
|
}
|
|
1722
1924
|
// Transient message in the actions bar (e.g. "2 files skipped — too large").
|
|
1723
1925
|
flashNotice(text) {
|
|
@@ -1736,6 +1938,15 @@ var Picker = class {
|
|
|
1736
1938
|
// ---- upload -------------------------------------------------------------
|
|
1737
1939
|
async startUpload() {
|
|
1738
1940
|
if (this.uploadStarted) return;
|
|
1941
|
+
if (this.anyValidating()) {
|
|
1942
|
+
this.flashNotice("Checking image dimensions\u2026");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (this.hasUncroppedImages()) {
|
|
1946
|
+
this.items.forEach((i) => this.updateItemBadge(i));
|
|
1947
|
+
this.flashNotice("Crop required \u2014 crop every image to the set ratio before uploading.");
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1739
1950
|
const queued = this.items.filter((i) => i.state === "queued");
|
|
1740
1951
|
if (queued.length === 0) return;
|
|
1741
1952
|
this.uploadStarted = true;
|
|
@@ -1891,6 +2102,34 @@ function cryptoId() {
|
|
|
1891
2102
|
}
|
|
1892
2103
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
1893
2104
|
}
|
|
2105
|
+
async function readImageSize(file) {
|
|
2106
|
+
if (typeof createImageBitmap === "function") {
|
|
2107
|
+
try {
|
|
2108
|
+
const bmp = await createImageBitmap(file);
|
|
2109
|
+
const w = bmp.width, h = bmp.height;
|
|
2110
|
+
bmp.close?.();
|
|
2111
|
+
return { w, h };
|
|
2112
|
+
} catch {
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
return new Promise((resolve, reject) => {
|
|
2116
|
+
if (typeof URL === "undefined" || !URL.createObjectURL) {
|
|
2117
|
+
reject(new Error("no object URL support"));
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
const url = URL.createObjectURL(file);
|
|
2121
|
+
const img = new Image();
|
|
2122
|
+
img.onload = () => {
|
|
2123
|
+
URL.revokeObjectURL(url);
|
|
2124
|
+
resolve({ w: img.naturalWidth, h: img.naturalHeight });
|
|
2125
|
+
};
|
|
2126
|
+
img.onerror = () => {
|
|
2127
|
+
URL.revokeObjectURL(url);
|
|
2128
|
+
reject(new Error("decode failed"));
|
|
2129
|
+
};
|
|
2130
|
+
img.src = url;
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
1894
2133
|
async function fetchUrlAsFile(url) {
|
|
1895
2134
|
let res;
|
|
1896
2135
|
try {
|