@michaelyagi/kiri 0.1.0-alpha.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/dist/kiri.mjs ADDED
@@ -0,0 +1,867 @@
1
+ var B = Object.defineProperty;
2
+ var G = (i, t, e) => t in i ? B(i, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : i[t] = e;
3
+ var z = (i, t, e) => G(i, typeof t != "symbol" ? t + "" : t, e);
4
+ const $ = {
5
+ brightness: 1,
6
+ contrast: 1,
7
+ saturation: 1,
8
+ sharpness: 1,
9
+ grayscale: !1,
10
+ sepia: !1
11
+ };
12
+ function O(i, t) {
13
+ return {
14
+ brightness: Math.max(0, t.brightness ?? i.brightness),
15
+ contrast: Math.max(0, t.contrast ?? i.contrast),
16
+ saturation: Math.max(0, t.saturation ?? i.saturation),
17
+ sharpness: Math.max(0, t.sharpness ?? i.sharpness),
18
+ grayscale: t.grayscale ?? i.grayscale,
19
+ sepia: t.sepia ?? i.sepia
20
+ };
21
+ }
22
+ function Y(i) {
23
+ const t = i > 1 ? (i - 1) / 4.5 : 0, e = 1 + 4 * t, s = -t;
24
+ return `0 ${s} 0 ${s} ${e} ${s} 0 ${s} 0`;
25
+ }
26
+ function X(i, t) {
27
+ const e = [];
28
+ return i.sharpness > 1 && e.push(`url(#${t})`), e.push(
29
+ `brightness(${i.brightness})`,
30
+ `contrast(${i.contrast})`,
31
+ `saturate(${i.saturation})`
32
+ ), i.grayscale && e.push("grayscale(1)"), i.sepia && e.push("sepia(1)"), e.join(" ");
33
+ }
34
+ const Z = "http://www.w3.org/2000/svg";
35
+ let T = 0;
36
+ function W() {
37
+ T += 1;
38
+ const i = `kiri-sharpen-${T}`, t = document.createElementNS(Z, "svg");
39
+ t.setAttribute("width", "0"), t.setAttribute("height", "0"), t.style.position = "absolute";
40
+ const e = document.createElementNS(Z, "filter");
41
+ e.setAttribute("id", i), e.setAttribute("color-interpolation-filters", "sRGB");
42
+ const s = document.createElementNS(Z, "feConvolveMatrix");
43
+ return s.setAttribute("order", "3"), s.setAttribute("divisor", "1"), s.setAttribute("edgeMode", "duplicate"), s.setAttribute("preserveAlpha", "true"), s.setAttribute("kernelMatrix", Y(1)), e.appendChild(s), t.appendChild(e), { svg: t, filterId: i, kernelEl: s };
44
+ }
45
+ function q(i, t, e, s, o, n, l) {
46
+ i.innerHTML = "";
47
+ const h = document.createElement("div");
48
+ h.className = "kiri-stage", h.tabIndex = 0, h.setAttribute("role", "application"), h.setAttribute(
49
+ "aria-label",
50
+ n ? "Image cropper. Drag or use arrow keys to move the crop frame over the fixed image. 0 resets." : "Image cropper. Drag to pan. Arrow keys to pan, plus/minus to zoom, 0 to reset."
51
+ );
52
+ const m = document.createElement("div");
53
+ m.className = "kiri-image-layer";
54
+ const f = document.createElement("img");
55
+ f.draggable = !1, m.appendChild(f);
56
+ const g = document.createElement("div");
57
+ g.className = t === "circle" ? "kiri-frame kiri-frame--circle" : t === "rounded-rectangle" ? "kiri-frame kiri-frame--rounded-rectangle" : "kiri-frame", j(g, e, s), t === "rounded-rectangle" && (g.style.borderRadius = `${o}px`);
58
+ const { svg: p, filterId: c, kernelEl: u } = W();
59
+ h.appendChild(m), h.appendChild(g), h.appendChild(p);
60
+ let r = null;
61
+ if (l.show) {
62
+ const a = document.createElement("div");
63
+ a.className = `kiri-root kiri-root--${l.position}`, r = document.createElement("input"), r.type = "range", r.className = "kiri-zoomer", r.min = String(l.min), r.max = String(l.max), r.step = "0.01", r.value = String(l.value), n && (r.disabled = !0), a.appendChild(h), a.appendChild(r), i.appendChild(a);
64
+ } else
65
+ i.appendChild(h);
66
+ return { stageEl: h, frameEl: g, imageLayerEl: m, imgEl: f, zoomerEl: r, sharpenFilterId: c, sharpenKernelEl: u };
67
+ }
68
+ function j(i, t, e) {
69
+ i.style.width = `${t}px`, i.style.height = `${e}px`;
70
+ }
71
+ const D = 20;
72
+ function N(i, t, e) {
73
+ i.style.width = `${t}px`, i.style.height = `${e}px`;
74
+ }
75
+ function J(i, t, e) {
76
+ const s = e * (t.flip.horizontal ? -1 : 1), o = e * (t.flip.vertical ? -1 : 1);
77
+ i.style.transform = `translate(-50%, -50%) translate(${t.offset.x}px, ${t.offset.y}px) scale(${s}, ${o}) rotate(${t.rotation}deg)`;
78
+ }
79
+ function U(i, t, e, s) {
80
+ e.setAttribute("kernelMatrix", Y(t.sharpness)), i.style.filter = X(t, s);
81
+ }
82
+ function Q(i, t) {
83
+ i.style.transform = `translate(-50%, -50%) translate(${t.x}px, ${t.y}px)`;
84
+ }
85
+ function F(i, t) {
86
+ return (t / 90 % 2 + 2) % 2 === 1 ? { width: i.height, height: i.width } : { width: i.width, height: i.height };
87
+ }
88
+ function L(i, t, e) {
89
+ const s = F(i, e);
90
+ return s.width <= 0 || s.height <= 0 ? 1 : Math.max(t.width / s.width, t.height / s.height);
91
+ }
92
+ function v(i, t, e, s) {
93
+ const o = F(i, e), n = L(i, t, e) * s;
94
+ return { width: o.width * n, height: o.height * n };
95
+ }
96
+ function C(i, t, e) {
97
+ return Math.min(Math.max(i, t), e);
98
+ }
99
+ function w(i, t, e) {
100
+ const s = Math.max(0, (t.width - e.width) / 2), o = Math.max(0, (t.height - e.height) / 2);
101
+ return {
102
+ x: Math.min(Math.max(i.x, -s), s) || 0,
103
+ y: Math.min(Math.max(i.y, -o), o) || 0
104
+ };
105
+ }
106
+ function I(i) {
107
+ return (i % 360 + 360) % 360;
108
+ }
109
+ const y = 15, _ = 0.1;
110
+ function k(i, t) {
111
+ return Math.hypot(i.clientX - t.clientX, i.clientY - t.clientY);
112
+ }
113
+ function tt(i, t, e) {
114
+ const s = /* @__PURE__ */ new Map();
115
+ let o = null, n = 0, l = 1;
116
+ function h(r) {
117
+ const { min: a, max: d } = t.getMinMaxZoom(), S = C(r.zoom, a, d), b = v(
118
+ t.getNaturalSize(),
119
+ t.getFrameSize(),
120
+ r.rotation,
121
+ S
122
+ ), x = w(r.offset, b, t.getFrameSize());
123
+ t.setState({
124
+ zoom: S,
125
+ offset: x,
126
+ rotation: I(r.rotation),
127
+ flip: r.flip,
128
+ filters: r.filters,
129
+ framePosition: r.framePosition
130
+ });
131
+ }
132
+ function m(r) {
133
+ const a = t.getState(), d = w(r, t.getFixedImageSize(), t.getFrameSize());
134
+ t.setState({ ...a, framePosition: d });
135
+ }
136
+ function f(r) {
137
+ if (i.setPointerCapture(r.pointerId), s.set(r.pointerId, r), s.size === 1) {
138
+ const a = t.getState();
139
+ o = {
140
+ x: r.clientX,
141
+ y: r.clientY,
142
+ offset: e.movableFrame ? a.framePosition : a.offset
143
+ }, i.classList.add("kiri-dragging");
144
+ } else if (s.size === 2) {
145
+ o = null;
146
+ const [a, d] = [...s.values()];
147
+ n = k(a, d), l = t.getState().zoom;
148
+ }
149
+ }
150
+ function g(r) {
151
+ if (s.has(r.pointerId)) {
152
+ if (s.set(r.pointerId, r), s.size === 2) {
153
+ if (e.movableFrame) return;
154
+ const [a, d] = [...s.values()], S = k(a, d);
155
+ if (n > 0) {
156
+ const b = S / n, x = t.getState();
157
+ h({ ...x, zoom: l * b });
158
+ }
159
+ return;
160
+ }
161
+ if (o) {
162
+ const a = r.clientX - o.x, d = r.clientY - o.y;
163
+ if (e.movableFrame) {
164
+ m({ x: o.offset.x + a, y: o.offset.y + d });
165
+ return;
166
+ }
167
+ const S = t.getState();
168
+ h({
169
+ ...S,
170
+ offset: { x: o.offset.x + a, y: o.offset.y + d }
171
+ });
172
+ }
173
+ }
174
+ }
175
+ function p(r) {
176
+ s.delete(r.pointerId), s.size < 2 && (n = 0), s.size === 0 && (o = null, i.classList.remove("kiri-dragging"));
177
+ }
178
+ function c(r) {
179
+ if (e.movableFrame || !e.mouseWheelZoom || e.mouseWheelZoom === "ctrl" && !r.ctrlKey) return;
180
+ r.preventDefault();
181
+ const a = t.getState(), d = -r.deltaY * 15e-4;
182
+ h({ ...a, zoom: a.zoom * (1 + d) });
183
+ }
184
+ function u(r) {
185
+ const a = t.getState();
186
+ if (e.movableFrame)
187
+ switch (r.key) {
188
+ case "ArrowLeft":
189
+ r.preventDefault(), m({ ...a.framePosition, x: a.framePosition.x - y });
190
+ return;
191
+ case "ArrowRight":
192
+ r.preventDefault(), m({ ...a.framePosition, x: a.framePosition.x + y });
193
+ return;
194
+ case "ArrowUp":
195
+ r.preventDefault(), m({ ...a.framePosition, y: a.framePosition.y - y });
196
+ return;
197
+ case "ArrowDown":
198
+ r.preventDefault(), m({ ...a.framePosition, y: a.framePosition.y + y });
199
+ return;
200
+ case "0":
201
+ r.preventDefault(), t.reset();
202
+ return;
203
+ default:
204
+ return;
205
+ }
206
+ switch (r.key) {
207
+ case "ArrowLeft":
208
+ r.preventDefault(), h({ ...a, offset: { ...a.offset, x: a.offset.x + y } });
209
+ break;
210
+ case "ArrowRight":
211
+ r.preventDefault(), h({ ...a, offset: { ...a.offset, x: a.offset.x - y } });
212
+ break;
213
+ case "ArrowUp":
214
+ r.preventDefault(), h({ ...a, offset: { ...a.offset, y: a.offset.y + y } });
215
+ break;
216
+ case "ArrowDown":
217
+ r.preventDefault(), h({ ...a, offset: { ...a.offset, y: a.offset.y - y } });
218
+ break;
219
+ case "+":
220
+ case "=":
221
+ r.preventDefault(), h({ ...a, zoom: a.zoom + _ });
222
+ break;
223
+ case "-":
224
+ case "_":
225
+ r.preventDefault(), h({ ...a, zoom: a.zoom - _ });
226
+ break;
227
+ case "0":
228
+ r.preventDefault(), t.reset();
229
+ break;
230
+ }
231
+ }
232
+ return i.addEventListener("pointerdown", f), i.addEventListener("pointermove", g), i.addEventListener("pointerup", p), i.addEventListener("pointercancel", p), i.addEventListener("wheel", c, { passive: !1 }), i.addEventListener("keydown", u), {
233
+ destroy() {
234
+ i.removeEventListener("pointerdown", f), i.removeEventListener("pointermove", g), i.removeEventListener("pointerup", p), i.removeEventListener("pointercancel", p), i.removeEventListener("wheel", c), i.removeEventListener("keydown", u);
235
+ }
236
+ };
237
+ }
238
+ function et(i) {
239
+ const t = new DataView(i);
240
+ if (t.byteLength < 4 || t.getUint16(0, !1) !== 65496) return 1;
241
+ let e = 2;
242
+ for (; e + 4 <= t.byteLength; ) {
243
+ const s = t.getUint16(e, !1), o = t.getUint16(e + 2, !1);
244
+ if (s === 65505) {
245
+ const n = e + 4;
246
+ return t.getUint32(n, !1) !== 1165519206 ? 1 : it(t, n + 6);
247
+ }
248
+ if ((s & 65280) !== 65280) break;
249
+ e += 2 + o;
250
+ }
251
+ return 1;
252
+ }
253
+ function it(i, t) {
254
+ const e = i.getUint16(t, !1) === 18761, s = i.getUint32(t + 4, e), o = t + s;
255
+ if (o + 2 > i.byteLength) return 1;
256
+ const n = i.getUint16(o, e);
257
+ for (let l = 0; l < n; l++) {
258
+ const h = o + 2 + l * 12;
259
+ if (h + 12 > i.byteLength) break;
260
+ if (i.getUint16(h, e) === 274) {
261
+ const f = i.getUint16(h + 8, e);
262
+ return f >= 1 && f <= 8 ? f : 1;
263
+ }
264
+ }
265
+ return 1;
266
+ }
267
+ function st(i) {
268
+ switch (i) {
269
+ case 2:
270
+ return { rotation: 0, flipHorizontal: !0 };
271
+ case 3:
272
+ return { rotation: 180, flipHorizontal: !1 };
273
+ case 4:
274
+ return { rotation: 180, flipHorizontal: !0 };
275
+ case 5:
276
+ return { rotation: 270, flipHorizontal: !0 };
277
+ case 6:
278
+ return { rotation: 90, flipHorizontal: !1 };
279
+ case 7:
280
+ return { rotation: 90, flipHorizontal: !0 };
281
+ case 8:
282
+ return { rotation: 270, flipHorizontal: !1 };
283
+ default:
284
+ return { rotation: 0, flipHorizontal: !1 };
285
+ }
286
+ }
287
+ function R(i, t, e, s) {
288
+ return i === void 0 ? e : t.includes(i) ? i : (console.warn(
289
+ `Kiri: invalid ${s} "${i}" — defaulting to "${e}". Valid values: ${t.join(", ")}.`
290
+ ), e);
291
+ }
292
+ function P(i, t, e) {
293
+ return Math.min(Math.max(i, t), e);
294
+ }
295
+ const ot = ["base64", "blob", "canvas"], rt = ["image/jpeg", "image/png", "image/webp"];
296
+ function V(i, t, e) {
297
+ return {
298
+ left: i.width / 2 - t.x - e.width / 2,
299
+ top: i.height / 2 - t.y - e.height / 2
300
+ };
301
+ }
302
+ function at(i, t, e) {
303
+ const s = L(i, t, e.rotation) * e.zoom, o = v(i, t, e.rotation, e.zoom), { left: n, top: l } = V(o, e.offset, t), h = { x: o.width / 2, y: o.height / 2 }, m = { x: i.width / 2, y: i.height / 2 }, f = I(e.rotation), g = [
304
+ { x: n, y: l },
305
+ { x: n + t.width, y: l },
306
+ { x: n, y: l + t.height },
307
+ { x: n + t.width, y: l + t.height }
308
+ ].map(({ x: S, y: b }) => {
309
+ const x = (S - h.x) / s, A = (b - h.y) / s;
310
+ let E, M;
311
+ return f === 90 ? (E = A, M = -x) : f === 180 ? (E = -x, M = -A) : f === 270 ? (E = -A, M = x) : (E = x, M = A), { x: E + m.x, y: M + m.y };
312
+ }), p = g.map((S) => S.x), c = g.map((S) => S.y), u = P(Math.min(...p), 0, i.width), r = P(Math.min(...c), 0, i.height), a = P(Math.max(...p), 0, i.width), d = P(Math.max(...c), 0, i.height);
313
+ return {
314
+ x: Math.round(u),
315
+ y: Math.round(r),
316
+ width: Math.round(a - u),
317
+ height: Math.round(d - r),
318
+ rotation: f,
319
+ flip: { ...e.flip }
320
+ };
321
+ }
322
+ function nt(i, t, e, s, o, n, l, h) {
323
+ const m = { width: i.naturalWidth, height: i.naturalHeight }, f = L(m, e, t.rotation) * t.zoom, g = v(m, e, t.rotation, t.zoom), p = document.createElement("canvas");
324
+ p.width = Math.max(1, Math.round(g.width)), p.height = Math.max(1, Math.round(g.height));
325
+ const c = p.getContext("2d");
326
+ if (!c) throw new Error("Kiri: unable to get 2D canvas context");
327
+ c.translate(p.width / 2, p.height / 2), c.scale(f * (t.flip.horizontal ? -1 : 1), f * (t.flip.vertical ? -1 : 1)), c.rotate(t.rotation * Math.PI / 180), c.filter = X(t.filters, h), c.drawImage(i, -m.width / 2, -m.height / 2, m.width, m.height);
328
+ const { left: u, top: r } = V(
329
+ g,
330
+ t.offset,
331
+ e
332
+ ), a = document.createElement("canvas");
333
+ a.width = s, a.height = o;
334
+ const d = a.getContext("2d");
335
+ if (!d) throw new Error("Kiri: unable to get 2D canvas context");
336
+ if (n === "circle")
337
+ d.save(), d.beginPath(), d.ellipse(
338
+ s / 2,
339
+ o / 2,
340
+ s / 2,
341
+ o / 2,
342
+ 0,
343
+ 0,
344
+ Math.PI * 2
345
+ ), d.clip();
346
+ else if (n === "rounded-rectangle") {
347
+ const S = s / e.width, b = P(l * S, 0, Math.min(s, o) / 2);
348
+ d.save(), d.beginPath(), d.roundRect(0, 0, s, o, b), d.clip();
349
+ }
350
+ return d.drawImage(
351
+ p,
352
+ u,
353
+ r,
354
+ e.width,
355
+ e.height,
356
+ 0,
357
+ 0,
358
+ s,
359
+ o
360
+ ), (n === "circle" || n === "rounded-rectangle") && d.restore(), a;
361
+ }
362
+ async function ht(i, t, e, s, o, n, l) {
363
+ const h = R(l.type, ot, "base64", "export type"), m = R(l.format, rt, "image/png", "export format"), f = l.quality, g = l.width ?? e.width, p = l.height ?? e.height;
364
+ (s === "circle" || s === "rounded-rectangle") && m === "image/jpeg" && console.warn(
365
+ `Kiri: exporting a ${s}-shaped frame as image/jpeg — JPEG has no alpha channel, so the area outside the shape will render as solid black instead of transparent. Use image/png or image/webp instead.`
366
+ );
367
+ const c = nt(
368
+ i,
369
+ t,
370
+ e,
371
+ g,
372
+ p,
373
+ s,
374
+ o,
375
+ n
376
+ );
377
+ return h === "canvas" ? c : h === "base64" ? c.toDataURL(m, f) : new Promise((u, r) => {
378
+ c.toBlob(
379
+ (a) => a ? u(a) : r(new Error("Kiri: canvas.toBlob failed")),
380
+ m,
381
+ f
382
+ );
383
+ });
384
+ }
385
+ async function lt(i, t) {
386
+ const e = t.fieldName ?? "file", s = t.fileName ?? `crop.${mt(t.format ?? "image/png")}`, o = new FormData();
387
+ o.append(e, i, s);
388
+ for (const [n, l] of Object.entries(t.extraFields ?? {}))
389
+ o.append(n, l);
390
+ return fetch(t.url, {
391
+ ...t.fetchOptions,
392
+ method: "POST",
393
+ body: o
394
+ });
395
+ }
396
+ function mt(i) {
397
+ switch (i) {
398
+ case "image/jpeg":
399
+ return "jpg";
400
+ case "image/webp":
401
+ return "webp";
402
+ default:
403
+ return "png";
404
+ }
405
+ }
406
+ const H = 200, K = 20, ft = 20, ct = ["rectangle", "circle", "rounded-rectangle"], dt = ["top", "bottom", "left", "right"];
407
+ function pt(i) {
408
+ return i === void 0 ? !0 : typeof i == "boolean" || i === "ctrl" ? i : (console.warn(
409
+ `Kiri: invalid mouseWheelZoom "${String(i)}" — defaulting to true. Valid values: true, false, "ctrl".`
410
+ ), !0);
411
+ }
412
+ class ut {
413
+ /**
414
+ * @param container An element already present in the DOM. Passing
415
+ * `null`/`undefined`, or an element that isn't in the DOM yet, throws.
416
+ * @param options See the {@link KiriOptions} fields for defaults.
417
+ */
418
+ constructor(t, e = {}) {
419
+ z(this, "container");
420
+ z(this, "opts");
421
+ z(this, "stage");
422
+ z(this, "gestureHandle");
423
+ z(this, "resizeHandle", null);
424
+ z(this, "zoomerHandle", null);
425
+ z(this, "naturalSize", { width: 0, height: 0 });
426
+ z(this, "state", {
427
+ zoom: 1,
428
+ offset: { x: 0, y: 0 },
429
+ rotation: 0,
430
+ flip: { horizontal: !1, vertical: !1 },
431
+ filters: $,
432
+ framePosition: { x: 0, y: 0 }
433
+ });
434
+ /**
435
+ * `movableFrame` mode only: the image's fixed on-screen size, frozen at
436
+ * `load()` time and recomputed on `rotate()` — see `getFixedImageSize()`.
437
+ */
438
+ z(this, "fixedImageSize", { width: 0, height: 0 });
439
+ /** Snapshot taken right after `load()` resolves, so `reset()` has something to revert to. */
440
+ z(this, "initialState", null);
441
+ z(this, "listeners", { change: [] });
442
+ var s, o, n, l;
443
+ if (!t || typeof t.appendChild != "function")
444
+ throw new Error(
445
+ "Kiri: container element is null/undefined or not a DOM element. This usually means the element wasn't in the DOM yet when `new Kiri(...)` ran — e.g. document.getElementById() was called before the element existed. Place the <script> after the element, or construct inside a DOMContentLoaded listener."
446
+ );
447
+ this.container = t, this.opts = {
448
+ frame: {
449
+ shape: R(
450
+ (s = e.frame) == null ? void 0 : s.shape,
451
+ ct,
452
+ "rectangle",
453
+ "frame.shape"
454
+ ),
455
+ width: ((o = e.frame) == null ? void 0 : o.width) ?? H,
456
+ height: ((n = e.frame) == null ? void 0 : n.height) ?? H,
457
+ cornerRadius: ((l = e.frame) == null ? void 0 : l.cornerRadius) ?? ft
458
+ },
459
+ minZoom: e.minZoom ?? 1,
460
+ maxZoom: e.maxZoom ?? 4,
461
+ rotatable: e.rotatable ?? !0,
462
+ flippable: e.flippable ?? !0,
463
+ resizableFrame: e.resizableFrame ?? !1,
464
+ lockAspectRatio: e.lockAspectRatio ?? !1,
465
+ movableFrame: e.movableFrame ?? !1,
466
+ mouseWheelZoom: pt(e.mouseWheelZoom),
467
+ useExifOrientation: e.useExifOrientation ?? !0,
468
+ uploader: e.uploader,
469
+ autoSizeStage: e.autoSizeStage ?? !0,
470
+ showZoomer: e.showZoomer ?? !1,
471
+ zoomerPosition: R(
472
+ e.zoomerPosition,
473
+ dt,
474
+ "bottom",
475
+ "zoomerPosition"
476
+ )
477
+ }, this.state.filters = O($, e.filters ?? {}), this.stage = q(
478
+ this.container,
479
+ this.opts.frame.shape,
480
+ this.opts.frame.width,
481
+ this.opts.frame.height,
482
+ this.opts.frame.cornerRadius,
483
+ this.opts.movableFrame,
484
+ {
485
+ show: this.opts.showZoomer,
486
+ position: this.opts.zoomerPosition,
487
+ min: this.opts.minZoom,
488
+ max: this.opts.maxZoom,
489
+ value: this.state.zoom
490
+ }
491
+ ), this.opts.autoSizeStage && this.syncStageSize(), U(this.stage.imgEl, this.state.filters, this.stage.sharpenKernelEl, this.stage.sharpenFilterId), this.stage.zoomerEl && this.enableZoomer(this.stage.zoomerEl), this.gestureHandle = tt(
492
+ this.stage.stageEl,
493
+ {
494
+ getNaturalSize: () => this.naturalSize,
495
+ getFrameSize: () => this.getFrameSize(),
496
+ getState: () => this.state,
497
+ getMinMaxZoom: () => ({ min: this.opts.minZoom, max: this.opts.maxZoom }),
498
+ setState: (h) => this.commitState(h),
499
+ reset: () => this.reset(),
500
+ getFixedImageSize: () => this.fixedImageSize
501
+ },
502
+ { mouseWheelZoom: this.opts.mouseWheelZoom, movableFrame: this.opts.movableFrame }
503
+ ), this.opts.resizableFrame && this.enableFrameResize();
504
+ }
505
+ /**
506
+ * Loads an image, replacing whatever was loaded before. EXIF orientation
507
+ * (rotation + horizontal flip) is corrected automatically unless
508
+ * `useExifOrientation: false` was passed to the constructor — only for
509
+ * `File`/`Blob` sources, since a plain URL string can't be read for EXIF
510
+ * data without an extra fetch.
511
+ * @param source A `File` (e.g. from a file input), a `Blob`, or a URL string.
512
+ * @param loadOptions Initial `zoom`/`offset`/`rotation`/`flip`.
513
+ */
514
+ async load(t, e = {}) {
515
+ var p, c;
516
+ let s = I(e.rotation ?? 0), o = ((p = e.flip) == null ? void 0 : p.horizontal) ?? !1;
517
+ const n = ((c = e.flip) == null ? void 0 : c.vertical) ?? !1;
518
+ let l = null, h;
519
+ if (typeof t == "string")
520
+ h = t;
521
+ else {
522
+ if (this.opts.useExifOrientation) {
523
+ const u = await t.arrayBuffer(), r = et(u), a = st(r);
524
+ s = I(s + a.rotation), o = o !== a.flipHorizontal;
525
+ }
526
+ l = URL.createObjectURL(t), h = l;
527
+ }
528
+ try {
529
+ await new Promise((u, r) => {
530
+ this.stage.imgEl.onload = () => u(), this.stage.imgEl.onerror = () => r(new Error("Kiri: failed to load image")), this.stage.imgEl.src = h;
531
+ });
532
+ } finally {
533
+ l && URL.revokeObjectURL(l);
534
+ }
535
+ this.naturalSize = {
536
+ width: this.stage.imgEl.naturalWidth,
537
+ height: this.stage.imgEl.naturalHeight
538
+ };
539
+ const m = this.opts.movableFrame ? 1 : C(e.zoom ?? this.opts.minZoom, this.opts.minZoom, this.opts.maxZoom), f = v(this.naturalSize, this.getFrameSize(), s, m), g = this.opts.movableFrame ? { x: 0, y: 0 } : w(e.offset ?? { x: 0, y: 0 }, f, this.getFrameSize());
540
+ this.fixedImageSize = v(this.naturalSize, this.getFrameSize(), s, 1), this.opts.movableFrame && this.opts.autoSizeStage && this.syncStageSizeToFixedImage(), this.commitState({
541
+ zoom: m,
542
+ offset: g,
543
+ rotation: s,
544
+ flip: { horizontal: o, vertical: n },
545
+ filters: this.state.filters,
546
+ framePosition: { x: 0, y: 0 }
547
+ }), this.initialState = this.getState();
548
+ }
549
+ /** A snapshot of the current state — mutating the returned object has no effect. */
550
+ getState() {
551
+ return {
552
+ zoom: this.state.zoom,
553
+ offset: { ...this.state.offset },
554
+ rotation: this.state.rotation,
555
+ flip: { ...this.state.flip },
556
+ filters: { ...this.state.filters },
557
+ framePosition: { ...this.state.framePosition }
558
+ };
559
+ }
560
+ /**
561
+ * Sets the zoom to an absolute value, clamped to `[minZoom, maxZoom]`.
562
+ * No-op if `movableFrame: true` — the image never zooms in that mode.
563
+ */
564
+ setZoom(t) {
565
+ if (this.opts.movableFrame) return;
566
+ const e = C(t, this.opts.minZoom, this.opts.maxZoom), s = v(
567
+ this.naturalSize,
568
+ this.getFrameSize(),
569
+ this.state.rotation,
570
+ e
571
+ ), o = w(this.state.offset, s, this.getFrameSize());
572
+ this.commitState({ ...this.state, zoom: e, offset: o });
573
+ }
574
+ /**
575
+ * Sets the pan offset to an absolute value (image-center offset from the
576
+ * frame center, in stage pixels), clamped so the frame stays fully covered
577
+ * by the rendered image. No-op if `movableFrame: true` — the image never
578
+ * pans in that mode; see `setFramePosition()` instead.
579
+ */
580
+ setOffset(t) {
581
+ if (this.opts.movableFrame) return;
582
+ const e = v(
583
+ this.naturalSize,
584
+ this.getFrameSize(),
585
+ this.state.rotation,
586
+ this.state.zoom
587
+ ), s = w(t, e, this.getFrameSize());
588
+ this.commitState({ ...this.state, offset: s });
589
+ }
590
+ /**
591
+ * Sets the frame's position to an absolute value (offset from stage
592
+ * center, in stage pixels), clamped so the frame stays fully within the
593
+ * image's fixed bounds. Only meaningful — and only takes effect — when
594
+ * `movableFrame: true`; a no-op otherwise, since the frame always stays
595
+ * centered in the default mode.
596
+ */
597
+ setFramePosition(t) {
598
+ if (!this.opts.movableFrame) return;
599
+ const e = w(t, this.fixedImageSize, this.getFrameSize());
600
+ this.commitState({ ...this.state, framePosition: e });
601
+ }
602
+ /**
603
+ * Reverts zoom/offset/rotation/flip/filters/framePosition to what they
604
+ * were right after `load()` resolved (including any `loadOptions` passed
605
+ * to it). No-op if nothing has been loaded yet.
606
+ */
607
+ reset() {
608
+ this.initialState && this.commitState({
609
+ zoom: this.initialState.zoom,
610
+ offset: { ...this.initialState.offset },
611
+ rotation: this.initialState.rotation,
612
+ flip: { ...this.initialState.flip },
613
+ filters: { ...this.initialState.filters },
614
+ framePosition: { ...this.initialState.framePosition }
615
+ });
616
+ }
617
+ /**
618
+ * Rotates relative to the current rotation, snapped to the nearest 90°.
619
+ * No-op if `rotatable: false` was passed to the constructor.
620
+ */
621
+ rotate(t) {
622
+ if (!this.opts.rotatable) return;
623
+ const e = Math.round(t / 90) * 90, s = I(this.state.rotation + e);
624
+ if (this.opts.movableFrame) {
625
+ const l = this.fixedImageSize.width / F(this.naturalSize, this.state.rotation).width, h = F(this.naturalSize, s);
626
+ this.fixedImageSize = {
627
+ width: h.width * l,
628
+ height: h.height * l
629
+ }, this.opts.autoSizeStage && this.syncStageSizeToFixedImage();
630
+ const m = w(this.state.framePosition, this.fixedImageSize, this.getFrameSize());
631
+ this.commitState({ ...this.state, rotation: s, framePosition: m });
632
+ return;
633
+ }
634
+ const o = v(
635
+ this.naturalSize,
636
+ this.getFrameSize(),
637
+ s,
638
+ this.state.zoom
639
+ ), n = w(this.state.offset, o, this.getFrameSize());
640
+ this.commitState({ ...this.state, rotation: s, offset: n });
641
+ }
642
+ /**
643
+ * Toggles horizontal flip. Always mirrors left-right as currently
644
+ * displayed on screen, regardless of the current `rotation` — not the
645
+ * image's own pre-rotation axes, so the visible effect stays "horizontal"
646
+ * no matter how the image is rotated. No-op if `flippable: false`.
647
+ */
648
+ flipHorizontal() {
649
+ if (!this.opts.flippable) return;
650
+ const t = { ...this.state.flip, horizontal: !this.state.flip.horizontal };
651
+ this.commitState({ ...this.state, flip: t });
652
+ }
653
+ /**
654
+ * Toggles vertical flip. Always mirrors top-to-bottom as currently
655
+ * displayed on screen, regardless of the current `rotation`. No-op if
656
+ * `flippable: false`.
657
+ */
658
+ flipVertical() {
659
+ if (!this.opts.flippable) return;
660
+ const t = { ...this.state.flip, vertical: !this.state.flip.vertical };
661
+ this.commitState({ ...this.state, flip: t });
662
+ }
663
+ /**
664
+ * Resizes the frame. Also resizes the stage to match, if
665
+ * `autoSizeStage: true` (the default) — except in `movableFrame` mode,
666
+ * where the stage stays pinned to the fixed image's own size (see
667
+ * `fixedImageSize`'s doc comment) instead of following the frame; letting
668
+ * the stage shrink along with the frame there would clip the (unchanged,
669
+ * still full-size) image down to whatever's left, visually reading as the
670
+ * picture itself shrinking even though its actual rendered size never
671
+ * changes. Each axis is clamped to a 20px minimum.
672
+ */
673
+ setFrameSize(t, e) {
674
+ if (this.opts.frame.width = Math.max(K, t), this.opts.frame.height = Math.max(K, e), this.opts.movableFrame && (this.opts.frame.width = Math.min(this.opts.frame.width, this.fixedImageSize.width), this.opts.frame.height = Math.min(this.opts.frame.height, this.fixedImageSize.height)), j(this.stage.frameEl, this.opts.frame.width, this.opts.frame.height), this.opts.autoSizeStage && !this.opts.movableFrame && this.syncStageSize(), this.opts.movableFrame) {
675
+ const n = w(this.state.framePosition, this.fixedImageSize, this.getFrameSize());
676
+ this.commitState({ ...this.state, framePosition: n });
677
+ return;
678
+ }
679
+ const s = v(
680
+ this.naturalSize,
681
+ this.getFrameSize(),
682
+ this.state.rotation,
683
+ this.state.zoom
684
+ ), o = w(this.state.offset, s, this.getFrameSize());
685
+ this.commitState({ ...this.state, offset: o });
686
+ }
687
+ /**
688
+ * Merges a partial update into the current filters (omitted fields are
689
+ * left as they are). Numeric values are clamped to `>= 0`.
690
+ */
691
+ setFilters(t) {
692
+ this.commitState({ ...this.state, filters: O(this.state.filters, t) });
693
+ }
694
+ /**
695
+ * Renders the current crop. A circle or rounded-rectangle frame is a real
696
+ * clip in the output (transparent corners on PNG/WebP); either shape
697
+ * exported as JPEG warns and renders solid black corners instead, since
698
+ * JPEG has no alpha channel.
699
+ * @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
700
+ */
701
+ async export(t = {}) {
702
+ return ht(
703
+ this.stage.imgEl,
704
+ this.getEffectiveStateForCrop(),
705
+ this.getFrameSize(),
706
+ this.opts.frame.shape,
707
+ this.opts.frame.cornerRadius,
708
+ this.stage.sharpenFilterId,
709
+ t
710
+ );
711
+ }
712
+ /**
713
+ * The current crop selection as a rectangle in the original, unrotated,
714
+ * unflipped source image's own pixel coordinates — for sending to a server
715
+ * that will crop the full-resolution original itself instead of uploading
716
+ * a client-re-encoded image. See {@link CropRegion}.
717
+ */
718
+ getCropRegion() {
719
+ return at(this.naturalSize, this.getFrameSize(), this.getEffectiveStateForCrop());
720
+ }
721
+ /**
722
+ * Exports the current crop as a blob, then uploads it — a default
723
+ * FormData/`fetch` POST, or a custom `uploader` (per-call `options.uploader`
724
+ * wins over the constructor's, which wins over the built-in default).
725
+ */
726
+ async upload(t, e = {}) {
727
+ const s = await this.export({ ...e, type: "blob" });
728
+ return (e.uploader ?? this.opts.uploader ?? lt)(s, { ...e, url: t });
729
+ }
730
+ /** Subscribes to `"change"` — fires on every state update (drag/zoom/rotate/flip/filters), and once after `load()` resolves. */
731
+ on(t, e) {
732
+ this.listeners[t].push(e);
733
+ }
734
+ /** Unsubscribes a callback previously passed to {@link on}. */
735
+ off(t, e) {
736
+ this.listeners[t] = this.listeners[t].filter((s) => s !== e);
737
+ }
738
+ /**
739
+ * Tears the instance down: removes all pointer/wheel event listeners
740
+ * (drag/zoom gestures), the resize-handle listener (if `resizableFrame`),
741
+ * and the zoom-slider listener (if `showZoomer`); clears the container's
742
+ * `innerHTML`, leaving an empty container element; and clears all
743
+ * `"change"` listeners. Call this when you're done with an instance (e.g.
744
+ * unmounting) to avoid leaking listeners.
745
+ */
746
+ destroy() {
747
+ var t, e;
748
+ this.gestureHandle.destroy(), (t = this.resizeHandle) == null || t.destroy(), (e = this.zoomerHandle) == null || e.destroy(), this.container.innerHTML = "", this.listeners.change = [];
749
+ }
750
+ getFrameSize() {
751
+ return { width: this.opts.frame.width, height: this.opts.frame.height };
752
+ }
753
+ /**
754
+ * `computeCropRegion()`/`renderCropToCanvas()` only know one model: an
755
+ * image shifted by `offset` under a frame fixed at center, scaled by
756
+ * `computeCoverScale(natural, frame, rotation) * zoom` — where `frame` is
757
+ * whatever's passed in, i.e. the *current* frame size. In `movableFrame`
758
+ * mode neither of those assumptions holds as-is: it's the frame that
759
+ * shifts (by `framePosition`, image fixed at center), and the image's
760
+ * scale must stay pinned to `fixedImageSize` regardless of how the frame
761
+ * has since been resized — not recomputed from the current frame size,
762
+ * which is exactly what those functions would otherwise do internally.
763
+ *
764
+ * Both are fixed by substituting two `state` fields before calling them,
765
+ * rather than changing either function:
766
+ * - `offset = -framePosition` — "frame moved right by d" and "image moved
767
+ * left by d" describe the identical relative geometry, so this alone
768
+ * carries the frame's position into the existing math unmodified.
769
+ * (`state.offset` is always `{0,0}` in this mode already, since
770
+ * `setOffset()`/gesture panning are no-ops here — this substitution is
771
+ * what actually does the work, not a redundant no-op.)
772
+ * - `zoom` = a value chosen so that
773
+ * `computeCoverScale(natural, currentFrame, rotation) * zoom` evaluates
774
+ * to exactly `fixedImageSize`'s own scale, whatever the current frame
775
+ * size is — i.e. `zoom = frozenScale / computeCoverScale(natural,
776
+ * currentFrame, rotation)`, which cancels the current-frame-size term
777
+ * out algebraically. Caught by an actual pixel test (a moved+resized
778
+ * frame exported as fully transparent) before trusting the offset
779
+ * substitution was sufficient on its own — it wasn't.
780
+ */
781
+ getEffectiveStateForCrop() {
782
+ if (!this.opts.movableFrame) return this.state;
783
+ const t = this.fixedImageSize.width / F(this.naturalSize, this.state.rotation).width, e = L(this.naturalSize, this.getFrameSize(), this.state.rotation);
784
+ return {
785
+ ...this.state,
786
+ offset: { x: -this.state.framePosition.x, y: -this.state.framePosition.y },
787
+ zoom: t / e
788
+ };
789
+ }
790
+ syncStageSize() {
791
+ N(
792
+ this.stage.stageEl,
793
+ this.opts.frame.width + D * 2,
794
+ this.opts.frame.height + D * 2
795
+ );
796
+ }
797
+ // movableFrame's stage-sizing counterpart to syncStageSize() above: pins
798
+ // the stage to the fixed image's own size (so the whole image stays
799
+ // visible/on-screen) instead of the frame's, which may since have been
800
+ // resized independently. Called after load() and rotate() establish/
801
+ // recompute fixedImageSize — never from setFrameSize(), which is the
802
+ // entire point of this mode.
803
+ syncStageSizeToFixedImage() {
804
+ N(
805
+ this.stage.stageEl,
806
+ this.fixedImageSize.width + D * 2,
807
+ this.fixedImageSize.height + D * 2
808
+ );
809
+ }
810
+ commitState(t) {
811
+ this.state = t;
812
+ const e = this.opts.movableFrame ? this.fixedImageSize.width / F(this.naturalSize, t.rotation).width : L(this.naturalSize, this.getFrameSize(), t.rotation) * t.zoom;
813
+ J(this.stage.imageLayerEl, t, e), U(this.stage.imgEl, t.filters, this.stage.sharpenKernelEl, this.stage.sharpenFilterId), this.opts.movableFrame && Q(this.stage.frameEl, t.framePosition), this.stage.zoomerEl && (this.stage.zoomerEl.value = String(t.zoom));
814
+ for (const s of this.listeners.change) s(this.getState());
815
+ }
816
+ enableZoomer(t) {
817
+ const e = () => this.setZoom(Number(t.value));
818
+ t.addEventListener("input", e), this.zoomerHandle = {
819
+ destroy() {
820
+ t.removeEventListener("input", e);
821
+ }
822
+ };
823
+ }
824
+ enableFrameResize() {
825
+ const t = [
826
+ { name: "top-left", edge: { left: "-5px", top: "-5px" }, signX: -1, signY: -1 },
827
+ { name: "top-right", edge: { right: "-5px", top: "-5px" }, signX: 1, signY: -1 },
828
+ { name: "bottom-left", edge: { left: "-5px", bottom: "-5px" }, signX: -1, signY: 1 },
829
+ { name: "bottom-right", edge: { right: "-5px", bottom: "-5px" }, signX: 1, signY: 1 }
830
+ ], e = [];
831
+ for (const s of t) {
832
+ const o = document.createElement("div");
833
+ o.className = `kiri-frame-handle kiri-frame-handle--${s.name}`, Object.assign(o.style, s.edge), this.stage.frameEl.appendChild(o);
834
+ let n = null;
835
+ const l = (f) => {
836
+ f.stopPropagation(), o.setPointerCapture(f.pointerId), n = {
837
+ x: f.clientX,
838
+ y: f.clientY,
839
+ width: this.opts.frame.width,
840
+ height: this.opts.frame.height
841
+ };
842
+ }, h = (f) => {
843
+ if (!n) return;
844
+ const g = (f.clientX - n.x) * 2 * s.signX, p = (f.clientY - n.y) * 2 * s.signY, c = n.width + g, u = n.height + p;
845
+ if (this.opts.lockAspectRatio) {
846
+ const r = n.width / n.height;
847
+ Math.abs(c - n.width) >= Math.abs(u - n.height) * r ? this.setFrameSize(c, c / r) : this.setFrameSize(u * r, u);
848
+ } else
849
+ this.setFrameSize(c, u);
850
+ }, m = () => {
851
+ n = null;
852
+ };
853
+ o.addEventListener("pointerdown", l), o.addEventListener("pointermove", h), o.addEventListener("pointerup", m), o.addEventListener("pointercancel", m), e.push(() => {
854
+ o.removeEventListener("pointerdown", l), o.removeEventListener("pointermove", h), o.removeEventListener("pointerup", m), o.removeEventListener("pointercancel", m), o.remove();
855
+ });
856
+ }
857
+ this.resizeHandle = {
858
+ destroy() {
859
+ for (const s of e) s();
860
+ }
861
+ };
862
+ }
863
+ }
864
+ export {
865
+ ut as Kiri
866
+ };
867
+ //# sourceMappingURL=kiri.mjs.map