Skip to content

Commit 9aa0019

Browse files
committed
feat: implement interactive crop selection with circle and rectangle support
- Enhance the `Crop` component with interactive drag-and-drop selection. - Introduce support for both rectangular and circular cropping shapes. - Update the `BasicImageCropExample` to demonstrate cropping with dynamic shape selection. - Add corresponding styles, handlers, and logic for improved customization and usability.
1 parent 42046cd commit 9aa0019

File tree

7 files changed

+265
-11
lines changed

7 files changed

+265
-11
lines changed
Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
1-
<p>crop works!</p>
1+
<ng-content></ng-content>
2+
3+
<div
4+
class="crop-selection-area"
5+
[class.is-circle]="isCircle()"
6+
[style]="selectionStyle()">
7+
<div class="crop-handle move-handle" (mousedown)="onDragStart($event, 'move')"></div>
8+
<div class="crop-handle top-left" (mousedown)="onDragStart($event, 'top-left')"></div>
9+
<div class="crop-handle top-right" (mousedown)="onDragStart($event, 'top-right')"></div>
10+
<div class="crop-handle bottom-left" (mousedown)="onDragStart($event, 'bottom-left')"></div>
11+
<div class="crop-handle bottom-right" (mousedown)="onDragStart($event, 'bottom-right')"></div>
12+
13+
<div class="crop-handle top" [class.hidden]="isCircle()" (mousedown)="onDragStart($event, 'top')"></div>
14+
<div class="crop-handle right" [class.hidden]="isCircle()" (mousedown)="onDragStart($event, 'right')"></div>
15+
<div class="crop-handle bottom" [class.hidden]="isCircle()" (mousedown)="onDragStart($event, 'bottom')"></div>
16+
<div class="crop-handle left" [class.hidden]="isCircle()" (mousedown)="onDragStart($event, 'left')"></div>
17+
</div>
18+
19+
<div class="controls-footer">
20+
<button (click)="applySelection()">Apply</button>
21+
</div>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
:host {
2+
display: inline-block; /* Important: Allows the host to size to its content */
3+
position: relative;
4+
user-select: none;
5+
}
6+
7+
/* The projected content dictates the size */
8+
::ng-deep > *:first-child {
9+
display: block; /* Ensure images and other elements behave predictably */
10+
max-width: 100%;
11+
height: auto;
12+
}
13+
14+
.crop-selection-area {
15+
position: absolute;
16+
top: 0;
17+
left: 0;
18+
right: 0;
19+
bottom: 0;
20+
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.5);
21+
border: 1px solid rgba(255, 255, 255, 0.7);
22+
23+
&.is-circle {
24+
border-radius: 50%;
25+
}
26+
}
27+
28+
.crop-handle {
29+
position: absolute;
30+
width: 10px;
31+
height: 10px;
32+
background: rgba(255, 255, 255, 0.9);
33+
border: 1px solid rgba(0, 0, 0, 0.5);
34+
border-radius: 50%;
35+
36+
&.hidden {
37+
display: none;
38+
}
39+
}
40+
41+
.move-handle {
42+
position: absolute;
43+
inset: 0;
44+
width: auto;
45+
height: auto;
46+
background: transparent;
47+
border: none;
48+
border-radius: 0;
49+
cursor: move;
50+
51+
.is-circle & {
52+
border-radius: 50%;
53+
}
54+
}
55+
56+
.top-left { top: -5px; left: -5px; cursor: nwse-resize; }
57+
.top-right { top: -5px; right: -5px; cursor: nesw-resize; }
58+
.bottom-left { bottom: -5px; left: -5px; cursor: nesw-resize; }
59+
.bottom-right { bottom: -5px; right: -5px; cursor: nwse-resize; }
60+
61+
.top { top: -5px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
62+
.right { top: 50%; right: -5px; transform: translateY(-50%); cursor: ew-resize; }
63+
.bottom { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
64+
.left { top: 50%; left: -5px; transform: translateY(-50%); cursor: ew-resize; }
65+
66+
.controls-footer {
67+
position: absolute;
68+
bottom: -20px;
69+
left: 50%;
70+
transform: translate(-50%, 100%);
71+
padding: 10px;
72+
background: #f0f0f0;
73+
border-radius: 4px;
74+
z-index: 1; /* Ensure it's above the shadow */
75+
}
Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,166 @@
1-
import { Component } from '@angular/core';
1+
import {
2+
AfterViewInit,
3+
ChangeDetectionStrategy,
4+
Component,
5+
computed,
6+
effect,
7+
ElementRef,
8+
inject,
9+
input,
10+
output,
11+
signal,
12+
} from '@angular/core';
13+
14+
export interface CropSelection {
15+
top: number;
16+
right: number;
17+
bottom: number;
18+
left: number;
19+
}
20+
21+
type DragHandle =
22+
| 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
23+
| 'top' | 'right' | 'bottom' | 'left'
24+
| 'move';
225

326
@Component({
427
selector: 'emr-crop',
5-
imports: [],
628
templateUrl: './crop.html',
7-
styleUrl: './crop.scss'
29+
styleUrl: './crop.scss',
30+
changeDetection: ChangeDetectionStrategy.OnPush,
31+
host: {
32+
'(document:mouseup)': 'onDragEnd()',
33+
'(document:mousemove)': 'onDrag($event)',
34+
},
835
})
9-
export class Crop {
36+
export class Crop implements AfterViewInit {
37+
minWidth = input(20);
38+
minHeight = input(20);
39+
shape = input<'rectangle' | 'circle'>('rectangle');
40+
selectionApplied = output<CropSelection>();
41+
42+
isCircle = computed(() => this.shape() === 'circle');
43+
selection = signal<CropSelection>({ top: 20, right: 20, bottom: 20, left: 20 });
44+
45+
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
46+
private activeDragHandle = signal<DragHandle | null>(null);
47+
private startDragPosition = signal<{ x: number; y: number } | null>(null);
48+
private startDragSelection = signal<CropSelection | null>(null);
49+
50+
constructor() {
51+
effect(() => {
52+
// Handles dynamic changes of the `shape` input after initialization.
53+
if (this.isCircle() && this.elementRef.nativeElement.offsetWidth > 0) {
54+
this.resetSelectionToSquare();
55+
}
56+
});
57+
}
58+
59+
ngAfterViewInit(): void {
60+
// Handles the initial case when the component loads with shape="circle".
61+
if (this.isCircle()) {
62+
this.resetSelectionToSquare();
63+
}
64+
}
65+
66+
private resetSelectionToSquare(): void {
67+
const hostEl = this.elementRef.nativeElement;
68+
const hostWidth = hostEl.offsetWidth;
69+
const hostHeight = hostEl.offsetHeight;
70+
71+
// Center a square that is 80% of the smallest dimension.
72+
const size = Math.min(hostWidth, hostHeight) * 0.8;
73+
const left = (hostWidth - size) / 2;
74+
const top = (hostHeight - size) / 2;
75+
76+
this.selection.set({
77+
top: top,
78+
right: left,
79+
bottom: top,
80+
left: left,
81+
});
82+
}
83+
84+
selectionStyle = computed(() => ({
85+
top: `${this.selection().top}px`,
86+
right: `${this.selection().right}px`,
87+
bottom: `${this.selection().bottom}px`,
88+
left: `${this.selection().left}px`,
89+
}));
90+
91+
onDragStart(event: MouseEvent, handle: DragHandle): void {
92+
event.preventDefault();
93+
event.stopPropagation();
94+
this.activeDragHandle.set(handle);
95+
this.startDragPosition.set({ x: event.clientX, y: event.clientY });
96+
this.startDragSelection.set(this.selection());
97+
}
98+
99+
onDragEnd(): void {
100+
this.activeDragHandle.set(null);
101+
}
102+
103+
onDrag(event: MouseEvent): void {
104+
const handle = this.activeDragHandle();
105+
if (!handle) return;
106+
107+
const startPos = this.startDragPosition()!;
108+
const startSelection = this.startDragSelection()!;
109+
const dx = event.clientX - startPos.x;
110+
const dy = event.clientY - startPos.y;
111+
const hostRect = this.elementRef.nativeElement.getBoundingClientRect();
112+
113+
this.selection.update(() => {
114+
const newSelection = { ...startSelection };
115+
116+
if (handle === 'move') {
117+
const selectionWidth = hostRect.width - startSelection.left - startSelection.right;
118+
const selectionHeight = hostRect.height - startSelection.top - startSelection.bottom;
119+
newSelection.top = Math.max(0, Math.min(startSelection.top + dy, hostRect.height - selectionHeight));
120+
newSelection.left = Math.max(0, Math.min(startSelection.left + dx, hostRect.width - selectionWidth));
121+
newSelection.bottom = hostRect.height - newSelection.top - selectionHeight;
122+
newSelection.right = hostRect.width - newSelection.left - selectionWidth;
123+
return newSelection;
124+
}
125+
126+
let currentDx = dx;
127+
let currentDy = dy;
128+
129+
if (this.isCircle() && handle.includes('-')) {
130+
if (handle === 'top-left' || handle === 'bottom-right') {
131+
currentDy = currentDx;
132+
} else {
133+
currentDy = -currentDx;
134+
}
135+
}
136+
137+
if (handle.includes('top')) newSelection.top = startSelection.top + currentDy;
138+
if (handle.includes('bottom')) newSelection.bottom = startSelection.bottom - currentDy;
139+
if (handle.includes('left')) newSelection.left = startSelection.left + currentDx;
140+
if (handle.includes('right')) newSelection.right = startSelection.right - currentDx;
141+
142+
const minW = this.minWidth();
143+
const minH = this.isCircle() ? minW : this.minHeight();
144+
145+
if (hostRect.width - newSelection.left - newSelection.right < minW) {
146+
if (handle.includes('left')) newSelection.left = hostRect.width - newSelection.right - minW;
147+
else newSelection.right = hostRect.width - newSelection.left - minW;
148+
}
149+
if (hostRect.height - newSelection.top - newSelection.bottom < minH) {
150+
if (handle.includes('top')) newSelection.top = hostRect.height - newSelection.bottom - minH;
151+
else newSelection.bottom = hostRect.height - newSelection.top - minH;
152+
}
153+
154+
newSelection.top = Math.max(0, newSelection.top);
155+
newSelection.bottom = Math.max(0, newSelection.bottom);
156+
newSelection.left = Math.max(0, newSelection.left);
157+
newSelection.right = Math.max(0, newSelection.right);
158+
159+
return newSelection;
160+
});
161+
}
10162

163+
applySelection(): void {
164+
this.selectionApplied.emit(this.selection());
165+
}
11166
}
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
<p>basic-image-crop-example works!</p>

src/app/components/crop/_examples/basic-image-crop-example/basic-image-crop-example.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { Component } from '@angular/core';
2+
import { Crop } from '@elementar-ui/components/crop';
23

34
@Component({
45
selector: 'app-basic-image-crop-example',
5-
imports: [],
6+
imports: [
7+
Crop
8+
],
69
templateUrl: './basic-image-crop-example.html',
710
styleUrl: './basic-image-crop-example.scss'
811
})

src/app/components/crop/overview/overview.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
<h1 appPageTitle>Crop</h1>
33
<div appPageContent>
44
<h3 class="mat-title-large">Basic image crop</h3>
5-
<emr-playground exampleUrl="_docs/components/crop/_examples" exampleName="basic-image-crop-example" compact>
6-
<app-basic-image-crop-example/>
7-
</emr-playground>
5+
<emr-crop class="w-[400px] h-[500px]" shape="circle">
6+
<img src="https://images.pexels.com/photos/34492076/pexels-photo-34492076.jpeg" class="w-[400px] h-[500px]" alt="">
7+
</emr-crop>
88
</div>
99
</app-page>

src/app/components/crop/overview/overview.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import { PageContentDirective } from '@meta/page/page-content.directive';
44
import { PageTitleDirective } from '@meta/page/page-title.directive';
55
import { PlaygroundComponent } from '@meta/playground/playground.component';
66
import { BasicImageCropExample } from '../_examples/basic-image-crop-example/basic-image-crop-example';
7+
import { Crop } from '@elementar-ui/components/crop';
78

89
@Component({
910
imports: [
1011
PageComponent,
1112
PageContentDirective,
1213
PageTitleDirective,
1314
PlaygroundComponent,
14-
BasicImageCropExample
15+
BasicImageCropExample,
16+
Crop
1517
],
1618
templateUrl: './overview.html',
1719
styleUrl: './overview.scss'

0 commit comments

Comments
 (0)