@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/README.md ADDED
@@ -0,0 +1,100 @@
1
+ > **AI-authored.** 100% of the code was written by Claude (Anthropic); I shaped the architecture, scope, and every decision, and did all the testing.
2
+
3
+ # Kiri
4
+
5
+ A dependency-free TypeScript library for interactive image cropping in the browser.
6
+
7
+ ## Features
8
+
9
+ - **Drag, zoom, rotate, flip** — wheel/trackpad pinch zoom, an optional
10
+ built-in zoom slider (`showZoomer`, placeable on any of the four sides),
11
+ 90° rotation, independent horizontal/vertical flip
12
+ - **Rectangle, circle, or rounded-rectangle frame** — circle/rounded-rectangle
13
+ are real clips on export (transparent corners on PNG/WebP), not just a
14
+ visual overlay
15
+ - **Zero-CSS sizing** — the stage auto-sizes itself to the frame's dimensions
16
+ - **Filters** — brightness/contrast/saturation/sharpness/grayscale/sepia,
17
+ applied identically to the live preview and the export
18
+ - **Automatic EXIF orientation correction** on load
19
+ - **Resizable frame**, four independent corner handles, with an optional
20
+ `lockAspectRatio` to keep a fixed ratio while resizing
21
+ - **`movableFrame`** — invert the interaction model: freeze the image and
22
+ drag/resize the frame over it instead of panning/zooming the image
23
+ - **`setOffset()`/`reset()`** for programmatic panning and reverting to the
24
+ post-`load()` state
25
+ - **`getCropRegion()`** — the crop mapped back to the original image's own
26
+ pixel coordinates, for server-side cropping of the full-resolution source
27
+ - **Keyboard-accessible** — the stage is focusable; arrow keys pan, `+`/`-`
28
+ zoom, `0` resets
29
+ - **Export** to base64, Blob, or Canvas — JPEG/PNG/WebP, custom output size
30
+ - **Upload** — a built-in FormData/fetch helper, or plug in your own
31
+ - **Batch cropping** — a documented recipe for stepping one shared cropper
32
+ through a queue of images, no separate class needed
33
+ - **React and Vue wrappers** (`kiri-react`, `kiri-vue`) — thin components, no
34
+ duplicated logic, reactive to prop changes after mount
35
+ - Runtime-validated options (a typo'd setting warns and falls back rather
36
+ than silently misbehaving), a clear error if the container element is
37
+ missing, and a real, CSP-safe stylesheet instead of injected styles
38
+ - Ships as ESM for bundlers and UMD/CJS for a plain `<script>` tag —
39
+ minified and unminified builds of both
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ npm install @michaelyagi/kiri
45
+ ```
46
+
47
+ [npmjs.com/package/@michaelyagi/kiri](https://www.npmjs.com/package/@michaelyagi/kiri)
48
+
49
+ ## Zero-config quickstart
50
+
51
+ ```html
52
+ <link rel="stylesheet" href="node_modules/@michaelyagi/kiri/dist/kiri.min.css" />
53
+ <script src="node_modules/@michaelyagi/kiri/dist/kiri.min.js"></script>
54
+ <div id="cropper"></div>
55
+ <script>
56
+ const cropper = new Kiri(document.getElementById("cropper"), {
57
+ frame: { shape: "circle", width: 200, height: 200 },
58
+ });
59
+ await cropper.load("photo.jpg");
60
+ const blob = await cropper.export({ type: "blob" });
61
+ </script>
62
+ ```
63
+
64
+ No CSS sizing needed on the container — the stage auto-sizes itself to the
65
+ frame's dimensions. See [Getting started](https://michaelyagi.github.io/kiri/guides/getting-started.html)
66
+ for npm/ESM installation too.
67
+
68
+ ## Documentation
69
+
70
+ **[michaelyagi.github.io/kiri](https://michaelyagi.github.io/kiri)** —
71
+ getting started, every method/event/setting, guides, real runnable examples,
72
+ an interactive playground, and the full API reference.
73
+
74
+ ## Packages
75
+
76
+ | Package | Path | What it is |
77
+ |---|---|---|
78
+ | `@michaelyagi/kiri` | `packages/core` | The library itself — framework-agnostic |
79
+ | `kiri-react` | `packages/react` | `<KiriCropper>` React component (unpublished) |
80
+ | `kiri-vue` | `packages/vue` | `<KiriCropper>` Vue component (unpublished) |
81
+
82
+ ## Status
83
+
84
+ **`0.1.0-alpha.10`** — see [CHANGELOG.md](./CHANGELOG.md)
85
+ for what's in it. Core publishes to npm automatically on version tags via
86
+ GitHub Actions; `kiri-react`/`kiri-vue` aren't published yet. The public API
87
+ is expected to be mostly stable but may still change before a `0.1.0`
88
+ (non-alpha) release.
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ npm install # installs all workspace packages
94
+ npm run dev # core demo at http://localhost:5173
95
+ npm run build # builds all three packages
96
+ npm test # runs all three packages' test suites
97
+ npm run docs:build # rebuilds the docs/ site (library + API reference + asset sync)
98
+ ```
99
+
100
+ See [design.md](./design.md) for the full design document.
package/dist/exif.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reads the EXIF orientation tag (1-8) from a JPEG ArrayBuffer, defaulting to 1
3
+ * (no adjustment) for images with no EXIF data or non-JPEG input.
4
+ */
5
+ export declare function readExifOrientation(buffer: ArrayBuffer): number;
6
+ export interface OrientationTransform {
7
+ rotation: number;
8
+ flipHorizontal: boolean;
9
+ }
10
+ /**
11
+ * Maps an EXIF orientation value to the rotation + horizontal-flip pair that
12
+ * normalizes it. A vertical flip is never needed on its own: orientation 4
13
+ * (mirror vertical) is expressed as rotate(180) + flip horizontal, which is
14
+ * mathematically equivalent.
15
+ *
16
+ * Rotation and flip don't commute, so which rotation value is paired with
17
+ * `flipHorizontal: true` depends on Kiri's actual render order: rotation is
18
+ * applied first, then flip mirrors the already-rotated result (see
19
+ * stage.ts's `applyTransform()`). That's why orientations 5 and 7 use `270`/
20
+ * `90` here rather than the more "obvious" `90`/`270` — reflections invert
21
+ * the effective rotation direction (`flip · rotate(θ) = rotate(-θ) · flip`),
22
+ * so producing the correct final image under this render order needs the
23
+ * negated angle. Orientations 2/3/4/6/8 are unaffected: 2 and 4 have no
24
+ * rotation-order ambiguity (0° and 180° are their own negation mod 360), and
25
+ * 3/6/8 have no flip at all. Verified directly (not just by this algebra) —
26
+ * confirmed in a real browser that `rotate(270) + flipHorizontal` under the
27
+ * current render order reproduces the exact same pixels as
28
+ * `rotate(90) + flipHorizontal` did under the old (flip-before-rotate) order,
29
+ * and vice versa.
30
+ */
31
+ export declare function orientationToTransform(orientation: number): OrientationTransform;
@@ -0,0 +1,27 @@
1
+ import { CropRegion, ExportOptions, ExportResult, FrameShape, KiriState, Offset } from './types';
2
+ import { Size } from './gestures';
3
+ /**
4
+ * Top-left corner of the frame, in the local pixel space of the rendered
5
+ * (rotated + scaled) image. Pure so the crop-region math is testable without
6
+ * a real canvas: the frame is centered on the stage, the image's own center
7
+ * sits at `offset` from that same point, so the frame's top-left in the
8
+ * rendered image's local space is the rendered center, shifted back by the
9
+ * offset.
10
+ */
11
+ export declare function computeFrameSourceRect(rendered: Size, offset: Offset, frame: Size): {
12
+ left: number;
13
+ top: number;
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;
26
+ export declare function renderCropToCanvas(img: HTMLImageElement, state: KiriState, frame: Size, outputWidth: number, outputHeight: number, frameShape: FrameShape, cornerRadius: number, sharpenFilterId: string): HTMLCanvasElement;
27
+ export declare function exportCrop(img: HTMLImageElement, state: KiriState, frame: Size, frameShape: FrameShape, cornerRadius: number, sharpenFilterId: string, options: ExportOptions): Promise<ExportResult>;
@@ -0,0 +1,45 @@
1
+ import { Filters } from './types';
2
+ export declare const DEFAULT_FILTERS: Filters;
3
+ /** Merges a partial filter update into the current filters, clamping numeric values to >= 0. */
4
+ export declare function mergeFilters(current: Filters, partial: Partial<Filters>): Filters;
5
+ /**
6
+ * The 3x3 unsharp-mask kernel for `sharpness`, as a `feConvolveMatrix`
7
+ * `kernelMatrix` attribute value (row-major, space-separated):
8
+ *
9
+ * ```
10
+ * 0 -k 0
11
+ * -k 1+4k -k
12
+ * 0 -k 0
13
+ * ```
14
+ *
15
+ * The matrix always sums to `1`, so the convolution needs no separate
16
+ * `divisor`. `sharpness <= 1` collapses to the identity kernel (center `1`,
17
+ * neighbors `0`) — no blur/sharpen either way, matching the `1` = unchanged
18
+ * convention used by the other numeric filters. `4.5` is chosen so a
19
+ * `sharpness` of `2`–`3` (a reasonable slider range) lands in a visually
20
+ * subtle-to-strong sharpening range, not an immediately harsh one.
21
+ */
22
+ export declare function sharpenKernelMatrix(sharpness: number): string;
23
+ /**
24
+ * Builds a CSS `filter` value from the current filter state. Used verbatim as
25
+ * both the live-preview `img.style.filter` and a canvas 2D context's
26
+ * `ctx.filter` before drawing, so preview and export always match exactly —
27
+ * no separately hand-rolled brightness/contrast/saturation/sharpness pixel
28
+ * math. Brightness/contrast/saturation/grayscale/sepia are native CSS filter
29
+ * functions; CSS has no `sharpen()`, so `sharpness` instead references an
30
+ * SVG `feConvolveMatrix` filter (by `sharpenFilterId`) that `stage.ts` keeps
31
+ * in sync with the current `sharpness` value — `ctx.filter` and
32
+ * `img.style.filter` both support `url(#id)` references to it identically.
33
+ *
34
+ * The `url(#id)` reference goes **first**, before the native CSS filter
35
+ * functions — not last, despite sharpening-as-a-final-step being the more
36
+ * common photo-editing order. Verified directly in a real browser (Chromium)
37
+ * that a `url(#id)` SVG filter reference placed *after* one or more native
38
+ * CSS filter functions in the same chain silently produces a fully blank
39
+ * (transparent) result, while the identical chain with `url(#id)` first
40
+ * renders correctly. Sharpening a source that hasn't had brightness/contrast/
41
+ * saturation applied yet is a legitimate (if less common) ordering rather
42
+ * than a compromise — but the ordering here is load-bearing either way, not
43
+ * arbitrary; don't reorder this without re-verifying in a real browser.
44
+ */
45
+ export declare function buildFilterString(filters: Filters, sharpenFilterId: string): string;
@@ -0,0 +1,56 @@
1
+ import { KiriState, Offset } from './types';
2
+ export interface Size {
3
+ width: number;
4
+ height: number;
5
+ }
6
+ /** Rotation-aware natural size: 90/270 degrees swap width and height. */
7
+ export declare function effectiveNaturalSize(natural: Size, rotationDeg: number): Size;
8
+ /** The smallest scale at which the (rotation-adjusted) image still fully covers the frame. */
9
+ export declare function computeCoverScale(natural: Size, frame: Size, rotationDeg: number): number;
10
+ /** Rendered size of the image layer given a user zoom multiplier (relative to cover scale). */
11
+ export declare function effectiveRenderedSize(natural: Size, frame: Size, rotationDeg: number, zoom: number): Size;
12
+ export declare function clampZoom(zoom: number, minZoom: number, maxZoom: number): number;
13
+ /**
14
+ * Clamp the image-center offset (from stage/frame center) so the frame stays
15
+ * fully covered by the rendered image on every axis.
16
+ */
17
+ export declare function clampOffset(offset: Offset, rendered: Size, frame: Size): Offset;
18
+ export declare function normalizeRotation(deg: number): number;
19
+ interface GestureCallbacks {
20
+ getNaturalSize: () => Size;
21
+ getFrameSize: () => Size;
22
+ getState: () => KiriState;
23
+ getMinMaxZoom: () => {
24
+ min: number;
25
+ max: number;
26
+ };
27
+ setState: (next: KiriState) => void;
28
+ /** Called on the "0" key — reverts to the post-`load()` state. */
29
+ reset: () => void;
30
+ /**
31
+ * `movableFrame` mode only: the image's fixed on-screen size — frozen at
32
+ * `load()` time (and recomputed on `rotate()`, since rotation swaps which
33
+ * natural dimension maps to width/height), *not* derived from the current
34
+ * `getFrameSize()`. Deliberately not just
35
+ * `effectiveRenderedSize(natural, getFrameSize(), rotation, 1)` computed
36
+ * on demand — the frame can be resized after load in this mode, and the
37
+ * image must stay the same size regardless, not jump every time the frame
38
+ * does.
39
+ */
40
+ getFixedImageSize: () => Size;
41
+ }
42
+ export interface GestureOptions {
43
+ mouseWheelZoom?: boolean | "ctrl";
44
+ /**
45
+ * When true, drag/wheel/pinch/arrow-keys move and (with `resizableFrame`)
46
+ * resize the *frame* instead of panning/zooming the image — see
47
+ * `KiriOptions.movableFrame`. The image is fixed at `zoom: 1`,
48
+ * `offset: { x: 0, y: 0 }` in this mode, so none of the zoom/offset
49
+ * gesture logic below runs at all.
50
+ */
51
+ movableFrame?: boolean;
52
+ }
53
+ export declare function attachGestures(stageEl: HTMLElement, callbacks: GestureCallbacks, options: GestureOptions): {
54
+ destroy: () => void;
55
+ };
56
+ export {};
@@ -0,0 +1,2 @@
1
+ export { Kiri } from './kiri';
2
+ 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 ADDED
@@ -0,0 +1,82 @@
1
+ .kiri-stage {
2
+ position: relative;
3
+ overflow: hidden;
4
+ width: 100%;
5
+ height: 100%;
6
+ min-height: 200px;
7
+ background: #333;
8
+ touch-action: none;
9
+ cursor: grab;
10
+ user-select: none;
11
+ }
12
+ .kiri-stage.kiri-dragging {
13
+ cursor: grabbing;
14
+ }
15
+ .kiri-image-layer {
16
+ position: absolute;
17
+ top: 50%;
18
+ left: 50%;
19
+ pointer-events: none;
20
+ }
21
+ .kiri-image-layer img {
22
+ display: block;
23
+ max-width: none;
24
+ }
25
+ .kiri-frame {
26
+ position: absolute;
27
+ top: 50%;
28
+ left: 50%;
29
+ transform: translate(-50%, -50%);
30
+ border: 1px solid rgba(255, 255, 255, 0.85);
31
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
32
+ pointer-events: none;
33
+ }
34
+ .kiri-frame--circle {
35
+ border-radius: 50%;
36
+ }
37
+ /* .kiri-frame--rounded-rectangle's border-radius is a per-instance pixel
38
+ value (frame.cornerRadius), so it's set inline by stage.ts instead of
39
+ here — this class exists as a CSS hook for consumers, not styling. */
40
+ .kiri-frame-handle {
41
+ position: absolute;
42
+ width: 10px;
43
+ height: 10px;
44
+ background: #fff;
45
+ border: 1px solid #333;
46
+ border-radius: 50%;
47
+ pointer-events: auto;
48
+ }
49
+ .kiri-frame-handle--top-left,
50
+ .kiri-frame-handle--bottom-right {
51
+ cursor: nwse-resize;
52
+ }
53
+ .kiri-frame-handle--top-right,
54
+ .kiri-frame-handle--bottom-left {
55
+ cursor: nesw-resize;
56
+ }
57
+ .kiri-root {
58
+ display: inline-flex;
59
+ gap: 10px;
60
+ align-items: stretch;
61
+ }
62
+ .kiri-root--bottom,
63
+ .kiri-root--top {
64
+ flex-direction: column;
65
+ }
66
+ .kiri-root--left,
67
+ .kiri-root--right {
68
+ flex-direction: row;
69
+ }
70
+ .kiri-root--top .kiri-zoomer,
71
+ .kiri-root--left .kiri-zoomer {
72
+ order: -1;
73
+ }
74
+ .kiri-zoomer {
75
+ cursor: pointer;
76
+ }
77
+ .kiri-root--left .kiri-zoomer,
78
+ .kiri-root--right .kiri-zoomer {
79
+ writing-mode: vertical-lr;
80
+ direction: rtl;
81
+ width: 24px;
82
+ }
package/dist/kiri.d.ts ADDED
@@ -0,0 +1,173 @@
1
+ import { CropRegion, ExportOptions, ExportResult, Filters, KiriEventCallback, KiriEventName, KiriOptions, KiriState, LoadOptions, Offset, UploadOptions } from './types';
2
+ /**
3
+ * An interactive image cropper attached to a plain DOM element. Drag to pan,
4
+ * zoom via wheel/pinch/an optional built-in slider, rotate in 90° steps,
5
+ * flip, apply filters, then export or upload the crop. With
6
+ * `movableFrame: true`, this inverts: the image is fixed and the frame
7
+ * itself is what you drag/resize instead — see `KiriOptions.movableFrame`.
8
+ */
9
+ export declare class Kiri {
10
+ private readonly container;
11
+ private readonly opts;
12
+ private readonly stage;
13
+ private readonly gestureHandle;
14
+ private resizeHandle;
15
+ private zoomerHandle;
16
+ private naturalSize;
17
+ private state;
18
+ /**
19
+ * `movableFrame` mode only: the image's fixed on-screen size, frozen at
20
+ * `load()` time and recomputed on `rotate()` — see `getFixedImageSize()`.
21
+ */
22
+ private fixedImageSize;
23
+ /** Snapshot taken right after `load()` resolves, so `reset()` has something to revert to. */
24
+ private initialState;
25
+ private listeners;
26
+ /**
27
+ * @param container An element already present in the DOM. Passing
28
+ * `null`/`undefined`, or an element that isn't in the DOM yet, throws.
29
+ * @param options See the {@link KiriOptions} fields for defaults.
30
+ */
31
+ constructor(container: HTMLElement, options?: KiriOptions);
32
+ /**
33
+ * Loads an image, replacing whatever was loaded before. EXIF orientation
34
+ * (rotation + horizontal flip) is corrected automatically unless
35
+ * `useExifOrientation: false` was passed to the constructor — only for
36
+ * `File`/`Blob` sources, since a plain URL string can't be read for EXIF
37
+ * data without an extra fetch.
38
+ * @param source A `File` (e.g. from a file input), a `Blob`, or a URL string.
39
+ * @param loadOptions Initial `zoom`/`offset`/`rotation`/`flip`.
40
+ */
41
+ load(source: File | Blob | string, loadOptions?: LoadOptions): Promise<void>;
42
+ /** A snapshot of the current state — mutating the returned object has no effect. */
43
+ getState(): KiriState;
44
+ /**
45
+ * Sets the zoom to an absolute value, clamped to `[minZoom, maxZoom]`.
46
+ * No-op if `movableFrame: true` — the image never zooms in that mode.
47
+ */
48
+ setZoom(zoom: number): void;
49
+ /**
50
+ * Sets the pan offset to an absolute value (image-center offset from the
51
+ * frame center, in stage pixels), clamped so the frame stays fully covered
52
+ * by the rendered image. No-op if `movableFrame: true` — the image never
53
+ * pans in that mode; see `setFramePosition()` instead.
54
+ */
55
+ setOffset(offset: Offset): void;
56
+ /**
57
+ * Sets the frame's position to an absolute value (offset from stage
58
+ * center, in stage pixels), clamped so the frame stays fully within the
59
+ * image's fixed bounds. Only meaningful — and only takes effect — when
60
+ * `movableFrame: true`; a no-op otherwise, since the frame always stays
61
+ * centered in the default mode.
62
+ */
63
+ setFramePosition(position: Offset): void;
64
+ /**
65
+ * Reverts zoom/offset/rotation/flip/filters/framePosition to what they
66
+ * were right after `load()` resolved (including any `loadOptions` passed
67
+ * to it). No-op if nothing has been loaded yet.
68
+ */
69
+ reset(): void;
70
+ /**
71
+ * Rotates relative to the current rotation, snapped to the nearest 90°.
72
+ * No-op if `rotatable: false` was passed to the constructor.
73
+ */
74
+ rotate(deltaDeg: number): void;
75
+ /**
76
+ * Toggles horizontal flip. Always mirrors left-right as currently
77
+ * displayed on screen, regardless of the current `rotation` — not the
78
+ * image's own pre-rotation axes, so the visible effect stays "horizontal"
79
+ * no matter how the image is rotated. No-op if `flippable: false`.
80
+ */
81
+ flipHorizontal(): void;
82
+ /**
83
+ * Toggles vertical flip. Always mirrors top-to-bottom as currently
84
+ * displayed on screen, regardless of the current `rotation`. No-op if
85
+ * `flippable: false`.
86
+ */
87
+ flipVertical(): void;
88
+ /**
89
+ * Resizes the frame. Also resizes the stage to match, if
90
+ * `autoSizeStage: true` (the default) — except in `movableFrame` mode,
91
+ * where the stage stays pinned to the fixed image's own size (see
92
+ * `fixedImageSize`'s doc comment) instead of following the frame; letting
93
+ * the stage shrink along with the frame there would clip the (unchanged,
94
+ * still full-size) image down to whatever's left, visually reading as the
95
+ * picture itself shrinking even though its actual rendered size never
96
+ * changes. Each axis is clamped to a 20px minimum.
97
+ */
98
+ setFrameSize(width: number, height: number): void;
99
+ /**
100
+ * Merges a partial update into the current filters (omitted fields are
101
+ * left as they are). Numeric values are clamped to `>= 0`.
102
+ */
103
+ setFilters(filters: Partial<Filters>): void;
104
+ /**
105
+ * Renders the current crop. A circle or rounded-rectangle frame is a real
106
+ * clip in the output (transparent corners on PNG/WebP); either shape
107
+ * exported as JPEG warns and renders solid black corners instead, since
108
+ * JPEG has no alpha channel.
109
+ * @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
110
+ */
111
+ export(options?: ExportOptions): Promise<ExportResult>;
112
+ /**
113
+ * The current crop selection as a rectangle in the original, unrotated,
114
+ * unflipped source image's own pixel coordinates — for sending to a server
115
+ * that will crop the full-resolution original itself instead of uploading
116
+ * a client-re-encoded image. See {@link CropRegion}.
117
+ */
118
+ getCropRegion(): CropRegion;
119
+ /**
120
+ * Exports the current crop as a blob, then uploads it — a default
121
+ * FormData/`fetch` POST, or a custom `uploader` (per-call `options.uploader`
122
+ * wins over the constructor's, which wins over the built-in default).
123
+ */
124
+ upload(url: string, options?: UploadOptions): Promise<unknown>;
125
+ /** Subscribes to `"change"` — fires on every state update (drag/zoom/rotate/flip/filters), and once after `load()` resolves. */
126
+ on(event: KiriEventName, callback: KiriEventCallback): void;
127
+ /** Unsubscribes a callback previously passed to {@link on}. */
128
+ off(event: KiriEventName, callback: KiriEventCallback): void;
129
+ /**
130
+ * Tears the instance down: removes all pointer/wheel event listeners
131
+ * (drag/zoom gestures), the resize-handle listener (if `resizableFrame`),
132
+ * and the zoom-slider listener (if `showZoomer`); clears the container's
133
+ * `innerHTML`, leaving an empty container element; and clears all
134
+ * `"change"` listeners. Call this when you're done with an instance (e.g.
135
+ * unmounting) to avoid leaking listeners.
136
+ */
137
+ destroy(): void;
138
+ private getFrameSize;
139
+ /**
140
+ * `computeCropRegion()`/`renderCropToCanvas()` only know one model: an
141
+ * image shifted by `offset` under a frame fixed at center, scaled by
142
+ * `computeCoverScale(natural, frame, rotation) * zoom` — where `frame` is
143
+ * whatever's passed in, i.e. the *current* frame size. In `movableFrame`
144
+ * mode neither of those assumptions holds as-is: it's the frame that
145
+ * shifts (by `framePosition`, image fixed at center), and the image's
146
+ * scale must stay pinned to `fixedImageSize` regardless of how the frame
147
+ * has since been resized — not recomputed from the current frame size,
148
+ * which is exactly what those functions would otherwise do internally.
149
+ *
150
+ * Both are fixed by substituting two `state` fields before calling them,
151
+ * rather than changing either function:
152
+ * - `offset = -framePosition` — "frame moved right by d" and "image moved
153
+ * left by d" describe the identical relative geometry, so this alone
154
+ * carries the frame's position into the existing math unmodified.
155
+ * (`state.offset` is always `{0,0}` in this mode already, since
156
+ * `setOffset()`/gesture panning are no-ops here — this substitution is
157
+ * what actually does the work, not a redundant no-op.)
158
+ * - `zoom` = a value chosen so that
159
+ * `computeCoverScale(natural, currentFrame, rotation) * zoom` evaluates
160
+ * to exactly `fixedImageSize`'s own scale, whatever the current frame
161
+ * size is — i.e. `zoom = frozenScale / computeCoverScale(natural,
162
+ * currentFrame, rotation)`, which cancels the current-frame-size term
163
+ * out algebraically. Caught by an actual pixel test (a moved+resized
164
+ * frame exported as fully transparent) before trusting the offset
165
+ * substitution was sufficient on its own — it wasn't.
166
+ */
167
+ private getEffectiveStateForCrop;
168
+ private syncStageSize;
169
+ private syncStageSizeToFixedImage;
170
+ private commitState;
171
+ private enableZoomer;
172
+ private enableFrameResize;
173
+ }