@michaelyagi/kiri 0.1.0-alpha.2
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/batch.d.ts +34 -0
- package/dist/exif.d.ts +17 -0
- package/dist/export.d.ts +16 -0
- package/dist/filters.d.ts +11 -0
- package/dist/gestures.d.ts +35 -0
- package/dist/index.d.ts +4 -0
- package/dist/kiri.css +72 -0
- package/dist/kiri.d.ts +88 -0
- package/dist/kiri.js +779 -0
- package/dist/kiri.js.map +1 -0
- package/dist/kiri.min.css +1 -0
- package/dist/kiri.min.js +10 -0
- package/dist/kiri.min.js.map +1 -0
- package/dist/kiri.mjs +613 -0
- package/dist/kiri.mjs.map +1 -0
- package/dist/stage.d.ts +24 -0
- package/dist/types.d.ts +117 -0
- package/dist/upload.d.ts +10 -0
- package/dist/validate.d.ts +7 -0
- package/package.json +57 -0
package/dist/kiri.js
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
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
|
+
grayscale: false,
|
|
13
|
+
sepia: false
|
|
14
|
+
};
|
|
15
|
+
function mergeFilters(current, partial) {
|
|
16
|
+
return {
|
|
17
|
+
brightness: Math.max(0, partial.brightness ?? current.brightness),
|
|
18
|
+
contrast: Math.max(0, partial.contrast ?? current.contrast),
|
|
19
|
+
saturation: Math.max(0, partial.saturation ?? current.saturation),
|
|
20
|
+
grayscale: partial.grayscale ?? current.grayscale,
|
|
21
|
+
sepia: partial.sepia ?? current.sepia
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function buildFilterString(filters) {
|
|
25
|
+
const parts = [
|
|
26
|
+
`brightness(${filters.brightness})`,
|
|
27
|
+
`contrast(${filters.contrast})`,
|
|
28
|
+
`saturate(${filters.saturation})`
|
|
29
|
+
];
|
|
30
|
+
if (filters.grayscale) parts.push("grayscale(1)");
|
|
31
|
+
if (filters.sepia) parts.push("sepia(1)");
|
|
32
|
+
return parts.join(" ");
|
|
33
|
+
}
|
|
34
|
+
function createStage(container, frameShape, frameWidth, frameHeight, zoomer) {
|
|
35
|
+
container.innerHTML = "";
|
|
36
|
+
const stageEl = document.createElement("div");
|
|
37
|
+
stageEl.className = "kiri-stage";
|
|
38
|
+
const imageLayerEl = document.createElement("div");
|
|
39
|
+
imageLayerEl.className = "kiri-image-layer";
|
|
40
|
+
const imgEl = document.createElement("img");
|
|
41
|
+
imgEl.draggable = false;
|
|
42
|
+
imageLayerEl.appendChild(imgEl);
|
|
43
|
+
const frameEl = document.createElement("div");
|
|
44
|
+
frameEl.className = frameShape === "circle" ? "kiri-frame kiri-frame--circle" : "kiri-frame";
|
|
45
|
+
setFrameSize(frameEl, frameWidth, frameHeight);
|
|
46
|
+
stageEl.appendChild(imageLayerEl);
|
|
47
|
+
stageEl.appendChild(frameEl);
|
|
48
|
+
let zoomerEl = null;
|
|
49
|
+
if (zoomer.show) {
|
|
50
|
+
const rootEl = document.createElement("div");
|
|
51
|
+
rootEl.className = `kiri-root kiri-root--${zoomer.position}`;
|
|
52
|
+
zoomerEl = document.createElement("input");
|
|
53
|
+
zoomerEl.type = "range";
|
|
54
|
+
zoomerEl.className = "kiri-zoomer";
|
|
55
|
+
zoomerEl.min = String(zoomer.min);
|
|
56
|
+
zoomerEl.max = String(zoomer.max);
|
|
57
|
+
zoomerEl.step = "0.01";
|
|
58
|
+
zoomerEl.value = String(zoomer.value);
|
|
59
|
+
rootEl.appendChild(stageEl);
|
|
60
|
+
rootEl.appendChild(zoomerEl);
|
|
61
|
+
container.appendChild(rootEl);
|
|
62
|
+
} else {
|
|
63
|
+
container.appendChild(stageEl);
|
|
64
|
+
}
|
|
65
|
+
return { stageEl, frameEl, imageLayerEl, imgEl, zoomerEl };
|
|
66
|
+
}
|
|
67
|
+
function setFrameSize(frameEl, width, height) {
|
|
68
|
+
frameEl.style.width = `${width}px`;
|
|
69
|
+
frameEl.style.height = `${height}px`;
|
|
70
|
+
}
|
|
71
|
+
const STAGE_AUTO_SIZE_PADDING = 20;
|
|
72
|
+
function setStageSize(stageEl, width, height) {
|
|
73
|
+
stageEl.style.width = `${width}px`;
|
|
74
|
+
stageEl.style.height = `${height}px`;
|
|
75
|
+
}
|
|
76
|
+
function applyTransform(imageLayerEl, state, renderedScale) {
|
|
77
|
+
const scaleX = renderedScale * (state.flip.horizontal ? -1 : 1);
|
|
78
|
+
const scaleY = renderedScale * (state.flip.vertical ? -1 : 1);
|
|
79
|
+
imageLayerEl.style.transform = `translate(-50%, -50%) translate(${state.offset.x}px, ${state.offset.y}px) rotate(${state.rotation}deg) scale(${scaleX}, ${scaleY})`;
|
|
80
|
+
}
|
|
81
|
+
function applyFilters(imgEl, filters) {
|
|
82
|
+
imgEl.style.filter = buildFilterString(filters);
|
|
83
|
+
}
|
|
84
|
+
function effectiveNaturalSize(natural, rotationDeg) {
|
|
85
|
+
const swapped = (rotationDeg / 90 % 2 + 2) % 2 === 1;
|
|
86
|
+
return swapped ? { width: natural.height, height: natural.width } : { width: natural.width, height: natural.height };
|
|
87
|
+
}
|
|
88
|
+
function computeCoverScale(natural, frame, rotationDeg) {
|
|
89
|
+
const eff = effectiveNaturalSize(natural, rotationDeg);
|
|
90
|
+
if (eff.width <= 0 || eff.height <= 0) return 1;
|
|
91
|
+
return Math.max(frame.width / eff.width, frame.height / eff.height);
|
|
92
|
+
}
|
|
93
|
+
function effectiveRenderedSize(natural, frame, rotationDeg, zoom) {
|
|
94
|
+
const eff = effectiveNaturalSize(natural, rotationDeg);
|
|
95
|
+
const scale = computeCoverScale(natural, frame, rotationDeg) * zoom;
|
|
96
|
+
return { width: eff.width * scale, height: eff.height * scale };
|
|
97
|
+
}
|
|
98
|
+
function clampZoom(zoom, minZoom, maxZoom) {
|
|
99
|
+
return Math.min(Math.max(zoom, minZoom), maxZoom);
|
|
100
|
+
}
|
|
101
|
+
function clampOffset(offset, rendered, frame) {
|
|
102
|
+
const maxX = Math.max(0, (rendered.width - frame.width) / 2);
|
|
103
|
+
const maxY = Math.max(0, (rendered.height - frame.height) / 2);
|
|
104
|
+
return {
|
|
105
|
+
x: Math.min(Math.max(offset.x, -maxX), maxX) || 0,
|
|
106
|
+
y: Math.min(Math.max(offset.y, -maxY), maxY) || 0
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function normalizeRotation(deg) {
|
|
110
|
+
return (deg % 360 + 360) % 360;
|
|
111
|
+
}
|
|
112
|
+
function pointerDistance(a, b) {
|
|
113
|
+
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
|
114
|
+
}
|
|
115
|
+
function attachGestures(stageEl, callbacks, options) {
|
|
116
|
+
const activePointers = /* @__PURE__ */ new Map();
|
|
117
|
+
let dragStart = null;
|
|
118
|
+
let pinchStartDistance = 0;
|
|
119
|
+
let pinchStartZoom = 1;
|
|
120
|
+
function applyClampedState(next) {
|
|
121
|
+
const { min, max } = callbacks.getMinMaxZoom();
|
|
122
|
+
const zoom = clampZoom(next.zoom, min, max);
|
|
123
|
+
const rendered = effectiveRenderedSize(
|
|
124
|
+
callbacks.getNaturalSize(),
|
|
125
|
+
callbacks.getFrameSize(),
|
|
126
|
+
next.rotation,
|
|
127
|
+
zoom
|
|
128
|
+
);
|
|
129
|
+
const offset = clampOffset(next.offset, rendered, callbacks.getFrameSize());
|
|
130
|
+
callbacks.setState({
|
|
131
|
+
zoom,
|
|
132
|
+
offset,
|
|
133
|
+
rotation: normalizeRotation(next.rotation),
|
|
134
|
+
flip: next.flip,
|
|
135
|
+
filters: next.filters
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function onPointerDown(e) {
|
|
139
|
+
stageEl.setPointerCapture(e.pointerId);
|
|
140
|
+
activePointers.set(e.pointerId, e);
|
|
141
|
+
if (activePointers.size === 1) {
|
|
142
|
+
const state = callbacks.getState();
|
|
143
|
+
dragStart = { x: e.clientX, y: e.clientY, offset: state.offset };
|
|
144
|
+
stageEl.classList.add("kiri-dragging");
|
|
145
|
+
} else if (activePointers.size === 2) {
|
|
146
|
+
dragStart = null;
|
|
147
|
+
const [a, b] = [...activePointers.values()];
|
|
148
|
+
pinchStartDistance = pointerDistance(a, b);
|
|
149
|
+
pinchStartZoom = callbacks.getState().zoom;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function onPointerMove(e) {
|
|
153
|
+
if (!activePointers.has(e.pointerId)) return;
|
|
154
|
+
activePointers.set(e.pointerId, e);
|
|
155
|
+
if (activePointers.size === 2) {
|
|
156
|
+
const [a, b] = [...activePointers.values()];
|
|
157
|
+
const distance = pointerDistance(a, b);
|
|
158
|
+
if (pinchStartDistance > 0) {
|
|
159
|
+
const ratio = distance / pinchStartDistance;
|
|
160
|
+
const state = callbacks.getState();
|
|
161
|
+
applyClampedState({ ...state, zoom: pinchStartZoom * ratio });
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (dragStart) {
|
|
166
|
+
const dx = e.clientX - dragStart.x;
|
|
167
|
+
const dy = e.clientY - dragStart.y;
|
|
168
|
+
const state = callbacks.getState();
|
|
169
|
+
applyClampedState({
|
|
170
|
+
...state,
|
|
171
|
+
offset: { x: dragStart.offset.x + dx, y: dragStart.offset.y + dy }
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function onPointerUp(e) {
|
|
176
|
+
activePointers.delete(e.pointerId);
|
|
177
|
+
if (activePointers.size < 2) pinchStartDistance = 0;
|
|
178
|
+
if (activePointers.size === 0) {
|
|
179
|
+
dragStart = null;
|
|
180
|
+
stageEl.classList.remove("kiri-dragging");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function onWheel(e) {
|
|
184
|
+
if (!options.mouseWheelZoom) return;
|
|
185
|
+
if (options.mouseWheelZoom === "ctrl" && !e.ctrlKey) return;
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
const state = callbacks.getState();
|
|
188
|
+
const delta = -e.deltaY * 15e-4;
|
|
189
|
+
applyClampedState({ ...state, zoom: state.zoom * (1 + delta) });
|
|
190
|
+
}
|
|
191
|
+
stageEl.addEventListener("pointerdown", onPointerDown);
|
|
192
|
+
stageEl.addEventListener("pointermove", onPointerMove);
|
|
193
|
+
stageEl.addEventListener("pointerup", onPointerUp);
|
|
194
|
+
stageEl.addEventListener("pointercancel", onPointerUp);
|
|
195
|
+
stageEl.addEventListener("wheel", onWheel, { passive: false });
|
|
196
|
+
return {
|
|
197
|
+
destroy() {
|
|
198
|
+
stageEl.removeEventListener("pointerdown", onPointerDown);
|
|
199
|
+
stageEl.removeEventListener("pointermove", onPointerMove);
|
|
200
|
+
stageEl.removeEventListener("pointerup", onPointerUp);
|
|
201
|
+
stageEl.removeEventListener("pointercancel", onPointerUp);
|
|
202
|
+
stageEl.removeEventListener("wheel", onWheel);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function readExifOrientation(buffer) {
|
|
207
|
+
const view = new DataView(buffer);
|
|
208
|
+
if (view.byteLength < 4 || view.getUint16(0, false) !== 65496) return 1;
|
|
209
|
+
let offset = 2;
|
|
210
|
+
while (offset + 4 <= view.byteLength) {
|
|
211
|
+
const marker = view.getUint16(offset, false);
|
|
212
|
+
const size = view.getUint16(offset + 2, false);
|
|
213
|
+
if (marker === 65505) {
|
|
214
|
+
const exifOffset = offset + 4;
|
|
215
|
+
if (view.getUint32(exifOffset, false) !== 1165519206) return 1;
|
|
216
|
+
return readOrientationFromTiff(view, exifOffset + 6);
|
|
217
|
+
}
|
|
218
|
+
if ((marker & 65280) !== 65280) break;
|
|
219
|
+
offset += 2 + size;
|
|
220
|
+
}
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
function readOrientationFromTiff(view, tiffStart) {
|
|
224
|
+
const littleEndian = view.getUint16(tiffStart, false) === 18761;
|
|
225
|
+
const firstIfdOffset = view.getUint32(tiffStart + 4, littleEndian);
|
|
226
|
+
const ifdStart = tiffStart + firstIfdOffset;
|
|
227
|
+
if (ifdStart + 2 > view.byteLength) return 1;
|
|
228
|
+
const entryCount = view.getUint16(ifdStart, littleEndian);
|
|
229
|
+
for (let i = 0; i < entryCount; i++) {
|
|
230
|
+
const entryOffset = ifdStart + 2 + i * 12;
|
|
231
|
+
if (entryOffset + 12 > view.byteLength) break;
|
|
232
|
+
const tag = view.getUint16(entryOffset, littleEndian);
|
|
233
|
+
if (tag === 274) {
|
|
234
|
+
const value = view.getUint16(entryOffset + 8, littleEndian);
|
|
235
|
+
return value >= 1 && value <= 8 ? value : 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
function orientationToTransform(orientation) {
|
|
241
|
+
switch (orientation) {
|
|
242
|
+
case 2:
|
|
243
|
+
return { rotation: 0, flipHorizontal: true };
|
|
244
|
+
case 3:
|
|
245
|
+
return { rotation: 180, flipHorizontal: false };
|
|
246
|
+
case 4:
|
|
247
|
+
return { rotation: 180, flipHorizontal: true };
|
|
248
|
+
case 5:
|
|
249
|
+
return { rotation: 90, flipHorizontal: true };
|
|
250
|
+
case 6:
|
|
251
|
+
return { rotation: 90, flipHorizontal: false };
|
|
252
|
+
case 7:
|
|
253
|
+
return { rotation: 270, flipHorizontal: true };
|
|
254
|
+
case 8:
|
|
255
|
+
return { rotation: 270, flipHorizontal: false };
|
|
256
|
+
default:
|
|
257
|
+
return { rotation: 0, flipHorizontal: false };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function resolveEnumOption(value, valid, fallback, optionName) {
|
|
261
|
+
if (value === void 0) return fallback;
|
|
262
|
+
if (valid.includes(value)) return value;
|
|
263
|
+
console.warn(
|
|
264
|
+
`Kiri: invalid ${optionName} "${value}" — defaulting to "${fallback}". Valid values: ${valid.join(", ")}.`
|
|
265
|
+
);
|
|
266
|
+
return fallback;
|
|
267
|
+
}
|
|
268
|
+
const VALID_EXPORT_TYPES = ["base64", "blob", "canvas"];
|
|
269
|
+
const VALID_EXPORT_FORMATS = ["image/jpeg", "image/png", "image/webp"];
|
|
270
|
+
function computeFrameSourceRect(rendered, offset, frame) {
|
|
271
|
+
return {
|
|
272
|
+
left: rendered.width / 2 - offset.x - frame.width / 2,
|
|
273
|
+
top: rendered.height / 2 - offset.y - frame.height / 2
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function renderCropToCanvas(img, state, frame, outputWidth, outputHeight, frameShape) {
|
|
277
|
+
const natural = { width: img.naturalWidth, height: img.naturalHeight };
|
|
278
|
+
const scale = computeCoverScale(natural, frame, state.rotation) * state.zoom;
|
|
279
|
+
const rendered = effectiveRenderedSize(natural, frame, state.rotation, state.zoom);
|
|
280
|
+
const sourceCanvas = document.createElement("canvas");
|
|
281
|
+
sourceCanvas.width = Math.max(1, Math.round(rendered.width));
|
|
282
|
+
sourceCanvas.height = Math.max(1, Math.round(rendered.height));
|
|
283
|
+
const sctx = sourceCanvas.getContext("2d");
|
|
284
|
+
if (!sctx) throw new Error("Kiri: unable to get 2D canvas context");
|
|
285
|
+
sctx.translate(sourceCanvas.width / 2, sourceCanvas.height / 2);
|
|
286
|
+
sctx.rotate(state.rotation * Math.PI / 180);
|
|
287
|
+
sctx.scale(scale * (state.flip.horizontal ? -1 : 1), scale * (state.flip.vertical ? -1 : 1));
|
|
288
|
+
sctx.filter = buildFilterString(state.filters);
|
|
289
|
+
sctx.drawImage(img, -natural.width / 2, -natural.height / 2, natural.width, natural.height);
|
|
290
|
+
const { left: frameLeft, top: frameTop } = computeFrameSourceRect(
|
|
291
|
+
rendered,
|
|
292
|
+
state.offset,
|
|
293
|
+
frame
|
|
294
|
+
);
|
|
295
|
+
const outCanvas = document.createElement("canvas");
|
|
296
|
+
outCanvas.width = outputWidth;
|
|
297
|
+
outCanvas.height = outputHeight;
|
|
298
|
+
const octx = outCanvas.getContext("2d");
|
|
299
|
+
if (!octx) throw new Error("Kiri: unable to get 2D canvas context");
|
|
300
|
+
if (frameShape === "circle") {
|
|
301
|
+
octx.save();
|
|
302
|
+
octx.beginPath();
|
|
303
|
+
octx.ellipse(
|
|
304
|
+
outputWidth / 2,
|
|
305
|
+
outputHeight / 2,
|
|
306
|
+
outputWidth / 2,
|
|
307
|
+
outputHeight / 2,
|
|
308
|
+
0,
|
|
309
|
+
0,
|
|
310
|
+
Math.PI * 2
|
|
311
|
+
);
|
|
312
|
+
octx.clip();
|
|
313
|
+
}
|
|
314
|
+
octx.drawImage(
|
|
315
|
+
sourceCanvas,
|
|
316
|
+
frameLeft,
|
|
317
|
+
frameTop,
|
|
318
|
+
frame.width,
|
|
319
|
+
frame.height,
|
|
320
|
+
0,
|
|
321
|
+
0,
|
|
322
|
+
outputWidth,
|
|
323
|
+
outputHeight
|
|
324
|
+
);
|
|
325
|
+
if (frameShape === "circle") octx.restore();
|
|
326
|
+
return outCanvas;
|
|
327
|
+
}
|
|
328
|
+
async function exportCrop(img, state, frame, frameShape, options) {
|
|
329
|
+
const type = resolveEnumOption(options.type, VALID_EXPORT_TYPES, "base64", "export type");
|
|
330
|
+
const format = resolveEnumOption(options.format, VALID_EXPORT_FORMATS, "image/png", "export format");
|
|
331
|
+
const quality = options.quality;
|
|
332
|
+
const width = options.width ?? frame.width;
|
|
333
|
+
const height = options.height ?? frame.height;
|
|
334
|
+
if (frameShape === "circle" && format === "image/jpeg") {
|
|
335
|
+
console.warn(
|
|
336
|
+
"Kiri: exporting a circle-shaped frame as image/jpeg — JPEG has no alpha channel, so the area outside the circle will render as solid black instead of transparent. Use image/png or image/webp instead."
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const canvas = renderCropToCanvas(img, state, frame, width, height, frameShape);
|
|
340
|
+
if (type === "canvas") return canvas;
|
|
341
|
+
if (type === "base64") return canvas.toDataURL(format, quality);
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
canvas.toBlob(
|
|
344
|
+
(blob) => blob ? resolve(blob) : reject(new Error("Kiri: canvas.toBlob failed")),
|
|
345
|
+
format,
|
|
346
|
+
quality
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
async function uploadBlob(blob, options) {
|
|
351
|
+
const fieldName = options.fieldName ?? "file";
|
|
352
|
+
const fileName = options.fileName ?? `crop.${extensionFor(options.format ?? "image/png")}`;
|
|
353
|
+
const formData = new FormData();
|
|
354
|
+
formData.append(fieldName, blob, fileName);
|
|
355
|
+
for (const [key, value] of Object.entries(options.extraFields ?? {})) {
|
|
356
|
+
formData.append(key, value);
|
|
357
|
+
}
|
|
358
|
+
return fetch(options.url, {
|
|
359
|
+
...options.fetchOptions,
|
|
360
|
+
method: "POST",
|
|
361
|
+
body: formData
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
function extensionFor(format) {
|
|
365
|
+
switch (format) {
|
|
366
|
+
case "image/jpeg":
|
|
367
|
+
return "jpg";
|
|
368
|
+
case "image/webp":
|
|
369
|
+
return "webp";
|
|
370
|
+
default:
|
|
371
|
+
return "png";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const DEFAULT_FRAME_SIZE = 200;
|
|
375
|
+
const MIN_FRAME_SIZE = 20;
|
|
376
|
+
const VALID_FRAME_SHAPES = ["rectangle", "circle"];
|
|
377
|
+
const VALID_ZOOMER_POSITIONS = ["top", "bottom", "left", "right"];
|
|
378
|
+
function resolveMouseWheelZoom(value) {
|
|
379
|
+
if (value === void 0) return true;
|
|
380
|
+
if (typeof value === "boolean" || value === "ctrl") return value;
|
|
381
|
+
console.warn(
|
|
382
|
+
`Kiri: invalid mouseWheelZoom "${String(value)}" — defaulting to true. Valid values: true, false, "ctrl".`
|
|
383
|
+
);
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
class Kiri {
|
|
387
|
+
/**
|
|
388
|
+
* @param container An element already present in the DOM. Passing
|
|
389
|
+
* `null`/`undefined`, or an element that isn't in the DOM yet, throws.
|
|
390
|
+
* @param options See the {@link KiriOptions} fields for defaults.
|
|
391
|
+
*/
|
|
392
|
+
constructor(container, options = {}) {
|
|
393
|
+
__publicField(this, "container");
|
|
394
|
+
__publicField(this, "opts");
|
|
395
|
+
__publicField(this, "stage");
|
|
396
|
+
__publicField(this, "gestureHandle");
|
|
397
|
+
__publicField(this, "resizeHandle", null);
|
|
398
|
+
__publicField(this, "zoomerHandle", null);
|
|
399
|
+
__publicField(this, "naturalSize", { width: 0, height: 0 });
|
|
400
|
+
__publicField(this, "state", {
|
|
401
|
+
zoom: 1,
|
|
402
|
+
offset: { x: 0, y: 0 },
|
|
403
|
+
rotation: 0,
|
|
404
|
+
flip: { horizontal: false, vertical: false },
|
|
405
|
+
filters: DEFAULT_FILTERS
|
|
406
|
+
});
|
|
407
|
+
__publicField(this, "listeners", { change: [] });
|
|
408
|
+
var _a, _b, _c;
|
|
409
|
+
if (!container || typeof container.appendChild !== "function") {
|
|
410
|
+
throw new Error(
|
|
411
|
+
"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."
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
this.container = container;
|
|
415
|
+
this.opts = {
|
|
416
|
+
frame: {
|
|
417
|
+
shape: resolveEnumOption(
|
|
418
|
+
(_a = options.frame) == null ? void 0 : _a.shape,
|
|
419
|
+
VALID_FRAME_SHAPES,
|
|
420
|
+
"rectangle",
|
|
421
|
+
"frame.shape"
|
|
422
|
+
),
|
|
423
|
+
width: ((_b = options.frame) == null ? void 0 : _b.width) ?? DEFAULT_FRAME_SIZE,
|
|
424
|
+
height: ((_c = options.frame) == null ? void 0 : _c.height) ?? DEFAULT_FRAME_SIZE
|
|
425
|
+
},
|
|
426
|
+
minZoom: options.minZoom ?? 1,
|
|
427
|
+
maxZoom: options.maxZoom ?? 4,
|
|
428
|
+
rotatable: options.rotatable ?? true,
|
|
429
|
+
flippable: options.flippable ?? true,
|
|
430
|
+
resizableFrame: options.resizableFrame ?? false,
|
|
431
|
+
mouseWheelZoom: resolveMouseWheelZoom(options.mouseWheelZoom),
|
|
432
|
+
useExifOrientation: options.useExifOrientation ?? true,
|
|
433
|
+
uploader: options.uploader,
|
|
434
|
+
autoSizeStage: options.autoSizeStage ?? true,
|
|
435
|
+
showZoomer: options.showZoomer ?? false,
|
|
436
|
+
zoomerPosition: resolveEnumOption(
|
|
437
|
+
options.zoomerPosition,
|
|
438
|
+
VALID_ZOOMER_POSITIONS,
|
|
439
|
+
"bottom",
|
|
440
|
+
"zoomerPosition"
|
|
441
|
+
)
|
|
442
|
+
};
|
|
443
|
+
this.state.filters = mergeFilters(DEFAULT_FILTERS, options.filters ?? {});
|
|
444
|
+
this.stage = createStage(
|
|
445
|
+
this.container,
|
|
446
|
+
this.opts.frame.shape,
|
|
447
|
+
this.opts.frame.width,
|
|
448
|
+
this.opts.frame.height,
|
|
449
|
+
{
|
|
450
|
+
show: this.opts.showZoomer,
|
|
451
|
+
position: this.opts.zoomerPosition,
|
|
452
|
+
min: this.opts.minZoom,
|
|
453
|
+
max: this.opts.maxZoom,
|
|
454
|
+
value: this.state.zoom
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
if (this.opts.autoSizeStage) this.syncStageSize();
|
|
458
|
+
applyFilters(this.stage.imgEl, this.state.filters);
|
|
459
|
+
if (this.stage.zoomerEl) this.enableZoomer(this.stage.zoomerEl);
|
|
460
|
+
this.gestureHandle = attachGestures(
|
|
461
|
+
this.stage.stageEl,
|
|
462
|
+
{
|
|
463
|
+
getNaturalSize: () => this.naturalSize,
|
|
464
|
+
getFrameSize: () => this.getFrameSize(),
|
|
465
|
+
getState: () => this.state,
|
|
466
|
+
getMinMaxZoom: () => ({ min: this.opts.minZoom, max: this.opts.maxZoom }),
|
|
467
|
+
setState: (next) => this.commitState(next)
|
|
468
|
+
},
|
|
469
|
+
{ mouseWheelZoom: this.opts.mouseWheelZoom }
|
|
470
|
+
);
|
|
471
|
+
if (this.opts.resizableFrame) this.enableFrameResize();
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Loads an image, replacing whatever was loaded before. EXIF orientation
|
|
475
|
+
* (rotation + horizontal flip) is corrected automatically unless
|
|
476
|
+
* `useExifOrientation: false` was passed to the constructor — only for
|
|
477
|
+
* `File`/`Blob` sources, since a plain URL string can't be read for EXIF
|
|
478
|
+
* data without an extra fetch.
|
|
479
|
+
* @param source A `File` (e.g. from a file input), a `Blob`, or a URL string.
|
|
480
|
+
* @param loadOptions Initial `zoom`/`offset`/`rotation`/`flip`.
|
|
481
|
+
*/
|
|
482
|
+
async load(source, loadOptions = {}) {
|
|
483
|
+
var _a, _b;
|
|
484
|
+
let rotation = normalizeRotation(loadOptions.rotation ?? 0);
|
|
485
|
+
let flipHorizontal = ((_a = loadOptions.flip) == null ? void 0 : _a.horizontal) ?? false;
|
|
486
|
+
const flipVertical = ((_b = loadOptions.flip) == null ? void 0 : _b.vertical) ?? false;
|
|
487
|
+
let objectUrl = null;
|
|
488
|
+
let url;
|
|
489
|
+
if (typeof source === "string") {
|
|
490
|
+
url = source;
|
|
491
|
+
} else {
|
|
492
|
+
if (this.opts.useExifOrientation) {
|
|
493
|
+
const buffer = await source.arrayBuffer();
|
|
494
|
+
const orientation = readExifOrientation(buffer);
|
|
495
|
+
const exifTransform = orientationToTransform(orientation);
|
|
496
|
+
rotation = normalizeRotation(rotation + exifTransform.rotation);
|
|
497
|
+
flipHorizontal = flipHorizontal !== exifTransform.flipHorizontal;
|
|
498
|
+
}
|
|
499
|
+
objectUrl = URL.createObjectURL(source);
|
|
500
|
+
url = objectUrl;
|
|
501
|
+
}
|
|
502
|
+
await new Promise((resolve, reject) => {
|
|
503
|
+
this.stage.imgEl.onload = () => resolve();
|
|
504
|
+
this.stage.imgEl.onerror = () => reject(new Error("Kiri: failed to load image"));
|
|
505
|
+
this.stage.imgEl.src = url;
|
|
506
|
+
});
|
|
507
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
508
|
+
this.naturalSize = {
|
|
509
|
+
width: this.stage.imgEl.naturalWidth,
|
|
510
|
+
height: this.stage.imgEl.naturalHeight
|
|
511
|
+
};
|
|
512
|
+
const zoom = clampZoom(
|
|
513
|
+
loadOptions.zoom ?? this.opts.minZoom,
|
|
514
|
+
this.opts.minZoom,
|
|
515
|
+
this.opts.maxZoom
|
|
516
|
+
);
|
|
517
|
+
const rendered = effectiveRenderedSize(this.naturalSize, this.getFrameSize(), rotation, zoom);
|
|
518
|
+
const offset = clampOffset(loadOptions.offset ?? { x: 0, y: 0 }, rendered, this.getFrameSize());
|
|
519
|
+
this.commitState({
|
|
520
|
+
zoom,
|
|
521
|
+
offset,
|
|
522
|
+
rotation,
|
|
523
|
+
flip: { horizontal: flipHorizontal, vertical: flipVertical },
|
|
524
|
+
filters: this.state.filters
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
/** A snapshot of the current state — mutating the returned object has no effect. */
|
|
528
|
+
getState() {
|
|
529
|
+
return {
|
|
530
|
+
zoom: this.state.zoom,
|
|
531
|
+
offset: { ...this.state.offset },
|
|
532
|
+
rotation: this.state.rotation,
|
|
533
|
+
flip: { ...this.state.flip },
|
|
534
|
+
filters: { ...this.state.filters }
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/** Sets the zoom to an absolute value, clamped to `[minZoom, maxZoom]`. */
|
|
538
|
+
setZoom(zoom) {
|
|
539
|
+
const clamped = clampZoom(zoom, this.opts.minZoom, this.opts.maxZoom);
|
|
540
|
+
const rendered = effectiveRenderedSize(
|
|
541
|
+
this.naturalSize,
|
|
542
|
+
this.getFrameSize(),
|
|
543
|
+
this.state.rotation,
|
|
544
|
+
clamped
|
|
545
|
+
);
|
|
546
|
+
const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
|
|
547
|
+
this.commitState({ ...this.state, zoom: clamped, offset });
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Rotates relative to the current rotation, snapped to the nearest 90°.
|
|
551
|
+
* No-op if `rotatable: false` was passed to the constructor.
|
|
552
|
+
*/
|
|
553
|
+
rotate(deltaDeg) {
|
|
554
|
+
if (!this.opts.rotatable) return;
|
|
555
|
+
const snapped = Math.round(deltaDeg / 90) * 90;
|
|
556
|
+
const rotation = normalizeRotation(this.state.rotation + snapped);
|
|
557
|
+
const rendered = effectiveRenderedSize(
|
|
558
|
+
this.naturalSize,
|
|
559
|
+
this.getFrameSize(),
|
|
560
|
+
rotation,
|
|
561
|
+
this.state.zoom
|
|
562
|
+
);
|
|
563
|
+
const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
|
|
564
|
+
this.commitState({ ...this.state, rotation, offset });
|
|
565
|
+
}
|
|
566
|
+
/** Toggles horizontal flip, independent of rotation. No-op if `flippable: false`. */
|
|
567
|
+
flipHorizontal() {
|
|
568
|
+
if (!this.opts.flippable) return;
|
|
569
|
+
const flip = { ...this.state.flip, horizontal: !this.state.flip.horizontal };
|
|
570
|
+
this.commitState({ ...this.state, flip });
|
|
571
|
+
}
|
|
572
|
+
/** Toggles vertical flip, independent of rotation. No-op if `flippable: false`. */
|
|
573
|
+
flipVertical() {
|
|
574
|
+
if (!this.opts.flippable) return;
|
|
575
|
+
const flip = { ...this.state.flip, vertical: !this.state.flip.vertical };
|
|
576
|
+
this.commitState({ ...this.state, flip });
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Resizes the frame. Also resizes the stage to match, if
|
|
580
|
+
* `autoSizeStage: true` (the default). Each axis is clamped to a 20px
|
|
581
|
+
* minimum.
|
|
582
|
+
*/
|
|
583
|
+
setFrameSize(width, height) {
|
|
584
|
+
this.opts.frame.width = Math.max(MIN_FRAME_SIZE, width);
|
|
585
|
+
this.opts.frame.height = Math.max(MIN_FRAME_SIZE, height);
|
|
586
|
+
setFrameSize(this.stage.frameEl, this.opts.frame.width, this.opts.frame.height);
|
|
587
|
+
if (this.opts.autoSizeStage) this.syncStageSize();
|
|
588
|
+
const rendered = effectiveRenderedSize(
|
|
589
|
+
this.naturalSize,
|
|
590
|
+
this.getFrameSize(),
|
|
591
|
+
this.state.rotation,
|
|
592
|
+
this.state.zoom
|
|
593
|
+
);
|
|
594
|
+
const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
|
|
595
|
+
this.commitState({ ...this.state, offset });
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Merges a partial update into the current filters (omitted fields are
|
|
599
|
+
* left as they are). Numeric values are clamped to `>= 0`.
|
|
600
|
+
*/
|
|
601
|
+
setFilters(filters) {
|
|
602
|
+
this.commitState({ ...this.state, filters: mergeFilters(this.state.filters, filters) });
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Renders the current crop. A circle frame is a real clip in the output
|
|
606
|
+
* (transparent corners on PNG/WebP); a circle exported as JPEG warns and
|
|
607
|
+
* renders solid black corners instead, since JPEG has no alpha channel.
|
|
608
|
+
* @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
|
|
609
|
+
*/
|
|
610
|
+
async export(options = {}) {
|
|
611
|
+
return exportCrop(
|
|
612
|
+
this.stage.imgEl,
|
|
613
|
+
this.state,
|
|
614
|
+
this.getFrameSize(),
|
|
615
|
+
this.opts.frame.shape,
|
|
616
|
+
options
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Exports the current crop as a blob, then uploads it — a default
|
|
621
|
+
* FormData/`fetch` POST, or a custom `uploader` (per-call `options.uploader`
|
|
622
|
+
* wins over the constructor's, which wins over the built-in default).
|
|
623
|
+
*/
|
|
624
|
+
async upload(url, options = {}) {
|
|
625
|
+
const blob = await this.export({ ...options, type: "blob" });
|
|
626
|
+
const uploader = options.uploader ?? this.opts.uploader ?? uploadBlob;
|
|
627
|
+
return uploader(blob, { ...options, url });
|
|
628
|
+
}
|
|
629
|
+
/** Subscribes to `"change"` — fires on every state update (drag/zoom/rotate/flip/filters), and once after `load()` resolves. */
|
|
630
|
+
on(event, callback) {
|
|
631
|
+
this.listeners[event].push(callback);
|
|
632
|
+
}
|
|
633
|
+
/** Unsubscribes a callback previously passed to {@link on}. */
|
|
634
|
+
off(event, callback) {
|
|
635
|
+
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Tears the instance down: removes all pointer/wheel event listeners
|
|
639
|
+
* (drag/zoom gestures), the resize-handle listener (if `resizableFrame`),
|
|
640
|
+
* and the zoom-slider listener (if `showZoomer`); clears the container's
|
|
641
|
+
* `innerHTML`, leaving an empty container element; and clears all
|
|
642
|
+
* `"change"` listeners. Call this when you're done with an instance (e.g.
|
|
643
|
+
* unmounting) to avoid leaking listeners.
|
|
644
|
+
*/
|
|
645
|
+
destroy() {
|
|
646
|
+
var _a, _b;
|
|
647
|
+
this.gestureHandle.destroy();
|
|
648
|
+
(_a = this.resizeHandle) == null ? void 0 : _a.destroy();
|
|
649
|
+
(_b = this.zoomerHandle) == null ? void 0 : _b.destroy();
|
|
650
|
+
this.container.innerHTML = "";
|
|
651
|
+
this.listeners.change = [];
|
|
652
|
+
}
|
|
653
|
+
getFrameSize() {
|
|
654
|
+
return { width: this.opts.frame.width, height: this.opts.frame.height };
|
|
655
|
+
}
|
|
656
|
+
syncStageSize() {
|
|
657
|
+
setStageSize(
|
|
658
|
+
this.stage.stageEl,
|
|
659
|
+
this.opts.frame.width + STAGE_AUTO_SIZE_PADDING * 2,
|
|
660
|
+
this.opts.frame.height + STAGE_AUTO_SIZE_PADDING * 2
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
commitState(next) {
|
|
664
|
+
this.state = next;
|
|
665
|
+
const scale = computeCoverScale(this.naturalSize, this.getFrameSize(), next.rotation) * next.zoom;
|
|
666
|
+
applyTransform(this.stage.imageLayerEl, next, scale);
|
|
667
|
+
applyFilters(this.stage.imgEl, next.filters);
|
|
668
|
+
if (this.stage.zoomerEl) this.stage.zoomerEl.value = String(next.zoom);
|
|
669
|
+
for (const cb of this.listeners.change) cb(this.getState());
|
|
670
|
+
}
|
|
671
|
+
enableZoomer(zoomerEl) {
|
|
672
|
+
const onInput = () => this.setZoom(Number(zoomerEl.value));
|
|
673
|
+
zoomerEl.addEventListener("input", onInput);
|
|
674
|
+
this.zoomerHandle = {
|
|
675
|
+
destroy() {
|
|
676
|
+
zoomerEl.removeEventListener("input", onInput);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
enableFrameResize() {
|
|
681
|
+
const handle = document.createElement("div");
|
|
682
|
+
handle.className = "kiri-frame-handle";
|
|
683
|
+
handle.style.right = "-5px";
|
|
684
|
+
handle.style.bottom = "-5px";
|
|
685
|
+
this.stage.frameEl.appendChild(handle);
|
|
686
|
+
let start = null;
|
|
687
|
+
const onDown = (e) => {
|
|
688
|
+
e.stopPropagation();
|
|
689
|
+
handle.setPointerCapture(e.pointerId);
|
|
690
|
+
start = {
|
|
691
|
+
x: e.clientX,
|
|
692
|
+
y: e.clientY,
|
|
693
|
+
width: this.opts.frame.width,
|
|
694
|
+
height: this.opts.frame.height
|
|
695
|
+
};
|
|
696
|
+
};
|
|
697
|
+
const onMove = (e) => {
|
|
698
|
+
if (!start) return;
|
|
699
|
+
const dx = (e.clientX - start.x) * 2;
|
|
700
|
+
const dy = (e.clientY - start.y) * 2;
|
|
701
|
+
this.setFrameSize(start.width + dx, start.height + dy);
|
|
702
|
+
};
|
|
703
|
+
const onUp = () => {
|
|
704
|
+
start = null;
|
|
705
|
+
};
|
|
706
|
+
handle.addEventListener("pointerdown", onDown);
|
|
707
|
+
handle.addEventListener("pointermove", onMove);
|
|
708
|
+
handle.addEventListener("pointerup", onUp);
|
|
709
|
+
handle.addEventListener("pointercancel", onUp);
|
|
710
|
+
this.resizeHandle = {
|
|
711
|
+
destroy() {
|
|
712
|
+
handle.removeEventListener("pointerdown", onDown);
|
|
713
|
+
handle.removeEventListener("pointermove", onMove);
|
|
714
|
+
handle.removeEventListener("pointerup", onUp);
|
|
715
|
+
handle.removeEventListener("pointercancel", onUp);
|
|
716
|
+
handle.remove();
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
class KiriBatch {
|
|
722
|
+
/** @param items An initial queue; more can be added later via `add()`. */
|
|
723
|
+
constructor(container, options = {}, items = []) {
|
|
724
|
+
/** The shared `Kiri` instance. Use its normal methods (drag/zoom/rotate/filters/etc.) to adjust the currently-loaded item. */
|
|
725
|
+
__publicField(this, "cropper");
|
|
726
|
+
__publicField(this, "items");
|
|
727
|
+
__publicField(this, "index", -1);
|
|
728
|
+
__publicField(this, "captures", []);
|
|
729
|
+
this.cropper = new Kiri(container, options);
|
|
730
|
+
this.items = [...items];
|
|
731
|
+
}
|
|
732
|
+
/** Appends an item to the queue. */
|
|
733
|
+
add(item) {
|
|
734
|
+
this.items.push(item);
|
|
735
|
+
}
|
|
736
|
+
/** Total number of queued items. */
|
|
737
|
+
get length() {
|
|
738
|
+
return this.items.length;
|
|
739
|
+
}
|
|
740
|
+
/** Loads the next queued image into `cropper`. Returns false once the queue is exhausted. */
|
|
741
|
+
async next() {
|
|
742
|
+
if (this.index + 1 >= this.items.length) return false;
|
|
743
|
+
this.index += 1;
|
|
744
|
+
const item = this.items[this.index];
|
|
745
|
+
await this.cropper.load(item.source, item.loadOptions);
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
/** Metadata for the currently loaded item, or null before the first next() / after exhaustion. */
|
|
749
|
+
current() {
|
|
750
|
+
return this.items[this.index] ?? null;
|
|
751
|
+
}
|
|
752
|
+
/** Exports the current crop and stores it, indexed by item order. */
|
|
753
|
+
async capture(options) {
|
|
754
|
+
const result = await this.cropper.export(options);
|
|
755
|
+
this.captures[this.index] = result;
|
|
756
|
+
return result;
|
|
757
|
+
}
|
|
758
|
+
/** All captures made so far, in item order (sparse where an item hasn't been captured yet). */
|
|
759
|
+
results() {
|
|
760
|
+
return [...this.captures];
|
|
761
|
+
}
|
|
762
|
+
/** Tears down the shared `Kiri` instance. */
|
|
763
|
+
destroy() {
|
|
764
|
+
this.cropper.destroy();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
exports2.Kiri = Kiri;
|
|
768
|
+
exports2.KiriBatch = KiriBatch;
|
|
769
|
+
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
|
770
|
+
});
|
|
771
|
+
//# sourceMappingURL=kiri.js.map
|
|
772
|
+
|
|
773
|
+
(function(){
|
|
774
|
+
if (typeof globalThis !== "undefined" && globalThis.Kiri && globalThis.Kiri.Kiri) {
|
|
775
|
+
var __KiriBatch = globalThis.Kiri.KiriBatch;
|
|
776
|
+
globalThis.Kiri = globalThis.Kiri.Kiri;
|
|
777
|
+
globalThis.KiriBatch = __KiriBatch;
|
|
778
|
+
}
|
|
779
|
+
})();
|