浏览代码

add vue-advanced-cropper setup input image modal and cropper

Olivier Massot 2 年之前
父节点
当前提交
163d4da6f7

+ 2 - 2
components/Layout/Header/Menu.vue

@@ -17,7 +17,7 @@ header principal (configuration, paramètres du compte...)
           size="30"
       >
         <UiImage
-            :id="menu.icon.avatarId"
+            :imageId="menu.icon.avatarId"
             :defaultImage="menu.icon.avatarByDefault"
             :width="30"
         />
@@ -56,7 +56,7 @@ header principal (configuration, paramètres du compte...)
               >
                 <span v-if="child.icon" class="pr-2 d-flex align-center">
                   <v-avatar v-if="menu.icon.avatarId || child.icon.avatarByDefault" size="30" >
-                    <UiImage :id="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
+                    <UiImage :imageId="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
                   </v-avatar>
                   <v-icon v-else class="on-primary" size="small">
                     {{ child.icon.name }}

+ 11 - 6
components/Layout/Parameters/General.vue

@@ -61,12 +61,16 @@
               label="students_are_also_association_members"
           />
 
-          <!-- TODO: reprendre l'UiInput -->
-          <UiInputImage
-              v-model="parameters['qrCode_id']"
-              field="qrCode_id"
-              label="licenceQrCode"
-          />
+          <div class="d-flex flex-column">
+            <span class="mb-1">{{ $t('qrCode')}} </span>
+            <UiInputImage
+                v-if="organizationProfile.isCMFCentralService"
+                v-model="parameters['qrCode']"
+                field="qrCode"
+                label="licenceQrCode"
+                :width="120"
+            />
+          </div>
         </v-col>
       </v-row>
     </UiForm>
@@ -88,6 +92,7 @@ if (organizationProfile.parametersId === null) {
 }
 
 const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+
 </script>
 
 <style scoped lang="scss">

+ 53 - 19
components/Ui/Image.vue

@@ -1,8 +1,6 @@
 <!--
-Composant Image permettant de charger et d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
+Composant Image permettant d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
 Permet d'afficher une image par défaut si l'image demandée n'est pas disponible ou invalide.
-
-Si la propriété 'upload' est à 'true', propose aussi un input pour uploader une nouvelle image.
 -->
 <template>
   <main>
@@ -27,47 +25,54 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
             />
           </v-row>
         </template>
+
+        <div v-if="!pending && overlayIcon" class="overlay" @click="emit('overlay-clicked')">
+          <v-icon>fas fa-upload</v-icon>
+        </div>
       </v-img>
     </div>
   </main>
 </template>
 
-
 <script setup lang="ts">
 import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
+import {onUnmounted, PropType, watch, WatchStopHandle} from "@vue/runtime-core";
 import ImageManager from "~/services/data/imageManager";
 
 const props = defineProps({
-  id: {
-    type: Number,
+  /**
+   * Id de l'image (null si aucune)
+   */
+  imageId: {
+    type: Number as PropType<Number | null>,
     required: false,
     default: null
   },
+  /**
+   * Image par défaut
+   */
   defaultImage: {
     type: String,
     required: false
   },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
   height: {
     type: Number,
     required: false
   },
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
   width: {
     type: Number,
     required: false
   },
-  field: {
+  overlayIcon: {
     type: String,
-    required: false
-  },
-  upload: {
-    type: Boolean,
     required: false,
-    default: false
-  },
-  ownerId:{
-    type: Number,
-    required: false
+    default: null
   }
 })
 
@@ -75,12 +80,15 @@ const { fetch } = useImageFetch()
 
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 
-const { data: imageSrc, pending, refresh } = fetch(props.id ?? null, defaultImagePath, props.height, props.width) as any
+const { data: imageSrc, pending, refresh } = fetch(props.imageId ?? null, defaultImagePath, props.height, props.width) as any
+
+
+const emit = defineEmits(['overlay-clicked'])
 
 /**
  * Si l'id change, on recharge l'image
  */
-const unwatch: WatchStopHandle = watch(() => props.id, async () => {
+const unwatch: WatchStopHandle = watch(() => props.imageId, async () => {
   await refresh()
 })
 
@@ -98,7 +106,33 @@ onUnmounted(() => {
     position: relative;
 
     img {
+      display: block;
       max-width: 100%;
     }
+
+    .overlay {
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      height: 100%;
+      width: 100%;
+      opacity: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      transition: .3s ease;
+    }
+    .overlay:hover {
+      opacity: 0.8;
+      background-color: rgb(var(--v-theme-neutral-strong));
+      cursor: pointer;
+    }
+
+    .overlay .v-icon {
+      color: rgb(var(--v-theme-on-neutral-strong));
+      font-size: 36px;
+    }
   }
 </style>

+ 264 - 111
components/Ui/Input/Image.vue

@@ -1,15 +1,27 @@
 <!--
 Assistant de création d'image
-https://norserium.github.io/vue-advanced-cropper/
+
+@see https://norserium.github.io/vue-advanced-cropper/
 -->
 <template>
-    <LazyLayoutDialog :show="true">
+  <div class="input-image" >
+    <UiImage
+        :image-id="modelValue"
+        :default-image="defaultImage"
+        :width="width"
+        :height="height"
+        class="image"
+        overlay-icon="fas fa-upload"
+        @overlay-clicked="openModal()"
+    />
+
+    <LazyLayoutDialog :show="showModal">
       <template #dialogType>{{ $t('image_assistant') }}</template>
       <template #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogText>
         <div class="upload">
           <v-row
-            v-if="fetchState.pending"
+            v-if="pending"
             class="fill-height ma-0 loading"
             align="center"
             justify="center"
@@ -22,22 +34,28 @@ https://norserium.github.io/vue-advanced-cropper/
 
           <div v-else >
             <div class="upload__cropper-wrapper">
-              <cropper
+              <Cropper
                 ref="cropper"
                 class="upload__cropper"
                 check-orientation
-                :src="image.src"
-                :default-position="{left : coordinates.left, top : coordinates.top}"
-                :default-size="coordinates.width ? {width : coordinates.width, height : coordinates.height}: defaultSize"
-                @change="onChange"
+                :src="uploadedImage.src"
+                :default-position="defaultPosition"
+                :default-size="defaultSize"
+                @change="onCropperChange"
               />
-              <div v-if="image.src" class="upload__reset-button" title="Reset Image" @click="reset()">
-                <v-icon>mdi-delete</v-icon>
+
+              <div
+                  v-if="uploadedImage.src"
+                  class="upload__reset-button"
+                  title="Reset Image"
+                  @click="reset()"
+              >
+                <v-icon>fas fa-trash</v-icon>
               </div>
             </div>
             <div class="upload__buttons-wrapper">
-              <button class="upload__button" @click="$refs.file.click()">
-                <input ref="file" type="file" accept="image/*" @change="uploadImage($event)" />
+              <button class="upload__button" @click="fileInput?.click()">
+                <input ref="fileInput" type="file" accept="image/*" @change="uploadImage($event)" />
                 {{$t('upload_image')}}
               </button>
             </div>
@@ -46,190 +64,324 @@ https://norserium.github.io/vue-advanced-cropper/
         </div>
       </template>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="$emit('close')">
+        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="cancel">
           {{ $t('cancel') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="save">
+        <v-btn class="submitBtn theme-primary" @click="save" :disabled="pending">
           {{ $t('save') }}
         </v-btn>
       </template>
     </LazyLayoutDialog>
-
+  </div>
 </template>
 
 <script setup lang="ts">
 
-import {useNuxtApp} from "#app";
 import {ref, Ref} from "@vue/reactivity";
 import File from '~/models/Core/File'
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import UrlUtils from "~/services/utils/urlUtils";
-import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
+import {PropType, watch} from "@vue/runtime-core";
 import {useEntityManager} from "~/composables/data/useEntityManager";
 import {AnyJson} from "~/types/data";
+import ApiResource from "~/models/ApiResource";
+import {useImageManager} from "~/composables/data/useImageManager";
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css';
 
 const props = defineProps({
-  imageId: {
+  /**
+   * Id de l'objet File, ou null
+   */
+  modelValue: {
+    type: Number as PropType<number | null>,
+    required: false,
+    default: null
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  field: {
+    type: String,
+    required: true
+  },
+  /**
+   * Image par défaut en cas d'absence d'une image uploadée
+   */
+  defaultImage: {
+    type: String,
+    required: false
+  },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
+  height: {
     type: Number,
     required: false
   },
-  ownerId: {
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
+  width: {
     type: Number,
     required: false
   },
-  field: {
-    type: String,
-    required: true
+  /**
+   * TODO: completer
+   */
+  ownerId: {
+    type: Number,
+    required: false
   }
 })
 
-const { emit } = useNuxtApp()
-
 const { em } = useEntityManager()
+const { imageManager } = useImageManager()
 
-const file = new File()
+const emit = defineEmits(['update', 'reload', 'reset', 'close'])
 
+/**
+ * Références à des composants
+ */
+const fileInput: Ref<null | any> = ref(null)
 const cropper: Ref<any> = ref(null)
 
-const image: Ref<AnyJson> = ref({
+/**
+ * L'objet File contenant les informations de l'image
+ */
+const file: Ref<ApiResource | null> = ref(null)
+
+/**
+ * L'image elle-même
+ */
+const image: Ref<ArrayBuffer | string | null> = ref(null)
+
+/**
+ * L'objet File ou l'image sont en cours de chargement
+ */
+const pending: Ref<boolean> = ref(false)
+
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const showModal = ref(false)
+
+/**
+ * Données d'une nouvelle image uploadée par l'utilisateur
+ */
+const uploadedImage: Ref<AnyJson> = ref({
   id: null,
   src: null,
   file: null,
   name: null
 })
 
-const coordinates: Ref<AnyJson> = ref({})
+/**
+ * Coordonnées du cropper
+ */
+const coordinates: Ref<{ left?: number, top?: number, height?: number, width?: number }> = ref({})
 
-const defaultSize = ({ imageSize, visibleArea }: any) => {
-  return {
-    width: (visibleArea || imageSize).width,
-    height: (visibleArea || imageSize).height,
-  };
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultposition
+ */
+const defaultPosition = () => {
+  return { left : coordinates.value.left, top : coordinates.value.top }
 }
 
-// Si l'id est renseigné, on récupère l'Item File afin d'avoir les informations de config, le nom, etc.
-if (props.imageId && props.imageId > 0) {
-  const { apiRequestService } = useAp2iRequestService()
-  const result: any = await apiRequestService.get(UrlUtils.join('api/files', '' + props.imageId))
-
-  const config = JSON.parse(result.data.config)
-  coordinates.value.left = config.x
-  coordinates.value.top = config.y
-  coordinates.value.height = config.height
-  coordinates.value.width = config.width
-  image.value.name = result.data.name
-  image.value.id = result.data.id
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultsize
+ */
+const defaultSize = (params: any): { width: number, height: number } | null => {
+  if (!params) {
+    return null
+  }
+  const { imageSize, visibleArea } = params
+
+  return {
+    width: coordinates.value.width ?? (visibleArea || imageSize).width,
+    height: coordinates.value.height ?? (visibleArea || imageSize).height
+  }
 }
 
-//On récupère l'image...
-const { fetch } = useImageFetch()
-const { data: imageLoaded, pending } = fetch(props.imageId ?? null)
+const openModal = () => {
+  if (!file.value) {
+    return
+  }
 
-const unwatch: WatchStopHandle = watch(
-  imageLoaded,
-(newValue, oldValue) => {
-    if (newValue === oldValue || typeof newValue === 'undefined') {
-      return
-    }
-    image.value.src = newValue
+  if (file.value.config) {
+    const config = JSON.parse(file.value.config)
+    coordinates.value.left = config.x
+    coordinates.value.top = config.y
+    coordinates.value.height = config.height
+    coordinates.value.width = config.width
   }
-)
+
+  uploadedImage.value.name = file.value.name
+  uploadedImage.value.id = file.value.id
+  uploadedImage.value.src = image.value
+
+  showModal.value = true
+
+  console.log(defaultSize.value)
+}
+
+
 
 /**
- * Quand l'utilisateur choisit une image sur sa machine
+ * Charge le File et l'image correspondante.
+ * Si le File possède une configuration, l'applique à l'image.
+ *
+ * @param id
+ */
+const loadFile = async (id: number | string) => {
+  pending.value = true
+  file.value = await em.fetch(File, id as number)
+  image.value = await imageManager.get(id as number)
+
+  pending.value = false
+}
+
+if (props.modelValue !== null) {
+  loadFile(props.modelValue)
+}
+
+/**
+ * Upload une image depuis le poste client
  * @param event
  */
 const uploadImage = (event:any) => {
   const { files } = event.target
-  if (files && files[0]) {
-    reset()
-    image.value.name = files[0].name
-    image.value.src = URL.createObjectURL(files[0])
-    image.value.file = files[0]
+
+  if (!files || !files[0]) {
+    return
   }
+
+  reset()
+  uploadedImage.value.name = files[0].name
+  uploadedImage.value.src = URL.createObjectURL(files[0])
+  uploadedImage.value.file = files[0]
 }
 
 /**
  * Lorsque le cropper change de position / taille, on met à jour les coordonnées
  * @param config
  */
-const onChange = ({ coordinates: config } : any) => {
+const onCropperChange = ({ coordinates: config } : any) => {
   coordinates.value = config;
 }
 
 /**
- * Lorsque l'on sauvegarde l'image
+ * Réinitialise l'image sélectionnée
  */
-// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
-const save = async () => {
-  file.config = JSON.stringify({
+const reset = () => {
+  uploadedImage.value.src = null
+  uploadedImage.value.file = null
+  uploadedImage.value.name = null
+  uploadedImage.value.id = null
+  URL.revokeObjectURL(uploadedImage.value.src)
+}
+
+/**
+ * Annule l'upload et les modifications, et ferme la modale
+ */
+const cancel = () => {
+  reset()
+  showModal.value = false
+  emit('close')
+}
+
+/**
+ * Enregistre une nouvelle image
+ */
+const saveNewImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
+
+  // On créé l'objet File à sauvegarder
+  file.value.name = uploadedImage.value.name
+  file.value.imgFieldName = props.field
+
+  // Construit la nouvelle config du fichier
+  file.value!!.config = JSON.stringify({
     x: coordinates.value.left,
     y: coordinates.value.top,
     height: coordinates.value.height,
     width: coordinates.value.width
   })
 
-  if (image.value.id > 0) {
-    // Mise à jour d'une image existante : on bouge simplement le cropper
+  // TODO: utiliser des enums?
+  file.value.visibility = 'EVERYBODY'
+  file.value.folder = 'IMAGES'
+  file.value.status = 'READY'
 
-    file.id = image.value.id as number
+  if (props.ownerId) {
+    // TODO: revoir
+    file.value.ownerId = props.ownerId
+  }
 
-    await em.persist(File, file) // TODO: à revoir
+  // TODO: A revoir, on doit pouvoir persister l'image aussi
+  const returnedFile = await em.persist(File, file.value)
 
-    // On émet un évent afin de mettre à jour le formulaire de départ
-    emit('reload')
+  //On émet un évent afin de mettre à jour le formulaire de départ
+  emit('update', returnedFile.data['@id'])
+}
 
-  } else {
-    // Création d'une nouvelle image
-    if (image.value.file) {
-
-      // On créé l'objet File à sauvegarder
-      file.name = image.value.name
-      file.imgFieldName = props.field
-      file.visibility = 'EVERYBODY'
-      file.folder = 'IMAGES'
-      file.status = 'READY'
-
-      if (props.ownerId) {
-        file.ownerId = props.ownerId
-      }
+/**
+ * Met à jour l'image existante
+ */
+const saveExistingImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
 
-      const returnedFile = await em.persist(File, file) // TODO: à revoir, il faudra pouvoir passer `image.value.file` avec la requête
+  file.value.id = uploadedImage.value.id
 
-      //On émet un évent afin de mettre à jour le formulaire de départ
-      emit('update', returnedFile.data['@id'])
+  // Construit la nouvelle config du fichier
+  file.value!!.config = JSON.stringify({
+    x: coordinates.value.left,
+    y: coordinates.value.top,
+    height: coordinates.value.height,
+    width: coordinates.value.width
+  })
 
-    } else {
-      // On reset l'image : on a appuyé sur "poubelle" puis on enregistre
-      emit('reset')
-    }
-  }
+  await em.persist(File, file.value) // TODO: à revoir
+
+  // On émet un évent afin de mettre à jour le formulaire de départ
+  emit('reload')
 }
 
 /**
- * On choisit de supprimer l'image présente
+ * Lorsque l'on sauvegarde l'image
  */
-const reset = () => {
-  image.value.src = null
-  image.value.file = null
-  image.value.name = null
-  image.value.id = null
-  URL.revokeObjectURL(image.value.src)
+// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
+const save = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
+
+  if (uploadedImage.value.src && uploadedImage.value.src !== image.value) {
+    // Une nouvelle image a été uploadée
+    await saveNewImage()
+  } else if (uploadedImage.value.id) {
+    // L'image existante a été modifiée
+    await saveExistingImage()
+  } else {
+    // On a reset l'image
+    emit('reset')
+  }
 }
 
 /**
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  */
 onUnmounted(() => {
-  unwatch()
-    if (image.value.src) {
-      URL.revokeObjectURL(image.value.src)
-    }
+  if (uploadedImage.value && uploadedImage.value.src) {
+    URL.revokeObjectURL(uploadedImage.value.src)
+  }
 })
 </script>
 
-<style lang="scss">
+<style scoped lang="scss">
   .vue-advanced-cropper__stretcher{
     height: auto !important;
     width: auto !important;
@@ -237,14 +389,15 @@ onUnmounted(() => {
   .loading{
     height: 300px;
   }
+
   .upload {
     user-select: none;
     padding: 20px;
     display: block;
     &__cropper {
        border: solid 1px rgb(var(--v-theme-on-neutral-strong));;
-       min-height: 500px;
-       max-height: 500px;
+       min-height: 300px;
+       max-height: 300px;
      }
     &__cropper-wrapper {
        position: relative;

+ 2 - 1
lang/fr.json

@@ -210,7 +210,8 @@
   "newSubDomain": "Nouveau sous domaine",
   "yourSubdomains": "Vos sous-domaines",
   "timezone": "Fuseau horaire",
-  "qrCode": "QrCode pour la licence",
+  "qrCode": "QrCode",
+  "qrCodeForLicence": "QrCode pour la licence",
   "studentsAreAdherents": "Les élèves sont également adhérents de l'association",
   "showAdherentList": "Afficher la liste des adhérents et leurs coordonnées",
   "endCourseDate": "Date de fin des cours ",

+ 1 - 0
package.json

@@ -46,6 +46,7 @@
     "sass": "^1.59.3",
     "uuid": "^9.0.0",
     "vite-plugin-vuetify": "^1.0.2",
+    "vue-advanced-cropper": "^2.8.8",
     "vue-tel-input-vuetify": "^1.5.3",
     "vue-the-mask": "^0.11.1",
     "vuetify": "3.3.5",

+ 1 - 1
pages/organization/index.vue

@@ -43,7 +43,7 @@ Contient toutes les informations sur l'organization courante
                     </UiHelp>
                   </div>
                   <UiImage
-                    :id="getIdFromUri(organization.logo)"
+                    :imageId="getIdFromUri(organization.logo)"
                     :upload="true"
                     :width="200"
                     field="logo"

+ 5 - 0
plugins/vue-advanced-cropper.ts

@@ -0,0 +1,5 @@
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css';
+
+export default defineNuxtPlugin((nuxtApp) => {
+})

+ 24 - 0
yarn.lock

@@ -2858,6 +2858,11 @@ citty@^0.1.1:
   resolved "https://registry.yarnpkg.com/citty/-/citty-0.1.1.tgz#2d722479b9a95453269165184dca6b2c5d808784"
   integrity sha512-fL/EEp9TyXlNkgYFQYNqtMJhnAk2tAq8lCST7O5LPn1NrzWPsOKE5wafR7J+8W87oxqolpxNli+w7khq5WP7tg==
 
+classnames@^2.2.6:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
+  integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
+
 clean-regexp@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/clean-regexp/-/clean-regexp-1.0.0.tgz#8df7c7aae51fd36874e8f8d05b9180bc11a3fed7"
@@ -3312,6 +3317,11 @@ deasync@^0.1.15:
     bindings "^1.5.0"
     node-addon-api "^1.7.1"
 
+debounce@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
+  integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
+
 debug@2.6.9, debug@^2.6.8:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -3608,6 +3618,11 @@ eastasianwidth@^0.2.0:
   resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
   integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==
 
+easy-bem@^1.0.2:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/easy-bem/-/easy-bem-1.1.1.tgz#1bfcc10425498090bcfddc0f9c000aba91399e03"
+  integrity sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==
+
 editorconfig@^0.15.3:
   version "0.15.3"
   resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5"
@@ -9220,6 +9235,15 @@ vscode-uri@^3.0.2:
   resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.7.tgz#6d19fef387ee6b46c479e5fb00870e15e58c1eb8"
   integrity sha512-eOpPHogvorZRobNqJGhapa0JdwaxpjVvyBp0QIUMRMSf8ZAlqOdEquKuRmw9Qwu0qXtJIWqFtMkmvJjUZmMjVA==
 
+vue-advanced-cropper@^2.8.8:
+  version "2.8.8"
+  resolved "https://registry.yarnpkg.com/vue-advanced-cropper/-/vue-advanced-cropper-2.8.8.tgz#af0e8324312be5a1a92ce9fd3aff8264d28a5b33"
+  integrity sha512-yDM7Jb/gnxcs//JdbOogBUoHr1bhCQSto7/ohgETKAe4wvRpmqIkKSppMm1huVQr+GP1YoVlX/fkjKxvYzwwDQ==
+  dependencies:
+    classnames "^2.2.6"
+    debounce "^1.2.0"
+    easy-bem "^1.0.2"
+
 vue-bundle-renderer@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/vue-bundle-renderer/-/vue-bundle-renderer-1.0.3.tgz#422438aa4a024e2833e87a5a2d0b97c6c12fb2d8"