Skip to content

Commit 7331252

Browse files
author
ricwilson
committed
feat: implement image loading and crop handling improvements in ImageCropControl and related hooks
1 parent 13d851d commit 7331252

File tree

8 files changed

+124
-44
lines changed

8 files changed

+124
-44
lines changed

ImageCrop/ImageCrop/components/imageCropControl.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
useKeepSelection,
1919
useRotation,
2020
useScaling,
21-
useImageLoaded,
2221
useDefaultCrop
2322
} from "../hooks";
2423
import CropWrapper from "./imageCropWrapper";
@@ -30,8 +29,6 @@ const ImageCropControl: React.FC<IImageCropControlProps> = (props) => {
3029
const [completedCrop, setCompletedCrop] = React.useState<PixelCrop>()
3130
// Crop state for the image, initialized as undefined
3231
const [crop, setCrop] = React.useState<Crop>();
33-
// Image loaded state (modular)
34-
const [imageLoaded, handleImageLoad, handleImageError, handleImageSrcChange] = useImageLoaded();
3532
// Reference to the image element for scaling and cropping
3633
const imgRef = React.useRef<HTMLImageElement>(null) as React.RefObject<HTMLImageElement>;
3734
//const appScaling = useResponsiveAppScaling(pcfContext.context, imgRef);
@@ -85,9 +82,7 @@ const ImageCropControl: React.FC<IImageCropControlProps> = (props) => {
8582
<img
8683
ref={imgRef}
8784
alt="Crop"
88-
src={imageSrc || ''}
89-
onLoad={handleImageLoad}
90-
onError={handleImageError}
85+
src={imageSrc}
9186
style={{
9287
maxWidth: '100%',
9388
maxHeight: '100%',

ImageCrop/ImageCrop/hooks/useCropToBase64.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, RefObject } from "react";
2-
import { Crop, PixelCrop } from "react-image-crop";
2+
import { PixelCrop } from "react-image-crop";
33

44
export function useCropToBase64(
55
imgRef: RefObject<HTMLImageElement | null>,
@@ -10,7 +10,10 @@ export function useCropToBase64(
1010
circularCrop = false
1111
) {
1212
useEffect(() => {
13-
if (!completedCrop || !imgRef.current) return;
13+
if (!completedCrop || !imgRef.current || completedCrop.width <= 0 || completedCrop.height <= 0) {
14+
onCropComplete(getBlankImageBase64());
15+
return;
16+
}
1417
const image = imgRef.current;
1518
const scaleX = image.naturalWidth / image.width;
1619
const scaleY = image.naturalHeight / image.height;
@@ -88,3 +91,14 @@ export function useCropToBase64(
8891
);
8992
}, [completedCrop, imgRef, rotation, scaling, circularCrop]);
9093
}
94+
95+
function getBlankImageBase64(width = 1, height = 1): string {
96+
const canvas = document.createElement("canvas");
97+
canvas.width = width;
98+
canvas.height = height;
99+
const ctx = canvas.getContext("2d");
100+
if (ctx) {
101+
ctx.clearRect(0, 0, width, height); // transparent
102+
}
103+
return canvas.toDataURL("image/png");
104+
}

ImageCrop/ImageCrop/hooks/useImageSrc.ts

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,72 @@
11
import { useState, useEffect } from "react";
22
import { IInputs } from "../generated/ManifestTypes";
33
import { Crop, PixelCrop, convertToPixelCrop } from "react-image-crop";
4-
5-
function stripQuotes(str: string): string {
6-
if (
7-
str &&
8-
str.length > 1 &&
9-
((str.startsWith('"') && str.endsWith('"')) ||
10-
(str.startsWith("'") && str.endsWith("'")))
11-
) {
12-
return str.substring(1, str.length - 1);
13-
}
14-
return str;
15-
}
4+
import { blankCrop } from "../types/imageCropTypes";
5+
import { stripQuotes } from "../utils/stringUtils";
166

177
/**
18-
* Custom hook to track the image source and automatically apply default crop
19-
* when the image is loaded.
20-
*
21-
* @param context The PCF context object
22-
* @param imgRef Ref to the HTMLImageElement
23-
* @param defaultCrop The default crop to apply
24-
* @param setCrop Setter to apply crop
25-
* @param setCompletedCrop Setter to apply completed pixel crop
26-
* @returns The current image source string
8+
* Custom hook to track the image source and apply crop logic on load or failure.
279
*/
2810
export function useImageSrc(
2911
context: ComponentFramework.Context<IInputs>,
3012
imgRef: React.RefObject<HTMLImageElement>,
3113
defaultCrop: Crop | undefined,
32-
setCrop: (crop: Crop) => void,
14+
setCrop: (crop: Crop | undefined) => void,
3315
setCompletedCrop: (crop: PixelCrop) => void
34-
): string {
35-
const rawSrc = stripQuotes(context.parameters.imageInput?.raw || "");
36-
const [imageSrc, setImageSrc] = useState<string>(rawSrc);
16+
): string | undefined {
17+
const rawSrc = stripQuotes(context.parameters.imageInput?.raw || undefined);
18+
const [imageSrc, setImageSrc] = useState<string | undefined>(rawSrc);
3719

3820
useEffect(() => {
39-
const cleanSrc = stripQuotes(context.parameters.imageInput?.raw || "");
21+
const cleanSrc = stripQuotes(context.parameters.imageInput?.raw || undefined);
4022
setImageSrc(cleanSrc);
4123

4224
const img = imgRef.current;
25+
const fallbackCrop = defaultCrop ?? blankCrop;
4326

44-
if (!img || !defaultCrop || !cleanSrc) return;
27+
if (!img || !cleanSrc) {
28+
// No image reference available
29+
setCrop(undefined);
30+
setCompletedCrop(convertToPixelCrop({...blankCrop}, 0,0));
31+
return;
32+
}
4533

46-
const applyCropIfReady = () => {
34+
const applyCrop = () => {
4735
if (img.complete && img.naturalWidth > 0) {
36+
// Valid image loaded
4837
setCrop(defaultCrop);
49-
setCompletedCrop(convertToPixelCrop(defaultCrop, img.width, img.height));
38+
setCompletedCrop(
39+
convertToPixelCrop(fallbackCrop, img.width, img.height)
40+
);
41+
} else {
42+
// Image is missing or broken
43+
setCrop(undefined);
44+
setCompletedCrop({
45+
unit: "px",
46+
x: 0,
47+
y: 0,
48+
width: 0,
49+
height: 0
50+
});
5051
}
5152
};
5253

53-
// Try to apply immediately
54-
applyCropIfReady();
54+
// Try immediately
55+
applyCrop();
5556

56-
// Fallback: wait for image load if needed
57-
const onLoad = () => {
58-
applyCropIfReady();
57+
// Listen for load/error events
58+
const onLoad = () => applyCrop();
59+
const onError = () => {
60+
setCrop(undefined);
61+
setCompletedCrop(convertToPixelCrop({...blankCrop}, 0,0));
5962
};
6063

6164
img.addEventListener("load", onLoad);
65+
img.addEventListener("error", onError);
66+
6267
return () => {
6368
img.removeEventListener("load", onLoad);
69+
img.removeEventListener("error", onError);
6470
};
6571
}, [context.parameters.imageInput?.raw, defaultCrop]);
6672

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useEffect } from "react";
2+
import { Crop, PixelCrop } from "react-image-crop";
3+
4+
/**
5+
* Custom hook to set crop and completedCrop when image is loaded and defaultCrop is available.
6+
* @param imageLoaded - boolean indicating if the image is loaded
7+
* @param defaultCrop - the default crop object
8+
* @param imgRef - ref to the image element
9+
* @param setCrop - setter for crop state
10+
* @param setCompletedCrop - setter for completedCrop state
11+
* @param convertToPixelCrop - utility to convert crop to PixelCrop
12+
*/
13+
export function useResetCropOnImageLoad(
14+
imageLoaded: boolean,
15+
defaultCrop: Crop | undefined,
16+
imgRef: React.RefObject<HTMLImageElement>,
17+
setCrop: (crop: Crop) => void,
18+
setCompletedCrop: (crop: PixelCrop) => void,
19+
convertToPixelCrop: (crop: Crop, width: number, height: number) => PixelCrop
20+
) {
21+
useEffect(() => {
22+
if (
23+
imageLoaded &&
24+
defaultCrop &&
25+
imgRef.current &&
26+
imgRef.current.width > 0 &&
27+
imgRef.current.height > 0
28+
) {
29+
setCrop(defaultCrop);
30+
setCompletedCrop(
31+
convertToPixelCrop(defaultCrop, imgRef.current.width, imgRef.current.height)
32+
);
33+
}
34+
}, [imageLoaded, defaultCrop, imgRef, setCrop, setCompletedCrop, convertToPixelCrop]);
35+
}

ImageCrop/ImageCrop/types/imageCropTypes.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactCropProps } from "react-image-crop";
1+
import { ReactCropProps, Crop } from "react-image-crop";
22
import { IInputs } from "../generated/ManifestTypes";
33

44
export interface IImageCropControlProps extends Partial<ReactCropProps> {
@@ -11,4 +11,12 @@ export interface IImageCropControlProps extends Partial<ReactCropProps> {
1111
onDragStart: (e: PointerEvent) => void;
1212
onDragEnd: (e: PointerEvent) => void;
1313
onCropComplete: (results: string) => void;
14-
}
14+
}
15+
16+
export const blankCrop: Crop = {
17+
unit: "px",
18+
x: 0,
19+
y: 0,
20+
width: 0,
21+
height: 0
22+
};

ImageCrop/ImageCrop/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./actionOutputSchema";
2+
export * from "./imageCropTypes";

ImageCrop/ImageCrop/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./cropUtils";
2+
export * from "./stringUtils";
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Utility functions for string manipulation in PCF controls
2+
3+
/**
4+
* Removes surrounding single or double quotes from a string, if present.
5+
* @param str The input string (may be undefined)
6+
* @returns The unquoted string, or undefined if input is undefined
7+
*/
8+
export function stripQuotes(str: string | undefined): string | undefined {
9+
if (
10+
str &&
11+
str.length > 1 &&
12+
((str.startsWith('"') && str.endsWith('"')) ||
13+
(str.startsWith("'") && str.endsWith("'")))
14+
) {
15+
return str.substring(1, str.length - 1);
16+
}
17+
return str;
18+
}

0 commit comments

Comments
 (0)