@michaelyagi/kiri 0.1.0-alpha.2 → 0.1.0-alpha.3

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 CHANGED
@@ -23,6 +23,8 @@ export declare class KiriBatch {
23
23
  get length(): number;
24
24
  /** Loads the next queued image into `cropper`. Returns false once the queue is exhausted. */
25
25
  next(): Promise<boolean>;
26
+ /** Loads the previous queued image into `cropper`. Returns false when already at the first item (or nothing loaded yet). */
27
+ previous(): Promise<boolean>;
26
28
  /** Metadata for the currently loaded item, or null before the first next() / after exhaustion. */
27
29
  current(): KiriBatchItem | null;
28
30
  /** Exports the current crop and stores it, indexed by item order. */
package/dist/export.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ExportOptions, ExportResult, FrameShape, KiriState, Offset } from './types';
1
+ import { CropRegion, ExportOptions, ExportResult, FrameShape, KiriState, Offset } from './types';
2
2
  import { Size } from './gestures';
3
3
  /**
4
4
  * Top-left corner of the frame, in the local pixel space of the rendered
@@ -12,5 +12,16 @@ export declare function computeFrameSourceRect(rendered: Size, offset: Offset, f
12
12
  left: number;
13
13
  top: number;
14
14
  };
15
+ /**
16
+ * Maps the current crop selection back into the *original, unrotated,
17
+ * unflipped* source image's own pixel coordinates. Corner-based: takes the
18
+ * frame rect's four corners in rendered (rotated + scaled) space, undoes the
19
+ * scale, then inverse-rotates each corner (rotation is always a multiple of
20
+ * 90°, so this stays an axis-aligned rectangle — no interpolation needed)
21
+ * back into the natural image's coordinate space, then takes the bounding
22
+ * box. Flip doesn't move the rectangle (mirroring is content-only), so it's
23
+ * carried through untouched in the result for the caller to apply.
24
+ */
25
+ export declare function computeCropRegion(natural: Size, frame: Size, state: KiriState): CropRegion;
15
26
  export declare function renderCropToCanvas(img: HTMLImageElement, state: KiriState, frame: Size, outputWidth: number, outputHeight: number, frameShape: FrameShape): HTMLCanvasElement;
16
27
  export declare function exportCrop(img: HTMLImageElement, state: KiriState, frame: Size, frameShape: FrameShape, options: ExportOptions): Promise<ExportResult>;
@@ -25,6 +25,8 @@ interface GestureCallbacks {
25
25
  max: number;
26
26
  };
27
27
  setState: (next: KiriState) => void;
28
+ /** Called on the "0" key — reverts to the post-`load()` state. */
29
+ reset: () => void;
28
30
  }
29
31
  export interface GestureOptions {
30
32
  mouseWheelZoom?: boolean | "ctrl";
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { Kiri } from './kiri';
2
2
  export { KiriBatch } from './batch';
3
3
  export type { KiriBatchItem } from './batch';
4
- export type { ExportFormat, ExportOptions, ExportResult, ExportType, Filters, Flip, FrameShape, FrameSize, KiriEventCallback, KiriEventName, KiriOptions, KiriState, LoadOptions, Offset, UploadOptions, Uploader, ZoomerPosition, } from './types';
4
+ export type { CropRegion, ExportFormat, ExportOptions, ExportResult, ExportType, Filters, Flip, FrameShape, FrameSize, KiriEventCallback, KiriEventName, KiriOptions, KiriState, LoadOptions, Offset, UploadOptions, Uploader, ZoomerPosition, } from './types';
package/dist/kiri.css CHANGED
@@ -42,8 +42,15 @@
42
42
  border: 1px solid #333;
43
43
  border-radius: 50%;
44
44
  pointer-events: auto;
45
+ }
46
+ .kiri-frame-handle--top-left,
47
+ .kiri-frame-handle--bottom-right {
45
48
  cursor: nwse-resize;
46
49
  }
50
+ .kiri-frame-handle--top-right,
51
+ .kiri-frame-handle--bottom-left {
52
+ cursor: nesw-resize;
53
+ }
47
54
  .kiri-root {
48
55
  display: inline-flex;
49
56
  gap: 10px;
package/dist/kiri.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ExportOptions, ExportResult, Filters, KiriEventCallback, KiriEventName, KiriOptions, KiriState, LoadOptions, UploadOptions } from './types';
1
+ import { CropRegion, ExportOptions, ExportResult, Filters, KiriEventCallback, KiriEventName, KiriOptions, KiriState, LoadOptions, Offset, UploadOptions } from './types';
2
2
  /**
3
3
  * An interactive image cropper attached to a plain DOM element. Drag to pan,
4
4
  * zoom via wheel/pinch/an optional built-in slider, rotate in 90° steps,
@@ -13,6 +13,8 @@ export declare class Kiri {
13
13
  private zoomerHandle;
14
14
  private naturalSize;
15
15
  private state;
16
+ /** Snapshot taken right after `load()` resolves, so `reset()` has something to revert to. */
17
+ private initialState;
16
18
  private listeners;
17
19
  /**
18
20
  * @param container An element already present in the DOM. Passing
@@ -34,6 +36,18 @@ export declare class Kiri {
34
36
  getState(): KiriState;
35
37
  /** Sets the zoom to an absolute value, clamped to `[minZoom, maxZoom]`. */
36
38
  setZoom(zoom: number): void;
39
+ /**
40
+ * Sets the pan offset to an absolute value (image-center offset from the
41
+ * frame center, in stage pixels), clamped so the frame stays fully covered
42
+ * by the rendered image.
43
+ */
44
+ setOffset(offset: Offset): void;
45
+ /**
46
+ * Reverts zoom/offset/rotation/flip/filters to what they were right after
47
+ * `load()` resolved (including any `loadOptions` passed to it). No-op if
48
+ * nothing has been loaded yet.
49
+ */
50
+ reset(): void;
37
51
  /**
38
52
  * Rotates relative to the current rotation, snapped to the nearest 90°.
39
53
  * No-op if `rotatable: false` was passed to the constructor.
@@ -61,6 +75,13 @@ export declare class Kiri {
61
75
  * @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
62
76
  */
63
77
  export(options?: ExportOptions): Promise<ExportResult>;
78
+ /**
79
+ * The current crop selection as a rectangle in the original, unrotated,
80
+ * unflipped source image's own pixel coordinates — for sending to a server
81
+ * that will crop the full-resolution original itself instead of uploading
82
+ * a client-re-encoded image. See {@link CropRegion}.
83
+ */
84
+ getCropRegion(): CropRegion;
64
85
  /**
65
86
  * Exports the current crop as a blob, then uploads it — a default
66
87
  * FormData/`fetch` POST, or a custom `uploader` (per-call `options.uploader`
package/dist/kiri.js CHANGED
@@ -35,6 +35,12 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
35
35
  container.innerHTML = "";
36
36
  const stageEl = document.createElement("div");
37
37
  stageEl.className = "kiri-stage";
38
+ stageEl.tabIndex = 0;
39
+ stageEl.setAttribute("role", "application");
40
+ stageEl.setAttribute(
41
+ "aria-label",
42
+ "Image cropper. Drag to pan. Arrow keys to pan, plus/minus to zoom, 0 to reset."
43
+ );
38
44
  const imageLayerEl = document.createElement("div");
39
45
  imageLayerEl.className = "kiri-image-layer";
40
46
  const imgEl = document.createElement("img");
@@ -109,6 +115,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
109
115
  function normalizeRotation(deg) {
110
116
  return (deg % 360 + 360) % 360;
111
117
  }
118
+ const KEYBOARD_PAN_STEP = 15;
119
+ const KEYBOARD_ZOOM_STEP = 0.1;
112
120
  function pointerDistance(a, b) {
113
121
  return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
114
122
  }
@@ -188,11 +196,47 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
188
196
  const delta = -e.deltaY * 15e-4;
189
197
  applyClampedState({ ...state, zoom: state.zoom * (1 + delta) });
190
198
  }
199
+ function onKeyDown(e) {
200
+ const state = callbacks.getState();
201
+ switch (e.key) {
202
+ case "ArrowLeft":
203
+ e.preventDefault();
204
+ applyClampedState({ ...state, offset: { ...state.offset, x: state.offset.x - KEYBOARD_PAN_STEP } });
205
+ break;
206
+ case "ArrowRight":
207
+ e.preventDefault();
208
+ applyClampedState({ ...state, offset: { ...state.offset, x: state.offset.x + KEYBOARD_PAN_STEP } });
209
+ break;
210
+ case "ArrowUp":
211
+ e.preventDefault();
212
+ applyClampedState({ ...state, offset: { ...state.offset, y: state.offset.y - KEYBOARD_PAN_STEP } });
213
+ break;
214
+ case "ArrowDown":
215
+ e.preventDefault();
216
+ applyClampedState({ ...state, offset: { ...state.offset, y: state.offset.y + KEYBOARD_PAN_STEP } });
217
+ break;
218
+ case "+":
219
+ case "=":
220
+ e.preventDefault();
221
+ applyClampedState({ ...state, zoom: state.zoom + KEYBOARD_ZOOM_STEP });
222
+ break;
223
+ case "-":
224
+ case "_":
225
+ e.preventDefault();
226
+ applyClampedState({ ...state, zoom: state.zoom - KEYBOARD_ZOOM_STEP });
227
+ break;
228
+ case "0":
229
+ e.preventDefault();
230
+ callbacks.reset();
231
+ break;
232
+ }
233
+ }
191
234
  stageEl.addEventListener("pointerdown", onPointerDown);
192
235
  stageEl.addEventListener("pointermove", onPointerMove);
193
236
  stageEl.addEventListener("pointerup", onPointerUp);
194
237
  stageEl.addEventListener("pointercancel", onPointerUp);
195
238
  stageEl.addEventListener("wheel", onWheel, { passive: false });
239
+ stageEl.addEventListener("keydown", onKeyDown);
196
240
  return {
197
241
  destroy() {
198
242
  stageEl.removeEventListener("pointerdown", onPointerDown);
@@ -200,6 +244,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
200
244
  stageEl.removeEventListener("pointerup", onPointerUp);
201
245
  stageEl.removeEventListener("pointercancel", onPointerUp);
202
246
  stageEl.removeEventListener("wheel", onWheel);
247
+ stageEl.removeEventListener("keydown", onKeyDown);
203
248
  }
204
249
  };
205
250
  }
@@ -265,6 +310,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
265
310
  );
266
311
  return fallback;
267
312
  }
313
+ function clampNum(value, min, max) {
314
+ return Math.min(Math.max(value, min), max);
315
+ }
268
316
  const VALID_EXPORT_TYPES = ["base64", "blob", "canvas"];
269
317
  const VALID_EXPORT_FORMATS = ["image/jpeg", "image/png", "image/webp"];
270
318
  function computeFrameSourceRect(rendered, offset, frame) {
@@ -273,6 +321,53 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
273
321
  top: rendered.height / 2 - offset.y - frame.height / 2
274
322
  };
275
323
  }
324
+ function computeCropRegion(natural, frame, state) {
325
+ const scale = computeCoverScale(natural, frame, state.rotation) * state.zoom;
326
+ const rendered = effectiveRenderedSize(natural, frame, state.rotation, state.zoom);
327
+ const { left: frameLeft, top: frameTop } = computeFrameSourceRect(rendered, state.offset, frame);
328
+ const renderedCenter = { x: rendered.width / 2, y: rendered.height / 2 };
329
+ const naturalCenter = { x: natural.width / 2, y: natural.height / 2 };
330
+ const rotation = normalizeRotation(state.rotation);
331
+ const corners = [
332
+ { x: frameLeft, y: frameTop },
333
+ { x: frameLeft + frame.width, y: frameTop },
334
+ { x: frameLeft, y: frameTop + frame.height },
335
+ { x: frameLeft + frame.width, y: frameTop + frame.height }
336
+ ].map(({ x, y }) => {
337
+ const cx = (x - renderedCenter.x) / scale;
338
+ const cy = (y - renderedCenter.y) / scale;
339
+ let u;
340
+ let v;
341
+ if (rotation === 90) {
342
+ u = cy;
343
+ v = -cx;
344
+ } else if (rotation === 180) {
345
+ u = -cx;
346
+ v = -cy;
347
+ } else if (rotation === 270) {
348
+ u = -cy;
349
+ v = cx;
350
+ } else {
351
+ u = cx;
352
+ v = cy;
353
+ }
354
+ return { x: u + naturalCenter.x, y: v + naturalCenter.y };
355
+ });
356
+ const xs = corners.map((c) => c.x);
357
+ const ys = corners.map((c) => c.y);
358
+ const left = clampNum(Math.min(...xs), 0, natural.width);
359
+ const top = clampNum(Math.min(...ys), 0, natural.height);
360
+ const right = clampNum(Math.max(...xs), 0, natural.width);
361
+ const bottom = clampNum(Math.max(...ys), 0, natural.height);
362
+ return {
363
+ x: Math.round(left),
364
+ y: Math.round(top),
365
+ width: Math.round(right - left),
366
+ height: Math.round(bottom - top),
367
+ rotation,
368
+ flip: { ...state.flip }
369
+ };
370
+ }
276
371
  function renderCropToCanvas(img, state, frame, outputWidth, outputHeight, frameShape) {
277
372
  const natural = { width: img.naturalWidth, height: img.naturalHeight };
278
373
  const scale = computeCoverScale(natural, frame, state.rotation) * state.zoom;
@@ -404,6 +499,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
404
499
  flip: { horizontal: false, vertical: false },
405
500
  filters: DEFAULT_FILTERS
406
501
  });
502
+ /** Snapshot taken right after `load()` resolves, so `reset()` has something to revert to. */
503
+ __publicField(this, "initialState", null);
407
504
  __publicField(this, "listeners", { change: [] });
408
505
  var _a, _b, _c;
409
506
  if (!container || typeof container.appendChild !== "function") {
@@ -428,6 +525,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
428
525
  rotatable: options.rotatable ?? true,
429
526
  flippable: options.flippable ?? true,
430
527
  resizableFrame: options.resizableFrame ?? false,
528
+ lockAspectRatio: options.lockAspectRatio ?? false,
431
529
  mouseWheelZoom: resolveMouseWheelZoom(options.mouseWheelZoom),
432
530
  useExifOrientation: options.useExifOrientation ?? true,
433
531
  uploader: options.uploader,
@@ -464,7 +562,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
464
562
  getFrameSize: () => this.getFrameSize(),
465
563
  getState: () => this.state,
466
564
  getMinMaxZoom: () => ({ min: this.opts.minZoom, max: this.opts.maxZoom }),
467
- setState: (next) => this.commitState(next)
565
+ setState: (next) => this.commitState(next),
566
+ reset: () => this.reset()
468
567
  },
469
568
  { mouseWheelZoom: this.opts.mouseWheelZoom }
470
569
  );
@@ -523,6 +622,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
523
622
  flip: { horizontal: flipHorizontal, vertical: flipVertical },
524
623
  filters: this.state.filters
525
624
  });
625
+ this.initialState = this.getState();
526
626
  }
527
627
  /** A snapshot of the current state — mutating the returned object has no effect. */
528
628
  getState() {
@@ -546,6 +646,36 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
546
646
  const offset = clampOffset(this.state.offset, rendered, this.getFrameSize());
547
647
  this.commitState({ ...this.state, zoom: clamped, offset });
548
648
  }
649
+ /**
650
+ * Sets the pan offset to an absolute value (image-center offset from the
651
+ * frame center, in stage pixels), clamped so the frame stays fully covered
652
+ * by the rendered image.
653
+ */
654
+ setOffset(offset) {
655
+ const rendered = effectiveRenderedSize(
656
+ this.naturalSize,
657
+ this.getFrameSize(),
658
+ this.state.rotation,
659
+ this.state.zoom
660
+ );
661
+ const clamped = clampOffset(offset, rendered, this.getFrameSize());
662
+ this.commitState({ ...this.state, offset: clamped });
663
+ }
664
+ /**
665
+ * Reverts zoom/offset/rotation/flip/filters to what they were right after
666
+ * `load()` resolved (including any `loadOptions` passed to it). No-op if
667
+ * nothing has been loaded yet.
668
+ */
669
+ reset() {
670
+ if (!this.initialState) return;
671
+ this.commitState({
672
+ zoom: this.initialState.zoom,
673
+ offset: { ...this.initialState.offset },
674
+ rotation: this.initialState.rotation,
675
+ flip: { ...this.initialState.flip },
676
+ filters: { ...this.initialState.filters }
677
+ });
678
+ }
549
679
  /**
550
680
  * Rotates relative to the current rotation, snapped to the nearest 90°.
551
681
  * No-op if `rotatable: false` was passed to the constructor.
@@ -616,6 +746,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
616
746
  options
617
747
  );
618
748
  }
749
+ /**
750
+ * The current crop selection as a rectangle in the original, unrotated,
751
+ * unflipped source image's own pixel coordinates — for sending to a server
752
+ * that will crop the full-resolution original itself instead of uploading
753
+ * a client-re-encoded image. See {@link CropRegion}.
754
+ */
755
+ getCropRegion() {
756
+ return computeCropRegion(this.naturalSize, this.getFrameSize(), this.state);
757
+ }
619
758
  /**
620
759
  * Exports the current crop as a blob, then uploads it — a default
621
760
  * FormData/`fetch` POST, or a custom `uploader` (per-call `options.uploader`
@@ -678,42 +817,64 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
678
817
  };
679
818
  }
680
819
  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
820
+ const corners = [
821
+ { name: "top-left", edge: { left: "-5px", top: "-5px" }, signX: -1, signY: -1 },
822
+ { name: "top-right", edge: { right: "-5px", top: "-5px" }, signX: 1, signY: -1 },
823
+ { name: "bottom-left", edge: { left: "-5px", bottom: "-5px" }, signX: -1, signY: 1 },
824
+ { name: "bottom-right", edge: { right: "-5px", bottom: "-5px" }, signX: 1, signY: 1 }
825
+ ];
826
+ const cleanups = [];
827
+ for (const corner of corners) {
828
+ const handle = document.createElement("div");
829
+ handle.className = `kiri-frame-handle kiri-frame-handle--${corner.name}`;
830
+ Object.assign(handle.style, corner.edge);
831
+ this.stage.frameEl.appendChild(handle);
832
+ let start = null;
833
+ const onDown = (e) => {
834
+ e.stopPropagation();
835
+ handle.setPointerCapture(e.pointerId);
836
+ start = {
837
+ x: e.clientX,
838
+ y: e.clientY,
839
+ width: this.opts.frame.width,
840
+ height: this.opts.frame.height
841
+ };
695
842
  };
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() {
843
+ const onMove = (e) => {
844
+ if (!start) return;
845
+ const dx = (e.clientX - start.x) * 2 * corner.signX;
846
+ const dy = (e.clientY - start.y) * 2 * corner.signY;
847
+ const rawWidth = start.width + dx;
848
+ const rawHeight = start.height + dy;
849
+ if (this.opts.lockAspectRatio) {
850
+ const aspect = start.width / start.height;
851
+ if (Math.abs(rawWidth - start.width) >= Math.abs(rawHeight - start.height) * aspect) {
852
+ this.setFrameSize(rawWidth, rawWidth / aspect);
853
+ } else {
854
+ this.setFrameSize(rawHeight * aspect, rawHeight);
855
+ }
856
+ } else {
857
+ this.setFrameSize(rawWidth, rawHeight);
858
+ }
859
+ };
860
+ const onUp = () => {
861
+ start = null;
862
+ };
863
+ handle.addEventListener("pointerdown", onDown);
864
+ handle.addEventListener("pointermove", onMove);
865
+ handle.addEventListener("pointerup", onUp);
866
+ handle.addEventListener("pointercancel", onUp);
867
+ cleanups.push(() => {
712
868
  handle.removeEventListener("pointerdown", onDown);
713
869
  handle.removeEventListener("pointermove", onMove);
714
870
  handle.removeEventListener("pointerup", onUp);
715
871
  handle.removeEventListener("pointercancel", onUp);
716
872
  handle.remove();
873
+ });
874
+ }
875
+ this.resizeHandle = {
876
+ destroy() {
877
+ for (const cleanup of cleanups) cleanup();
717
878
  }
718
879
  };
719
880
  }
@@ -745,6 +906,14 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
745
906
  await this.cropper.load(item.source, item.loadOptions);
746
907
  return true;
747
908
  }
909
+ /** Loads the previous queued image into `cropper`. Returns false when already at the first item (or nothing loaded yet). */
910
+ async previous() {
911
+ if (this.index <= 0) return false;
912
+ this.index -= 1;
913
+ const item = this.items[this.index];
914
+ await this.cropper.load(item.source, item.loadOptions);
915
+ return true;
916
+ }
748
917
  /** Metadata for the currently loaded item, or null before the first next() / after exhaustion. */
749
918
  current() {
750
919
  return this.items[this.index] ?? null;