|
|
@@ -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;
|