| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- <!--
- Assistant de création d'image
- @see https://norserium.github.io/vue-advanced-cropper/
- -->
- <template>
- <div class="input-image">
- <UiImage
- ref="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="pending"
- class="fill-height ma-0 loading"
- align="center"
- justify="center"
- >
- <v-progress-circular :indeterminate="true" color="neutral"/>
- </v-row>
- <div v-else>
- <div class="upload__cropper-wrapper">
- <Cropper
- v-if="croppingEnabled"
- ref="cropper"
- class="upload__cropper"
- check-orientation
- :src="currentImage.src"
- :default-position="defaultPosition"
- :default-size="defaultSize"
- @change="onCropperChange"
- />
- <v-img
- v-else
- :src="currentImage.src ?? ''"
- class="upload__cropper"
- />
- <div
- v-if="currentImage.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="fileInput?.click()">
- <input
- ref="fileInput"
- type="file"
- accept="image/*"
- @change="uploadImage($event)"
- >
- {{ $t('upload_image') }}
- </button>
- </div>
- <span class="max-size-label">{{ $t('max_size_4_mb') }}</span>
- </div>
- </div>
- </template>
- <template #dialogBtn>
- <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="cancel">
- {{ $t('cancel') }}
- </v-btn>
- <v-btn
- class="submitBtn theme-primary"
- :disabled="pending"
- @click="save"
- >
- {{ $t('save') }}
- </v-btn>
- </template>
- </LazyLayoutDialog>
- </div>
- </template>
- <script setup lang="ts">
- import { Cropper } from 'vue-advanced-cropper'
- import 'vue-advanced-cropper/dist/style.css'
- import { type Ref, ref } from 'vue';
- import File from '~/models/Core/File'
- import { useEntityManager } from '~/composables/data/useEntityManager'
- import { useImageManager } from '~/composables/data/useImageManager'
- import { FILE_VISIBILITY, IMAGE_SIZE, TYPE_ALERT } from '~/types/enum/enums'
- import { usePageStore } from '~/stores/page'
- import FileUtils from '~/services/utils/fileUtils'
- import { useImageFetch } from '~/composables/data/useImageFetch'
- const props = defineProps({
- /**
- * Id de l'objet File, ou null
- */
- modelValue: {
- type: Number as PropType<number | null>,
- required: false,
- default: null,
- },
- /**
- * Nom du champ
- */
- field: {
- type: String,
- required: false,
- default: null,
- },
- /**
- * 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,
- },
- /**
- * Largeur de l'image à l'écran (en px)
- */
- width: {
- type: Number,
- required: false,
- },
- /**
- * Donne la possibilité de rogner les images
- */
- croppingEnabled: {
- type: Boolean,
- required: false,
- default: true,
- },
- /**
- * TODO: completer
- */
- ownerId: {
- type: Number,
- required: false,
- },
- })
- const { em } = useEntityManager()
- const { imageManager } = useImageManager()
- const pageStore = usePageStore()
- const emit = defineEmits(['update:modelValue'])
- /**
- * Références à des composants
- */
- const fileInput: Ref<null | any> = ref(null)
- const cropper: Ref<any> = ref(null)
- const uiImage: Ref<any> = 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)
- /**
- * L'objet File contenant les informations de l'image
- */
- const file: Ref<File | null> = ref(null)
- /**
- * Données d'une nouvelle image uploadée par l'utilisateur
- */
- const currentImage: Ref<{
- id: string | number | null
- src: string | null
- content: string | null
- name: string | null
- }> = ref({
- id: null,
- src: null,
- content: null,
- name: null,
- })
- /**
- * Taille maximale autorisée pour les images uploadées (en bytes)
- */
- const MAX_FILE_SIZE = 4 * 1024 * 1024
- /**
- * Coordonnées du cropper
- */
- const cropperConfig: Ref<{
- left?: number
- top?: number
- height?: number
- width?: number
- }> = ref({})
- /**
- * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultposition
- */
- const defaultPosition = () => {
- return { left: cropperConfig.value.left, top: cropperConfig.value.top }
- }
- /**
- * @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: cropperConfig.value.width ?? (visibleArea || imageSize).width,
- height: cropperConfig.value.height ?? (visibleArea || imageSize).height,
- }
- }
- /**
- * Charge l'image correspondant au fichier dans le cropper
- *
- * @param fileId
- */
- const loadImage = async (fileId: number) => {
- file.value = (await em.fetch(File, fileId)) as File
- if (file.value.config) {
- const fileConfig = JSON.parse(file.value.config)
- cropperConfig.value.left = fileConfig.x
- cropperConfig.value.top = fileConfig.y
- cropperConfig.value.height = fileConfig.height
- cropperConfig.value.width = fileConfig.width
- }
- currentImage.value.name = file.value.name
- currentImage.value.id = file.value.id
- currentImage.value.src = await imageManager.get(fileId, IMAGE_SIZE.RAW)
- }
- /**
- * Affiche la modale d'upload / modification de l'image
- */
- const openModal = async () => {
- showModal.value = true
- pending.value = true
- if (props.modelValue !== null) {
- // Un objet File existe déjà: on le récupère
- await loadImage(props.modelValue)
- } else {
- // Nouveau File
- file.value = em.newInstance(File) as File
- }
- pending.value = false
- }
- /**
- * Réinitialise l'image actuellement chargée dans le cropper
- */
- const reset = () => {
- if (currentImage.value.src !== null) {
- URL.revokeObjectURL(currentImage.value.src)
- }
- currentImage.value = {
- src: null,
- content: null,
- name: null,
- id: null,
- }
- cropperConfig.value = {
- left: undefined,
- height: undefined,
- top: undefined,
- width: undefined,
- }
- }
- /**
- * Upload une image depuis le poste client
- * @param event
- */
- const uploadImage = async (event: any) => {
- const { files } = event.target
- if (!files || !files[0]) {
- return
- }
- const uploadedFile = files[0]
- if (uploadedFile.size > MAX_FILE_SIZE) {
- pageStore.alerts.push({
- type: TYPE_ALERT.ALERT,
- messages: ['file_too_large'],
- })
- return
- }
- // Met à jour l'image dans le cropper
- currentImage.value.id = null
- currentImage.value.name = uploadedFile.name
- currentImage.value.src = URL.createObjectURL(uploadedFile)
- currentImage.value.content = await FileUtils.blobToBase64(uploadedFile)
- // Met à jour la configuration du cropper
- cropperConfig.value.top = 0
- cropperConfig.value.left = 0
- cropperConfig.value.height = uploadedFile.height
- cropperConfig.value.width = uploadedFile.width
- }
- /**
- * Lorsque le cropper change de position / taille, on met à jour les coordonnées
- * @param newCoordinates
- */
- const onCropperChange = ({ coordinates: newCoordinates }: any) => {
- cropperConfig.value = newCoordinates
- }
- /**
- * Annule l'upload et les modifications, et ferme la modale
- */
- const cancel = () => {
- reset()
- showModal.value = false
- }
- /**
- * Enregistre une nouvelle image et retourne l'id du fichier nouvellement créé
- */
- const saveNewImage = async (): Promise<number> => {
- if (!file.value) {
- throw new Error('No File object defined')
- }
- if (!currentImage.value.name) {
- throw new Error("Missing file's name")
- }
- if (!currentImage.value.content) {
- throw new Error("Missing file's content")
- }
- const config = JSON.stringify({
- x: cropperConfig.value.left,
- y: cropperConfig.value.top,
- height: cropperConfig.value.height,
- width: cropperConfig.value.width,
- })
- const response = (await imageManager.upload(
- currentImage.value.name,
- currentImage.value.content,
- FILE_VISIBILITY.EVERYBODY,
- config,
- )) as any
- return response.fileId
- }
- /**
- * Met à jour l'image existante
- */
- const saveExistingImage = async () => {
- if (!file.value) {
- throw new Error('No File object defined')
- }
- file.value.config = JSON.stringify({
- x: cropperConfig.value.left,
- y: cropperConfig.value.top,
- height: cropperConfig.value.height,
- width: cropperConfig.value.width,
- })
- await em.persist(file.value)
- }
- /**
- * Lorsque l'on sauvegarde l'image
- */
- // TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
- const save = async () => {
- pageStore.loading = true
- if (currentImage.value.src && currentImage.value.id === null) {
- // Une nouvelle image a été uploadée
- const fileId = await saveNewImage()
- emit('update:modelValue', fileId)
- } else if (currentImage.value.id) {
- // L'image existante a été modifiée
- await saveExistingImage()
- } else {
- // On a reset l'image
- emit('update:modelValue', null)
- }
- showModal.value = false
- uiImage.value.refresh()
- pageStore.loading = false
- }
- /**
- * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
- */
- onUnmounted(() => {
- if (currentImage.value && currentImage.value.src) {
- URL.revokeObjectURL(currentImage.value.src)
- }
- })
- </script>
- <style scoped lang="scss">
- :deep(.vue-advanced-cropper__stretcher) {
- height: auto !important;
- width: auto !important;
- }
- .loading {
- height: 300px;
- }
- .upload {
- user-select: none;
- padding: 20px;
- display: block;
- &__cropper {
- border: solid 1px rgb(var(--v-theme-on-neutral-strong));
- min-height: 300px;
- max-height: 300px;
- }
- &__cropper-wrapper {
- position: relative;
- }
- &__reset-button {
- position: absolute;
- right: 20px;
- bottom: 20px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- height: 42px;
- width: 42px;
- background: rgb(var(--v-theme-neutral));
- transition: background 0.5s;
- &:hover {
- background: rgb(var(--v-theme-primary-alt));
- }
- }
- &__buttons-wrapper {
- display: flex;
- justify-content: center;
- margin-top: 17px;
- }
- &__button {
- border: none;
- outline: solid transparent;
- color: rgb(var(--v-theme-on-neutral));
- font-size: 16px;
- padding: 10px 20px;
- background: rgb(var(--v-theme-neutral));
- cursor: pointer;
- transition: background 0.5s;
- margin: 0 16px;
- &:hover,
- &:focus {
- background: rgb(var(--v-theme-primary-alt));
- }
- input {
- display: none;
- }
- }
- }
- .max-size-label {
- display: block;
- width: 100%;
- text-align: center;
- font-size: 13px;
- color: rgb(var(--v-theme-on-neutral-soft));
- margin-top: 6px;
- }
- </style>
|