@inglorious/ui 8.2.5 → 8.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/ui",
3
- "version": "8.2.5",
3
+ "version": "8.3.0",
4
4
  "description": "A design system for Inglorious Web with render-function components and customizable themes",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "license": "MIT",
@@ -84,6 +84,11 @@
84
84
  "import": "./src/controls/icon-button/index.js"
85
85
  },
86
86
  "./icon-button.css": "./src/controls/icon-button/style.css",
87
+ "./image-cropper": {
88
+ "types": "./types/controls/image-cropper.d.ts",
89
+ "import": "./src/controls/image-cropper/index.js"
90
+ },
91
+ "./image-cropper.css": "./src/controls/image-cropper/style.css",
87
92
  "./input": {
88
93
  "types": "./types/controls/input.d.ts",
89
94
  "import": "./src/controls/input/index.js"
@@ -4,6 +4,7 @@
4
4
  @import "./combobox/style.css";
5
5
  @import "./fab/style.css";
6
6
  @import "./icon-button/style.css";
7
+ @import "./image-cropper/style.css";
7
8
  @import "./input/style.css";
8
9
  @import "./radio-group/style.css";
9
10
  @import "./rating/style.css";
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @typedef {import('../../../types/controls/image-cropper.js').ImageCropperProps} ImageCropperEntity
3
+ * @typedef {import('../../../types/controls/image-cropper.js').CropRect} CropRect
4
+ */
5
+
6
+ import {
7
+ clampRect,
8
+ DEFAULT_MIN_SIZE,
9
+ DEFAULT_STEP,
10
+ fitRect,
11
+ FULL_RECT,
12
+ matchesAspect,
13
+ normalizeAspect,
14
+ } from "./helpers.js"
15
+
16
+ /**
17
+ * Initialize missing image cropper state defaults.
18
+ * The persistent state is `rect`, the frame in image fractions, plus the
19
+ * image's natural size once it has loaded. Everything else is configuration.
20
+ * @param {ImageCropperEntity} entity
21
+ */
22
+ export function create(entity) {
23
+ entity.rect ??= { ...FULL_RECT }
24
+ entity.naturalWidth ??= 0
25
+ entity.naturalHeight ??= 0
26
+ entity.hasError ??= false
27
+
28
+ entity.src ??= ""
29
+ entity.alt ??= ""
30
+ entity.label ??= ""
31
+ entity.aspectRatio ??= null
32
+ entity.minSize ??= DEFAULT_MIN_SIZE
33
+ entity.step ??= DEFAULT_STEP
34
+ entity.isDisabled ??= false
35
+ }
36
+
37
+ /**
38
+ * The image failed to load: there is nothing to crop.
39
+ * @param {ImageCropperEntity} entity
40
+ */
41
+ export function imageError(entity) {
42
+ entity.naturalWidth = 0
43
+ entity.naturalHeight = 0
44
+ entity.hasError = true
45
+ }
46
+
47
+ /**
48
+ * The image loaded: record its size, which is what turns `aspectRatio` into a
49
+ * frame shape. A frame that already has that shape is kept, so a previous crop
50
+ * can be restored; any other is replaced by the largest centred one.
51
+ * @param {ImageCropperEntity} entity
52
+ * @param {{ width: number, height: number }} size
53
+ */
54
+ export function imageLoad(entity, { width, height } = {}) {
55
+ if (!width || !height) {
56
+ imageError(entity)
57
+ return
58
+ }
59
+
60
+ entity.naturalWidth = width
61
+ entity.naturalHeight = height
62
+ entity.hasError = false
63
+
64
+ const aspect = normalizeAspect(entity.aspectRatio, width, height)
65
+ const rect = entity.rect ?? FULL_RECT
66
+ entity.rect =
67
+ aspect && !matchesAspect(rect, aspect)
68
+ ? fitRect(aspect)
69
+ : clampRect(rect, { minSize: entity.minSize, aspect })
70
+ }
71
+
72
+ /**
73
+ * Move or resize the frame. The rect is clamped to the image, the minimum size
74
+ * and, once the image has loaded, the aspect ratio.
75
+ * @param {ImageCropperEntity} entity
76
+ * @param {CropRect} rect
77
+ */
78
+ export function rectChange(entity, rect) {
79
+ if (entity.isDisabled || !rect) return
80
+
81
+ const aspect = normalizeAspect(
82
+ entity.aspectRatio,
83
+ entity.naturalWidth,
84
+ entity.naturalHeight,
85
+ )
86
+ entity.rect = clampRect(rect, { minSize: entity.minSize, aspect })
87
+ }
88
+
89
+ /**
90
+ * Show a different image. Its size is unknown until it loads, so the frame
91
+ * starts over on the whole image unless a rect is passed along with it.
92
+ * @param {ImageCropperEntity} entity
93
+ * @param {string | { src: string, rect?: CropRect }} payload
94
+ */
95
+ export function srcChange(entity, payload) {
96
+ const { src = "", rect } =
97
+ typeof payload === "string" ? { src: payload } : (payload ?? {})
98
+
99
+ entity.rect = rect ? { ...rect } : { ...FULL_RECT }
100
+
101
+ // The same src never fires another load, so the size it has still holds and
102
+ // the frame is fitted to it right away.
103
+ if (src === entity.src && entity.naturalWidth) {
104
+ imageLoad(entity, {
105
+ width: entity.naturalWidth,
106
+ height: entity.naturalHeight,
107
+ })
108
+ return
109
+ }
110
+
111
+ entity.src = src
112
+ entity.naturalWidth = 0
113
+ entity.naturalHeight = 0
114
+ entity.hasError = false
115
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @typedef {import('../../../types/controls/image-cropper.js').CropRect} CropRect
3
+ * @typedef {{ minSize?: number, aspect?: number | null }} RectOptions
4
+ */
5
+
6
+ import { clamp } from "@inglorious/utils/math/number.js"
7
+
8
+ // Every rect is in fractions of the image (0-1 on both axes), so it survives
9
+ // any display size and maps onto source pixels only when the image is cropped.
10
+ export const DEFAULT_MIN_SIZE = 0.1
11
+ export const DEFAULT_STEP = 0.02
12
+ export const FULL_RECT = { x: 0, y: 0, w: 1, h: 1 }
13
+ export const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
14
+ export const PERCENT = 100
15
+
16
+ const ASPECT_TOLERANCE = 0.01
17
+ const HALF = 0.5
18
+
19
+ /**
20
+ * Keep a rect inside the image, at least `minSize` on each side and, when an
21
+ * aspect is given, at that width/height ratio (in image fractions).
22
+ * @param {CropRect} rect
23
+ * @param {RectOptions} [options]
24
+ * @returns {CropRect}
25
+ */
26
+ export function clampRect(rect, { minSize = DEFAULT_MIN_SIZE, aspect } = {}) {
27
+ let { w, h } = rect
28
+ if (aspect) {
29
+ h = clamp(h, Math.max(minSize, minSize / aspect), Math.min(1, 1 / aspect))
30
+ w = h * aspect
31
+ } else {
32
+ w = clamp(w, minSize, 1)
33
+ h = clamp(h, minSize, 1)
34
+ }
35
+ return {
36
+ x: clamp(rect.x, 0, 1 - w),
37
+ y: clamp(rect.y, 0, 1 - h),
38
+ w,
39
+ h,
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The largest centred rect at the given aspect, or the whole image without one.
45
+ * @param {number | null} [aspect]
46
+ * @returns {CropRect}
47
+ */
48
+ export function fitRect(aspect) {
49
+ if (!aspect) return { ...FULL_RECT }
50
+ const w = Math.min(1, aspect)
51
+ const h = w / aspect
52
+ return { x: (1 - w) * HALF, y: (1 - h) * HALF, w, h }
53
+ }
54
+
55
+ /**
56
+ * Which handle, if any, sits under a point; "move" inside the rect.
57
+ * @param {{ x: number, y: number }} point In image fractions.
58
+ * @param {CropRect} rect
59
+ * @param {{ x: number, y: number }} tolerance Hit distance, in image fractions.
60
+ * @returns {string | null}
61
+ */
62
+ export function hitTest(point, rect, tolerance) {
63
+ const right = rect.x + rect.w
64
+ const bottom = rect.y + rect.h
65
+ const nearLeft = Math.abs(point.x - rect.x) <= tolerance.x
66
+ const nearRight = Math.abs(point.x - right) <= tolerance.x
67
+ const nearTop = Math.abs(point.y - rect.y) <= tolerance.y
68
+ const nearBottom = Math.abs(point.y - bottom) <= tolerance.y
69
+ const withinX =
70
+ point.x >= rect.x - tolerance.x && point.x <= right + tolerance.x
71
+ const withinY =
72
+ point.y >= rect.y - tolerance.y && point.y <= bottom + tolerance.y
73
+
74
+ if (!withinX || !withinY) return null
75
+ const vertical = (nearTop && "n") || (nearBottom && "s") || ""
76
+ const horizontal = (nearLeft && "w") || (nearRight && "e") || ""
77
+ return vertical + horizontal || "move"
78
+ }
79
+
80
+ /**
81
+ * Whether a rect already has the given aspect (in image fractions).
82
+ * @param {CropRect} rect
83
+ * @param {number} aspect
84
+ * @returns {boolean}
85
+ */
86
+ export function matchesAspect(rect, aspect) {
87
+ return Math.abs(rect.w / rect.h / aspect - 1) <= ASPECT_TOLERANCE
88
+ }
89
+
90
+ /**
91
+ * Turn an output aspect ratio (width/height in pixels) into one in image
92
+ * fractions, which is what every other helper works with.
93
+ * @param {number | null | undefined} aspectRatio
94
+ * @param {number} width The image's natural width.
95
+ * @param {number} height The image's natural height.
96
+ * @returns {number | null}
97
+ */
98
+ export function normalizeAspect(aspectRatio, width, height) {
99
+ if (!aspectRatio || !width || !height) return null
100
+ return (aspectRatio * height) / width
101
+ }
102
+
103
+ /**
104
+ * Move a rect without changing its size.
105
+ * @param {CropRect} rect
106
+ * @param {number} dx
107
+ * @param {number} dy
108
+ * @param {RectOptions} [options]
109
+ * @returns {CropRect}
110
+ */
111
+ export function panRect(rect, dx, dy, options) {
112
+ return clampRect({ ...rect, x: rect.x + dx, y: rect.y + dy }, options)
113
+ }
114
+
115
+ /**
116
+ * Drag one edge or corner; the opposite one stays put. With an aspect, an edge
117
+ * keeps the rect centred on the other axis and a corner follows whichever axis
118
+ * moved further.
119
+ * @param {CropRect} start The rect when the drag began.
120
+ * @param {string} handle One of HANDLES.
121
+ * @param {number} dx Pointer travel since the drag began, in image fractions.
122
+ * @param {number} dy
123
+ * @param {RectOptions} [options]
124
+ * @returns {CropRect}
125
+ */
126
+ export function resizeRect(start, handle, dx, dy, options = {}) {
127
+ const { minSize = DEFAULT_MIN_SIZE, aspect } = options
128
+ const east = handle.includes("e")
129
+ const west = handle.includes("w")
130
+ const south = handle.includes("s")
131
+ const north = handle.includes("n")
132
+ const right = start.x + start.w
133
+ const bottom = start.y + start.h
134
+
135
+ if (!aspect) {
136
+ const x = west ? clamp(start.x + dx, 0, right - minSize) : start.x
137
+ const y = north ? clamp(start.y + dy, 0, bottom - minSize) : start.y
138
+ const w = east ? clamp(start.w + dx, minSize, 1 - start.x) : right - x
139
+ const h = south ? clamp(start.h + dy, minSize, 1 - start.y) : bottom - y
140
+ return clampRect({ x, y, w, h }, options)
141
+ }
142
+
143
+ const centerX = start.x + start.w * HALF
144
+ const centerY = start.y + start.h * HALF
145
+ const widthFromX = east ? start.w + dx : west ? start.w - dx : null
146
+ const heightFromY = south ? start.h + dy : north ? start.h - dy : null
147
+ const widthFromY = heightFromY == null ? null : heightFromY * aspect
148
+ const followsX =
149
+ widthFromY == null ||
150
+ (widthFromX != null && Math.abs(dx) >= Math.abs(dy * aspect))
151
+
152
+ const maxWidth = east
153
+ ? 1 - start.x
154
+ : west
155
+ ? right
156
+ : 2 * Math.min(centerX, 1 - centerX)
157
+ const maxHeight = south
158
+ ? 1 - start.y
159
+ : north
160
+ ? bottom
161
+ : 2 * Math.min(centerY, 1 - centerY)
162
+ const w = clamp(
163
+ followsX ? widthFromX : widthFromY,
164
+ Math.max(minSize, minSize * aspect),
165
+ Math.min(maxWidth, maxHeight * aspect),
166
+ )
167
+ const h = w / aspect
168
+
169
+ return {
170
+ x: east ? start.x : west ? right - w : centerX - w * HALF,
171
+ y: south ? start.y : north ? bottom - h : centerY - h * HALF,
172
+ w,
173
+ h,
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Grow or shrink a rect around its centre.
179
+ * @param {CropRect} rect
180
+ * @param {number} factor Above 1 grows, below 1 shrinks.
181
+ * @param {RectOptions} [options]
182
+ * @returns {CropRect}
183
+ */
184
+ export function scaleRect(rect, factor, options) {
185
+ const w = rect.w * factor
186
+ const h = rect.h * factor
187
+ return clampRect(
188
+ {
189
+ x: rect.x + (rect.w - w) * HALF,
190
+ y: rect.y + (rect.h - h) * HALF,
191
+ w,
192
+ h,
193
+ },
194
+ options,
195
+ )
196
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ createEntityRender,
3
+ notifyActionArgType,
4
+ } from "../../stories/notifyStory.js"
5
+ import { ImageCropper } from "./index.js"
6
+
7
+ export default {
8
+ title: "Controls/ImageCropper",
9
+ tags: ["autodocs"],
10
+ render: createEntityRender({ ImageCropper }),
11
+ argTypes: {
12
+ ...notifyActionArgType,
13
+ label: { control: "text", description: "Accessible label for the stage." },
14
+ src: { control: "text", description: "Image URL, often a blob: URL." },
15
+ alt: { control: "text", description: "Alternative text for the image." },
16
+ aspectRatio: {
17
+ control: "number",
18
+ description:
19
+ "Output width/height in pixels to lock the frame to. Empty crops freely.",
20
+ },
21
+ minSize: {
22
+ control: { type: "range", min: 0.05, max: 1, step: 0.05 },
23
+ description: "Smallest frame side, as a fraction of the image.",
24
+ },
25
+ step: {
26
+ control: "number",
27
+ description: "Keyboard increment, as a fraction of the image.",
28
+ },
29
+ rect: {
30
+ control: "object",
31
+ description: "The frame { x, y, w, h } in image fractions (0-1).",
32
+ },
33
+ isDisabled: { control: "boolean", description: "Disable interaction." },
34
+ },
35
+ parameters: {
36
+ docs: {
37
+ description: {
38
+ component:
39
+ "Movable, resizable crop frame over a whole image. The frame is kept in image fractions, so it maps onto the real pixels whatever the display size; `cropImage(file, rect, options)` then crops and encodes in the browser. Drag the frame to move it, its edges and corners to resize it, pinch or scroll to scale it. With focus: arrows move, Shift+arrows resize, +/- scale, Home resets.",
40
+ },
41
+ },
42
+ },
43
+ }
44
+
45
+ export const Default = {}
46
+ Default.args = {
47
+ id: "image-cropper",
48
+ type: "ImageCropper",
49
+ label: "Crop the photo",
50
+ src: "https://picsum.photos/id/1015/1200/700",
51
+ alt: "River between mountains",
52
+ aspectRatio: null,
53
+ minSize: 0.1,
54
+ step: 0.02,
55
+ isDisabled: false,
56
+ }
57
+
58
+ export const Square = {}
59
+ Square.args = {
60
+ ...Default.args,
61
+ id: "image-cropper-square",
62
+ aspectRatio: 1,
63
+ }
64
+
65
+ export const Banner = {}
66
+ Banner.args = {
67
+ ...Default.args,
68
+ id: "image-cropper-banner",
69
+ aspectRatio: 21 / 9,
70
+ }
71
+
72
+ export const Restored = {}
73
+ Restored.args = {
74
+ ...Default.args,
75
+ id: "image-cropper-restored",
76
+ rect: { x: 0.3, y: 0.2, w: 0.4, h: 0.5 },
77
+ }
78
+
79
+ export const Disabled = {}
80
+ Disabled.args = {
81
+ ...Default.args,
82
+ id: "image-cropper-disabled",
83
+ rect: { x: 0.1, y: 0.1, w: 0.6, h: 0.6 },
84
+ isDisabled: true,
85
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @typedef {import('../../../types/controls/image-cropper.js').CropRect} CropRect
3
+ * @typedef {import('../../../types/controls/image-cropper.js').CropImageOptions} CropImageOptions
4
+ */
5
+
6
+ import { clampRect, fitRect, normalizeAspect } from "./helpers.js"
7
+
8
+ const DEFAULT_QUALITY = 0.9
9
+ const DEFAULT_TYPE = "image/jpeg"
10
+ const OPAQUE_TYPES = ["image/jpeg"]
11
+
12
+ /**
13
+ * Crop an image file to a rect and encode the result, all in the browser.
14
+ * Without a rect it takes the largest centred one at `aspectRatio`, or the
15
+ * whole image. The output is capped at `maxWidth` x `maxHeight` and never
16
+ * upscaled. Re-encoding also drops the camera metadata of the original.
17
+ * @param {Blob} file
18
+ * @param {CropRect | null} [rect] In image fractions, as the cropper reports it.
19
+ * @param {CropImageOptions} [options]
20
+ * @returns {Promise<Blob>}
21
+ */
22
+ export async function cropImage(file, rect, options = {}) {
23
+ const {
24
+ aspectRatio = null,
25
+ maxWidth = Infinity,
26
+ maxHeight = Infinity,
27
+ type = DEFAULT_TYPE,
28
+ quality = DEFAULT_QUALITY,
29
+ background = "white",
30
+ } = options
31
+
32
+ const source = await decodeImage(file)
33
+ try {
34
+ const { width, height } = sizeOf(source)
35
+ const aspect = normalizeAspect(aspectRatio, width, height)
36
+ const crop = rect
37
+ ? clampRect(rect, { minSize: 0, aspect })
38
+ : fitRect(aspect)
39
+ const sw = crop.w * width
40
+ const sh = crop.h * height
41
+ const scale = Math.min(1, maxWidth / sw, maxHeight / sh)
42
+ const output = {
43
+ width: Math.max(1, Math.round(sw * scale)),
44
+ height: Math.max(1, Math.round(sh * scale)),
45
+ }
46
+
47
+ const canvas = document.createElement("canvas")
48
+ canvas.width = output.width
49
+ canvas.height = output.height
50
+ const context = canvas.getContext("2d")
51
+ if (!context) throw new Error("Canvas is unavailable")
52
+
53
+ // Formats without alpha would turn transparent pixels black.
54
+ if (OPAQUE_TYPES.includes(type) && background) {
55
+ context.fillStyle = background
56
+ context.fillRect(0, 0, output.width, output.height)
57
+ }
58
+ context.imageSmoothingEnabled = true
59
+ context.imageSmoothingQuality = "high"
60
+ context.drawImage(
61
+ source,
62
+ crop.x * width,
63
+ crop.y * height,
64
+ sw,
65
+ sh,
66
+ 0,
67
+ 0,
68
+ output.width,
69
+ output.height,
70
+ )
71
+ return await toBlob(canvas, type, quality)
72
+ } finally {
73
+ source.close?.()
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Decode an image file once, honouring its EXIF orientation where the browser
79
+ * can. Falls back to an <img> element when `createImageBitmap` is missing or
80
+ * rejects the file.
81
+ * @param {Blob} file
82
+ * @returns {Promise<ImageBitmap | HTMLImageElement>}
83
+ */
84
+ export async function decodeImage(file) {
85
+ if (typeof createImageBitmap === "function") {
86
+ try {
87
+ return await createImageBitmap(file, { imageOrientation: "from-image" })
88
+ } catch {
89
+ // Some engines reject the options object; try the plain decoder.
90
+ try {
91
+ return await createImageBitmap(file)
92
+ } catch {
93
+ // Fall through to the element decoder.
94
+ }
95
+ }
96
+ }
97
+ return decodeWithElement(file)
98
+ }
99
+
100
+ /**
101
+ * @param {Blob} file
102
+ * @returns {Promise<HTMLImageElement>}
103
+ */
104
+ function decodeWithElement(file) {
105
+ return new Promise((resolve, reject) => {
106
+ const url = URL.createObjectURL(file)
107
+ const image = new Image()
108
+ image.onload = () => {
109
+ URL.revokeObjectURL(url)
110
+ resolve(image)
111
+ }
112
+ image.onerror = () => {
113
+ URL.revokeObjectURL(url)
114
+ reject(new Error("Could not read image"))
115
+ }
116
+ image.src = url
117
+ })
118
+ }
119
+
120
+ /**
121
+ * @param {ImageBitmap | HTMLImageElement} source
122
+ * @returns {{ width: number, height: number }}
123
+ */
124
+ function sizeOf(source) {
125
+ return {
126
+ width: source.naturalWidth ?? source.width,
127
+ height: source.naturalHeight ?? source.height,
128
+ }
129
+ }
130
+
131
+ /**
132
+ * @param {HTMLCanvasElement} canvas
133
+ * @param {string} type
134
+ * @param {number} quality
135
+ * @returns {Promise<Blob>}
136
+ */
137
+ function toBlob(canvas, type, quality) {
138
+ return new Promise((resolve, reject) => {
139
+ canvas.toBlob(
140
+ (blob) =>
141
+ blob ? resolve(blob) : reject(new Error("Could not encode image")),
142
+ type,
143
+ quality,
144
+ )
145
+ })
146
+ }
@@ -0,0 +1,20 @@
1
+ import * as handlers from "./handlers.js"
2
+ import { ImageCropper as renderers } from "./template.js"
3
+
4
+ export { cropImage, decodeImage } from "./image.js"
5
+
6
+ export const ImageCropper = {
7
+ ...handlers,
8
+ ...renderers,
9
+ render(entity, api) {
10
+ const id = entity.id
11
+ const props = {
12
+ ...entity,
13
+ onImageError: () => api.notify(`#${id}:imageError`),
14
+ onImageLoad: (size) => api.notify(`#${id}:imageLoad`, size),
15
+ onRectChange: (rect) => api.notify(`#${id}:rectChange`, rect),
16
+ }
17
+
18
+ return this.renderImageCropper(props)
19
+ },
20
+ }
@@ -0,0 +1,127 @@
1
+ .iw-image-cropper {
2
+ /* Set from the image's natural size once it loads. */
3
+ --iw-image-cropper-aspect: 16 / 9;
4
+ /* Caps the stage height; the width follows the image's proportions. */
5
+ --iw-image-cropper-max-height: 70vh;
6
+
7
+ /* Neutral in every theme on purpose, like the before/after divider: the
8
+ frame must read on any photo without competing with it. */
9
+ --iw-image-cropper-bg: var(--iw-color-gray-900);
10
+ --iw-image-cropper-overlay: rgb(0 0 0 / 55%);
11
+ --iw-image-cropper-frame-width: 2px;
12
+ --iw-image-cropper-frame-color: rgb(255 255 255 / 95%);
13
+ --iw-image-cropper-handle-size: 14px;
14
+ --iw-image-cropper-handle-bg: rgb(255 255 255 / 98%);
15
+ --iw-image-cropper-handle-border: rgb(0 0 0 / 35%);
16
+
17
+ position: relative;
18
+ display: block;
19
+ width: 100%;
20
+ max-width: calc(
21
+ var(--iw-image-cropper-max-height) * var(--iw-image-cropper-aspect)
22
+ );
23
+ aspect-ratio: var(--iw-image-cropper-aspect);
24
+ margin-inline: auto;
25
+ overflow: hidden;
26
+ border-radius: var(--iw-radius-md);
27
+ background: var(--iw-image-cropper-bg);
28
+ line-height: 0;
29
+ touch-action: none;
30
+ user-select: none;
31
+ -webkit-user-select: none;
32
+ outline: none;
33
+
34
+ &:focus-visible {
35
+ outline: 2px solid var(--iw-color-primary);
36
+ outline-offset: 2px;
37
+ }
38
+
39
+ &.iw-image-cropper-disabled {
40
+ --iw-image-cropper-overlay: rgb(0 0 0 / 35%);
41
+ --iw-image-cropper-frame-color: rgb(156 163 175 / 60%);
42
+ --iw-image-cropper-handle-bg: rgb(156 163 175 / 60%);
43
+
44
+ .iw-image-cropper-frame,
45
+ .iw-image-cropper-handle {
46
+ cursor: default;
47
+ }
48
+ }
49
+
50
+ .iw-image-cropper-image {
51
+ display: block;
52
+ width: 100%;
53
+ height: 100%;
54
+ object-fit: cover;
55
+ pointer-events: none;
56
+ user-select: none;
57
+ -webkit-user-drag: none;
58
+ }
59
+
60
+ /* The events land on the stage either way; the frame and handles only take
61
+ pointer events so the cursor shows what a drag there would do. */
62
+ .iw-image-cropper-frame {
63
+ position: absolute;
64
+ box-sizing: border-box;
65
+ border: var(--iw-image-cropper-frame-width) solid
66
+ var(--iw-image-cropper-frame-color);
67
+ box-shadow: 0 0 0 9999px var(--iw-image-cropper-overlay);
68
+ cursor: move;
69
+ }
70
+
71
+ .iw-image-cropper-handle {
72
+ position: absolute;
73
+ box-sizing: border-box;
74
+ width: var(--iw-image-cropper-handle-size);
75
+ height: var(--iw-image-cropper-handle-size);
76
+ border: 1px solid var(--iw-image-cropper-handle-border);
77
+ border-radius: var(--iw-radius-sm);
78
+ background: var(--iw-image-cropper-handle-bg);
79
+ transform: translate(-50%, -50%);
80
+ }
81
+
82
+ .iw-image-cropper-handle-n,
83
+ .iw-image-cropper-handle-s {
84
+ left: 50%;
85
+ cursor: ns-resize;
86
+ }
87
+
88
+ .iw-image-cropper-handle-e,
89
+ .iw-image-cropper-handle-w {
90
+ top: 50%;
91
+ cursor: ew-resize;
92
+ }
93
+
94
+ .iw-image-cropper-handle-nw,
95
+ .iw-image-cropper-handle-se {
96
+ cursor: nwse-resize;
97
+ }
98
+
99
+ .iw-image-cropper-handle-ne,
100
+ .iw-image-cropper-handle-sw {
101
+ cursor: nesw-resize;
102
+ }
103
+
104
+ .iw-image-cropper-handle-n,
105
+ .iw-image-cropper-handle-ne,
106
+ .iw-image-cropper-handle-nw {
107
+ top: 0;
108
+ }
109
+
110
+ .iw-image-cropper-handle-s,
111
+ .iw-image-cropper-handle-se,
112
+ .iw-image-cropper-handle-sw {
113
+ top: 100%;
114
+ }
115
+
116
+ .iw-image-cropper-handle-w,
117
+ .iw-image-cropper-handle-nw,
118
+ .iw-image-cropper-handle-sw {
119
+ left: 0;
120
+ }
121
+
122
+ .iw-image-cropper-handle-e,
123
+ .iw-image-cropper-handle-ne,
124
+ .iw-image-cropper-handle-se {
125
+ left: 100%;
126
+ }
127
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @typedef {import('../../../types/controls/image-cropper.js').ImageCropperProps} ImageCropperProps
3
+ * @typedef {import('../../../types/controls/image-cropper.js').CropRect} CropRect
4
+ * @typedef {import('@inglorious/web').TemplateResult} TemplateResult
5
+ */
6
+
7
+ import { html } from "@inglorious/web"
8
+ import { classMap } from "@inglorious/web/directives/class-map"
9
+
10
+ import {
11
+ DEFAULT_MIN_SIZE,
12
+ DEFAULT_STEP,
13
+ fitRect,
14
+ FULL_RECT,
15
+ HANDLES,
16
+ hitTest,
17
+ normalizeAspect,
18
+ panRect,
19
+ PERCENT,
20
+ resizeRect,
21
+ scaleRect,
22
+ } from "./helpers.js"
23
+
24
+ const HANDLE_HIT_PX = 22
25
+ const MIN_PINCH_POINTERS = 2
26
+ const PRIMARY_BUTTON = 0
27
+ const WHEEL_SCALE_RATE = 0.0015
28
+
29
+ const ARROW_MOVES = {
30
+ ArrowLeft: [-1, 0],
31
+ ArrowRight: [1, 0],
32
+ ArrowUp: [0, -1],
33
+ ArrowDown: [0, 1],
34
+ }
35
+
36
+ // Pointers and the drag in progress belong to one element and never reach the
37
+ // store: they change on every move and are meaningless once the gesture ends.
38
+ /** @type {WeakMap<Element, { pointers: Map<number, { x: number, y: number }>, drag: object | null, pinch: object | null }>} */
39
+ const gestures = new WeakMap()
40
+
41
+ export const ImageCropper = {
42
+ /**
43
+ * Main entrypoint for the image cropper template. It delegates to the base renderer so overrides can still reuse it.
44
+ * The image cropper shows the whole image with a movable, resizable frame on top.
45
+ * @param {ImageCropperProps} props The image cropper props.
46
+ * @returns {TemplateResult} The rendered control.
47
+ */
48
+ render(props) {
49
+ return this.renderImageCropper(props)
50
+ },
51
+
52
+ /**
53
+ * Renders the stage and wires the pointer, wheel and keyboard interactions.
54
+ * This is the canonical renderer that custom `render()` overrides should call.
55
+ * @param {ImageCropperProps} props The image cropper props.
56
+ * @returns {TemplateResult} The rendered control.
57
+ */
58
+ renderImageCropper(props) {
59
+ const { label, isDisabled = false, hasError = false } = props
60
+ const isLoaded = isImageLoaded(props)
61
+
62
+ const classes = {
63
+ "iw-image-cropper": true,
64
+ "iw-image-cropper-loaded": isLoaded,
65
+ "iw-image-cropper-error": hasError,
66
+ "iw-image-cropper-disabled": isDisabled,
67
+ }
68
+ const style = isLoaded
69
+ ? `--iw-image-cropper-aspect: ${props.naturalWidth / props.naturalHeight};`
70
+ : ""
71
+
72
+ return html`<div
73
+ class=${classMap(classes)}
74
+ style=${style}
75
+ role="group"
76
+ aria-roledescription="crop area"
77
+ aria-label=${label || "Crop area"}
78
+ tabindex=${isDisabled ? -1 : 0}
79
+ aria-disabled=${isDisabled}
80
+ @pointerdown=${handlePointerDown(props)}
81
+ @pointermove=${handlePointerMove(props)}
82
+ @pointerup=${handlePointerUp}
83
+ @pointercancel=${handlePointerUp}
84
+ @keydown=${handleKeyDown(props)}
85
+ @wheel=${handleWheel(props)}
86
+ >
87
+ ${this.renderImage?.(props)} ${isLoaded ? this.renderFrame?.(props) : ""}
88
+ </div>`
89
+ },
90
+
91
+ /**
92
+ * Renders the frame over the image. Its outline shadow dims everything
93
+ * outside of it, and the stage clips the shadow.
94
+ * @param {ImageCropperProps} props The image cropper props.
95
+ * @returns {TemplateResult} The rendered frame.
96
+ */
97
+ renderFrame(props) {
98
+ const { x, y, w, h } = props.rect ?? FULL_RECT
99
+
100
+ return html`<div
101
+ class="iw-image-cropper-frame"
102
+ style="left: ${x * PERCENT}%; top: ${y * PERCENT}%; width: ${
103
+ w * PERCENT
104
+ }%; height: ${h * PERCENT}%;"
105
+ >
106
+ ${HANDLES.map((handle) => this.renderHandle?.(props, handle))}
107
+ </div>`
108
+ },
109
+
110
+ /**
111
+ * Renders the grip on one edge or corner of the frame.
112
+ * Override this to supply a custom handle, or return nothing to hide them.
113
+ * @param {ImageCropperProps} props The image cropper props.
114
+ * @param {string} handle Which grip: n, ne, e, se, s, sw, w or nw.
115
+ * @returns {TemplateResult} The rendered handle.
116
+ */
117
+ renderHandle(props, handle) {
118
+ return html`<span
119
+ class="iw-image-cropper-handle iw-image-cropper-handle-${handle}"
120
+ aria-hidden="true"
121
+ ></span>`
122
+ },
123
+
124
+ /**
125
+ * Renders the whole image, which also gives the stage its proportions.
126
+ * @param {ImageCropperProps} props The image cropper props.
127
+ * @returns {TemplateResult} The rendered image.
128
+ */
129
+ renderImage(props) {
130
+ return html`<img
131
+ class="iw-image-cropper-image"
132
+ src=${props.src ?? ""}
133
+ alt=${props.alt ?? ""}
134
+ draggable="false"
135
+ @load=${handleLoad(props)}
136
+ @error=${() => props.onImageError?.()}
137
+ />`
138
+ },
139
+ }
140
+
141
+ /**
142
+ * Distance between the first two active pointers, for pinch scaling.
143
+ * @param {Map<number, { x: number, y: number }>} pointers
144
+ * @returns {number}
145
+ */
146
+ function distanceBetween(pointers) {
147
+ const [first, second] = pointers.values()
148
+ return Math.hypot(first.x - second.x, first.y - second.y)
149
+ }
150
+
151
+ /**
152
+ * The gesture state of an element, created on first use.
153
+ * @param {Element} element
154
+ */
155
+ function gestureOf(element) {
156
+ let gesture = gestures.get(element)
157
+ if (!gesture) {
158
+ gesture = { pointers: new Map(), drag: null, pinch: null }
159
+ gestures.set(element, gesture)
160
+ }
161
+ return gesture
162
+ }
163
+
164
+ /**
165
+ * Arrows move the frame, Shift+arrows resize it from the bottom-right corner,
166
+ * +/- scale it, and Home resets it to the largest frame that fits.
167
+ * @param {ImageCropperProps} props
168
+ * @returns {(event: KeyboardEvent) => void}
169
+ */
170
+ function handleKeyDown(props) {
171
+ return (event) => {
172
+ if (props.isDisabled || !isImageLoaded(props)) return
173
+
174
+ const rect = props.rect ?? FULL_RECT
175
+ const step = props.step ?? DEFAULT_STEP
176
+ const options = rectOptions(props)
177
+
178
+ let next
179
+ if (event.key in ARROW_MOVES) {
180
+ const [dx, dy] = ARROW_MOVES[event.key].map((sign) => sign * step)
181
+ next = event.shiftKey
182
+ ? resizeRect(rect, "se", dx, dy, options)
183
+ : panRect(rect, dx, dy, options)
184
+ } else if (event.key === "+" || event.key === "=") {
185
+ next = scaleRect(rect, 1 + step, options)
186
+ } else if (event.key === "-" || event.key === "_") {
187
+ next = scaleRect(rect, 1 / (1 + step), options)
188
+ } else if (event.key === "Home") {
189
+ next = fitRect(options.aspect)
190
+ } else {
191
+ return
192
+ }
193
+
194
+ event.preventDefault()
195
+ props.onRectChange?.(next)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Report the image's natural size once it has loaded.
201
+ * @param {ImageCropperProps} props
202
+ * @returns {(event: Event) => void}
203
+ */
204
+ function handleLoad(props) {
205
+ return (event) => {
206
+ const image = /** @type {HTMLImageElement} */ (event.currentTarget)
207
+ props.onImageLoad?.({
208
+ width: image.naturalWidth,
209
+ height: image.naturalHeight,
210
+ })
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Start a gesture: a second finger turns any drag into a pinch; otherwise the
216
+ * handle under the pointer decides between moving and resizing the frame.
217
+ * @param {ImageCropperProps} props
218
+ * @returns {(event: PointerEvent) => void}
219
+ */
220
+ function handlePointerDown(props) {
221
+ return (event) => {
222
+ if (props.isDisabled || !isImageLoaded(props)) return
223
+ if (event.pointerType === "mouse" && event.button !== PRIMARY_BUTTON) return
224
+
225
+ const element = /** @type {Element} */ (event.currentTarget)
226
+ element.setPointerCapture?.(event.pointerId)
227
+
228
+ const gesture = gestureOf(element)
229
+ gesture.pointers.set(event.pointerId, {
230
+ x: event.clientX,
231
+ y: event.clientY,
232
+ })
233
+ const rect = props.rect ?? FULL_RECT
234
+
235
+ if (gesture.pointers.size >= MIN_PINCH_POINTERS) {
236
+ gesture.drag = null
237
+ gesture.pinch = { distance: distanceBetween(gesture.pointers), rect }
238
+ return
239
+ }
240
+
241
+ const point = pointFromEvent(event)
242
+ if (!point) return
243
+ const handle = hitTest(point, rect, hitTolerance(element))
244
+ gesture.drag = handle ? { handle, origin: point, rect } : null
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Follow the gesture. Every step is computed from where it started, so it does
250
+ * not drift while the store catches up.
251
+ * @param {ImageCropperProps} props
252
+ * @returns {(event: PointerEvent) => void}
253
+ */
254
+ function handlePointerMove(props) {
255
+ return (event) => {
256
+ const gesture = gestures.get(/** @type {Element} */ (event.currentTarget))
257
+ if (!gesture?.pointers.has(event.pointerId)) return
258
+
259
+ gesture.pointers.set(event.pointerId, {
260
+ x: event.clientX,
261
+ y: event.clientY,
262
+ })
263
+ const options = rectOptions(props)
264
+
265
+ if (gesture.pinch) {
266
+ if (gesture.pointers.size < MIN_PINCH_POINTERS) return
267
+ const { distance, rect } = gesture.pinch
268
+ if (!distance) return
269
+ const factor = distanceBetween(gesture.pointers) / distance
270
+ props.onRectChange?.(scaleRect(rect, factor, options))
271
+ return
272
+ }
273
+
274
+ const { drag } = gesture
275
+ const point = drag && pointFromEvent(event)
276
+ if (!point) return
277
+
278
+ const dx = point.x - drag.origin.x
279
+ const dy = point.y - drag.origin.y
280
+ props.onRectChange?.(
281
+ drag.handle === "move"
282
+ ? panRect(drag.rect, dx, dy, options)
283
+ : resizeRect(drag.rect, drag.handle, dx, dy, options),
284
+ )
285
+ }
286
+ }
287
+
288
+ /**
289
+ * End the gesture for this pointer. Lifting one finger of a pinch ends the
290
+ * pinch without starting a drag, so the frame does not jump.
291
+ * @param {PointerEvent} event
292
+ */
293
+ function handlePointerUp(event) {
294
+ const element = /** @type {Element} */ (event.currentTarget)
295
+ element.releasePointerCapture?.(event.pointerId)
296
+
297
+ const gesture = gestures.get(element)
298
+ if (!gesture) return
299
+ gesture.pointers.delete(event.pointerId)
300
+ gesture.drag = null
301
+ if (!gesture.pointers.size) gesture.pinch = null
302
+ }
303
+
304
+ /**
305
+ * The wheel scales the frame: scrolling down grows it, scrolling up shrinks it.
306
+ * @param {ImageCropperProps} props
307
+ * @returns {(event: WheelEvent) => void}
308
+ */
309
+ function handleWheel(props) {
310
+ return (event) => {
311
+ if (props.isDisabled || !isImageLoaded(props) || !event.deltaY) return
312
+
313
+ event.preventDefault()
314
+ const factor = Math.exp(event.deltaY * WHEEL_SCALE_RATE)
315
+ props.onRectChange?.(
316
+ scaleRect(props.rect ?? FULL_RECT, factor, rectOptions(props)),
317
+ )
318
+ }
319
+ }
320
+
321
+ /**
322
+ * How close to an edge, in image fractions, still counts as grabbing it.
323
+ * @param {Element} element
324
+ * @returns {{ x: number, y: number }}
325
+ */
326
+ function hitTolerance(element) {
327
+ const { width, height } = element.getBoundingClientRect()
328
+ return {
329
+ x: width ? HANDLE_HIT_PX / width : 0,
330
+ y: height ? HANDLE_HIT_PX / height : 0,
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Whether the image has reported its size, which is when the frame shows.
336
+ * @param {ImageCropperProps} props
337
+ * @returns {boolean}
338
+ */
339
+ function isImageLoaded(props) {
340
+ return props.naturalWidth > 0 && props.naturalHeight > 0
341
+ }
342
+
343
+ /**
344
+ * Convert a pointer event into image fractions, relative to the element the
345
+ * listener is attached to. Null when it has no measurable size.
346
+ * @param {PointerEvent} event
347
+ * @returns {{ x: number, y: number } | null}
348
+ */
349
+ function pointFromEvent(event) {
350
+ const bounds = /** @type {Element} */ (
351
+ event.currentTarget
352
+ ).getBoundingClientRect()
353
+ if (!bounds.width || !bounds.height) return null
354
+
355
+ return {
356
+ x: (event.clientX - bounds.left) / bounds.width,
357
+ y: (event.clientY - bounds.top) / bounds.height,
358
+ }
359
+ }
360
+
361
+ /**
362
+ * The constraints every frame change obeys.
363
+ * @param {ImageCropperProps} props
364
+ */
365
+ function rectOptions(props) {
366
+ return {
367
+ minSize: props.minSize ?? DEFAULT_MIN_SIZE,
368
+ aspect: normalizeAspect(
369
+ props.aspectRatio,
370
+ props.naturalWidth,
371
+ props.naturalHeight,
372
+ ),
373
+ }
374
+ }
@@ -152,6 +152,11 @@ function getDefaultItems() {
152
152
  primary: "Icon Button",
153
153
  href: "/controls/icon-button",
154
154
  },
155
+ {
156
+ id: "image-cropper",
157
+ primary: "Image Cropper",
158
+ href: "/controls/image-cropper",
159
+ },
155
160
  { id: "input", primary: "Input", href: "/controls/input" },
156
161
  {
157
162
  id: "file-upload",
@@ -20,6 +20,7 @@ import { Combobox } from "../../controls/combobox/index.js"
20
20
  import { Fab } from "../../controls/fab/index.js"
21
21
  import { FileUpload } from "../../controls/file-upload/index.js"
22
22
  import { IconButton } from "../../controls/icon-button/index.js"
23
+ import { ImageCropper } from "../../controls/image-cropper/index.js"
23
24
  import { Input } from "../../controls/input/index.js"
24
25
  import { RadioGroup } from "../../controls/radio-group/index.js"
25
26
  import { Rating } from "../../controls/rating/index.js"
@@ -496,6 +497,40 @@ IconButton.render({
496
497
  ]),
497
498
  },
498
499
  },
500
+ "/controls/image-cropper": {
501
+ name: "Image Cropper",
502
+ summary: "Movable, resizable crop frame over a whole image.",
503
+ description:
504
+ "Image Cropper shows the whole image with a frame the user moves, resizes, pinches and scrolls, freely or locked to an aspect ratio. The frame is kept in image fractions, and cropImage turns it into an encoded Blob in the browser.",
505
+ useCases: [
506
+ "Profile photos",
507
+ "Cover images",
508
+ "Uploads framed before saving",
509
+ ],
510
+ example: {
511
+ code: `import { cropImage, ImageCropper } from "@inglorious/ui/image-cropper"
512
+
513
+ ImageCropper.render({
514
+ id: "demo",
515
+ src: URL.createObjectURL(file),
516
+ aspectRatio: 1
517
+ })
518
+
519
+ // Later, with the entity's rect:
520
+ const blob = await cropImage(file, entity.rect, { maxWidth: 512, maxHeight: 512 })`,
521
+ preview: () =>
522
+ livePreview(
523
+ { ImageCropper },
524
+ {
525
+ id: "imageCropper",
526
+ type: "ImageCropper",
527
+ label: "Crop the photo",
528
+ src: beforeAfterImages.after.src,
529
+ aspectRatio: 1,
530
+ },
531
+ ),
532
+ },
533
+ },
499
534
  "/controls/input": {
500
535
  summary: "Text input with labeling, validation, and helper text.",
501
536
  description:
package/src/index.js CHANGED
@@ -4,6 +4,11 @@ export { Checkbox } from "./controls/checkbox/index.js"
4
4
  export { Fab } from "./controls/fab/index.js"
5
5
  export { FileUpload } from "./controls/file-upload/index.js"
6
6
  export { IconButton } from "./controls/icon-button/index.js"
7
+ export {
8
+ cropImage,
9
+ decodeImage,
10
+ ImageCropper,
11
+ } from "./controls/image-cropper/index.js"
7
12
  export { Input } from "./controls/input/index.js"
8
13
  export { RadioGroup } from "./controls/radio-group/index.js"
9
14
  export { Rating } from "./controls/rating/index.js"
@@ -0,0 +1,83 @@
1
+ import type { TemplateResult, Api } from "@inglorious/web"
2
+
3
+ /** A crop frame in fractions of the image: 0-1 on both axes. */
4
+ export interface CropRect {
5
+ x: number
6
+ y: number
7
+ w: number
8
+ h: number
9
+ }
10
+
11
+ export interface ImageCropperProps {
12
+ id?: string
13
+ type?: string
14
+ label?: string
15
+
16
+ src?: string
17
+ alt?: string
18
+
19
+ /** The crop frame in image fractions. The persistent state. */
20
+ rect?: CropRect
21
+ /** Output width/height in pixels to lock the frame to; null crops freely. */
22
+ aspectRatio?: number | null
23
+ /** Smallest frame side, as a fraction of the image. */
24
+ minSize?: number
25
+ /** Keyboard increment, as a fraction of the image. */
26
+ step?: number
27
+
28
+ /** The image's natural size, set when it loads. 0 until then. */
29
+ naturalWidth?: number
30
+ naturalHeight?: number
31
+ hasError?: boolean
32
+
33
+ isDisabled?: boolean
34
+
35
+ onImageError?: () => void
36
+ onImageLoad?: (size: { width: number; height: number }) => void
37
+ onRectChange?: (rect: CropRect) => void
38
+ [key: string]: unknown
39
+ }
40
+
41
+ export interface CropImageOptions {
42
+ /** Used when no rect is given: crop the largest centred frame at this ratio. */
43
+ aspectRatio?: number | null
44
+ maxWidth?: number
45
+ maxHeight?: number
46
+ /** Output MIME type. Defaults to "image/jpeg". */
47
+ type?: string
48
+ /** Encoder quality, 0-1. Defaults to 0.9. */
49
+ quality?: number
50
+ /** Fill behind transparent pixels for opaque formats. Defaults to "white". */
51
+ background?: string | null
52
+ }
53
+
54
+ export type ImageCropperType = {
55
+ create(entity: ImageCropperProps): void
56
+ imageError(entity: ImageCropperProps): void
57
+ imageLoad(
58
+ entity: ImageCropperProps,
59
+ size: { width: number; height: number },
60
+ ): void
61
+ rectChange(entity: ImageCropperProps, rect: CropRect): void
62
+ srcChange(
63
+ entity: ImageCropperProps,
64
+ payload: string | { src: string; rect?: CropRect },
65
+ ): void
66
+ render(entity: ImageCropperProps, api: Api): TemplateResult
67
+ renderImageCropper(props: ImageCropperProps): TemplateResult
68
+ renderImage(props: ImageCropperProps): TemplateResult
69
+ renderFrame(props: ImageCropperProps): TemplateResult
70
+ renderHandle(props: ImageCropperProps, handle: string): TemplateResult
71
+ }
72
+
73
+ export declare const ImageCropper: ImageCropperType
74
+
75
+ export declare function cropImage(
76
+ file: Blob,
77
+ rect?: CropRect | null,
78
+ options?: CropImageOptions,
79
+ ): Promise<Blob>
80
+
81
+ export declare function decodeImage(
82
+ file: Blob,
83
+ ): Promise<ImageBitmap | HTMLImageElement>
package/types/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { Checkbox } from "./controls/checkbox"
4
4
  export { Fab } from "./controls/fab"
5
5
  export { FileUpload } from "./controls/file-upload"
6
6
  export { IconButton } from "./controls/icon-button"
7
+ export { cropImage, decodeImage, ImageCropper } from "./controls/image-cropper"
7
8
  export { Input } from "./controls/input"
8
9
  export { RadioGroup } from "./controls/radio-group"
9
10
  export { Rating } from "./controls/rating"
@@ -58,6 +59,12 @@ export type {
58
59
  } from "./controls/combobox"
59
60
  export type { FabProps, FabType } from "./controls/fab"
60
61
  export type { IconButtonProps, IconButtonType } from "./controls/icon-button"
62
+ export type {
63
+ CropImageOptions,
64
+ CropRect,
65
+ ImageCropperProps,
66
+ ImageCropperType,
67
+ } from "./controls/image-cropper"
61
68
  export type { InputProps, InputType } from "./controls/input"
62
69
  export type {
63
70
  RadioGroupProps,