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-fill select-none"
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
- <div class="size-2 rounded-full bg-white shadow-md"></div>
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 { width, height } = crop;
48
- if (width > maxWidth || height > maxHeight) {
49
- const ratio = Math.min(maxWidth / width, maxHeight / height);
50
- width = Math.round(width * ratio);
51
- height = Math.round(height * ratio);
52
- }
53
- const canvas = document.createElement('canvas');
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 { width, height } = img;
65
- if (width > maxWidth || height > maxHeight) {
66
- const ratio = Math.min(maxWidth / width, maxHeight / height);
67
- width = Math.round(width * ratio);
68
- height = Math.round(height * ratio);
69
- }
70
- const canvas = document.createElement('canvas');
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compote-ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",