@inglorious/ui 8.2.4 → 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 +11 -6
- package/src/controls/controls.css +1 -0
- package/src/controls/image-cropper/handlers.js +115 -0
- package/src/controls/image-cropper/helpers.js +196 -0
- package/src/controls/image-cropper/image-cropper.stories.js +85 -0
- package/src/controls/image-cropper/image.js +146 -0
- package/src/controls/image-cropper/index.js +20 -0
- package/src/controls/image-cropper/style.css +127 -0
- package/src/controls/image-cropper/template.js +374 -0
- package/src/examples/dashboard/app-drawer.js +5 -0
- package/src/examples/dashboard/primitive-content.js +35 -0
- package/src/index.js +5 -0
- package/types/controls/image-cropper.d.ts +83 -0
- package/types/index.d.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/ui",
|
|
3
|
-
"version": "8.
|
|
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"
|
|
@@ -301,21 +306,21 @@
|
|
|
301
306
|
"**/*.css"
|
|
302
307
|
],
|
|
303
308
|
"peerDependencies": {
|
|
304
|
-
"@inglorious/store": "11.
|
|
309
|
+
"@inglorious/store": "11.2.0",
|
|
305
310
|
"@inglorious/utils": "5.0.0",
|
|
306
|
-
"@inglorious/web": "6.7.
|
|
311
|
+
"@inglorious/web": "6.7.1"
|
|
307
312
|
},
|
|
308
313
|
"devDependencies": {
|
|
309
314
|
"@iconify-icons/fa7-brands": "^2.0.0",
|
|
310
315
|
"@iconify-icons/fa7-regular": "^2.0.0",
|
|
311
316
|
"@iconify-icons/fa7-solid": "^2.0.0",
|
|
312
317
|
"@iconify-icons/material-symbols": "^2.0.6",
|
|
313
|
-
"@inglorious/charts": "11.1.
|
|
318
|
+
"@inglorious/charts": "11.1.3",
|
|
314
319
|
"@inglorious/eslint-config": "1.1.4",
|
|
315
|
-
"@inglorious/store": "11.
|
|
320
|
+
"@inglorious/store": "11.2.0",
|
|
316
321
|
"@inglorious/vite-plugin-jsx": "2.0.4",
|
|
317
322
|
"@inglorious/vite-plugin-vue": "1.0.5",
|
|
318
|
-
"@inglorious/web": "6.7.
|
|
323
|
+
"@inglorious/web": "6.7.1",
|
|
319
324
|
"@storybook/addon-docs": "^10.6.0",
|
|
320
325
|
"@storybook/web-components-vite": "^10.6.0",
|
|
321
326
|
"happy-dom": "^20.14.0",
|
|
@@ -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,
|