Skip to content

Commit fd39731

Browse files
committed
feat(module-editor): add local panel crop workflow
1 parent d2bdd32 commit fd39731

12 files changed

Lines changed: 761 additions & 16 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
"luxon-angular": "^6.0.0",
134134
"modern-screenshot": "^4.6.8",
135135
"ngx-dropzone": "3.1.0",
136+
"ngx-image-cropper": "9.1.6",
136137
"ngx-lottie": "^11.0.2",
137138
"ngx-pipes": "^3.0.0",
138139
"ngx-timeago": "^3.0.0",

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/components/module-parts/get-module-height-for-standard.pipe.spec.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Standard } from '../../models/standard';
2-
import { GetModuleHeightForStandardPipe } from './get-module-height-for-standard.pipe';
2+
import {
3+
getModulePanelAspectRatio,
4+
GetModuleHeightForStandardPipe
5+
} from './get-module-height-for-standard.pipe';
36

47

58
describe('GetModuleHeightForStandardPipe', () => {
@@ -28,4 +31,18 @@ describe('GetModuleHeightForStandardPipe', () => {
2831
it('returns 7.6 rem for any standard id other than 0 or 1000', () => {
2932
expect(pipe.transform({id: 99, name: 'Other'} as Standard)).toBe(7.6);
3033
});
31-
});
34+
35+
it('derives the same aspect ratio used by realistic module rendering for 3U modules', () => {
36+
expect(getModulePanelAspectRatio({
37+
hp: 10,
38+
standard: {id: 0, name: '3U'}
39+
} as any)).toBeCloseTo(10 / 25.4, 6);
40+
});
41+
42+
it('derives the same aspect ratio used by realistic module rendering for 1U modules', () => {
43+
expect(getModulePanelAspectRatio({
44+
hp: 20,
45+
standard: {id: 2, name: 'Pulp Logic 1U'}
46+
} as any)).toBeCloseTo(20 / 7.6, 6);
47+
});
48+
});

src/app/components/module-parts/get-module-height-for-standard.pipe.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,33 @@ import {
22
Pipe,
33
PipeTransform
44
} from '@angular/core';
5+
import { MinimalModule } from 'src/app/models/module';
56
import { Standard } from '../../models/standard';
67

78

89
/**
910
* Output in REM
1011
*/
12+
export const VISUAL_3U_MODULE_HEIGHT_REM = 25.4;
13+
export const VISUAL_1U_MODULE_HEIGHT_REM = 7.6;
14+
15+
export function getModuleHeightForStandard(standard: Standard | undefined): number {
16+
return (standard?.id === 0) || (standard?.id === 1000) ? VISUAL_3U_MODULE_HEIGHT_REM : VISUAL_1U_MODULE_HEIGHT_REM;
17+
}
18+
19+
export function getModulePanelAspectRatio(module: Pick<MinimalModule, 'hp' | 'standard'> | undefined): number {
20+
const widthRem = Math.max(module?.hp ?? 1, 1);
21+
const heightRem = getModuleHeightForStandard(module?.standard);
22+
return widthRem / heightRem;
23+
}
24+
1125
@Pipe({
1226
name: 'getModuleHeightForStandard',
1327
standalone: false
1428
})
1529
export class GetModuleHeightForStandardPipe implements PipeTransform {
1630

1731
transform(standard: Standard): number {
18-
const visuallyFound3UHeight: number = 25.4;
19-
const visuallyFound1UHeight: number = 7.6;
20-
return (standard.id === 0) || (standard.id === 1000) ? visuallyFound3UHeight : visuallyFound1UHeight;
32+
return getModuleHeightForStandard(standard);
2133
}
2234
}

src/app/components/module-parts/module-editor/module-editor-data.service.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,29 @@ describe('ModuleEditorDataService', () => {
8686
expect(result.locked).toBe(1);
8787
});
8888
});
89+
90+
describe('buildCroppedPanelFile', () => {
91+
it('creates a jpg file for the cropped panel output', () => {
92+
const sourceFile = new File(['source'], 'front-panel.jpeg', {type: 'image/jpeg'});
93+
const croppedBlob = new Blob(['cropped'], {type: 'image/jpeg'});
94+
95+
const result = service.buildCroppedPanelFile(sourceFile, croppedBlob);
96+
97+
expect(result.name).toBe('front-panel-cropped.jpg');
98+
expect(result.type).toBe('image/jpeg');
99+
expect(result.size).toBe(croppedBlob.size);
100+
});
101+
102+
it('falls back to the source file extension when the blob type is missing', () => {
103+
const sourceFile = new File(['source'], 'front-panel.png', {type: 'image/png'});
104+
const croppedBlob = new Blob(['cropped']);
105+
106+
const result = service.buildCroppedPanelFile(sourceFile, croppedBlob);
107+
108+
expect(result.name).toBe('front-panel-cropped.png');
109+
expect(result.type).toBe('image/png');
110+
});
111+
});
89112

90113
describe('createFormCV', () => {
91114
it('uses empty string when name is undefined', () => {
@@ -336,4 +359,4 @@ describe('ModuleEditorDataService', () => {
336359
expect(result.depth).toBe(99);
337360
});
338361
});
339-
});
362+
});

src/app/components/module-parts/module-editor/module-editor-data.service.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ interface BuildPersistPlanArgs {
6464
export class ModuleEditorDataService {
6565
constructor(private readonly backend: SupabaseService) {}
6666

67+
buildCroppedPanelFile(sourceFile: File, blob: Blob): File {
68+
const fileType = blob.type || sourceFile.type || 'image/jpeg';
69+
const extension = this.fileExtensionFromType(fileType) || this.fileExtensionFromName(sourceFile.name) || 'jpg';
70+
const baseName = this.stripFileExtension(sourceFile.name) || 'module-panel';
71+
return new File([blob], `${ baseName }-cropped.${ extension }`, {
72+
type: fileType,
73+
lastModified: Date.now()
74+
});
75+
}
76+
6777
buildCvSummary(cvs: FormCV[]): CvSectionSummary {
6878
const editable = cvs.filter(cv => cv.id === 0).length;
6979
return {
@@ -321,4 +331,29 @@ export class ModuleEditorDataService {
321331
private safeString(str: string | undefined): string {
322332
return (str || '').replace(/[^a-z0-9]/gi, '_');
323333
}
334+
335+
private fileExtensionFromName(filename: string | undefined): string {
336+
if (!filename || !filename.includes('.')) {
337+
return '';
338+
}
339+
return filename.split('.').pop()?.toLowerCase() ?? '';
340+
}
341+
342+
private stripFileExtension(filename: string | undefined): string {
343+
if (!filename) {
344+
return '';
345+
}
346+
return filename.replace(/\.[^.]+$/, '');
347+
}
348+
349+
private fileExtensionFromType(fileType: string | undefined): string {
350+
const normalizedType = (fileType || '').toLowerCase();
351+
if (!normalizedType) {
352+
return '';
353+
}
354+
if (normalizedType === 'image/jpeg') {
355+
return 'jpg';
356+
}
357+
return normalizedType.split('/').pop() ?? '';
358+
}
324359
}

src/app/components/module-parts/module-editor/module-editor.component.html

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,147 @@
4040
acceptedFileType="image/jpeg,image/jpg"
4141
></lib-file-drag-host>
4242

43+
@if ({
44+
selectedPanelFile: selectedPanelSourceFile$ | async,
45+
selectedPanelPreviewUrl: selectedPanelSourcePreviewUrl$ | async,
46+
croppedPreviewUrl: croppedPanelPreviewUrl$ | async,
47+
croppedPanelFile: croppedPanelFile$ | async,
48+
cropLoading: panelCropLoading$ | async,
49+
cropLoadFailed: panelCropLoadFailed$ | async
50+
}; as cropBag) {
51+
@if (cropBag.selectedPanelFile) {
52+
<section class="setup-panel-crop col gap1"
53+
aria-label="Panel crop workspace"
54+
>
55+
<div class="setup-panel-crop-copy">
56+
<mat-card-subtitle>Adjust the crop locally. Only the cropped image preview below will be uploaded.</mat-card-subtitle>
57+
</div>
58+
59+
<div class="setup-panel-controls">
60+
<div class="setup-panel-crop-actions">
61+
<button type="button"
62+
mat-stroked-button
63+
(click)="fitPanelImage()"
64+
>
65+
Fit
66+
</button>
67+
<button type="button"
68+
mat-stroked-button
69+
(click)="fillPanelImage()"
70+
>
71+
Fill
72+
</button>
73+
<button type="button"
74+
mat-stroked-button
75+
(click)="resetPanelCropper()"
76+
>
77+
Reset
78+
</button>
79+
</div>
80+
81+
<div class="setup-panel-nudge"
82+
aria-label="Precision crop controls"
83+
>
84+
<button type="button"
85+
mat-icon-button
86+
[disabled]="!panelCropPosition"
87+
(click)="nudgePanelCrop('ArrowUp')"
88+
aria-label="Nudge crop up"
89+
>
90+
<mat-icon>keyboard_arrow_up</mat-icon>
91+
</button>
92+
<button type="button"
93+
mat-icon-button
94+
[disabled]="!panelCropPosition"
95+
(click)="nudgePanelCrop('ArrowLeft')"
96+
aria-label="Nudge crop left"
97+
>
98+
<mat-icon>keyboard_arrow_left</mat-icon>
99+
</button>
100+
<button type="button"
101+
mat-icon-button
102+
[disabled]="!panelCropPosition"
103+
(click)="nudgePanelCrop('ArrowRight')"
104+
aria-label="Nudge crop right"
105+
>
106+
<mat-icon>keyboard_arrow_right</mat-icon>
107+
</button>
108+
<button type="button"
109+
mat-icon-button
110+
[disabled]="!panelCropPosition"
111+
(click)="nudgePanelCrop('ArrowDown')"
112+
aria-label="Nudge crop down"
113+
>
114+
<mat-icon>keyboard_arrow_down</mat-icon>
115+
</button>
116+
</div>
117+
</div>
118+
119+
<div class="setup-panel-crop-grid">
120+
<div class="setup-panel-crop-stage">
121+
<image-cropper
122+
[imageFile]="cropBag.selectedPanelFile"
123+
[output]="'blob'"
124+
[format]="'jpeg'"
125+
[imageQuality]="92"
126+
[maintainAspectRatio]="true"
127+
[aspectRatio]="panelCropAspectRatio"
128+
[containWithinAspectRatio]="true"
129+
[onlyScaleDown]="true"
130+
[cropperMinWidth]="120"
131+
[cropperMinHeight]="120"
132+
[checkImageType]="true"
133+
[imageAltText]="'Selected panel image'"
134+
[cropperFrameAriaLabel]="'Panel crop selection'"
135+
[initialStepSize]="1"
136+
[transform]="panelCropTransform"
137+
(imageCropped)="onPanelImageCropped($event)"
138+
(imageLoaded)="onPanelImageLoaded()"
139+
(cropperChange)="onPanelCropperChange($event)"
140+
(cropperReady)="onPanelCropperReady()"
141+
(loadImageFailed)="onPanelImageLoadFailed()"
142+
></image-cropper>
143+
144+
@if (cropBag.cropLoading) {
145+
<div class="setup-panel-crop-status">
146+
Preparing the local cropper...
147+
</div>
148+
}
149+
150+
@if (cropBag.cropLoadFailed) {
151+
<div class="setup-panel-crop-error">
152+
This image could not be opened locally. Pick another JPG file to continue.
153+
</div>
154+
}
155+
</div>
156+
157+
<div class="setup-panel-preview">
158+
<div class="setup-panel-preview-header">
159+
<span class="setup-panel-preview-label">Final preview</span>
160+
@if (cropBag.croppedPanelFile) {
161+
<span class="setup-panel-preview-meta">
162+
{{ cropBag.croppedPanelFile.type || 'image/jpeg' }}
163+
</span>
164+
}
165+
</div>
166+
167+
@if (cropBag.croppedPreviewUrl || cropBag.selectedPanelPreviewUrl) {
168+
<img
169+
class="setup-panel-preview-image"
170+
[src]="cropBag.croppedPreviewUrl || cropBag.selectedPanelPreviewUrl"
171+
alt="Cropped panel preview"
172+
>
173+
} @else {
174+
<div class="setup-panel-preview-placeholder">
175+
Move or resize the crop to generate the final panel preview.
176+
</div>
177+
}
178+
</div>
179+
</div>
180+
</section>
181+
}
182+
}
183+
43184
<div class="col gap1 setup-field-stack setup-panel-fields">
44185
<lib-mat-form-entity
45186
[dataPack]="panelType"

0 commit comments

Comments
 (0)