Skip to content

Commit ee1f48e

Browse files
committed
feat(cropper): Cropper with image preview
1 parent 0f4e176 commit ee1f48e

3 files changed

Lines changed: 179 additions & 1 deletion

File tree

apps/origin-ui/src/app/config/components.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ export const categories: ComponentCategory[] = [
9595
name: 'Cropper',
9696
components: [
9797
{ name: 'cropper-01' },
98-
{ name: 'cropper-02' }]
98+
{ name: 'cropper-02' },
99+
{ name: 'cropper-10' }]
99100
},
100101
{
101102
slug: 'dialog',

apps/origin-ui/src/registry.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3313,6 +3313,23 @@
33133313
"tags": ["image", "crop", "zoom"],
33143314
"colSpan": 2
33153315
}
3316+
},
3317+
{
3318+
"name": "cropper-10",
3319+
"type": "registry:component",
3320+
"registryDependencies": [
3321+
"https://originui.com/r/cropper.json"
3322+
],
3323+
"files": [
3324+
{
3325+
"path": "registry/default/components/croppers/cropper-10.ts",
3326+
"type": "registry:component"
3327+
}
3328+
],
3329+
"meta": {
3330+
"tags": ["image", "crop", "zoom"],
3331+
"colSpan": 2
3332+
}
33163333
}
33173334
]
33183335
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Component, effect, signal } from '@angular/core';
2+
import { Area } from '@radix-ng/primitives/cropper';
3+
import { OriButton } from '~/registry/default/ui/button';
4+
import { OriCropper, OriCropperArea, OriCropperDescription, OriCropperImage } from '~/registry/default/ui/cropper';
5+
6+
// --- Start: Copied Helper Functions ---
7+
const createImage = (url: string): Promise<HTMLImageElement> =>
8+
new Promise((resolve, reject) => {
9+
const image = new Image();
10+
image.addEventListener('load', () => resolve(image));
11+
image.addEventListener('error', (error) => reject(error));
12+
image.setAttribute('crossOrigin', 'anonymous'); // Needed for canvas Tainted check
13+
image.src = url;
14+
});
15+
16+
async function getCroppedImg(
17+
imageSrc: string,
18+
pixelCrop: Area,
19+
outputWidth: number = pixelCrop.width, // Optional: specify output size
20+
outputHeight: number = pixelCrop.height
21+
): Promise<Blob | null> {
22+
try {
23+
const image = await createImage(imageSrc);
24+
const canvas = document.createElement('canvas');
25+
const ctx = canvas.getContext('2d');
26+
27+
if (!ctx) {
28+
return null;
29+
}
30+
31+
// Set canvas size to desired output size
32+
canvas.width = outputWidth;
33+
canvas.height = outputHeight;
34+
35+
// Draw the cropped image onto the canvas
36+
ctx.drawImage(
37+
image,
38+
pixelCrop.x,
39+
pixelCrop.y,
40+
pixelCrop.width,
41+
pixelCrop.height,
42+
0,
43+
0,
44+
outputWidth, // Draw onto the output size
45+
outputHeight
46+
);
47+
48+
// Convert canvas to blob
49+
return new Promise((resolve) => {
50+
canvas.toBlob((blob) => {
51+
resolve(blob);
52+
}, 'image/jpeg'); // Specify format and quality if needed
53+
});
54+
} catch (error) {
55+
console.error('Error in getCroppedImg:', error);
56+
return null;
57+
}
58+
}
59+
// --- End: Copied Helper Functions ---
60+
61+
@Component({
62+
selector: 'demo-cropper-10',
63+
imports: [
64+
OriCropper,
65+
OriCropperImage,
66+
OriCropperArea,
67+
OriCropperDescription,
68+
OriButton
69+
],
70+
template: `
71+
<div class="flex flex-col items-center gap-2">
72+
<div class="flex w-full flex-col gap-4 md:flex-row">
73+
<ori-cropper
74+
class="h-64 md:flex-1"
75+
[image]="ORIGINAL_IMAGE_URL"
76+
(onCropChange)="handleCropChange($event)"
77+
>
78+
<ori-cropper-description />
79+
<ori-cropper-image />
80+
<ori-cropper-area />
81+
</ori-cropper>
82+
<div class="flex w-26 flex-col gap-4">
83+
<button [disabled]="!croppedAreaPixels()" (click)="handleCrop()" oriButton>Crop preview</button>
84+
<div class="aspect-square w-full shrink-0 overflow-hidden rounded-lg border">
85+
@if (croppedImageUrl()) {
86+
<img class="h-full w-full object-cover" [src]="croppedImageUrl()" alt="Cropped result" />
87+
} @else {
88+
<div
89+
class="bg-muted text-muted-foreground/80 flex h-full w-full items-center justify-center p-2 text-center text-xs"
90+
>
91+
Image preview
92+
</div>
93+
}
94+
</div>
95+
</div>
96+
</div>
97+
<p class="text-muted-foreground mt-2 text-xs" aria-live="polite" role="region">
98+
Cropper with image preview ∙{{ ' ' }}
99+
<a
100+
class="hover:text-foreground underline"
101+
href="https://sb-primitives.radix-ng.com/?path=/docs/primitives-cropper--docs"
102+
target="_blank"
103+
>
104+
API
105+
</a>
106+
</p>
107+
</div>
108+
`
109+
})
110+
export default class Cropper10 {
111+
readonly ORIGINAL_IMAGE_URL = 'https://res.cloudinary.com/dlzlfasou/image/upload/v1746526187/cropper-10_k24zxk.jpg';
112+
113+
readonly croppedAreaPixels = signal<Area | null>(null);
114+
readonly croppedImageUrl = signal<string | null>(null);
115+
116+
private cleanupEffect = effect(() => {
117+
const currentUrl = this.croppedImageUrl();
118+
return () => {
119+
if (currentUrl && currentUrl.startsWith('blob:')) {
120+
URL.revokeObjectURL(currentUrl);
121+
console.log('Revoked URL:', currentUrl);
122+
}
123+
};
124+
});
125+
handleCropChange(pixels: Area | null) {
126+
this.croppedAreaPixels.set(pixels);
127+
}
128+
129+
async handleCrop() {
130+
if (!this.croppedAreaPixels()) {
131+
console.error('No crop area selected.');
132+
return;
133+
}
134+
135+
try {
136+
const croppedBlob = await getCroppedImg(this.ORIGINAL_IMAGE_URL, this.croppedAreaPixels()!);
137+
if (!croppedBlob) {
138+
throw new Error('Failed to generate cropped image blob.');
139+
}
140+
141+
// Create a new object URL
142+
const newCroppedUrl = URL.createObjectURL(croppedBlob);
143+
144+
// Revoke the old URL if it exists
145+
if (this.croppedImageUrl()) {
146+
URL.revokeObjectURL(this.croppedImageUrl()!);
147+
}
148+
149+
// Set the new URL
150+
this.croppedImageUrl.set(newCroppedUrl);
151+
} catch (error) {
152+
console.error('Error during cropping:', error);
153+
// Optionally: Clear the old image URL on error
154+
if (this.croppedImageUrl()) {
155+
URL.revokeObjectURL(this.croppedImageUrl()!);
156+
}
157+
this.croppedImageUrl.set(null);
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)