Skip to content

Commit 53846e9

Browse files
author
ricwilson
committed
init
1 parent 9c35469 commit 53846e9

40 files changed

+9716
-0
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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest>
3+
<control namespace="RAW" constructor="ImageCrop" version="0.0.5" 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+
<resources>
36+
<code path="index.ts" order="1" />
37+
<!-- UNCOMMENT TO ADD MORE RESOURCES
38+
<css path="css/ImageCrop.css" order="1" />
39+
-->
40+
<resx path="resources/ImageCrop.1033.resx" version="1.0.0" />
41+
</resources>
42+
<!-- UNCOMMENT TO ENABLE THE SPECIFIED API
43+
<feature-usage>
44+
<uses-feature name="Device.captureAudio" required="true" />
45+
<uses-feature name="Device.captureImage" required="true" />
46+
<uses-feature name="Device.captureVideo" required="true" />
47+
<uses-feature name="Device.getBarcodeValue" required="true" />
48+
<uses-feature name="Device.getCurrentPosition" required="true" />
49+
<uses-feature name="Device.pickFile" required="true" />
50+
<uses-feature name="Utility" required="true" />
51+
<uses-feature name="WebAPI" required="true" />
52+
</feature-usage>
53+
-->
54+
</control>
55+
</manifest>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import * as React from "react";
2+
import ReactCrop, { 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+
useBrowserScaling,
18+
useCropToBase64,
19+
useKeepSelection,
20+
useRotation,
21+
useScaling
22+
} from "../hooks";
23+
import CropWrapper from "./imageCropWrapper";
24+
25+
const ImageCropControl: React.FC<IImageCropControlProps> = (props) => {
26+
const pcfContext = usePcfContext();
27+
const [crop, setCrop] = React.useState<Crop>();
28+
const [completedCrop, setCompletedCrop] = React.useState<PixelCrop>()
29+
const imgRef = React.useRef<HTMLImageElement>(null) as React.RefObject<HTMLImageElement>;
30+
const browserScaling = useBrowserScaling(imgRef);
31+
32+
// Get the locked property from PCF context
33+
const locked = useLocked(pcfContext.context);
34+
35+
// Get the disabled property from PCF context
36+
const disabled = useDisabled(pcfContext.context);
37+
38+
// Get the ruleOfThirds property from PCF context
39+
const ruleOfThirds = useRuleOfThirds(pcfContext.context);
40+
41+
// Get the circularCrop property from PCF context
42+
const circularCrop = useCircularCrop(pcfContext.context);
43+
44+
// Get min/max width/height from PCF context, scaled for browser
45+
const minWidth = useMinWidth(pcfContext.context, browserScaling);
46+
const maxWidth = useMaxWidth(pcfContext.context, browserScaling);
47+
const minHeight = useMinHeight(pcfContext.context, browserScaling);
48+
const maxHeight = useMaxHeight(pcfContext.context, browserScaling);
49+
50+
// Get the aspect ratio from PCF context and helper to center crop
51+
const [aspect, centerCropIfNeeded] = useAspect(pcfContext.context, imgRef, setCrop);
52+
// Get the keepSelection property from PCF context
53+
const keepSelection = useKeepSelection(pcfContext.context);
54+
55+
// Get the image from the PCF context property (should be base64)
56+
const imageSrc = useImageSrc(pcfContext.context);
57+
58+
// Get the rotation property from PCF context
59+
const rotation = useRotation(pcfContext.context);
60+
61+
// Get the scaling property from PCF context
62+
const scaling = useScaling(pcfContext.context);
63+
64+
// Use custom hook to handle crop-to-base64 conversion and callback
65+
useCropToBase64(imgRef, completedCrop, props.onCropComplete, rotation, scaling);
66+
67+
// Optionally, recenter crop when aspect changes (already handled in hook)
68+
69+
return (
70+
<CropWrapper
71+
crop={crop}
72+
onChange={(c: Crop) => setCrop(c)}
73+
onDragStart={(e: PointerEvent) => props.onDragStart(e)}
74+
onDragEnd={(e: PointerEvent) => props.onDragEnd(e)}
75+
onComplete={(c: PixelCrop) => setCompletedCrop(c)}
76+
locked={locked}
77+
disabled={disabled}
78+
ruleOfThirds={ruleOfThirds}
79+
circularCrop={circularCrop}
80+
minWidth={minWidth}
81+
maxWidth={maxWidth}
82+
minHeight={minHeight}
83+
maxHeight={maxHeight}
84+
aspect={aspect}
85+
keepSelection={keepSelection}
86+
>
87+
<img
88+
ref={imgRef}
89+
alt="Crop"
90+
src={imageSrc || ''}
91+
style={{
92+
maxWidth: '100%',
93+
maxHeight: '100%',
94+
display: imageSrc ? 'block' : 'none',
95+
transform: `rotate(${rotation}deg) scale(${scaling})`
96+
}}
97+
onError={(e) => {
98+
(e.target as HTMLImageElement).style.display = 'none';
99+
}}
100+
/>
101+
</CropWrapper>
102+
);
103+
};
104+
105+
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/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 "./useBrowserScaling";
12+
export * from "./useCropToBase64";
13+
export * from "./useKeepSelection";
14+
export * from "./useRotation";
15+
export * from "./useScaling";
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useState, useEffect, RefObject,MutableRefObject } 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, () => void] {
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+
const centerCropIfNeeded = () => {
44+
if (!aspect || aspect === 0) return;
45+
if (imgRef?.current && setCrop) {
46+
const img = imgRef.current;
47+
const newCrop = centerAspectCrop(img.width, img.height, aspect);
48+
setCrop(newCrop);
49+
}
50+
};
51+
52+
return [aspect, centerCropIfNeeded];
53+
}

0 commit comments

Comments
 (0)