diff --git a/Classes/GraphQL/Resolver/Type/MutationResolver.php b/Classes/GraphQL/Resolver/Type/MutationResolver.php index a8fc40611..b4dea193e 100644 --- a/Classes/GraphQL/Resolver/Type/MutationResolver.php +++ b/Classes/GraphQL/Resolver/Type/MutationResolver.php @@ -706,4 +706,20 @@ public function deleteTag($_, array $variables): bool return true; } + + /** + * @throws Exception|IllegalObjectTypeException + */ + public function updateVariant($_, array $variables): ?Tag + { + [ + 'id' => $id, + 'cropInformation' => $cropInformation, + ] = $variables; + + $variant = $this->assetRepository->findByIdentifier($id); + + + return $variant; + } } diff --git a/Resources/Private/GraphQL/schema.root.graphql b/Resources/Private/GraphQL/schema.root.graphql index 6ec52a678..cf870eed7 100644 --- a/Resources/Private/GraphQL/schema.root.graphql +++ b/Resources/Private/GraphQL/schema.root.graphql @@ -144,6 +144,8 @@ type Mutation { updateAssetCollection(id: AssetCollectionId!, title: String, tagIds: [TagId]): AssetCollection! updateTag(id: TagId!, label: String): Tag! + + updateVariant(id: AssetId!, cropInformation: CropInformationInput!): AssetVariant! } """ @@ -211,6 +213,13 @@ type CropInformation { y: Int } +input CropInformationInput { + width: Int + height: Int + x: Int + y: Int +} + type UsageDetailsGroup { serviceId: ServiceId! label: String! diff --git a/Resources/Private/JavaScript/asset-variants/package.json b/Resources/Private/JavaScript/asset-variants/package.json index e0dd90093..ddf75c38a 100644 --- a/Resources/Private/JavaScript/asset-variants/package.json +++ b/Resources/Private/JavaScript/asset-variants/package.json @@ -5,6 +5,7 @@ "private": true, "main": "src/index.ts", "dependencies": { - "@media-ui/core": "*" + "@media-ui/core": "*", + "react-image-crop": "^10.0.7" } } diff --git a/Resources/Private/JavaScript/asset-variants/src/components/ImageCrop.tsx b/Resources/Private/JavaScript/asset-variants/src/components/ImageCrop.tsx new file mode 100644 index 000000000..c6968a8bf --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/components/ImageCrop.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import ReactCrop, { PercentCrop, PixelCrop } from 'react-image-crop'; + +interface ImageCropPros { + aspectRatio: number; + currentCrop: any; + onCropChange(crop: PixelCrop, percentageCrop: PercentCrop): void; + onCropComplete(crop: PixelCrop, percentageCrop: PercentCrop): void; + originalPreviewUrl: string; +} + +const ImageCrop: FC = ({ + aspectRatio, + currentCrop, + onCropChange, + onCropComplete, + originalPreviewUrl, +}: ImageCropPros) => { + return ( + + + + ); +}; + +export default React.memo(ImageCrop); diff --git a/Resources/Private/JavaScript/asset-variants/src/components/Variant.tsx b/Resources/Private/JavaScript/asset-variants/src/components/Variant.tsx index 9689ef858..9eae25ab5 100644 --- a/Resources/Private/JavaScript/asset-variants/src/components/Variant.tsx +++ b/Resources/Private/JavaScript/asset-variants/src/components/Variant.tsx @@ -1,14 +1,16 @@ import React from 'react'; import { createUseMediaUiStyles, MediaUiTheme } from '@media-ui/core/src'; import AssetVariant from '../interfaces/AssetVariant'; +import useSelectVariant from '../hooks/useSelectVariant'; // eslint-disable-next-line @typescript-eslint/no-empty-interface interface VariantProps extends AssetVariant {} const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ - variantContainer: { + variantContainer: ({ isCroppable }) => ({ backgroundColor: theme.colors.assetBackground, - }, + cursor: isCroppable ? 'pointer' : 'default', + }), picture: { height: 200, display: 'flex', @@ -54,10 +56,18 @@ const Variant: React.FC = ({ width, height, previewUrl, + id, + hasCrop, }: VariantProps) => { - const classes = useStyles(); + // TODO: Find out why we need to check both + const isCroppable = presetIdentifier && hasCrop; + const classes = useStyles({ isCroppable }); + const selectVariant = useSelectVariant(); + const handleVariantClick = () => { + selectVariant(id); + }; return ( -
+
{variantName} diff --git a/Resources/Private/JavaScript/asset-variants/src/components/VariantModal.tsx b/Resources/Private/JavaScript/asset-variants/src/components/VariantModal.tsx new file mode 100644 index 000000000..6128b562f --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/components/VariantModal.tsx @@ -0,0 +1,104 @@ +import React, { FC, useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import 'react-image-crop/dist/ReactCrop.css'; +import { Button, Dialog } from '@neos-project/react-ui-components'; + +import { useIntl, createUseMediaUiStyles, MediaUiTheme } from '@media-ui/core/src'; +import { useSelectedAsset } from '@media-ui/core/src/hooks'; + +import assetVariantModalState from '../state/assetVariantModalState'; +import useSelectedAssetVariant from '../hooks/useSelectedAssetVariant'; +import selectedVariantIdState from '../state/selectedVariantIdState'; +import ImageCrop from './ImageCrop'; +import { PixelCrop } from 'react-image-crop'; + +const useStyles = createUseMediaUiStyles((theme: MediaUiTheme) => ({ + cropContainer: { + display: 'flex', + justifyContent: 'center', + }, +})); + +const VariantModal: FC = () => { + const classes = useStyles(); + const { translate } = useIntl(); + const [isOpen, setIsOpen] = useRecoilState(assetVariantModalState); + const setSelectedVariantId = useSetRecoilState(selectedVariantIdState); + const asset = useSelectedAsset(); + const assetVariant = useSelectedAssetVariant(); + const [currentCrop, setCurrentCrop] = useState(); + const [hasChanged, setHasChanged] = useState(false); + const aspectRatio = useRef(); + const handleRequestClose = useCallback(() => { + setSelectedVariantId(undefined); + setIsOpen(false); + }, [setIsOpen, setSelectedVariantId]); + const handleCropComplete = (pxCrop: PixelCrop) => { + const savedCropInformation = assetVariant.cropInformation; + setHasChanged( + savedCropInformation?.x !== pxCrop.x || + savedCropInformation?.y !== pxCrop.y || + savedCropInformation?.width !== pxCrop.width || + savedCropInformation?.height !== pxCrop.height + ); + }; + + const cropHasChanged = () => { + const savedCropInformation = assetVariant.cropInformation; + return ( + savedCropInformation?.x !== currentCrop.x || + savedCropInformation?.y !== currentCrop.y || + savedCropInformation?.width !== currentCrop.width || + savedCropInformation?.height !== currentCrop.height + ); + }; + + const handleSave = () => { + console.log('saving', currentCrop); + }; + + useEffect(() => { + setCurrentCrop({ unit: 'px', ...assetVariant.cropInformation }); + aspectRatio.current = assetVariant.width / assetVariant.height; + }, [assetVariant]); + + return ( + + {translate('assetVariantModal.cancel', 'Cancel')} + , + , + ]} + > +
+ {assetVariant ? ( + + ) : ( + {translate('assetVariantModal.loading', 'Loading...')} + )} +
+
+ ); +}; + +export default React.memo(VariantModal); diff --git a/Resources/Private/JavaScript/asset-variants/src/hooks/useSelectVariant.ts b/Resources/Private/JavaScript/asset-variants/src/hooks/useSelectVariant.ts new file mode 100644 index 000000000..af7768855 --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/hooks/useSelectVariant.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; +import { useSetRecoilState } from 'recoil'; + +import selectedVariantIdState from '../state/selectedVariantIdState'; +import assetVariantModalState from '../state/assetVariantModalState'; + +const useSelectVariant = () => { + const setSelectedVariantId = useSetRecoilState(selectedVariantIdState); + const setAssetVariantModalState = useSetRecoilState(assetVariantModalState); + + return useCallback( + (variantId: string) => { + if (!variantId) return; + setSelectedVariantId(variantId); + setAssetVariantModalState(true); + }, + [setSelectedVariantId, setAssetVariantModalState] + ); +}; +export default useSelectVariant; diff --git a/Resources/Private/JavaScript/asset-variants/src/hooks/useSelectedAssetVariant.ts b/Resources/Private/JavaScript/asset-variants/src/hooks/useSelectedAssetVariant.ts new file mode 100644 index 000000000..a12357eb8 --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/hooks/useSelectedAssetVariant.ts @@ -0,0 +1,14 @@ +import { useRecoilValue } from 'recoil'; + +import { useSelectedAsset } from '@media-ui/core/src/hooks'; +import AssetVariant from '../interfaces/AssetVariant'; +import selectedVariantIdState from '../state/selectedVariantIdState'; +import useAssetVariants from './useAssetVariants'; + +export default function useSelectedAssetVariant(): AssetVariant { + const selectedVariantId = useRecoilValue(selectedVariantIdState); + const selectedAsset = useSelectedAsset(); + const assetVariants = useAssetVariants({ assetId: selectedAsset.id, assetSourceId: selectedAsset.assetSource.id }); + + return assetVariants.variants?.find((variant) => variant.id === selectedVariantId); +} diff --git a/Resources/Private/JavaScript/asset-variants/src/hooks/useUpdateVariant.ts b/Resources/Private/JavaScript/asset-variants/src/hooks/useUpdateVariant.ts new file mode 100644 index 000000000..2ca6e7631 --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/hooks/useUpdateVariant.ts @@ -0,0 +1,34 @@ +// import { useMutation } from '@apollo/client'; + +// import { Asset } from '@media-ui/core/src/interfaces'; + +// import { REPLACE_ASSET } from '../mutations'; +// import { FileUploadResult } from '../interfaces'; + +// export interface AssetReplacementOptions { +// generateRedirects: boolean; +// keepOriginalFilename: boolean; +// } + +// interface ReplaceAssetProps { +// asset: Asset; +// file: File; +// options: AssetReplacementOptions; +// } + +// export default function useReplaceAsset() { +// const [action, { error, data, loading }] = useMutation<{ replaceAsset: FileUploadResult }>(REPLACE_ASSET); + +// const replaceAsset = ({ asset, file, options }: ReplaceAssetProps) => { +// return action({ +// variables: { +// id: asset.id, +// assetSourceId: asset.assetSource.id, +// file, +// options, +// }, +// }); +// }; + +// return { replaceAsset, uploadState: data?.replaceAsset || null, error, loading }; +// } diff --git a/Resources/Private/JavaScript/asset-variants/src/index.ts b/Resources/Private/JavaScript/asset-variants/src/index.ts index a5fc2808f..81fd2c2c2 100644 --- a/Resources/Private/JavaScript/asset-variants/src/index.ts +++ b/Resources/Private/JavaScript/asset-variants/src/index.ts @@ -1,4 +1,7 @@ export { default as ASSET_VARIANTS } from './queries/assetVariants'; export { default as CROP_INFORMATION_FRAGMENT } from './fragments/cropInformation'; +export { default as VariantModal } from './components/VariantModal'; export { default as VariantsInspector } from './components/VariantsInspector'; + +export { default as assetVariantModalState } from './state/assetVariantModalState'; diff --git a/Resources/Private/JavaScript/asset-variants/src/state/assetVariantModalState.ts b/Resources/Private/JavaScript/asset-variants/src/state/assetVariantModalState.ts new file mode 100644 index 000000000..b0409e951 --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/state/assetVariantModalState.ts @@ -0,0 +1,9 @@ +import { atom, useSetRecoilState } from 'recoil'; +import selectedVariantIdState from './selectedVariantIdState'; + +const assetVariantModalState = atom({ + key: 'assetVariantModalState', + default: false, +}); + +export default assetVariantModalState; diff --git a/Resources/Private/JavaScript/asset-variants/src/state/selectedVariantIdState.ts b/Resources/Private/JavaScript/asset-variants/src/state/selectedVariantIdState.ts new file mode 100644 index 000000000..d14fe7bea --- /dev/null +++ b/Resources/Private/JavaScript/asset-variants/src/state/selectedVariantIdState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +const selectedVariantIdState = atom({ + key: 'selectedVariantIdState', + default: null, +}); + +export default selectedVariantIdState; diff --git a/Resources/Private/JavaScript/media-module/src/components/App.tsx b/Resources/Private/JavaScript/media-module/src/components/App.tsx index 7da5533de..4eb88dd48 100644 --- a/Resources/Private/JavaScript/media-module/src/components/App.tsx +++ b/Resources/Private/JavaScript/media-module/src/components/App.tsx @@ -11,6 +11,7 @@ import { SimilarAssetsModal, similarAssetsModalState } from '@media-ui/feature-s import { uploadDialogVisibleState } from '@media-ui/feature-asset-upload/src/state'; import { UploadDialog } from '@media-ui/feature-asset-upload/src/components'; import { AssetPreview } from '@media-ui/feature-asset-preview/src'; +import { assetVariantModalState, VariantModal } from '@media-ui/feature-asset-variants/src'; import { SideBarLeft } from './SideBarLeft'; import { SideBarRight } from './SideBarRight'; @@ -100,6 +101,7 @@ const App = () => { const { visible: showCreateAssetCollectionDialog } = useRecoilValue(createAssetCollectionDialogState); const showAssetUsagesModal = useRecoilValue(assetUsageDetailsModalState); const showSimilarAssetsModal = useRecoilValue(similarAssetsModalState); + const showVariantModal = useRecoilValue(assetVariantModalState); const searchTerm = useRecoilValue(searchTermState); const selectAsset = useSelectAsset(); const [, selectAssetSource] = useSelectAssetSource(); @@ -151,6 +153,7 @@ const App = () => { {showCreateTagDialog && } {showCreateAssetCollectionDialog && } {showSimilarAssetsModal && } + {showVariantModal && } diff --git a/yarn.lock b/yarn.lock index aef5086de..4a6acd303 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4975,6 +4975,11 @@ clone@^2.1.1: resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= +clsx@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + coa@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" @@ -12745,6 +12750,13 @@ react-hot-loader@^4.13.0: shallowequal "^1.1.0" source-map "^0.7.3" +react-image-crop@^10.0.7: + version "10.0.7" + resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-10.0.7.tgz#79ae04ac8886f93c5945c9378bfc38ef3b29df2d" + integrity sha512-doV3sz101q9IaTWlx+DlErJ+BUknJ5CMVIRV72thWC3Fn5bg2XWoft7FbbrFGIr46zYrKKS9QuWNEzNkaqRvfQ== + dependencies: + clsx "^1.2.1" + react-image-lightbox@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-image-lightbox/-/react-image-lightbox-5.1.1.tgz#872d1a4336b5a6410ea7909b767cf59014081004"