ldc-lionor/resources/ts/bo/components/ImageInputComponent.vue
2023-08-28 08:46:47 +00:00

962 lines
29 KiB
Vue

<template>
<div class="container image-input-vue p-0">
<div class="row">
<div :class="`col-12 ${inTpreview ? 'col-md-6' : ''} form-group`">
<label
v-if="inTpreview && showLabels"
:for="inTId"
class="col-form-label"
>
<b>{{ __("vue.ImageInputComponent.choose_a_file") }}</b>
</label>
<div class="input-group">
<button
@click.prevent="chooseAFile"
class="btn btn-outline-secondary"
type="button"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.change_source_picture')"
>
<FontAwesomeIcon icon="fa-solid fa-folder-open" />
</button>
<input
:id="inTId"
ref="actualImage"
@change="changedImage"
type="file"
class="d-none"
:name="inTname"
accept=".jpg,.jpeg,.gif,.png"
:aria-describedby="`Help${inTId}`"
>
<input
v-if="inTvalue.indexOf('data:image/png;base64') !== 0"
class="d-none"
:name="inTname"
type="text"
:value="submitIntValue"
>
<input
@click.prevent="chooseAFile"
type="text"
:value="inTvalueFileName"
class="form-control right-aligned"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.change_source_picture')"
:aria-describedby="`Help${inTId}`"
readonly
>
<button
v-if="!inTrequired && inThasImage"
@click.prevent="removeFile"
class="btn btn-outline-danger"
type="button"
>
<FontAwesomeIcon icon="fa-solid fa-eraser" />
</button>
<button
v-if="!inTpreview && inTvalue"
class="btn btn-warning"
type="button"
data-bs-toggle="modal"
:data-bs-target="`#ModalPreview${inTId}`"
>
<span
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.view_source_picture')"
>
<FontAwesomeIcon icon="fa-solid fa-eye" />
</span>
</button>
<button
@click.prevent="prepareImageEditor($event)"
:disabled="!inThasImage"
:class="`btn ${!inThasModdedImage ? 'btn-primary' : 'btn-success'}`"
type="button"
data-bs-toggle="modal"
:data-bs-target="`#Modal${inTId}`"
>
<span
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.resize_source_picture')"
>
<FontAwesomeIcon icon="fa-solid fa-crop" />
</span>
</button>
<span
v-if="inThasModdedImage"
class="input-group-text text-success"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.resized_source_picture')"
>
<FontAwesomeIcon icon="fa-solid fa-wand-magic" />
</span>
</div>
<small
:id="`Help${inTId}`"
class="form-text text-muted"
>{{
inThelper
}}</small>
</div>
<div
v-if="inTpreview"
:class="`col-12 ${inTpreview ? 'col-md-6' : ''} form-group`"
>
<small
v-if="inTvalue && showLabels"
:id="`PreviewHelp${inTId}`"
class="form-text text-muted d-block"
>{{ __("vue.ImageInputComponent.preview_picture") }}
<span
v-if="inToriginalFile"
class="fw-bold"
>
{{ humanFileSize(inToriginalFile.size) }}
{{ `hauteur ${inToriginalFileDimensions.height}px` }}
{{ `largeur ${inToriginalFileDimensions.width}px` }}
</span>
</small>
<img
v-if="inTvalue"
class="img-fluid"
:src="inTvalue"
:alt="__('Image placeholder')"
:aria-describedby="`PreviewHelp${inTId}`"
>
</div>
</div>
<div
:id="`Modal${inTId}`"
class="modal"
tabindex="-1"
role="dialog"
data-bs-backdrop="static"
data-bs-keyboard="false"
@mousedown="preventMouseDown"
@dragstart.prevent="() => false"
@ondrop.prevent="() => false"
@dragover.prevent="() => false"
>
<div
class="modal-dialog modal-xl"
role="document"
>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ __("vue.ImageInputComponent.change_picture_before_import") }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
:aria-label="__('vue.ImageInputComponent.close_window')"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.close_window_without_validation')"
/>
</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')"
@click.prevent="reset()"
>
<FontAwesomeIcon icon="fa-solid fa-sync-alt" />
</button>
</div>
<div class="btn-group me-4 mt-2">
<button
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.zoom_in')"
@click.prevent="zoom(0.1)"
>
<FontAwesomeIcon icon="fa-solid fa-search-plus" />
</button>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.zoom_out')"
@click.prevent="zoom(-0.1)"
>
<FontAwesomeIcon icon="fa-solid fa-search-minus" />
</a>
</div>
<div class="btn-group me-4 mt-2">
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.move_left')"
@click.prevent="move(-10, 0)"
>
<FontAwesomeIcon icon="fa-solid fa-arrow-left" />
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.move_right')"
@click.prevent="move(10, 0)"
>
<FontAwesomeIcon icon="fa-solid fa-arrow-right" />
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.move_up')"
@click.prevent="move(0, -10)"
>
<FontAwesomeIcon icon="fa-solid fa-arrow-up" />
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.move_down')"
@click.prevent="move(0, 10)"
>
<FontAwesomeIcon icon="fa-solid fa-arrow-down" />
</a>
</div>
<div class="btn-group me-4 mt-2">
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.mirror_horizontal')"
@click.prevent="scale(true)"
>
<FontAwesomeIcon icon="fa-solid fa-arrows-alt-h" />
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.mirror_vertical')"
@click.prevent="scale(false)"
>
<FontAwesomeIcon icon="fa-solid fa-arrows-alt-v" />
</a>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="form-group d-flex flex-row">
<div class="rotate-button">
<span class="text-dark">{{ __("vue.ImageInputComponent.rotation") }}&nbsp;:</span>
<div class="btn-group mt-1">
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.rotate_rewind')"
@click.prevent="rotate(-45)"
>
<FontAwesomeIcon icon="fa-solid fa-undo-alt" />
</a>
<a
class="btn btn-primary"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.rotate')"
@click.prevent="rotate(45)"
>
<FontAwesomeIcon icon="fa-solid fa-redo-alt" />
</a>
</div>
</div>
<div class="mt-1 ms-4 rotate-drag">
<label
for="range"
class="form-label me-4"
>{{
__("vue.ImageInputComponent.degres", {
deg: String(inTrotationText),
})
}}
</label>
<input
ref="rotationSlider"
@input.stop="rotationChange"
@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="image-modification"
ref="imgEditor"
src=""
>
</div>
<div
ref="previewBox"
class="col-12 col-md-6 p-0 bg-light preview-img"
>
<div
:id="`Preview${inTId}`"
:style="inTpreviewStyle"
/>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
:aria-label="__('vue.ImageInputComponent.close_window_without_validation')"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.close_window_without_validation')"
>
{{ __("vue.ImageInputComponent.close_window") }}
</button>
<button
@click.prevent="exportCropperFile"
type="button"
class="btn btn-primary"
data-bs-toggle="tooltip"
data-bs-dismiss="modal"
:title="__('vue.ImageInputComponent.close_window_and_validate')"
>
{{ __("vue.ImageInputComponent.validate") }}
</button>
</div>
</div>
</div>
</div>
<div
v-if="!inTpreview && inTvalue"
:id="`ModalPreview${inTId}`"
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">
{{ __("vue.ImageInputComponent.preview") }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
:aria-label="__('vue.ImageInputComponent.close_window')"
data-bs-toggle="tooltip"
:title="__('vue.ImageInputComponent.close_window_without_validation')"
/>
</div>
<div class="modal-body">
<div class="container">
<div class="row">
<div class="col-12 text-center">
<small
:id="`PreviewHelp${inTId}`"
class="form-text text-muted d-block"
>{{ __("vue.ImageInputComponent.preview_picture") }}</small>
<img
class="img-fluid"
:src="inTvalue"
:alt="__('Image placeholder')"
:aria-describedby="`PreviewHelp${inTId}`"
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Cropper from "cropperjs";
import trans from "../../modules/trans";
import bsTooltips from "../../modules/bsTooltips";
const UNITS = [
"byte",
"kilobyte",
"megabyte",
"gigabyte",
"terabyte",
"petabyte",
];
const BYTES_PER_KB = 1024;
export default defineComponent({
components: {
FontAwesomeIcon,
},
mixins: [trans, bsTooltips],
emits: {
browseFile: (inTId: string) => {
inTId;
return true;
},
imageFile: (file: File | null) => {
file;
return true;
},
},
mounted() {
const json = String(this.$attrs.json ?? "");
this.initTooltips();
if (json.length) {
// * Init uppon Laravel Blade
const data = JSON.parse(json);
this.inTId = data.id;
this.inTname = data.name;
this.inThelper = data.helper ?? "";
this.inTwidth = Number.parseInt(String(data.width));
this.inTheight = Number.parseInt(String(data.height));
this.inTvalue = String(data.value);
if (this.inTvalue.length) {
this.inTvalueFileName = this.inTvalue.replace(/^\/|^/, "");
this.inTvalue = this.inTvalue.replace(/^\/|^/, "/");
}
if (data.preview !== undefined) {
this.inTpreview = data.preview ? true : false;
}
if (data.required !== undefined) {
this.inTrequired = data.required ? true : false;
}
if (data.browseEvent !== undefined) {
this.inTbrowseEvent = data.browseEvent ? true : false;
}
} else {
// * Init Uppon Vue component
this.inTId = this.id;
this.inTname = this.name;
this.inThelper = this.helper;
this.inTwidth = this.width;
this.inTheight = this.height;
this.inTvalue = this.value;
if (this.inTvalue.length) {
this.inTvalueFileName = this.inTvalue.replace(/^\/|^/, "");
this.inTvalue = this.inTvalue.replace(/^\/|^/, "/");
}
this.inTpreview = this.preview;
this.inTrequired = this.required;
this.inTbrowseEvent = this.browseEvent;
}
this.inThasImage = false;
},
props: {
id: {
type: String,
default: String(Math.pow(10, 16) / Math.random()),
},
required: {
type: Boolean,
default: false,
},
showLabels: {
type: Boolean,
default: true,
},
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(): {
inTId: string;
inTvalue: string;
inTvalueFileName: string;
inThelper: string;
inTname: string;
inTrequired: boolean;
inTwidth: number;
inTheight: number;
inTpreview: boolean;
inTbrowseEvent: boolean;
inTrotationText: number;
inTrotationValue: number;
inTscaleX: number;
inTscaleY: number;
inThasImage: boolean;
inThasModdedImage: boolean;
inTcropper: Cropper | null;
inToriginalFile: File | null;
inToriginalFileDimensions: {
height: number;
width: number;
};
inToriginalFileName: string;
inTpreviewStyle: string;
} {
return {
inTId: "",
inTvalue: "",
inTvalueFileName: this.__("vue.ImageInputComponent.choose_a_file"),
inThelper: "",
inTname: "",
inTrequired: true,
inTwidth: 0,
inTheight: 0,
inTpreview: true,
inTbrowseEvent: false,
inTrotationText: 0,
inTrotationValue: 0,
inTscaleX: 1,
inTscaleY: 1,
inThasImage: false,
inThasModdedImage: false,
inTcropper: null,
inToriginalFile: null,
inToriginalFileDimensions: {
height: 0,
width: 0,
},
inToriginalFileName: "",
inTpreviewStyle: "",
};
},
computed: {
imageRatio() {
return this.inTwidth / this.inTheight;
},
cropperOption(): Cropper.Options {
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.inTwidth / this.inTheight,
preview: `#Preview${this.inTId}`,
};
},
/** Removes prepended slash */
submitIntValue(): string {
return this.inTvalue.replace(/^\/|^/, "");
},
},
watch: {
inTrotationValue(value) {
if (!this.inTcropper) {
return;
}
this.inTcropper.rotateTo(value);
},
inTvalue() {
if (this.inTbrowseEvent) {
this.inThasImage = true;
}
},
value: {
deep: true,
immediate: true,
handler(value: string) {
// * Ignore non prop usage.
if (String(this.$attrs.json ?? "").length) {
return;
}
if (!value.length) {
this.$nextTick(() => {
this.removeFile();
});
return;
} else {
this.inThasImage = true;
}
// * Using browse event, If user changed imagem recompile blob (File Upload storage strategy)
if (this.inTbrowseEvent && this.inThasImage) {
const self = this,
actualImage = this.$refs.actualImage as HTMLInputElement,
container = new DataTransfer();
this.$nextTick(() => {
this.inTvalue = value.replace(/^\/|^/, "/");
this.inTvalueFileName = value.replace(/^\/|^/, "");
fetch(this.inTvalue)
.then((response) => {
self.inToriginalFileName = response.url.substring(
response.url.lastIndexOf("/") + 1
);
return response.blob();
})
.then((blob: Blob) => {
if (!actualImage.files) {
throw new Error("3- missing files list");
}
this.fetchDataUrlImageSizes(self.inTvalue);
const file = new File([blob], this.inTvalueFileName, {
type: "image/png",
lastModified: new Date().getTime(),
});
this.inThasImage = true;
this.inToriginalFile = file;
this.$emit("imageFile", file);
container.items.add(file);
actualImage.files = container.files;
this.changedImage();
});
});
}
},
},
},
methods: {
preventMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement)?.id !== "range") {
e.preventDefault();
return false;
}
},
previewStyleCompute() {
const previewBox = this.$refs.previewBox as HTMLElement,
style = window.getComputedStyle(previewBox),
targetWidth = Number.parseFloat(style.width) / 1.2,
targetHeight =
(this.inTheight * Number.parseFloat(style.width)) /
this.inTwidth /
1.2;
this.inTpreviewStyle = `width: ${targetWidth.toFixed(
3
)}px;height:${targetHeight.toFixed(3)}px;`;
},
reset() {
if (!this.inTcropper) {
return;
}
this.inTcropper.reset();
},
zoom(v: number) {
if (!this.inTcropper) {
return;
}
this.inTcropper.zoom(v);
},
move(x: number, y: number) {
if (!this.inTcropper) {
return;
}
this.inTcropper.move(x, y);
},
scale(horizontal: boolean) {
if (!this.inTcropper) {
return;
}
if (horizontal) {
this.inTscaleX = -1 / this.inTscaleX;
this.inTcropper.scaleX(this.inTscaleX);
} else {
this.inTscaleY = -1 / this.inTscaleY;
this.inTcropper.scaleY(this.inTscaleY);
}
},
rotate(value: number) {
const rotationSlider = this.$refs.rotationSlider as HTMLInputElement;
let res = this.inTrotationValue + value;
res = res < 0 ? 0 : res;
res = res > 360 ? 360 : res;
this.inTrotationValue = res;
this.inTrotationText = this.inTrotationValue;
rotationSlider.value = String(this.inTrotationValue);
},
rotateTo(value: number) {
value = value < 0 ? 0 : value;
value = value > 360 ? 360 : value;
this.inTrotationValue = value;
this.inTrotationText = this.inTrotationValue;
},
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() {
const actualImage = this.$refs.actualImage as HTMLInputElement;
if (this.inTbrowseEvent) {
this.$emit("browseFile", this.inTId);
return;
}
actualImage.click();
},
removeFile() {
const actualImage = this.$refs.actualImage as HTMLInputElement;
actualImage.value = "";
this.inTvalueFileName = this.__("vue.ImageInputComponent.choose_a_file");
this.inTvalue = "";
this.inToriginalFileName = "";
this.inToriginalFile = null;
this.inThasModdedImage = false;
this.inToriginalFileDimensions = {
height: 0,
width: 0,
};
// * To overload watcher for inTvalue
this.$nextTick(() => (this.inThasImage = false));
this.$emit("imageFile", null);
},
changedImage() {
const self = this,
reader = new FileReader(),
actualImage = this.$refs.actualImage as HTMLInputElement;
if (!actualImage.files) {
throw new Error("missing files list");
}
self.inThasImage = true;
reader.addEventListener(
"load",
() => {
// On convertit l'image en une chaîne de caractères base64.
self.inTvalue = String(reader.result);
},
false
);
if (actualImage.files[0]) {
self.inToriginalFile = actualImage.files[0];
self.inToriginalFileName = actualImage.files[0].name;
reader.readAsDataURL(actualImage.files[0]);
reader.onloadend = () => {
this.fetchDataUrlImageSizes(String(reader.result));
this.exportToBlob();
};
this.inTvalueFileName = actualImage.files[0].name;
}
},
prepareImageEditor(e: Event) {
const actualImage = this.$refs.actualImage as HTMLInputElement,
imgEditor = this.$refs.imgEditor as HTMLImageElement,
self = this,
createCropper = () => {
if (self.inTcropper) {
self.inTcropper.destroy();
}
self.inTcropper = new Cropper(imgEditor, self.cropperOption);
};
if (!this.browseEvent) {
if (!actualImage || !actualImage.files) {
throw new Error("actualImage is not defined");
}
this.inToriginalFile = actualImage.files[0];
this.inToriginalFileName = actualImage.files[0].name;
imgEditor.src = URL.createObjectURL(actualImage.files[0]);
this.previewStyleCompute();
this.$nextTick(() => createCropper());
return;
}
e.preventDefault();
this.$nextTick(() => {
fetch(this.value.replace(/^\/|^/, "/"))
.then((response) => {
this.inToriginalFileName = response.url.substring(
response.url.lastIndexOf("/") + 1
);
return response.blob();
})
.then((blob: Blob) => {
imgEditor.src = URL.createObjectURL(blob);
this.previewStyleCompute();
this.$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] });
},
fetchDataUrlImageSizes(dataUrl: string) {
const img = new Image(),
self = this;
try {
img.onload = () => {
self.inToriginalFileDimensions.width = img.width;
self.inToriginalFileDimensions.height = img.height;
};
img.src = dataUrl;
} catch (e) {
self.inToriginalFileDimensions.width = img.width;
self.inToriginalFileDimensions.height = img.height;
}
},
exportCropperFile() {
this.inThasModdedImage = true;
if (!this.inTcropper) {
return;
}
this.inTvalue = this.inTcropper
.getCroppedCanvas({
fillColor: "rgba(0, 0, 0, 0)",
width: this.inTwidth,
height: this.inTheight,
imageSmoothingEnabled: true,
imageSmoothingQuality: "high",
})
.toDataURL("image/png");
this.exportToBlob();
},
exportToBlob() {
const self = this,
actualImage = this.$refs.actualImage as HTMLInputElement,
container = new DataTransfer();
if (!actualImage.files) {
throw new Error("2- missing files list");
}
this.fetchDataUrlImageSizes(self.inTvalue);
const file = new File(
[self.dataURLtoBlob(self.inTvalue)],
self.inToriginalFileName,
{
type: "image/png",
lastModified: new Date().getTime(),
}
);
this.inToriginalFile = file;
this.$emit("imageFile", 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";
.image-input-vue {
/* Ensure the size of the image fit the container perfectly */
.image-modification {
display: block;
/* This rule is very important, please don't ignore this */
max-width: 100%;
}
.rotateButton {
display: flex;
flex-direction: column;
}
.rotate-drag {
display: flex;
flex-direction: column-reverse;
width: 100%;
& > input {
width: 100%;
}
}
.preview-img {
position: relative;
& > div {
position: relative;
overflow: hidden;
border: 1px solid black;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
input.right-aligned {
direction: ltr !important;
overflow: hidden !important;
}
input.right-aligned:not(:focus) {
direction: rtl !important;
text-align: left !important;
text-overflow: ellipsis !important;
}
}
</style>