@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.js ADDED
@@ -0,0 +1,1115 @@
1
+ (function(global, factory) {
2
+ typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.Kiri = {}));
3
+ })(this, function(exports2) {
4
+ "use strict";var __defProp = Object.defineProperty;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
+
8
+ const DEFAULT_FILTERS = {
9
+ brightness: 1,
10
+ contrast: 1,
11
+ saturation: 1,
12
+ sharpness: 1,
13
+ grayscale: false,
14
+ sepia: false
15
+ };
16
+ function mergeFilters(current, partial) {
17
+ return {
18
+ brightness: Math.max(0, partial.brightness ?? current.brightness),
19
+ contrast: Math.max(0, partial.contrast ?? current.contrast),
20
+ saturation: Math.max(0, partial.saturation ?? current.saturation),
21
+ sharpness: Math.max(0, partial.sharpness ?? current.sharpness),
22
+ grayscale: partial.grayscale ?? current.grayscale,
23
+ sepia: partial.sepia ?? current.sepia
24
+ };
25
+ }
26
+ function sharpenKernelMatrix(sharpness) {
27
+ const weight = sharpness > 1 ? (sharpness - 1) / 4.5 : 0;
28
+ const center = 1 + 4 * weight;
29
+ const neighbor = -weight;
30
+ return `0 ${neighbor} 0 ${neighbor} ${center} ${neighbor} 0 ${neighbor} 0`;
31
+ }
32
+ function buildFilterString(filters, sharpenFilterId) {
33
+ const parts = [];
34
+ if (filters.sharpness > 1) parts.push(`url(#${sharpenFilterId})`);
35
+ parts.push(
36
+ `brightness(${filters.brightness})`,
37
+ `contrast(${filters.contrast})`,
38
+ `saturate(${filters.saturation})`
39
+ );
40
+ if (filters.grayscale) parts.push("grayscale(1)");
41
+ if (filters.sepia) parts.push("sepia(1)");
42
+ return parts.join(" ");
43
+ }
44
+ const SVG_NS = "http://www.w3.org/2000/svg";
45
+ let sharpenFilterCounter = 0;
46
+ function createSharpenFilter() {
47
+ sharpenFilterCounter += 1;
48
+ const filterId = `kiri-sharpen-${sharpenFilterCounter}`;
49
+ const svg = document.createElementNS(SVG_NS, "svg");
50
+ svg.setAttribute("width", "0");
51
+ svg.setAttribute("height", "0");
52
+ svg.style.position = "absolute";
53
+ const filterEl = document.createElementNS(SVG_NS, "filter");
54
+ filterEl.setAttribute("id", filterId);
55
+ filterEl.setAttribute("color-interpolation-filters", "sRGB");
56
+ const kernelEl = document.createElementNS(SVG_NS, "feConvolveMatrix");
57
+ kernelEl.setAttribute("order", "3");
58
+ kernelEl.setAttribute("divisor", "1");
59
+ kernelEl.setAttribute("edgeMode", "duplicate");
60
+ kernelEl.setAttribute("preserveAlpha", "true");
61
+ kernelEl.setAttribute("kernelMatrix", sharpenKernelMatrix(1));
62
+ filterEl.appendChild(kernelEl);
63
+ svg.appendChild(filterEl);
64
+ return { svg, filterId, kernelEl };
65
+ }
66
+ function createStage(container, frameShape, frameWidth, frameHeight, cornerRadius, movableFrame, zoomer) {
67
+ container.innerHTML = "";
68
+ const stageEl = document.createElement("div");
69
+ stageEl.className = "kiri-stage";
70
+ stageEl.tabIndex = 0;
71
+ stageEl.setAttribute("role", "application");
72
+ stageEl.setAttribute(
73
+ "aria-label",
74
+ movableFrame ? "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."
75
+ );
76
+ const imageLayerEl = document.createElement("div");
77
+ imageLayerEl.className = "kiri-image-layer";
78
+ const imgEl = document.createElement("img");
79
+ imgEl.draggable = false;
80
+ imageLayerEl.appendChild(imgEl);
81
+ const frameEl = document.createElement("div");
82
+ frameEl.className = frameShape === "circle" ? "kiri-frame kiri-frame--circle" : frameShape === "rounded-rectangle" ? "kiri-frame kiri-frame--rounded-rectangle" : "kiri-frame";
83
+ setFrameSize(frameEl, frameWidth, frameHeight);
84
+ if (frameShape === "rounded-rectangle") {
85
+ frameEl.style.borderRadius = `${cornerRadius}px`;
86
+ }
87
+ const { svg: sharpenSvg, filterId: sharpenFilterId, kernelEl: sharpenKernelEl } = createSharpenFilter();
88
+ stageEl.appendChild(imageLayerEl);
89
+ stageEl.appendChild(frameEl);
90
+ stageEl.appendChild(sharpenSvg);
91
+ let zoomerEl = null;
92
+ if (zoomer.show) {
93
+ const rootEl = document.createElement("div");
94
+ rootEl.className = `kiri-root kiri-root--${zoomer.position}`;
95
+ zoomerEl = document.createElement("input");
96
+ zoomerEl.type = "range";
97
+ zoomerEl.className = "kiri-zoomer";
98
+ zoomerEl.min = String(zoomer.min);
99
+ zoomerEl.max = String(zoomer.max);
100
+ zoomerEl.step = "0.01";
101
+ zoomerEl.value = String(zoomer.value);
102
+ if (movableFrame) zoomerEl.disabled = true;
103
+ rootEl.appendChild(stageEl);
104
+ rootEl.appendChild(zoomerEl);
105
+ container.appendChild(rootEl);
106
+ } else {
107
+ container.appendChild(stageEl);
108
+ }
109
+ return { stageEl, frameEl, imageLayerEl, imgEl, zoomerEl, sharpenFilterId, sharpenKernelEl };
110
+ }
111
+ function setFrameSize(frameEl, width, height) {
112
+ frameEl.style.width = `${width}px`;
113
+ frameEl.style.height = `${height}px`;
114
+ }
115
+ const STAGE_AUTO_SIZE_PADDING = 20;
116
+ function setStageSize(stageEl, width, height) {
117
+ stageEl.style.width = `${width}px`;
118
+ stageEl.style.height = `${height}px`;
119
+ }
120
+ function applyTransform(imageLayerEl, state, renderedScale) {
121
+ const scaleX = renderedScale * (state.flip.horizontal ? -1 : 1);
122
+ const scaleY = renderedScale * (state.flip.vertical ? -1 : 1);
123
+ imageLayerEl.style.transform = `translate(-50%, -50%) translate(${state.offset.x}px, ${state.offset.y}px) scale(${scaleX}, ${scaleY}) rotate(${state.rotation}deg)`;
124
+ }
125
+ function applyFilters(imgEl, filters, sharpenKernelEl, sharpenFilterId) {
126
+ sharpenKernelEl.setAttribute("kernelMatrix", sharpenKernelMatrix(filters.sharpness));
127
+ imgEl.style.filter = buildFilterString(filters, sharpenFilterId);
128
+ }
129
+ function applyFramePosition(frameEl, framePosition) {
130
+ frameEl.style.transform = `translate(-50%, -50%) translate(${framePosition.x}px, ${framePosition.y}px)`;
131
+ }
132
+ function effectiveNaturalSize(natural, rotationDeg) {
133
+ const swapped = (rotationDeg / 90 % 2 + 2) % 2 === 1;
134
+ return swapped ? { width: natural.height, height: natural.width } : { width: natural.width, height: natural.height };
135
+ }
136
+ function computeCoverScale(natural, frame, rotationDeg) {
137
+ const eff = effectiveNaturalSize(natural, rotationDeg);
138
+ if (eff.width <= 0 || eff.height <= 0) return 1;
139
+ return Math.max(frame.width / eff.width, frame.height / eff.height);
140
+ }
141
+ function effectiveRenderedSize(natural, frame, rotationDeg, zoom) {
142
+ const eff = effectiveNaturalSize(natural, rotationDeg);
143
+ const scale = computeCoverScale(natural, frame, rotationDeg) * zoom;
144
+ return { width: eff.width * scale, height: eff.height * scale };
145
+ }
146
+ function clampZoom(zoom, minZoom, maxZoom) {
147
+ return Math.min(Math.max(zoom, minZoom), maxZoom);
148
+ }
149
+ function clampOffset(offset, rendered, frame) {
150
+ const maxX = Math.max(0, (rendered.width - frame.width) / 2);
151
+ const maxY = Math.max(0, (rendered.height - frame.height) / 2);
152
+ return {
153
+ x: Math.min(Math.max(offset.x, -maxX), maxX) || 0,
154
+ y: Math.min(Math.max(offset.y, -maxY), maxY) || 0
155
+ };
156
+ }
157
+ function normalizeRotation(deg) {
158
+ return (deg % 360 + 360) % 360;
159
+ }
160
+ const KEYBOARD_PAN_STEP = 15;
161
+ const KEYBOARD_ZOOM_STEP = 0.1;
162
+ function pointerDistance(a, b) {
163
+ return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
164
+ }
165
+ function attachGestures(stageEl, callbacks, options) {
166
+ const activePointers = /* @__PURE__ */ new Map();
167
+ let dragStart = null;
168
+ let pinchStartDistance = 0;
169
+ let pinchStartZoom = 1;
170
+ function applyClampedState(next) {
171
+ const { min, max } = callbacks.getMinMaxZoom();
172
+ const zoom = clampZoom(next.zoom, min, max);
173
+ const rendered = effectiveRenderedSize(
174
+ callbacks.getNaturalSize(),
175
+ callbacks.getFrameSize(),
176
+ next.rotation,
177
+ zoom
178
+ );
179
+ const offset = clampOffset(next.offset, rendered, callbacks.getFrameSize());
180
+ callbacks.setState({
181
+ zoom,
182
+ offset,
183
+ rotation: normalizeRotation(next.rotation),
184
+ flip: next.flip,
185
+ filters: next.filters,
186
+ framePosition: next.framePosition
187
+ });
188
+ }
189
+ function applyFramePosition2(candidate) {
190
+ const state = callbacks.getState();
191
+ const framePosition = clampOffset(candidate, callbacks.getFixedImageSize(), callbacks.getFrameSize());
192
+ callbacks.setState({ ...state, framePosition });
193
+ }
194
+ function onPointerDown(e) {
195
+ stageEl.setPointerCapture(e.pointerId);
196
+ activePointers.set(e.pointerId, e);
197
+ if (activePointers.size === 1) {
198
+ const state = callbacks.getState();
199
+ dragStart = {
200
+ x: e.clientX,
201
+ y: e.clientY,
202
+ offset: options.movableFrame ? state.framePosition : state.offset
203
+ };
204
+ stageEl.classList.add("kiri-dragging");
205
+ } else if (activePointers.size === 2) {
206
+ dragStart = null;
207
+ const [a, b] = [...activePointers.values()];
208
+ pinchStartDistance = pointerDistance(a, b);
209
+ pinchStartZoom = callbacks.getState().zoom;
210
+ }
211
+ }
212
+ function onPointerMove(e) {
213
+ if (!activePointers.has(e.pointerId)) return;
214
+ activePointers.set(e.pointerId, e);
215
+ if (activePointers.size === 2) {
216
+ if (options.movableFrame) return;
217
+ const [a, b] = [...activePointers.values()];
218
+ const distance = pointerDistance(a, b);
219
+ if (pinchStartDistance > 0) {
220
+ const ratio = distance / pinchStartDistance;
221
+ const state = callbacks.getState();
222
+ applyClampedState({ ...state, zoom: pinchStartZoom * ratio });
223
+ }
224
+ return;
225
+ }
226
+ if (dragStart) {
227
+ const dx = e.clientX - dragStart.x;
228
+ const dy = e.clientY - dragStart.y;
229
+ if (options.movableFrame) {
230
+ applyFramePosition2({ x: dragStart.offset.x + dx, y: dragStart.offset.y + dy });
231
+ return;
232
+ }
233
+ const state = callbacks.getState();
234
+ applyClampedState({
235
+ ...state,
236
+ offset: { x: dragStart.offset.x + dx, y: dragStart.offset.y + dy }
237
+ });
238
+ }
239
+ }
240
+ function onPointerUp(e) {
241
+ activePointers.delete(e.pointerId);
242
+ if (activePointers.size < 2) pinchStartDistance = 0;
243
+ if (activePointers.size === 0) {
244
+ dragStart = null;
245
+ stageEl.classList.remove("kiri-dragging");
246
+ }
247
+ }
248
+ function onWheel(e) {
249
+ if (options.movableFrame) return;
250
+ if (!options.mouseWheelZoom) return;
251
+ if (options.mouseWheelZoom === "ctrl" && !e.ctrlKey) return;
252
+ e.preventDefault();
253
+ const state = callbacks.getState();
254
+ const delta = -e.deltaY * 15e-4;
255
+ applyClampedState({ ...state, zoom: state.zoom * (1 + delta) });
256
+ }
257
+ function onKeyDown(e) {
258
+ const state = callbacks.getState();
259
+ if (options.movableFrame) {
260
+ switch (e.key) {
261
+ case "ArrowLeft":
262
+ e.preventDefault();
263
+ applyFramePosition2({ ...state.framePosition, x: state.framePosition.x - KEYBOARD_PAN_STEP });
264
+ return;
265
+ case "ArrowRight":
266
+ e.preventDefault();
267
+ applyFramePosition2({ ...state.framePosition, x: state.framePosition.x + KEYBOARD_PAN_STEP });
268
+ return;
269
+ case "ArrowUp":
270
+ e.preventDefault();
271
+ applyFramePosition2({ ...state.framePosition, y: state.framePosition.y - KEYBOARD_PAN_STEP });
272
+ return;
273
+ case "ArrowDown":
274
+ e.preventDefault();
275
+ applyFramePosition2({ ...state.framePosition, y: state.framePosition.y + KEYBOARD_PAN_STEP });
276
+ return;
277
+ case "0":
278
+ e.preventDefault();
279
+ callbacks.reset();
280
+ return;
281
+ default:
282
+ return;
283
+ }
284
+ }
285
+ switch (e.key) {
286
+ case "ArrowLeft":
287
+ e.preventDefault();
288
+ applyClampedState({ ...state, offset: { ...state.offset, x: state.offset.x + KEYBOARD_PAN_STEP } });
289
+ break;
290
+ case "ArrowRight":
291
+ e.preventDefault();
292
+ applyClampedState({ ...state, offset: { ...state.offset, x: state.offset.x - KEYBOARD_PAN_STEP } });
293
+ break;
294
+ case "ArrowUp":
295
+ e.preventDefault();
296
+ applyClampedState({ ...state, offset: { ...state.offset, y: state.offset.y + KEYBOARD_PAN_STEP } });
297
+ break;
298
+ case "ArrowDown":
299
+ e.preventDefault();
300
+ applyClampedState({ ...state, offset: { ...state.offset, y: state.offset.y - KEYBOARD_PAN_STEP } });
301
+ break;
302
+ case "+":
303
+ case "=":
304
+ e.preventDefault();
305
+ applyClampedState({ ...state, zoom: state.zoom + KEYBOARD_ZOOM_STEP });
306
+ break;
307
+ case "-":
308
+ case "_":
309
+ e.preventDefault();
310
+ applyClampedState({ ...state, zoom: state.zoom - KEYBOARD_ZOOM_STEP });
311
+ break;
312
+ case "0":
313
+ e.preventDefault();
314
+ callbacks.reset();
315
+ break;
316
+ }
317
+ }
318
+ stageEl.addEventListener("pointerdown", onPointerDown);
319
+ stageEl.addEventListener("pointermove", onPointerMove);
320
+ stageEl.addEventListener("pointerup", onPointerUp);
321
+ stageEl.addEventListener("pointercancel", onPointerUp);
322
+ stageEl.addEventListener("wheel", onWheel, { passive: false });
323
+ stageEl.addEventListener("keydown", onKeyDown);
324
+ return {
325
+ destroy() {
326
+ stageEl.removeEventListener("pointerdown", onPointerDown);
327
+ stageEl.removeEventListener("pointermove", onPointerMove);
328
+ stageEl.removeEventListener("pointerup", onPointerUp);
329
+ stageEl.removeEventListener("pointercancel", onPointerUp);
330
+ stageEl.removeEventListener("wheel", onWheel);
331
+ stageEl.removeEventListener("keydown", onKeyDown);
332
+ }
333
+ };
334
+ }
335
+ function readExifOrientation(buffer) {
336
+ const view = new DataView(buffer);
337
+ if (view.byteLength < 4 || view.getUint16(0, false) !== 65496) return 1;
338
+ let offset = 2;
339
+ while (offset + 4 <= view.byteLength) {
340
+ const marker = view.getUint16(offset, false);
341
+ const size = view.getUint16(offset + 2, false);
342
+ if (marker === 65505) {
343
+ const exifOffset = offset + 4;
344
+ if (view.getUint32(exifOffset, false) !== 1165519206) return 1;
345
+ return readOrientationFromTiff(view, exifOffset + 6);
346
+ }
347
+ if ((marker & 65280) !== 65280) break;
348
+ offset += 2 + size;
349
+ }
350
+ return 1;
351
+ }
352
+ function readOrientationFromTiff(view, tiffStart) {
353
+ const littleEndian = view.getUint16(tiffStart, false) === 18761;
354
+ const firstIfdOffset = view.getUint32(tiffStart + 4, littleEndian);
355
+ const ifdStart = tiffStart + firstIfdOffset;
356
+ if (ifdStart + 2 > view.byteLength) return 1;
357
+ const entryCount = view.getUint16(ifdStart, littleEndian);
358
+ for (let i = 0; i < entryCount; i++) {
359
+ const entryOffset = ifdStart + 2 + i * 12;
360
+ if (entryOffset + 12 > view.byteLength) break;
361
+ const tag = view.getUint16(entryOffset, littleEndian);
362
+ if (tag === 274) {
363
+ const value = view.getUint16(entryOffset + 8, littleEndian);
364
+ return value >= 1 && value <= 8 ? value : 1;
365
+ }
366
+ }
367
+ return 1;
368
+ }
369
+ function orientationToTransform(orientation) {
370
+ switch (orientation) {
371
+ case 2:
372
+ return { rotation: 0, flipHorizontal: true };
373
+ case 3:
374
+ return { rotation: 180, flipHorizontal: false };
375
+ case 4:
376
+ return { rotation: 180, flipHorizontal: true };
377
+ case 5:
378
+ return { rotation: 270, flipHorizontal: true };
379
+ case 6:
380
+ return { rotation: 90, flipHorizontal: false };
381
+ case 7:
382
+ return { rotation: 90, flipHorizontal: true };
383
+ case 8:
384
+ return { rotation: 270, flipHorizontal: false };
385
+ default:
386
+ return { rotation: 0, flipHorizontal: false };
387
+ }
388
+ }
389
+ function resolveEnumOption(value, valid, fallback, optionName) {
390
+ if (value === void 0) return fallback;
391
+ if (valid.includes(value)) return value;
392
+ console.warn(
393
+ `Kiri: invalid ${optionName} "${value}" — defaulting to "${fallback}". Valid values: ${valid.join(", ")}.`
394
+ );
395
+ return fallback;
396
+ }
397
+ function clampNum(value, min, max) {
398
+ return Math.min(Math.max(value, min), max);
399
+ }
400
+ const VALID_EXPORT_TYPES = ["base64", "blob", "canvas"];
401
+ const VALID_EXPORT_FORMATS = ["image/jpeg", "image/png", "image/webp"];
402
+ function computeFrameSourceRect(rendered, offset, frame) {
403
+ return {
404
+ left: rendered.width / 2 - offset.x - frame.width / 2,
405
+ top: rendered.height / 2 - offset.y - frame.height / 2
406
+ };
407
+ }
408
+ function computeCropRegion(natural, frame, state) {
409
+ const scale = computeCoverScale(natural, frame, state.rotation) * state.zoom;
410
+ const rendered = effectiveRenderedSize(natural, frame, state.rotation, state.zoom);
411
+ const { left: frameLeft, top: frameTop } = computeFrameSourceRect(rendered, state.offset, frame);
412
+ const renderedCenter = { x: rendered.width / 2, y: rendered.height / 2 };
413
+ const naturalCenter = { x: natural.width / 2, y: natural.height / 2 };
414
+ const rotation = normalizeRotation(state.rotation);
415
+ const corners = [
416
+ { x: frameLeft, y: frameTop },
417
+ { x: frameLeft + frame.width, y: frameTop },
418
+ { x: frameLeft, y: frameTop + frame.height },
419
+ { x: frameLeft + frame.width, y: frameTop + frame.height }
420
+ ].map(({ x, y }) => {
421
+ const cx = (x - renderedCenter.x) / scale;
422
+ const cy = (y - renderedCenter.y) / scale;
423
+ let u;
424
+ let v;
425
+ if (rotation === 90) {
426
+ u = cy;
427
+ v = -cx;
428
+ } else if (rotation === 180) {
429
+ u = -cx;
430
+ v = -cy;
431
+ } else if (rotation === 270) {
432
+ u = -cy;
433
+ v = cx;
434
+ } else {
435
+ u = cx;
436
+ v = cy;
437
+ }
438
+ return { x: u + naturalCenter.x, y: v + naturalCenter.y };
439
+ });
440
+ const xs = corners.map((c) => c.x);
441
+ const ys = corners.map((c) => c.y);
442
+ const left = clampNum(Math.min(...xs), 0, natural.width);
443
+ const top = clampNum(Math.min(...ys), 0, natural.height);
444
+ const right = clampNum(Math.max(...xs), 0, natural.width);
445
+ const bottom = clampNum(Math.max(...ys), 0, natural.height);
446
+ return {
447
+ x: Math.round(left),
448
+ y: Math.round(top),
449
+ width: Math.round(right - left),
450
+ height: Math.round(bottom - top),
451
+ rotation,
452
+ flip: { ...state.flip }
453
+ };
454
+ }
455
+ function renderCropToCanvas(img, state, frame, outputWidth, outputHeight, frameShape, cornerRadius, sharpenFilterId) {
456
+ const natural = { width: img.naturalWidth, height: img.naturalHeight };
457
+ const scale = computeCoverScale(natural, frame, state.rotation) * state.zoom;
458
+ const rendered = effectiveRenderedSize(natural, frame, state.rotation, state.zoom);
459
+ const sourceCanvas = document.createElement("canvas");
460
+ sourceCanvas.width = Math.max(1, Math.round(rendered.width));
461
+ sourceCanvas.height = Math.max(1, Math.round(rendered.height));
462
+ const sctx = sourceCanvas.getContext("2d");
463
+ if (!sctx) throw new Error("Kiri: unable to get 2D canvas context");
464
+ sctx.translate(sourceCanvas.width / 2, sourceCanvas.height / 2);
465
+ sctx.scale(scale * (state.flip.horizontal ? -1 : 1), scale * (state.flip.vertical ? -1 : 1));
466
+ sctx.rotate(state.rotation * Math.PI / 180);
467
+ sctx.filter = buildFilterString(state.filters, sharpenFilterId);
468
+ sctx.drawImage(img, -natural.width / 2, -natural.height / 2, natural.width, natural.height);
469
+ const { left: frameLeft, top: frameTop } = computeFrameSourceRect(
470
+ rendered,
471
+ state.offset,
472
+ frame
473
+ );
474
+ const outCanvas = document.createElement("canvas");
475
+ outCanvas.width = outputWidth;
476
+ outCanvas.height = outputHeight;
477
+ const octx = outCanvas.getContext("2d");
478
+ if (!octx) throw new Error("Kiri: unable to get 2D canvas context");
479
+ if (frameShape === "circle") {
480
+ octx.save();
481
+ octx.beginPath();
482
+ octx.ellipse(
483
+ outputWidth / 2,
484
+ outputHeight / 2,
485
+ outputWidth / 2,
486
+ outputHeight / 2,
487
+ 0,
488
+ 0,
489
+ Math.PI * 2
490
+ );
491
+ octx.clip();
492
+ } else if (frameShape === "rounded-rectangle") {
493
+ const scale2 = outputWidth / frame.width;
494
+ const radius = clampNum(cornerRadius * scale2, 0, Math.min(outputWidth, outputHeight) / 2);
495
+ octx.save();
496
+ octx.beginPath();
497
+ octx.roundRect(0, 0, outputWidth, outputHeight, radius);
498
+ octx.clip();
499
+ }
500
+ octx.drawImage(
501
+ sourceCanvas,
502
+ frameLeft,
503
+ frameTop,
504
+ frame.width,
505
+ frame.height,
506
+ 0,
507
+ 0,
508
+ outputWidth,
509
+ outputHeight
510
+ );
511
+ if (frameShape === "circle" || frameShape === "rounded-rectangle") octx.restore();
512
+ return outCanvas;
513
+ }
514
+ async function exportCrop(img, state, frame, frameShape, cornerRadius, sharpenFilterId, options) {
515
+ const type = resolveEnumOption(options.type, VALID_EXPORT_TYPES, "base64", "export type");
516
+ const format = resolveEnumOption(options.format, VALID_EXPORT_FORMATS, "image/png", "export format");
517
+ const quality = options.quality;
518
+ const width = options.width ?? frame.width;
519
+ const height = options.height ?? frame.height;
520
+ if ((frameShape === "circle" || frameShape === "rounded-rectangle") && format === "image/jpeg") {
521
+ console.warn(
522
+ `Kiri: exporting a ${frameShape}-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.`
523
+ );
524
+ }
525
+ const canvas = renderCropToCanvas(
526
+ img,
527
+ state,
528
+ frame,
529
+ width,
530
+ height,
531
+ frameShape,
532
+ cornerRadius,
533
+ sharpenFilterId
534
+ );
535
+ if (type === "canvas") return canvas;
536
+ if (type === "base64") return canvas.toDataURL(format, quality);
537
+ return new Promise((resolve, reject) => {
538
+ canvas.toBlob(
539
+ (blob) => blob ? resolve(blob) : reject(new Error("Kiri: canvas.toBlob failed")),
540
+ format,
541
+ quality
542
+ );
543
+ });
544
+ }
545
+ async function uploadBlob(blob, options) {
546
+ const fieldName = options.fieldName ?? "file";
547
+ const fileName = options.fileName ?? `crop.${extensionFor(options.format ?? "image/png")}`;
548
+ const formData = new FormData();
549
+ formData.append(fieldName, blob, fileName);
550
+ for (const [key, value] of Object.entries(options.extraFields ?? {})) {
551
+ formData.append(key, value);
552
+ }
553
+ return fetch(options.url, {
554
+ ...options.fetchOptions,
555
+ method: "POST",
556
+ body: formData
557
+ });
558
+ }
559
+ function extensionFor(format) {
560
+ switch (format) {
561
+ case "image/jpeg":
562
+ return "jpg";
563
+ case "image/webp":
564
+ return "webp";
565
+ default:
566
+ return "png";
567
+ }
568
+ }
569
+ const DEFAULT_FRAME_SIZE = 200;
570
+ const MIN_FRAME_SIZE = 20;
571
+ const DEFAULT_CORNER_RADIUS = 20;
572
+ const VALID_FRAME_SHAPES = ["rectangle", "circle", "rounded-rectangle"];
573
+ const VALID_ZOOMER_POSITIONS = ["top", "bottom", "left", "right"];
574
+ function resolveMouseWheelZoom(value) {
575
+ if (value === void 0) return true;
576
+ if (typeof value === "boolean" || value === "ctrl") return value;
577
+ console.warn(
578
+ `Kiri: invalid mouseWheelZoom "${String(value)}" — defaulting to true. Valid values: true, false, "ctrl".`
579
+ );
580
+ return true;
581
+ }
582
+ class Kiri {
583
+ /**
584
+ * @param container An element already present in the DOM. Passing
585
+ * `null`/`undefined`, or an element that isn't in the DOM yet, throws.
586
+ * @param options See the {@link KiriOptions} fields for defaults.
587
+ */
588
+ constructor(container, options = {}) {
589
+ __publicField(this, "container");
590
+ __publicField(this, "opts");
591
+ __publicField(this, "stage");
592
+ __publicField(this, "gestureHandle");
593
+ __publicField(this, "resizeHandle", null);
594
+ __publicField(this, "zoomerHandle", null);
595
+ __publicField(this, "naturalSize", { width: 0, height: 0 });
596
+ __publicField(this, "state", {
597
+ zoom: 1,
598
+ offset: { x: 0, y: 0 },
599
+ rotation: 0,
600
+ flip: { horizontal: false, vertical: false },
601
+ filters: DEFAULT_FILTERS,
602
+ framePosition: { x: 0, y: 0 }
603
+ });
604
+ /**
605
+ * `movableFrame` mode only: the image's fixed on-screen size, frozen at
606
+ * `load()` time and recomputed on `rotate()` — see `getFixedImageSize()`.
607
+ */
608
+ __publicField(this, "fixedImageSize", { width: 0, height: 0 });
609
+ /** Snapshot taken right after `load()` resolves, so `reset()` has something to revert to. */
610
+ __publicField(this, "initialState", null);
611
+ __publicField(this, "listeners", { change: [] });
612
+ var _a, _b, _c, _d;
613
+ if (!container || typeof container.appendChild !== "function") {
614
+ throw new Error(
615
+ "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."
616
+ );
617
+ }
618
+ this.container = container;
619
+ this.opts = {
620
+ frame: {
621
+ shape: resolveEnumOption(
622
+ (_a = options.frame) == null ? void 0 : _a.shape,
623
+ VALID_FRAME_SHAPES,
624
+ "rectangle",
625
+ "frame.shape"
626
+ ),
627
+ width: ((_b = options.frame) == null ? void 0 : _b.width) ?? DEFAULT_FRAME_SIZE,
628
+ height: ((_c = options.frame) == null ? void 0 : _c.height) ?? DEFAULT_FRAME_SIZE,
629
+ cornerRadius: ((_d = options.frame) == null ? void 0 : _d.cornerRadius) ?? DEFAULT_CORNER_RADIUS
630
+ },
631
+ minZoom: options.minZoom ?? 1,
632
+ maxZoom: options.maxZoom ?? 4,
633
+ rotatable: options.rotatable ?? true,
634
+ flippable: options.flippable ?? true,
635
+ resizableFrame: options.resizableFrame ?? false,
636
+ lockAspectRatio: options.lockAspectRatio ?? false,
637
+ movableFrame: options.movableFrame ?? false,
638
+ mouseWheelZoom: resolveMouseWheelZoom(options.mouseWheelZoom),
639
+ useExifOrientation: options.useExifOrientation ?? true,
640
+ uploader: options.uploader,
641
+ autoSizeStage: options.autoSizeStage ?? true,
642
+ showZoomer: options.showZoomer ?? false,
643
+ zoomerPosition: resolveEnumOption(
644
+ options.zoomerPosition,
645
+ VALID_ZOOMER_POSITIONS,
646
+ "bottom",
647
+ "zoomerPosition"
648
+ )
649
+ };
650
+ this.state.filters = mergeFilters(DEFAULT_FILTERS, options.filters ?? {});
651
+ this.stage = createStage(
652
+ this.container,
653
+ this.opts.frame.shape,
654
+ this.opts.frame.width,
655
+ this.opts.frame.height,
656
+ this.opts.frame.cornerRadius,
657
+ this.opts.movableFrame,
658
+ {
659
+ show: this.opts.showZoomer,
660
+ position: this.opts.zoomerPosition,
661
+ min: this.opts.minZoom,
662
+ max: this.opts.maxZoom,
663
+ value: this.state.zoom
664
+ }
665
+ );
666
+ if (this.opts.autoSizeStage) this.syncStageSize();
667
+ applyFilters(this.stage.imgEl, this.state.filters, this.stage.sharpenKernelEl, this.stage.sharpenFilterId);
668
+ if (this.stage.zoomerEl) this.enableZoomer(this.stage.zoomerEl);
669
+ this.gestureHandle = attachGestures(
670
+ this.stage.stageEl,
671
+ {
672
+ getNaturalSize: () => this.naturalSize,
673
+ getFrameSize: () => this.getFrameSize(),
674
+ getState: () => this.state,
675
+ getMinMaxZoom: () => ({ min: this.opts.minZoom, max: this.opts.maxZoom }),
676
+ setState: (next) => this.commitState(next),
677
+ reset: () => this.reset(),
678
+ getFixedImageSize: () => this.fixedImageSize
679
+ },
680
+ { mouseWheelZoom: this.opts.mouseWheelZoom, movableFrame: this.opts.movableFrame }
681
+ );
682
+ if (this.opts.resizableFrame) this.enableFrameResize();
683
+ }
684
+ /**
685
+ * Loads an image, replacing whatever was loaded before. EXIF orientation
686
+ * (rotation + horizontal flip) is corrected automatically unless
687
+ * `useExifOrientation: false` was passed to the constructor — only for
688
+ * `File`/`Blob` sources, since a plain URL string can't be read for EXIF
689
+ * data without an extra fetch.
690
+ * @param source A `File` (e.g. from a file input), a `Blob`, or a URL string.
691
+ * @param loadOptions Initial `zoom`/`offset`/`rotation`/`flip`.
692
+ */
693
+ async load(source, loadOptions = {}) {
694
+ var _a, _b;
695
+ let rotation = normalizeRotation(loadOptions.rotation ?? 0);
696
+ let flipHorizontal = ((_a = loadOptions.flip) == null ? void 0 : _a.horizontal) ?? false;
697
+ const flipVertical = ((_b = loadOptions.flip) == null ? void 0 : _b.vertical) ?? false;
698
+ let objectUrl = null;
699
+ let url;
700
+ if (typeof source === "string") {
701
+ url = source;
702
+ } else {
703
+ if (this.opts.useExifOrientation) {
704
+ const buffer = await source.arrayBuffer();
705
+ const orientation = readExifOrientation(buffer);
706
+ const exifTransform = orientationToTransform(orientation);
707
+ rotation = normalizeRotation(rotation + exifTransform.rotation);
708
+ flipHorizontal = flipHorizontal !== exifTransform.flipHorizontal;
709
+ }
710
+ objectUrl = URL.createObjectURL(source);
711
+ url = objectUrl;
712
+ }
713
+ try {
714
+ await new Promise((resolve, reject) => {
715
+ this.stage.imgEl.onload = () => resolve();
716
+ this.stage.imgEl.onerror = () => reject(new Error("Kiri: failed to load image"));
717
+ this.stage.imgEl.src = url;
718
+ });
719
+ } finally {
720
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
721
+ }
722
+ this.naturalSize = {
723
+ width: this.stage.imgEl.naturalWidth,
724
+ height: this.stage.imgEl.naturalHeight
725
+ };
726
+ const zoom = this.opts.movableFrame ? 1 : clampZoom(loadOptions.zoom ?? this.opts.minZoom, this.opts.minZoom, this.opts.maxZoom);
727
+ const rendered = effectiveRenderedSize(this.naturalSize, this.getFrameSize(), rotation, zoom);
728
+ const offset = this.opts.movableFrame ? { x: 0, y: 0 } : clampOffset(loadOptions.offset ?? { x: 0, y: 0 }, rendered, this.getFrameSize());
729
+ this.fixedImageSize = effectiveRenderedSize(this.naturalSize, this.getFrameSize(), rotation, 1);
730
+ if (this.opts.movableFrame && this.opts.autoSizeStage) this.syncStageSizeToFixedImage();
731
+ this.commitState({
732
+ zoom,
733
+ offset,
734
+ rotation,
735
+ flip: { horizontal: flipHorizontal, vertical: flipVertical },
736
+ filters: this.state.filters,
737
+ framePosition: { x: 0, y: 0 }
738
+ });
739
+ this.initialState = this.getState();
740
+ }
741
+ /** A snapshot of the current state — mutating the returned object has no effect. */
742
+ getState() {
743
+ return {
744
+ zoom: this.state.zoom,
745
+ offset: { ...this.state.offset },
746
+ rotation: this.state.rotation,
747
+ flip: { ...this.state.flip },
748
+ filters: { ...this.state.filters },
749
+ framePosition: { ...this.state.framePosition }
750
+ };
751
+ }
752
+ /**
753
+ * Sets the zoom to an absolute value, clamped to `[minZoom, maxZoom]`.
754
+ * No-op if `movableFrame: true` — the image never zooms in that mode.
755
+ */
756
+ setZoom(zoom) {
757
+ if (this.opts.movableFrame) return;
758
+ const clamped = clampZoom(zoom, this.opts.minZoom, this.opts.maxZoom);
759
+ const rendered = effectiveRenderedSize(
760
+ this.naturalSize,
761
+ this.getFrameSize(),
762
+ this.state.rotation,
763
+ clamped
764
+ );
765
+ const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
766
+ this.commitState({ ...this.state, zoom: clamped, offset });
767
+ }
768
+ /**
769
+ * Sets the pan offset to an absolute value (image-center offset from the
770
+ * frame center, in stage pixels), clamped so the frame stays fully covered
771
+ * by the rendered image. No-op if `movableFrame: true` — the image never
772
+ * pans in that mode; see `setFramePosition()` instead.
773
+ */
774
+ setOffset(offset) {
775
+ if (this.opts.movableFrame) return;
776
+ const rendered = effectiveRenderedSize(
777
+ this.naturalSize,
778
+ this.getFrameSize(),
779
+ this.state.rotation,
780
+ this.state.zoom
781
+ );
782
+ const clamped = clampOffset(offset, rendered, this.getFrameSize());
783
+ this.commitState({ ...this.state, offset: clamped });
784
+ }
785
+ /**
786
+ * Sets the frame's position to an absolute value (offset from stage
787
+ * center, in stage pixels), clamped so the frame stays fully within the
788
+ * image's fixed bounds. Only meaningful — and only takes effect — when
789
+ * `movableFrame: true`; a no-op otherwise, since the frame always stays
790
+ * centered in the default mode.
791
+ */
792
+ setFramePosition(position) {
793
+ if (!this.opts.movableFrame) return;
794
+ const clamped = clampOffset(position, this.fixedImageSize, this.getFrameSize());
795
+ this.commitState({ ...this.state, framePosition: clamped });
796
+ }
797
+ /**
798
+ * Reverts zoom/offset/rotation/flip/filters/framePosition to what they
799
+ * were right after `load()` resolved (including any `loadOptions` passed
800
+ * to it). No-op if nothing has been loaded yet.
801
+ */
802
+ reset() {
803
+ if (!this.initialState) return;
804
+ this.commitState({
805
+ zoom: this.initialState.zoom,
806
+ offset: { ...this.initialState.offset },
807
+ rotation: this.initialState.rotation,
808
+ flip: { ...this.initialState.flip },
809
+ filters: { ...this.initialState.filters },
810
+ framePosition: { ...this.initialState.framePosition }
811
+ });
812
+ }
813
+ /**
814
+ * Rotates relative to the current rotation, snapped to the nearest 90°.
815
+ * No-op if `rotatable: false` was passed to the constructor.
816
+ */
817
+ rotate(deltaDeg) {
818
+ if (!this.opts.rotatable) return;
819
+ const snapped = Math.round(deltaDeg / 90) * 90;
820
+ const rotation = normalizeRotation(this.state.rotation + snapped);
821
+ if (this.opts.movableFrame) {
822
+ const frozenScale = this.fixedImageSize.width / effectiveNaturalSize(this.naturalSize, this.state.rotation).width;
823
+ const newEffNatural = effectiveNaturalSize(this.naturalSize, rotation);
824
+ this.fixedImageSize = {
825
+ width: newEffNatural.width * frozenScale,
826
+ height: newEffNatural.height * frozenScale
827
+ };
828
+ if (this.opts.autoSizeStage) this.syncStageSizeToFixedImage();
829
+ const framePosition = clampOffset(this.state.framePosition, this.fixedImageSize, this.getFrameSize());
830
+ this.commitState({ ...this.state, rotation, framePosition });
831
+ return;
832
+ }
833
+ const rendered = effectiveRenderedSize(
834
+ this.naturalSize,
835
+ this.getFrameSize(),
836
+ rotation,
837
+ this.state.zoom
838
+ );
839
+ const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
840
+ this.commitState({ ...this.state, rotation, offset });
841
+ }
842
+ /**
843
+ * Toggles horizontal flip. Always mirrors left-right as currently
844
+ * displayed on screen, regardless of the current `rotation` — not the
845
+ * image's own pre-rotation axes, so the visible effect stays "horizontal"
846
+ * no matter how the image is rotated. No-op if `flippable: false`.
847
+ */
848
+ flipHorizontal() {
849
+ if (!this.opts.flippable) return;
850
+ const flip = { ...this.state.flip, horizontal: !this.state.flip.horizontal };
851
+ this.commitState({ ...this.state, flip });
852
+ }
853
+ /**
854
+ * Toggles vertical flip. Always mirrors top-to-bottom as currently
855
+ * displayed on screen, regardless of the current `rotation`. No-op if
856
+ * `flippable: false`.
857
+ */
858
+ flipVertical() {
859
+ if (!this.opts.flippable) return;
860
+ const flip = { ...this.state.flip, vertical: !this.state.flip.vertical };
861
+ this.commitState({ ...this.state, flip });
862
+ }
863
+ /**
864
+ * Resizes the frame. Also resizes the stage to match, if
865
+ * `autoSizeStage: true` (the default) — except in `movableFrame` mode,
866
+ * where the stage stays pinned to the fixed image's own size (see
867
+ * `fixedImageSize`'s doc comment) instead of following the frame; letting
868
+ * the stage shrink along with the frame there would clip the (unchanged,
869
+ * still full-size) image down to whatever's left, visually reading as the
870
+ * picture itself shrinking even though its actual rendered size never
871
+ * changes. Each axis is clamped to a 20px minimum.
872
+ */
873
+ setFrameSize(width, height) {
874
+ this.opts.frame.width = Math.max(MIN_FRAME_SIZE, width);
875
+ this.opts.frame.height = Math.max(MIN_FRAME_SIZE, height);
876
+ if (this.opts.movableFrame) {
877
+ this.opts.frame.width = Math.min(this.opts.frame.width, this.fixedImageSize.width);
878
+ this.opts.frame.height = Math.min(this.opts.frame.height, this.fixedImageSize.height);
879
+ }
880
+ setFrameSize(this.stage.frameEl, this.opts.frame.width, this.opts.frame.height);
881
+ if (this.opts.autoSizeStage && !this.opts.movableFrame) this.syncStageSize();
882
+ if (this.opts.movableFrame) {
883
+ const framePosition = clampOffset(this.state.framePosition, this.fixedImageSize, this.getFrameSize());
884
+ this.commitState({ ...this.state, framePosition });
885
+ return;
886
+ }
887
+ const rendered = effectiveRenderedSize(
888
+ this.naturalSize,
889
+ this.getFrameSize(),
890
+ this.state.rotation,
891
+ this.state.zoom
892
+ );
893
+ const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
894
+ this.commitState({ ...this.state, offset });
895
+ }
896
+ /**
897
+ * Merges a partial update into the current filters (omitted fields are
898
+ * left as they are). Numeric values are clamped to `>= 0`.
899
+ */
900
+ setFilters(filters) {
901
+ this.commitState({ ...this.state, filters: mergeFilters(this.state.filters, filters) });
902
+ }
903
+ /**
904
+ * Renders the current crop. A circle or rounded-rectangle frame is a real
905
+ * clip in the output (transparent corners on PNG/WebP); either shape
906
+ * exported as JPEG warns and renders solid black corners instead, since
907
+ * JPEG has no alpha channel.
908
+ * @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
909
+ */
910
+ async export(options = {}) {
911
+ return exportCrop(
912
+ this.stage.imgEl,
913
+ this.getEffectiveStateForCrop(),
914
+ this.getFrameSize(),
915
+ this.opts.frame.shape,
916
+ this.opts.frame.cornerRadius,
917
+ this.stage.sharpenFilterId,
918
+ options
919
+ );
920
+ }
921
+ /**
922
+ * The current crop selection as a rectangle in the original, unrotated,
923
+ * unflipped source image's own pixel coordinates — for sending to a server
924
+ * that will crop the full-resolution original itself instead of uploading
925
+ * a client-re-encoded image. See {@link CropRegion}.
926
+ */
927
+ getCropRegion() {
928
+ return computeCropRegion(this.naturalSize, this.getFrameSize(), this.getEffectiveStateForCrop());
929
+ }
930
+ /**
931
+ * Exports the current crop as a blob, then uploads it — a default
932
+ * FormData/`fetch` POST, or a custom `uploader` (per-call `options.uploader`
933
+ * wins over the constructor's, which wins over the built-in default).
934
+ */
935
+ async upload(url, options = {}) {
936
+ const blob = await this.export({ ...options, type: "blob" });
937
+ const uploader = options.uploader ?? this.opts.uploader ?? uploadBlob;
938
+ return uploader(blob, { ...options, url });
939
+ }
940
+ /** Subscribes to `"change"` — fires on every state update (drag/zoom/rotate/flip/filters), and once after `load()` resolves. */
941
+ on(event, callback) {
942
+ this.listeners[event].push(callback);
943
+ }
944
+ /** Unsubscribes a callback previously passed to {@link on}. */
945
+ off(event, callback) {
946
+ this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
947
+ }
948
+ /**
949
+ * Tears the instance down: removes all pointer/wheel event listeners
950
+ * (drag/zoom gestures), the resize-handle listener (if `resizableFrame`),
951
+ * and the zoom-slider listener (if `showZoomer`); clears the container's
952
+ * `innerHTML`, leaving an empty container element; and clears all
953
+ * `"change"` listeners. Call this when you're done with an instance (e.g.
954
+ * unmounting) to avoid leaking listeners.
955
+ */
956
+ destroy() {
957
+ var _a, _b;
958
+ this.gestureHandle.destroy();
959
+ (_a = this.resizeHandle) == null ? void 0 : _a.destroy();
960
+ (_b = this.zoomerHandle) == null ? void 0 : _b.destroy();
961
+ this.container.innerHTML = "";
962
+ this.listeners.change = [];
963
+ }
964
+ getFrameSize() {
965
+ return { width: this.opts.frame.width, height: this.opts.frame.height };
966
+ }
967
+ /**
968
+ * `computeCropRegion()`/`renderCropToCanvas()` only know one model: an
969
+ * image shifted by `offset` under a frame fixed at center, scaled by
970
+ * `computeCoverScale(natural, frame, rotation) * zoom` — where `frame` is
971
+ * whatever's passed in, i.e. the *current* frame size. In `movableFrame`
972
+ * mode neither of those assumptions holds as-is: it's the frame that
973
+ * shifts (by `framePosition`, image fixed at center), and the image's
974
+ * scale must stay pinned to `fixedImageSize` regardless of how the frame
975
+ * has since been resized — not recomputed from the current frame size,
976
+ * which is exactly what those functions would otherwise do internally.
977
+ *
978
+ * Both are fixed by substituting two `state` fields before calling them,
979
+ * rather than changing either function:
980
+ * - `offset = -framePosition` — "frame moved right by d" and "image moved
981
+ * left by d" describe the identical relative geometry, so this alone
982
+ * carries the frame's position into the existing math unmodified.
983
+ * (`state.offset` is always `{0,0}` in this mode already, since
984
+ * `setOffset()`/gesture panning are no-ops here — this substitution is
985
+ * what actually does the work, not a redundant no-op.)
986
+ * - `zoom` = a value chosen so that
987
+ * `computeCoverScale(natural, currentFrame, rotation) * zoom` evaluates
988
+ * to exactly `fixedImageSize`'s own scale, whatever the current frame
989
+ * size is — i.e. `zoom = frozenScale / computeCoverScale(natural,
990
+ * currentFrame, rotation)`, which cancels the current-frame-size term
991
+ * out algebraically. Caught by an actual pixel test (a moved+resized
992
+ * frame exported as fully transparent) before trusting the offset
993
+ * substitution was sufficient on its own — it wasn't.
994
+ */
995
+ getEffectiveStateForCrop() {
996
+ if (!this.opts.movableFrame) return this.state;
997
+ const frozenScale = this.fixedImageSize.width / effectiveNaturalSize(this.naturalSize, this.state.rotation).width;
998
+ const currentCoverScale = computeCoverScale(this.naturalSize, this.getFrameSize(), this.state.rotation);
999
+ return {
1000
+ ...this.state,
1001
+ offset: { x: -this.state.framePosition.x, y: -this.state.framePosition.y },
1002
+ zoom: frozenScale / currentCoverScale
1003
+ };
1004
+ }
1005
+ syncStageSize() {
1006
+ setStageSize(
1007
+ this.stage.stageEl,
1008
+ this.opts.frame.width + STAGE_AUTO_SIZE_PADDING * 2,
1009
+ this.opts.frame.height + STAGE_AUTO_SIZE_PADDING * 2
1010
+ );
1011
+ }
1012
+ // movableFrame's stage-sizing counterpart to syncStageSize() above: pins
1013
+ // the stage to the fixed image's own size (so the whole image stays
1014
+ // visible/on-screen) instead of the frame's, which may since have been
1015
+ // resized independently. Called after load() and rotate() establish/
1016
+ // recompute fixedImageSize — never from setFrameSize(), which is the
1017
+ // entire point of this mode.
1018
+ syncStageSizeToFixedImage() {
1019
+ setStageSize(
1020
+ this.stage.stageEl,
1021
+ this.fixedImageSize.width + STAGE_AUTO_SIZE_PADDING * 2,
1022
+ this.fixedImageSize.height + STAGE_AUTO_SIZE_PADDING * 2
1023
+ );
1024
+ }
1025
+ commitState(next) {
1026
+ this.state = next;
1027
+ const scale = this.opts.movableFrame ? this.fixedImageSize.width / effectiveNaturalSize(this.naturalSize, next.rotation).width : computeCoverScale(this.naturalSize, this.getFrameSize(), next.rotation) * next.zoom;
1028
+ applyTransform(this.stage.imageLayerEl, next, scale);
1029
+ applyFilters(this.stage.imgEl, next.filters, this.stage.sharpenKernelEl, this.stage.sharpenFilterId);
1030
+ if (this.opts.movableFrame) applyFramePosition(this.stage.frameEl, next.framePosition);
1031
+ if (this.stage.zoomerEl) this.stage.zoomerEl.value = String(next.zoom);
1032
+ for (const cb of this.listeners.change) cb(this.getState());
1033
+ }
1034
+ enableZoomer(zoomerEl) {
1035
+ const onInput = () => this.setZoom(Number(zoomerEl.value));
1036
+ zoomerEl.addEventListener("input", onInput);
1037
+ this.zoomerHandle = {
1038
+ destroy() {
1039
+ zoomerEl.removeEventListener("input", onInput);
1040
+ }
1041
+ };
1042
+ }
1043
+ enableFrameResize() {
1044
+ const corners = [
1045
+ { name: "top-left", edge: { left: "-5px", top: "-5px" }, signX: -1, signY: -1 },
1046
+ { name: "top-right", edge: { right: "-5px", top: "-5px" }, signX: 1, signY: -1 },
1047
+ { name: "bottom-left", edge: { left: "-5px", bottom: "-5px" }, signX: -1, signY: 1 },
1048
+ { name: "bottom-right", edge: { right: "-5px", bottom: "-5px" }, signX: 1, signY: 1 }
1049
+ ];
1050
+ const cleanups = [];
1051
+ for (const corner of corners) {
1052
+ const handle = document.createElement("div");
1053
+ handle.className = `kiri-frame-handle kiri-frame-handle--${corner.name}`;
1054
+ Object.assign(handle.style, corner.edge);
1055
+ this.stage.frameEl.appendChild(handle);
1056
+ let start = null;
1057
+ const onDown = (e) => {
1058
+ e.stopPropagation();
1059
+ handle.setPointerCapture(e.pointerId);
1060
+ start = {
1061
+ x: e.clientX,
1062
+ y: e.clientY,
1063
+ width: this.opts.frame.width,
1064
+ height: this.opts.frame.height
1065
+ };
1066
+ };
1067
+ const onMove = (e) => {
1068
+ if (!start) return;
1069
+ const dx = (e.clientX - start.x) * 2 * corner.signX;
1070
+ const dy = (e.clientY - start.y) * 2 * corner.signY;
1071
+ const rawWidth = start.width + dx;
1072
+ const rawHeight = start.height + dy;
1073
+ if (this.opts.lockAspectRatio) {
1074
+ const aspect = start.width / start.height;
1075
+ if (Math.abs(rawWidth - start.width) >= Math.abs(rawHeight - start.height) * aspect) {
1076
+ this.setFrameSize(rawWidth, rawWidth / aspect);
1077
+ } else {
1078
+ this.setFrameSize(rawHeight * aspect, rawHeight);
1079
+ }
1080
+ } else {
1081
+ this.setFrameSize(rawWidth, rawHeight);
1082
+ }
1083
+ };
1084
+ const onUp = () => {
1085
+ start = null;
1086
+ };
1087
+ handle.addEventListener("pointerdown", onDown);
1088
+ handle.addEventListener("pointermove", onMove);
1089
+ handle.addEventListener("pointerup", onUp);
1090
+ handle.addEventListener("pointercancel", onUp);
1091
+ cleanups.push(() => {
1092
+ handle.removeEventListener("pointerdown", onDown);
1093
+ handle.removeEventListener("pointermove", onMove);
1094
+ handle.removeEventListener("pointerup", onUp);
1095
+ handle.removeEventListener("pointercancel", onUp);
1096
+ handle.remove();
1097
+ });
1098
+ }
1099
+ this.resizeHandle = {
1100
+ destroy() {
1101
+ for (const cleanup of cleanups) cleanup();
1102
+ }
1103
+ };
1104
+ }
1105
+ }
1106
+ exports2.Kiri = Kiri;
1107
+ Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
1108
+ });
1109
+ //# sourceMappingURL=kiri.js.map
1110
+
1111
+ (function(){
1112
+ if (typeof globalThis !== "undefined" && globalThis.Kiri && globalThis.Kiri.Kiri) {
1113
+ globalThis.Kiri = globalThis.Kiri.Kiri;
1114
+ }
1115
+ })();