ldc-loue/resources/ts/components/bo/ImageInputComponent.vue
2022-06-17 17:23:41 +02:00

782 lines
24 KiB
Vue

<template>
<div class="container ImageInput p-0">
<div class="row">
<div :class="`col-12 ${_preview ? 'col-md-6' : ''} form-group`">
<label v-if="_preview" :for="_id" class="col-form-label"
><b>{{ __("Choisir un fichier") }}</b></label
>
<div class="input-group">
<button
v-on:click.prevent="chooseAFile"
class="btn btn-outline-secondary"
type="button"
data-bs-toggle="tooltip"
:title="__('Modifier l\'image source')"
>
<i class="fa-solid fa-folder-open"></i>
</button>
<input
:id="_id"
ref="actualImage"
@change="changedImage"
type="file"
class="d-none"
:name="_name"
accept="image/png,image/jpeg,.svg"
:aria-describedby="`Help${_id}`"
/>
<input
v-if="typeof _value === 'string'"
class="d-none"
:name="_name"
type="text"
:value="_value"
/>
<input
v-on:click.prevent="chooseAFile"
type="text"
:value="_valueFileName"
class="form-control rightAligned"
data-bs-toggle="tooltip"
:title="__('Modifier l\'image source')"
:aria-describedby="`Help${_id}`"
readonly
/>
<button
v-if="!_required"
v-on:click.prevent="removeFile"
class="btn btn-outline-danger"
type="button"
>
<i class="fas fa-eraser"></i>
</button>
<button
v-if="!_preview && _value"
class="btn btn-warning"
type="button"
data-bs-toggle="modal"
:data-bs-target="`#ModalPreview${_id}`"
>
<span data-bs-toggle="tooltip" :title="__('Visualiser l\'image')"
><i class="fa-solid fa-eye"></i
></span>
</button>
<button
v-on:click.prevent="prepareImageEditor($event, Number.parseInt(_id))"
:disabled="!_hasImage"
:class="`btn ${!_hasModdedImage ? 'btn-primary' : 'btn-success'}`"
type="button"
data-bs-toggle="modal"
:data-bs-target="`#Modal${_id}`"
>
<span data-bs-toggle="tooltip" :title="__('Redimensionner l\'image')">
<i class="fa-solid fa-crop"></i>
</span>
</button>
<span
v-if="_hasModdedImage"
class="input-group-text text-success"
data-bs-toggle="tooltip"
:title="__('Image redimensionée')"
>
<i class="fa-solid fa-wand-magic"></i>
</span>
</div>
<small :id="`Help${_id}`" class="form-text text-muted">{{ _helper }}</small>
</div>
<div v-if="_preview" :class="`col-12 ${_preview ? 'col-md-6' : ''} form-group`">
<small
v-if="_value"
:id="`PreviewHelp${_id}`"
class="form-text text-muted d-block"
>{{ __("Prévisualisation de l'image")
}}<span v-if="_originalFile" class="fw-bold"
>&nbsp;{{ humanFileSize(_originalFile.size) }}&nbsp;</span
></small
>
<img
v-if="_value"
class="img-fluid"
:src="_value"
:alt="__('Image placeholder')"
:aria-describedby="`PreviewHelp${_id}`"
/>
</div>
</div>
<div
:id="`Modal${_id}`"
class="modal"
tabindex="-1"
role="dialog"
data-bs-backdrop="static"
data-bs-keyboard="false"
>
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ __("Modification d'image avant import") }}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
:aria-label="__('Fermer la fenêtre')"
data-bs-toggle="tooltip"
:title="__('Fermer la fenêtre sans valider le contenu')"
></button>
</div>
<div class="modal-body">
<div class="container">
<div class="row">
<div class="col text-center">
<div class="btn-group me-4 mt-2">
<button
class="btn btn-warning"
data-bs-toggle="tooltip"
:title="__('Réinitialiser l\'image')"
v-on:click.prevent="reset()"
>
<i class="fa fa-sync-alt"></i>
</button>
</div>
<div class="btn-group me-4 mt-2">
<button
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Zoom avant')"
v-on:click.prevent="zoom(0.1)"
>
<i class="fa fa-search-plus"></i>
</button>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Zoom arrière')"
v-on:click.prevent="zoom(-0.1)"
>
<i class="fa fa-search-minus"></i>
</a>
</div>
<div class="btn-group me-4 mt-2">
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Déplacer vers la gauche')"
v-on:click.prevent="move(-10, 0)"
>
<i class="fa fa-arrow-left"></i>
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Déplacer vers la droite')"
v-on:click.prevent="move(10, 0)"
>
<i class="fa fa-arrow-right"></i>
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Déplacer vers le haut')"
v-on:click.prevent="move(0, -10)"
>
<i class="fa fa-arrow-up"></i>
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Déplacer vers le bas')"
v-on:click.prevent="move(0, 10)"
>
<i class="fa fa-arrow-down"></i>
</a>
</div>
<div class="btn-group me-4 mt-2">
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Mirroir Horizontal')"
v-on:click.prevent="scale(true)"
>
<i class="fa fa-arrows-alt-h"></i>
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Mirroir Vertical')"
v-on:click.prevent="scale(false)"
>
<i class="fa fa-arrows-alt-v"></i>
</a>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex flex-row">
<div class="rotateButton">
<span class="text-dark">{{ __("Rotation") }}&nbsp;:</span>
<div class="btn-group mt-1">
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Rotation sens anti-horaire')"
v-on:click.prevent="rotate(-45)"
>
<i class="fa fa-undo-alt"></i>
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('Rotation sens horaire')"
v-on:click.prevent="rotate(45)"
>
<i class="fa fa-redo-alt"></i>
</a>
</div>
</div>
<div class="mt-1 ms-4 rotateDrag">
<label for="range" class="form-label me-4"
>{{ __(":deg degrès", { deg: String(_rotationText) }) }}
</label>
<input
ref="rotationSlider"
v-on:input.stop="rotationChange($event as Event)"
v-on:change.stop="rotateTo(Number.parseInt(($event.target as HTMLInputElement).value))"
type="range"
class="form-range"
id="range"
min="0"
max="360"
value="0"
/>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-md-6 p-0 mb-3">
<img class="imageModification" ref="imgEditor" src="" />
</div>
<div ref="previewBox" class="col-12 col-md-6 p-0 bg-light previewIMG">
<div :id="`Preview${_id}`" :style="_previewStyle"></div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
:aria-label="__('Fermer la fenêtre sans valider le contenu')"
data-bs-toggle="tooltip"
:title="__('Fermer la fenêtre sans valider le contenu')"
>
{{ __("Fermer") }}
</button>
<button
v-on:click.prevent="exportToBlob"
type="button"
class="btn btn-primary"
data-bs-toggle="tooltip"
data-bs-dismiss="modal"
:title="__('Valider le contenu et fermer la fenêtre')"
>
{{ __("Valider") }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="!_preview && _value"
:id="`ModalPreview${_id}`"
class="modal"
tabindex="-1"
role="dialog"
data-bs-backdrop="static"
data-bs-keyboard="false"
>
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ __("Prévisualisation") }}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
:aria-label="__('Fermer la fenêtre')"
data-bs-toggle="tooltip"
:title="__('Fermer la fenêtre sans valider le contenu')"
></button>
</div>
<div class="modal-body">
<div class="container">
<div class="row">
<div class="col-12 text-center">
<small :id="`PreviewHelp${_id}`" class="form-text text-muted d-block">{{
__("Prévisualisation de l'image")
}}</small>
<img
class="img-fluid"
:src="_value"
:alt="__('Image placeholder')"
:aria-describedby="`PreviewHelp${_id}`"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
import Cropper from "cropperjs";
import { Tooltip } from "bootstrap";
import trans from "../../modules/trans";
const UNITS = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte", "petabyte"];
const BYTES_PER_KB = 1024;
export default defineComponent({
mixins: [trans],
emits: ["browseFile"],
mounted() {
const json = String(this.$attrs.json ?? "");
// * INIT Bootstrap tooltips
let tooltipTriggerList = [].slice.call(
document.querySelectorAll('.ImageInput [data-bs-toggle="tooltip"]')
);
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new Tooltip(tooltipTriggerEl, { delay: { show: 1000, hide: 300 } });
});
if (json.length) {
// * Init uppon Laravel Blade
const data = JSON.parse(json);
this._id = data.id;
this._name = data.name;
this._helper = data.helper ?? "";
this._width = data.width;
this._height = data.height;
this._value = String(data.value);
if (this._value.length) {
this._valueFileName = this._value;
this._value = this._value.replace(/^\/|^/, "/");
}
if (data.preview !== undefined) {
this._preview = data.preview ? true : false;
}
if (data.required !== undefined) {
this._required = data.required ? true : false;
}
if (data.browseEvent !== undefined) {
this._browseEvent = data.browseEvent ? true : false;
}
} else {
// * Init Uppon Vue component
this._id = this.id;
this._name = this.name;
this._helper = this.helper;
this._width = this.width;
this._height = this.height;
this._value = this.value;
if (this._value.length) {
this._valueFileName = this._value;
this._value = this._value.replace(/^\/|^/, "/");
}
this._preview = this.preview;
this._required = this.required;
this._browseEvent = this.browseEvent;
}
this._hasImage = false;
},
props: {
id: {
default: "",
},
required: {
type: Boolean,
default: false,
},
name: {
type: String,
default: "",
},
helper: {
type: String,
default: "",
},
width: {
type: Number,
default: 0,
},
height: {
type: Number,
default: 0,
},
value: {
type: String,
default: "",
},
preview: {
type: Boolean,
default: true,
},
browseEvent: {
type: Boolean,
default: false,
},
},
data(): {
_id: string;
_value: string;
_valueFileName: string;
_helper: string;
_name: string;
_required: boolean;
_width: number;
_height: number;
_preview: boolean;
_browseEvent: boolean;
_rotationText: number;
_rotationValue: number;
_scaleX: number;
_scaleY: number;
_hasImage: boolean;
_hasModdedImage: boolean;
_cropper: Cropper | null;
_originalFile: File | null;
_originalFileName: string;
_previewStyle: string;
} {
return {
_id: "",
_value: "",
_valueFileName: this.__("Sélectionnez une image"),
_helper: "",
_name: "",
_required: true,
_width: 0,
_height: 0,
_preview: true,
_browseEvent: false,
_rotationText: 0,
_rotationValue: 0,
_scaleX: 1,
_scaleY: 1,
_hasImage: false,
_hasModdedImage: false,
_cropper: null,
_originalFile: null,
_originalFileName: "",
_previewStyle: "",
};
},
computed: {
imageRatio() {
return this._width / this._height;
},
cropperOption(): Cropper.Options {
const self = this;
return {
autoCrop: true,
background: true,
autoCropArea: 1,
// * full crop box
viewMode: 0,
dragMode: "move",
movable: true,
rotatable: true,
scalable: true,
zoomable: true,
zoomOnTouch: false,
zoomOnWheel: false,
cropBoxMovable: true,
cropBoxResizable: false,
toggleDragModeOnDblclick: false,
aspectRatio: this._width / this._height,
preview: `#Preview${this._id}`,
};
},
},
watch: {
_rotationValue(value) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
}
this._cropper.rotateTo(value);
},
_value() {
if (this._browseEvent) {
this._hasImage = true;
}
},
value() {
this._value = this.value.replace(/^\/|^/, "/");
this._valueFileName = this.value;
},
},
methods: {
previewStyleCompute() {
const previewBox = this.$refs.previewBox as HTMLElement,
style = window.getComputedStyle(previewBox),
targetWidth = Number.parseFloat(style.width) / 1.2,
targetHeight =
(this._height * Number.parseFloat(style.width)) / this._width / 1.2;
this._previewStyle = `width: ${targetWidth.toFixed(
3
)}px;height:${targetHeight.toFixed(3)}px;`;
},
reset() {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
}
this._cropper.reset();
},
zoom(v: number) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
}
this._cropper.zoom(v);
},
move(x: number, y: number) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
}
this._cropper.move(x, y);
},
scale(horizontal: boolean) {
if (!(this._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
}
if (horizontal) {
this._scaleX = -1 / this._scaleX;
this._cropper.scaleX(this._scaleX);
} else {
this._scaleY = -1 / this._scaleY;
this._cropper.scaleY(this._scaleY);
}
},
rotate(value: number) {
const rotationSlider = this.$refs.rotationSlider as HTMLInputElement;
let res = this._rotationValue + value;
res = res < 0 ? 0 : res;
res = res > 360 ? 360 : res;
this._rotationValue = res;
this._rotationText = this._rotationValue;
rotationSlider.value = String(this._rotationValue);
},
rotateTo(value: number) {
value = value < 0 ? 0 : value;
value = value > 360 ? 360 : value;
this._rotationValue = value;
this._rotationText = this._rotationValue;
},
rotationChange(e: Event) {
const el = e.target;
if (!el || !(el instanceof HTMLInputElement)) {
throw new Error("target element is not an input");
}
this.rotateTo(Number.parseFloat(el.value));
},
chooseAFile(e: Event) {
const actualImage = this.$refs.actualImage as HTMLInputElement;
if (this._browseEvent) {
this.$emit("browseFile", this._id);
return;
}
actualImage.click();
},
removeFile(e: Event) {
const actualImage = this.$refs.actualImage as HTMLInputElement;
actualImage.value = "";
this._valueFileName = this.__("Sélectionnez une image");
this._value = "";
},
changedImage(e: Event) {
const self = this,
reader = new FileReader();
const el = e.target;
if (!el || !(el instanceof HTMLInputElement)) {
throw new Error("confirmDoneJS can only be executed on an exising form");
}
if (!el.files) {
throw new Error();
}
self._hasImage = true;
reader.addEventListener(
"load",
() => {
// On convertit l'image en une chaîne de caractères base64.
self._value = String(reader.result);
},
false
);
if (el.files[0]) {
self._originalFile = el.files[0];
reader.readAsDataURL(el.files[0]);
this._valueFileName = el.files[0].name;
}
},
prepareImageEditor(e: Event, id: number) {
const self = this,
actualImage = this.$refs.actualImage as HTMLInputElement,
imgEditor = this.$refs.imgEditor as HTMLImageElement,
createCropper = () => {
if (self._cropper) {
self._cropper.destroy();
}
self._cropper = new Cropper(imgEditor, self.cropperOption);
};
if (!self.browseEvent) {
if (!actualImage || !actualImage.files) {
throw new Error("actualImage is not defined");
}
self._originalFile = actualImage.files[0];
self._originalFileName = actualImage.files[0].name;
imgEditor.src = URL.createObjectURL(actualImage.files[0]);
self.previewStyleCompute();
this.$nextTick(() => createCropper());
return;
}
e.preventDefault();
this.$nextTick(() => {
fetch(self.value.replace(/^\/|^/, "/"))
.then((response) => {
self._originalFileName = response.url.substring(
response.url.lastIndexOf("/") + 1
);
return response.blob();
})
.then((blob: Blob) => {
imgEditor.src = URL.createObjectURL(blob);
self.previewStyleCompute();
self.$nextTick(() => createCropper());
});
});
},
dataURLtoBlob(dataUrl: string) {
const arr = dataUrl.split(",");
if (!arr[0] || !arr[1]) {
throw new Error("invalid data url");
}
const mime = arr[0].match(/:(.*?);/);
if (!mime || mime.length < 2) {
throw new Error("invalid mime");
}
const bstr = window.atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime[1] });
},
exportToBlob() {
const self = this,
actualImage = this.$refs.actualImage as HTMLInputElement,
container = new DataTransfer();
if (!(self._cropper instanceof Cropper)) {
throw new Error("cropped not initialized");
}
if (!actualImage.files) {
throw new Error();
}
self._hasModdedImage = true;
self._value = self._cropper
.getCroppedCanvas({
fillColor: "rgba(0, 0, 0, 0)",
width: self._width,
height: self._height,
imageSmoothingEnabled: true,
imageSmoothingQuality: "high",
})
.toDataURL("image/png");
const file = new File([self.dataURLtoBlob(self._value)], self._originalFileName, {
type: "image/png",
lastModified: new Date().getTime(),
});
this._originalFile = file;
container.items.add(file);
actualImage.files = container.files;
},
humanFileSize(sizeBytes: number | bigint): string {
let size = Math.abs(Number(sizeBytes));
let u = 0;
while (size >= BYTES_PER_KB && u < UNITS.length - 1) {
size /= BYTES_PER_KB;
++u;
}
return new Intl.NumberFormat([], {
style: "unit",
unit: UNITS[u],
unitDisplay: "short",
maximumFractionDigits: 1,
}).format(size);
},
},
});
</script>
<style lang="scss" scopped>
@import "~cropperjs/dist/cropper.css";
@import "../../../../node_modules/bootstrap/scss/bootstrap-utilities.scss";
.ImageInput {
/* Ensure the size of the image fit the container perfectly */
.imageModification {
display: block;
/* This rule is very important, please don't ignore this */
max-width: 100%;
}
.rotateButton {
display: flex;
flex-direction: column;
}
.rotateDrag {
display: flex;
flex-direction: column-reverse;
width: 100%;
& > input {
width: 100%;
}
}
.previewIMG {
position: relative;
& > div {
position: relative;
overflow: hidden;
border: 1px solid black;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
input.rightAligned {
direction: ltr !important;
overflow: hidden !important;
}
input.rightAligned:not(:focus) {
direction: rtl !important;
text-align: left !important;
text-overflow: ellipsis !important;
}
}
</style>