compote-ui 0.18.0 → 0.19.1
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.
|
@@ -74,22 +74,23 @@
|
|
|
74
74
|
|
|
75
75
|
<ImageCropper.RootProvider value={imageCropper}>
|
|
76
76
|
<ImageCropper.Viewport
|
|
77
|
-
class="relative overflow-hidden rounded-lg bg-surface-2"
|
|
77
|
+
class="relative overflow-hidden rounded-lg bg-surface-2 max-h-[60vh]"
|
|
78
78
|
style={imageAspectRatio ? `aspect-ratio: ${imageAspectRatio}` : 'aspect-ratio: 1'}
|
|
79
79
|
>
|
|
80
80
|
<ImageCropper.Image
|
|
81
81
|
{src}
|
|
82
82
|
{alt}
|
|
83
83
|
crossorigin="anonymous"
|
|
84
|
-
class="pointer-events-none absolute inset-0 h-full w-full object-
|
|
84
|
+
class="pointer-events-none absolute inset-0 h-full w-full object-contain select-none"
|
|
85
85
|
/>
|
|
86
86
|
<ImageCropper.Selection
|
|
87
87
|
class="cursor-move border-2 border-white/50 [box-shadow:0_0_0_9999px_rgb(0_0_0/0.5)] outline-none focus-visible:border-primary data-dragging:cursor-grabbing data-dragging:border-white/80"
|
|
88
88
|
>
|
|
89
89
|
{#each ImageCropper.handles as position (position)}
|
|
90
|
+
{@const isCorner = ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(position)}
|
|
90
91
|
<ImageCropper.Handle
|
|
91
92
|
{position}
|
|
92
|
-
class="absolute flex touch-none items-center justify-center
|
|
93
|
+
class="group absolute flex size-5 touch-none items-center justify-center
|
|
93
94
|
data-[position=bottom]:cursor-ns-resize
|
|
94
95
|
data-[position=bottom-left]:cursor-nesw-resize
|
|
95
96
|
data-[position=bottom-right]:cursor-nwse-resize
|
|
@@ -99,7 +100,20 @@
|
|
|
99
100
|
data-[position=top-left]:cursor-nwse-resize
|
|
100
101
|
data-[position=top-right]:cursor-nesw-resize"
|
|
101
102
|
>
|
|
102
|
-
|
|
103
|
+
{#if isCorner}
|
|
104
|
+
<div
|
|
105
|
+
class="size-2 shadow-sm transition-transform group-hover:scale-110
|
|
106
|
+
group-data-[position=top-left]:border-l-2 group-data-[position=top-left]:border-t-2
|
|
107
|
+
group-data-[position=top-right]:border-r-2 group-data-[position=top-right]:border-t-2
|
|
108
|
+
group-data-[position=bottom-right]:border-r-2 group-data-[position=bottom-right]:border-b-2
|
|
109
|
+
group-data-[position=bottom-left]:border-l-2 group-data-[position=bottom-left]:border-b-2
|
|
110
|
+
border-white"
|
|
111
|
+
></div>
|
|
112
|
+
{:else}
|
|
113
|
+
<div
|
|
114
|
+
class="size-1.5 rounded-full bg-white opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
|
|
115
|
+
></div>
|
|
116
|
+
{/if}
|
|
103
117
|
</ImageCropper.Handle>
|
|
104
118
|
{/each}
|
|
105
119
|
<ImageCropper.Grid
|
|
@@ -7,6 +7,10 @@ export interface ProcessImageOptions {
|
|
|
7
7
|
maxHeight?: number;
|
|
8
8
|
quality?: number;
|
|
9
9
|
format?: 'image/webp' | 'image/jpeg' | 'image/png';
|
|
10
|
+
/** Trim near-white/near-transparent edges before resizing (default: false) */
|
|
11
|
+
trim?: boolean;
|
|
12
|
+
/** 0–255 tolerance for what counts as "white" (default: 10) */
|
|
13
|
+
trimThreshold?: number;
|
|
10
14
|
}
|
|
11
15
|
export interface CropRegion {
|
|
12
16
|
x: number;
|
|
@@ -6,7 +6,9 @@ const defaults = {
|
|
|
6
6
|
maxWidth: 1000,
|
|
7
7
|
maxHeight: 1000,
|
|
8
8
|
quality: 0.85,
|
|
9
|
-
format: 'image/webp'
|
|
9
|
+
format: 'image/webp',
|
|
10
|
+
trim: false,
|
|
11
|
+
trimThreshold: 10
|
|
10
12
|
};
|
|
11
13
|
function canvasToBlob(canvas, format, quality) {
|
|
12
14
|
return new Promise((resolve, reject) => {
|
|
@@ -18,6 +20,80 @@ function canvasToBlob(canvas, format, quality) {
|
|
|
18
20
|
}, format, quality);
|
|
19
21
|
});
|
|
20
22
|
}
|
|
23
|
+
/** Returns the bounding box of non-white/non-transparent pixels */
|
|
24
|
+
function getTrimBounds(ctx, width, height, threshold) {
|
|
25
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
26
|
+
function isBackground(i) {
|
|
27
|
+
const a = data[i + 3];
|
|
28
|
+
if (a < threshold)
|
|
29
|
+
return true; // transparent
|
|
30
|
+
const r = data[i];
|
|
31
|
+
const g = data[i + 1];
|
|
32
|
+
const b = data[i + 2];
|
|
33
|
+
return r >= 255 - threshold && g >= 255 - threshold && b >= 255 - threshold;
|
|
34
|
+
}
|
|
35
|
+
let top = 0;
|
|
36
|
+
outer: for (let y = 0; y < height; y++) {
|
|
37
|
+
for (let x = 0; x < width; x++) {
|
|
38
|
+
if (!isBackground((y * width + x) * 4)) {
|
|
39
|
+
top = y;
|
|
40
|
+
break outer;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let bottom = height - 1;
|
|
45
|
+
outer: for (let y = height - 1; y >= 0; y--) {
|
|
46
|
+
for (let x = 0; x < width; x++) {
|
|
47
|
+
if (!isBackground((y * width + x) * 4)) {
|
|
48
|
+
bottom = y;
|
|
49
|
+
break outer;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
let left = 0;
|
|
54
|
+
outer: for (let x = 0; x < width; x++) {
|
|
55
|
+
for (let y = top; y <= bottom; y++) {
|
|
56
|
+
if (!isBackground((y * width + x) * 4)) {
|
|
57
|
+
left = x;
|
|
58
|
+
break outer;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let right = width - 1;
|
|
63
|
+
outer: for (let x = width - 1; x >= 0; x--) {
|
|
64
|
+
for (let y = top; y <= bottom; y++) {
|
|
65
|
+
if (!isBackground((y * width + x) * 4)) {
|
|
66
|
+
right = x;
|
|
67
|
+
break outer;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
|
72
|
+
}
|
|
73
|
+
function applyTrim(sourceCanvas, threshold) {
|
|
74
|
+
const ctx = sourceCanvas.getContext('2d');
|
|
75
|
+
const { x, y, width, height } = getTrimBounds(ctx, sourceCanvas.width, sourceCanvas.height, threshold);
|
|
76
|
+
const trimmed = document.createElement('canvas');
|
|
77
|
+
trimmed.width = width;
|
|
78
|
+
trimmed.height = height;
|
|
79
|
+
trimmed.getContext('2d').drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height);
|
|
80
|
+
return trimmed;
|
|
81
|
+
}
|
|
82
|
+
function scaleCanvas(src, maxWidth, maxHeight) {
|
|
83
|
+
let { width, height } = src;
|
|
84
|
+
if (width > maxWidth || height > maxHeight) {
|
|
85
|
+
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
|
86
|
+
width = Math.round(width * ratio);
|
|
87
|
+
height = Math.round(height * ratio);
|
|
88
|
+
}
|
|
89
|
+
if (width === src.width && height === src.height)
|
|
90
|
+
return src;
|
|
91
|
+
const scaled = document.createElement('canvas');
|
|
92
|
+
scaled.width = width;
|
|
93
|
+
scaled.height = height;
|
|
94
|
+
scaled.getContext('2d').drawImage(src, 0, 0, width, height);
|
|
95
|
+
return scaled;
|
|
96
|
+
}
|
|
21
97
|
/** Load an image element from a src URL (data URL, blob URL, or regular URL) */
|
|
22
98
|
export function loadImage(src) {
|
|
23
99
|
return new Promise((resolve, reject) => {
|
|
@@ -42,35 +118,27 @@ export function fileToDataUrl(file) {
|
|
|
42
118
|
* Use this instead of getCroppedImage() from Ark UI which outputs at CSS/display resolution.
|
|
43
119
|
*/
|
|
44
120
|
export async function cropImage(src, crop, opts) {
|
|
45
|
-
const { maxWidth, maxHeight, quality, format } = { ...defaults, ...opts };
|
|
121
|
+
const { maxWidth, maxHeight, quality, format, trim, trimThreshold } = { ...defaults, ...opts };
|
|
46
122
|
const img = await loadImage(src);
|
|
47
|
-
let
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
canvas.width = width;
|
|
55
|
-
canvas.height = height;
|
|
56
|
-
const ctx = canvas.getContext('2d');
|
|
57
|
-
ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, width, height);
|
|
123
|
+
let canvas = document.createElement('canvas');
|
|
124
|
+
canvas.width = crop.width;
|
|
125
|
+
canvas.height = crop.height;
|
|
126
|
+
canvas.getContext('2d').drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
|
|
127
|
+
if (trim)
|
|
128
|
+
canvas = applyTrim(canvas, trimThreshold);
|
|
129
|
+
canvas = scaleCanvas(canvas, maxWidth, maxHeight);
|
|
58
130
|
return canvasToBlob(canvas, format, quality);
|
|
59
131
|
}
|
|
60
132
|
/** Resize and convert an image without cropping, returns a Blob */
|
|
61
133
|
export async function processImage(src, opts) {
|
|
62
|
-
const { maxWidth, maxHeight, quality, format } = { ...defaults, ...opts };
|
|
134
|
+
const { maxWidth, maxHeight, quality, format, trim, trimThreshold } = { ...defaults, ...opts };
|
|
63
135
|
const img = await loadImage(src);
|
|
64
|
-
let
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
canvas.width = width;
|
|
72
|
-
canvas.height = height;
|
|
73
|
-
const ctx = canvas.getContext('2d');
|
|
74
|
-
ctx.drawImage(img, 0, 0, width, height);
|
|
136
|
+
let canvas = document.createElement('canvas');
|
|
137
|
+
canvas.width = img.naturalWidth;
|
|
138
|
+
canvas.height = img.naturalHeight;
|
|
139
|
+
canvas.getContext('2d').drawImage(img, 0, 0);
|
|
140
|
+
if (trim)
|
|
141
|
+
canvas = applyTrim(canvas, trimThreshold);
|
|
142
|
+
canvas = scaleCanvas(canvas, maxWidth, maxHeight);
|
|
75
143
|
return canvasToBlob(canvas, format, quality);
|
|
76
144
|
}
|