@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 +100 -0
- package/dist/exif.d.ts +31 -0
- package/dist/export.d.ts +27 -0
- package/dist/filters.d.ts +45 -0
- package/dist/gestures.d.ts +56 -0
- package/dist/index.d.ts +2 -0
- package/dist/kiri.css +82 -0
- package/dist/kiri.d.ts +173 -0
- package/dist/kiri.js +1115 -0
- package/dist/kiri.js.map +1 -0
- package/dist/kiri.min.css +1 -0
- package/dist/kiri.min.js +8 -0
- package/dist/kiri.min.js.map +1 -0
- package/dist/kiri.mjs +867 -0
- package/dist/kiri.mjs.map +1 -0
- package/dist/stage.d.ts +38 -0
- package/dist/types.d.ts +179 -0
- package/dist/upload.d.ts +10 -0
- package/dist/validate.d.ts +7 -0
- package/package.json +57 -0
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;
|
package/dist/export.d.ts
ADDED
|
@@ -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 {};
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|