Skip to content

Commit 36b8d6a

Browse files
authored
Merge pull request #348 from rwilson504/imageCrop
Image crop
2 parents 9c35469 + 0ad6673 commit 36b8d6a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+10093
-1
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
"chat.instructionsFilesLocations": {
44
".github": true
55
},
6+
"azureAutomation.directory.basePath": "c:\\Users\\ricwilson",
67
}

ImageCrop/.gitignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2+
3+
# dependencies
4+
/node_modules
5+
6+
# generated directory
7+
**/generated
8+
9+
# output directory
10+
/out
11+
12+
# msbuild output directories
13+
/bin
14+
/obj
15+
16+
# MSBuild Binary and Structured Log
17+
*.binlog
18+
19+
# Visual Studio cache/options directory
20+
/.vs
21+
22+
# macos
23+
.DS_Store

ImageCrop/ImageCrop.pcfproj

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<Project ToolsVersion="15.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
3+
<PropertyGroup>
4+
<PowerAppsTargetsPath>$(MSBuildExtensionsPath)\Microsoft\VisualStudio\v$(VisualStudioVersion)\PowerApps</PowerAppsTargetsPath>
5+
</PropertyGroup>
6+
7+
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
8+
<Import Project="$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.props" Condition="Exists('$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.props')" />
9+
10+
<PropertyGroup>
11+
<Name>ImageCrop</Name>
12+
<ProjectGuid>d107f6ef-fbba-4e85-9f69-f0d69b57abd9</ProjectGuid>
13+
<OutputPath>$(MSBuildThisFileDirectory)out\controls</OutputPath>
14+
</PropertyGroup>
15+
16+
<PropertyGroup>
17+
<TargetFrameworkVersion>v4.6.2</TargetFrameworkVersion>
18+
<!--Remove TargetFramework when this is available in 16.1-->
19+
<TargetFramework>net462</TargetFramework>
20+
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
21+
</PropertyGroup>
22+
23+
<ItemGroup>
24+
<PackageReference Include="Microsoft.PowerApps.MSBuild.Pcf" Version="1.*" />
25+
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="All" />
26+
</ItemGroup>
27+
28+
<ItemGroup>
29+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\.gitignore" />
30+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\bin\**" />
31+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\obj\**" />
32+
<ExcludeDirectories Include="$(OutputPath)\**" />
33+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.pcfproj" />
34+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.pcfproj.user" />
35+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\*.sln" />
36+
<ExcludeDirectories Include="$(MSBuildThisFileDirectory)\node_modules\**" />
37+
</ItemGroup>
38+
39+
<ItemGroup>
40+
<None Include="$(MSBuildThisFileDirectory)\**" Exclude="@(ExcludeDirectories)" />
41+
</ItemGroup>
42+
43+
<Import Project="$(MSBuildToolsPath)\Microsoft.Common.targets" />
44+
<Import Project="$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.targets" Condition="Exists('$(PowerAppsTargetsPath)\Microsoft.PowerApps.VisualStudio.Pcf.targets')" />
45+
46+
</Project>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest>
3+
<control namespace="RAW" constructor="ImageCrop" version="0.0.12" display-name-key="ImageCrop_DisplayName" description-key="ImageCrop_Description" control-type="standard">
4+
<!--external-service-usage node declares whether this 3rd party PCF control is using external service or not, if yes, this control will be considered as premium and please also add the external domain it is using.
5+
If it is not using any external service, please set the enabled="false" and DO NOT add any domain below. The "enabled" will be false by default.
6+
Example1:
7+
<external-service-usage enabled="true">
8+
<domain>www.Microsoft.com</domain>
9+
</external-service-usage>
10+
Example2:
11+
<external-service-usage enabled="false">
12+
</external-service-usage>
13+
-->
14+
<external-service-usage enabled="false">
15+
<!--UNCOMMENT TO ADD EXTERNAL DOMAINS
16+
<domain></domain>
17+
<domain></domain>
18+
-->
19+
</external-service-usage>
20+
<!-- property node identifies a specific, configurable piece of data that the control expects from CDS -->
21+
<property name="imageInput" display-name-key="ImageCrop_ImageInput_DisplayName" description-key="ImageCrop_ImageInput_Description" of-type="Multiple" usage="input" required="true" />
22+
<property name="imageOutput" display-name-key="ImageCrop_ImageOutput_DisplayName" description-key="ImageCrop_ImageOutput_Description" of-type="Multiple" usage="output" required="false" />
23+
<property name="aspect" display-name-key="ImageCrop_Aspect_DisplayName" description-key="ImageCrop_Aspect_Description" of-type="Decimal" usage="input" required="false" />
24+
<property name="minWidth" display-name-key="ImageCrop_MinWidth_DisplayName" description-key="ImageCrop_MinWidth_Description" of-type="Whole.None" usage="input" required="false" default-value="-1" />
25+
<property name="minHeight" display-name-key="ImageCrop_MinHeight_DisplayName" description-key="ImageCrop_MinHeight_Description" of-type="Whole.None" usage="input" required="false" default-value="-1" />
26+
<property name="maxWidth" display-name-key="ImageCrop_MaxWidth_DisplayName" description-key="ImageCrop_MaxWidth_Description" of-type="Whole.None" usage="input" required="false" default-value="-1" />
27+
<property name="maxHeight" display-name-key="ImageCrop_MaxHeight_DisplayName" description-key="ImageCrop_MaxHeight_Description" of-type="Whole.None" usage="input" required="false" default-value="-1" />
28+
<property name="keepSelection" display-name-key="ImageCrop_KeepSelection_DisplayName" description-key="ImageCrop_KeepSelection_Description" of-type="TwoOptions" usage="input" required="false" default-value="false" />
29+
<property name="rotation" display-name-key="ImageCrop_Rotation_DisplayName" description-key="ImageCrop_Rotation_Description" of-type="Whole.None" usage="input" required="false" default-value="0" />
30+
<property name="scaling" display-name-key="ImageCrop_Scaling_DisplayName" description-key="ImageCrop_Scaling_Description" of-type="Decimal" usage="input" required="false" default-value="1" />
31+
<property name="disabled" display-name-key="ImageCrop_Disabled_DisplayName" description-key="ImageCrop_Disabled_Description" of-type="TwoOptions" usage="input" required="false" default-value="false" />
32+
<property name="locked" display-name-key="ImageCrop_Locked_DisplayName" description-key="ImageCrop_Locked_Description" of-type="TwoOptions" usage="input" required="false" default-value="false" />
33+
<property name="ruleOfThirds" display-name-key="ImageCrop_RuleOfThirds_DisplayName" description-key="ImageCrop_RuleOfThirds_Description" of-type="TwoOptions" usage="input" required="false" default-value="false" />
34+
<property name="circularCrop" display-name-key="ImageCrop_CircularCrop_DisplayName" description-key="ImageCrop_CircularCrop_Description" of-type="TwoOptions" usage="input" required="false" default-value="false" />
35+
<property name="DefaultUnit" display-name-key="ImageCrop_DefaultUnit_DisplayName" description-key="ImageCrop_DefaultUnit_Description" of-type="Enum" usage="input" required="false" default-value="%">
36+
<value name="px" display-name-key="ImageCrop_DefaultUnit_Enum_px" description-key="ImageCrop_DefaultUnit_Enum_px_Description">px</value>
37+
<value name="%" display-name-key="ImageCrop_DefaultUnit_Enum_percent" description-key="ImageCrop_DefaultUnit_Enum_percent_Description">%</value>
38+
</property>
39+
<property name="DefaultX" display-name-key="ImageCrop_DefaultX_DisplayName" description-key="ImageCrop_DefaultX_Description" of-type="Decimal" usage="input" required="false" default-value="-1" />
40+
<property name="DefaultY" display-name-key="ImageCrop_DefaultY_DisplayName" description-key="ImageCrop_DefaultY_Description" of-type="Decimal" usage="input" required="false" default-value="-1" />
41+
<property name="DefaultWidth" display-name-key="ImageCrop_DefaultWidth_DisplayName" description-key="ImageCrop_DefaultWidth_Description" of-type="Decimal" usage="input" required="false" default-value="-1" />
42+
<property name="DefaultHeight" display-name-key="ImageCrop_DefaultHeight_DisplayName" description-key="ImageCrop_DefaultHeight_Description" of-type="Decimal" usage="input" required="false" default-value="-1" />
43+
<property name="actionSchema" display-name-key="ImageCrop_ActionSchema_DisplayName" description-key="ImageCrop_ActionSchema_Description" of-type="SingleLine.Text" usage="bound" hidden="true" />
44+
<property name="actionOutput" display-name-key="ImageCrop_ActionOutput_DisplayName" description-key="ImageCrop_ActionOutput_Description" of-type="Object" usage="output" default-value="" />
45+
<property-dependencies>
46+
<property-dependency input="actionSchema" output="actionOutput" required-for="schema" />
47+
</property-dependencies>
48+
<resources>
49+
<code path="index.ts" order="1" />
50+
<!-- UNCOMMENT TO ADD MORE RESOURCES
51+
<css path="css/ImageCrop.css" order="1" />
52+
-->
53+
<resx path="resources/ImageCrop.1033.resx" version="1.0.0" />
54+
</resources>
55+
<!-- UNCOMMENT TO ENABLE THE SPECIFIED API
56+
<feature-usage>
57+
<uses-feature name="Device.captureAudio" required="true" />
58+
<uses-feature name="Device.captureImage" required="true" />
59+
<uses-feature name="Device.captureVideo" required="true" />
60+
<uses-feature name="Device.getBarcodeValue" required="true" />
61+
<uses-feature name="Device.getCurrentPosition" required="true" />
62+
<uses-feature name="Device.pickFile" required="true" />
63+
<uses-feature name="Utility" required="true" />
64+
<uses-feature name="WebAPI" required="true" />
65+
</feature-usage>
66+
-->
67+
</control>
68+
</manifest>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as React from "react";
2+
import { Crop, PixelCrop } from "react-image-crop";
3+
import "react-image-crop/dist/ReactCrop.css";
4+
import { usePcfContext } from "../services/pcfContext";
5+
import { IImageCropControlProps } from "../types/imageCropTypes";
6+
import {
7+
useImageSrc,
8+
useMinWidth,
9+
useMaxWidth,
10+
useMinHeight,
11+
useMaxHeight,
12+
useAspect,
13+
useLocked,
14+
useRuleOfThirds,
15+
useCircularCrop,
16+
useDisabled,
17+
useCropToBase64,
18+
useKeepSelection,
19+
useRotation,
20+
useScaling,
21+
useDefaultCrop
22+
} from "../hooks";
23+
import CropWrapper from "./imageCropWrapper";
24+
25+
const ImageCropControl: React.FC<IImageCropControlProps> = (props) => {
26+
// Get the PCF context using the custom hook
27+
const pcfContext = usePcfContext();
28+
// State to hold the completed crop object, initialized as undefined
29+
const [completedCrop, setCompletedCrop] = React.useState<PixelCrop>()
30+
// Crop state for the image, initialized as undefined
31+
const [crop, setCrop] = React.useState<Crop>();
32+
// Reference to the image element for scaling and cropping
33+
const imgRef = React.useRef<HTMLImageElement>(null) as React.RefObject<HTMLImageElement>;
34+
//const appScaling = useResponsiveAppScaling(pcfContext.context, imgRef);
35+
// Get the locked property from PCF context
36+
const locked = useLocked(pcfContext.context);
37+
// Get the disabled property from PCF context
38+
const disabled = useDisabled(pcfContext.context);
39+
// Get the ruleOfThirds property from PCF context
40+
const ruleOfThirds = useRuleOfThirds(pcfContext.context);
41+
// Get the circularCrop property from PCF context
42+
const circularCrop = useCircularCrop(pcfContext.context);
43+
// Get min/max width/height from PCF context, scaled for browser
44+
const minWidth = useMinWidth(pcfContext.context);
45+
const maxWidth = useMaxWidth(pcfContext.context);
46+
const minHeight = useMinHeight(pcfContext.context);
47+
const maxHeight = useMaxHeight(pcfContext.context);
48+
// Get the aspect ratio from PCF context and helper to center crop
49+
const [aspect] = useAspect(pcfContext.context, imgRef, setCrop);
50+
// Get the keepSelection property from PCF context
51+
const keepSelection = useKeepSelection(pcfContext.context);
52+
// Get the default crop object (not a hook)
53+
const defaultCrop = useDefaultCrop(pcfContext.context);
54+
// Get the image from the PCF context property (should be base64)
55+
const imageSrc = useImageSrc(pcfContext.context, imgRef, defaultCrop, setCrop, setCompletedCrop);
56+
// Get the rotation property from PCF context
57+
const rotation = useRotation(pcfContext.context);
58+
// Get the scaling property from PCF context
59+
const scaling = useScaling(pcfContext.context);
60+
// Use custom hook to handle crop-to-base64 conversion and callback
61+
useCropToBase64(imgRef, completedCrop, props.onCropComplete, rotation, scaling, circularCrop);
62+
63+
return (
64+
<CropWrapper
65+
crop={crop}
66+
onChange={(c: Crop) => setCrop(c)}
67+
onDragStart={(e: PointerEvent) => props.onDragStart(e)}
68+
onDragEnd={(e: PointerEvent) => props.onDragEnd(e)}
69+
onComplete={(c: PixelCrop) => setCompletedCrop(c)}
70+
locked={locked}
71+
disabled={disabled}
72+
ruleOfThirds={ruleOfThirds}
73+
circularCrop={circularCrop}
74+
minWidth={minWidth}
75+
maxWidth={maxWidth}
76+
minHeight={minHeight}
77+
maxHeight={maxHeight}
78+
aspect={aspect}
79+
keepSelection={keepSelection}
80+
style={{ display: imageSrc && pcfContext.isVisible() ? 'block' : 'none', }}
81+
>
82+
<img
83+
ref={imgRef}
84+
alt="Crop"
85+
src={imageSrc}
86+
style={{
87+
maxWidth: '100%',
88+
maxHeight: '100%',
89+
transform: `rotate(${rotation}deg) scale(${scaling})`
90+
}}
91+
/>
92+
</CropWrapper>
93+
);
94+
};
95+
96+
export default ImageCropControl;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as React from "react";
2+
import ReactCrop from "react-image-crop";
3+
4+
/**
5+
* Wrapper for ReactCrop that overrides getBox to account for scaling.
6+
*/
7+
const CropWrapper = (props: React.ComponentProps<typeof ReactCrop>) => {
8+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9+
const cropRef = React.useRef<any>(null);
10+
11+
React.useEffect(() => {
12+
if (cropRef.current && typeof cropRef.current.getBox === "function") {
13+
const originalGetBox = cropRef.current.getBox.bind(cropRef.current);
14+
15+
cropRef.current.getBox = () => {
16+
const box = originalGetBox();
17+
const el = cropRef.current.mediaRef?.current;
18+
if (!el) return box;
19+
20+
const rect = el.getBoundingClientRect();
21+
const scaleX = el.clientWidth / rect.width;
22+
const scaleY = el.clientHeight / rect.height;
23+
24+
return {
25+
x: box.x,
26+
y: box.y,
27+
width: rect.width * scaleX,
28+
height: rect.height * scaleY,
29+
};
30+
};
31+
}
32+
}, []);
33+
34+
return (
35+
<ReactCrop
36+
ref={cropRef}
37+
{...props}
38+
/>
39+
);
40+
};
41+
42+
export default CropWrapper;

ImageCrop/ImageCrop/hooks/cropUtils.ts

Whitespace-only changes.

ImageCrop/ImageCrop/hooks/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export * from "./useImageSrc";
2+
export * from "./useMinWidth";
3+
export * from "./useMaxWidth";
4+
export * from "./useMinHeight";
5+
export * from "./useMaxHeight";
6+
export * from "./useAspect";
7+
export * from "./useLocked";
8+
export * from "./useRuleOfThirds";
9+
export * from "./useCircularCrop";
10+
export * from "./useDisabled";
11+
export * from "./useResponsiveAppScaling";
12+
export * from "./useCropToBase64";
13+
export * from "./useKeepSelection";
14+
export * from "./useRotation";
15+
export * from "./useScaling";
16+
export * from "./useImageLoaded"
17+
export * from "./useDefaultCrop";
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useState, useEffect} from "react";
2+
import { IInputs } from "../generated/ManifestTypes";
3+
import { centerAspectCrop } from "../utils/cropUtils";
4+
import { Crop } from "react-image-crop";
5+
6+
/**
7+
* Custom hook to track the aspect property from PCF context and update when it changes.
8+
* If aspect is set, will return the aspect and a function to center the crop.
9+
* @param context The PCF context object
10+
* @param imgRef Optional image ref to auto-center crop when aspect changes
11+
* @param setCrop Optional setter to update crop state
12+
* @param imageLoadedRef Optional ref to indicate if the image has loaded
13+
* @returns [aspect, centerCropIfNeeded]
14+
*/
15+
export function useAspect(
16+
context: ComponentFramework.Context<IInputs>,
17+
imgRef: React.RefObject<HTMLImageElement | null>,
18+
setCrop: (crop: Crop) => void
19+
): [number | undefined] {
20+
const getAspect = () => {
21+
const raw = context.parameters.aspect?.raw;
22+
if (raw === undefined || raw === null) return undefined;
23+
const num = Number(raw);
24+
return isNaN(num) ? undefined : num;
25+
};
26+
27+
const [aspect, setAspect] = useState<number | undefined>(getAspect());
28+
29+
// Recalculate aspect and re-center crop when aspect changes and image is loaded
30+
useEffect(() => {
31+
const currentAspect = getAspect();
32+
setAspect(currentAspect);
33+
34+
if (!currentAspect || currentAspect === 0) return;
35+
36+
if (imgRef?.current && setCrop) {
37+
const img = imgRef.current;
38+
const newCrop = centerAspectCrop(img.width, img.height, currentAspect);
39+
setCrop(newCrop);
40+
}
41+
}, [context.parameters.aspect?.raw]); // Note: no dependency on imageLoaded
42+
43+
return [aspect];
44+
}

ImageCrop/ImageCrop/hooks/useBrowserScaling.ts

Whitespace-only changes.

0 commit comments

Comments
 (0)