1013 lines
31 KiB
Vue
1013 lines
31 KiB
Vue
<template>
|
|
<div
|
|
ref="imageInput"
|
|
class="image-input-vue p-0"
|
|
>
|
|
<!-- INPUT GROUP -->
|
|
<div class="row">
|
|
<!-- INPUT GROUP -->
|
|
<div :class="`col-12 ${parameters.preview ? 'col-md-6' : ''} form-group`">
|
|
<label
|
|
v-if="parameters.preview && parameters.showLabels"
|
|
:for="parameters.id"
|
|
class="col-form-label"
|
|
>
|
|
<b>{{ trans.methods.__("vue.ImageInputComponent.choose_a_file") }}</b>
|
|
</label>
|
|
<div class="input-group">
|
|
<button
|
|
@click.prevent="chooseAFile"
|
|
class="btn btn-outline-secondary z-1"
|
|
type="button"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.change_source_picture')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-folder-open" />
|
|
</button>
|
|
<!-- OUTPUT HTML INPUT -->
|
|
<input
|
|
:id="parameters.id"
|
|
ref="inputFileImage"
|
|
@change="pictureInputHasChanged"
|
|
type="file"
|
|
class="d-none"
|
|
:name="parameters.name"
|
|
accept=".jpg,.jpeg,.gif,.png,.svg"
|
|
:aria-describedby="`Help${parameters.id}`"
|
|
>
|
|
<input
|
|
v-if="typeof fileOutput === 'string'"
|
|
class="d-none"
|
|
:name="parameters.name"
|
|
type="text"
|
|
:value="fileOutput"
|
|
>
|
|
<!-- END OUTPUT HTML INPUT -->
|
|
<!-- FILENAME DISPLAY -->
|
|
<input
|
|
@click.prevent="chooseAFile"
|
|
type="text"
|
|
:value="fileParameters.fileName"
|
|
class="form-control right-aligned"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.change_source_picture')"
|
|
:aria-describedby="`Help${parameters.id}`"
|
|
readonly
|
|
>
|
|
<button
|
|
v-if="!parameters.required && hasPicture"
|
|
@click.prevent="resetComponent"
|
|
class="btn btn-outline-danger"
|
|
type="button"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.remove_picture')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-eraser" />
|
|
</button>
|
|
<button
|
|
v-if="!parameters.preview && fileDataUrl"
|
|
class="btn btn-warning"
|
|
type="button"
|
|
data-bs-toggle="modal"
|
|
:data-bs-target="`#ModalPreview${parameters.id}`"
|
|
>
|
|
<span
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.view_source_picture')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-eye" />
|
|
</span>
|
|
</button>
|
|
<button
|
|
v-if="!isSvg"
|
|
@click.prevent="prepareImageEditor()"
|
|
:disabled="!hasPicture"
|
|
:class="`btn ${!hasCroppedPicture ? 'btn-primary' : 'btn-success'}`"
|
|
type="button"
|
|
data-bs-toggle="modal"
|
|
:data-bs-target="`#Modal${parameters.id}`"
|
|
>
|
|
<span
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.resize_source_picture')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-crop" />
|
|
</span>
|
|
</button>
|
|
<span
|
|
v-if="hasCroppedPicture"
|
|
class="input-group-text text-success"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.resized_source_picture')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-wand-magic" />
|
|
</span>
|
|
</div>
|
|
<small
|
|
:id="`Help${parameters.id}`"
|
|
class="form-text text-body-secondary"
|
|
>
|
|
{{ parameters.helper }}
|
|
</small>
|
|
</div>
|
|
<!-- END INPUT GROUP -->
|
|
<!-- INLINE PREVIEW PART -->
|
|
<div
|
|
v-if="parameters.preview"
|
|
:class="`col-12 ${parameters.preview ? 'col-md-6' : ''} form-group d-flex flex-column`"
|
|
>
|
|
<p
|
|
v-if="fileDataUrl && parameters.showLabels"
|
|
:id="`PreviewHelp${parameters.id}`"
|
|
class="col-form-label fw-bold m-0"
|
|
>
|
|
{{ trans.methods.__("vue.ImageInputComponent.preview_picture") }}
|
|
</p>
|
|
<div>
|
|
<img
|
|
v-if="fileDataUrl"
|
|
:class="`img-fluid ${isSvg ? 'max-h-svg' : ''}`"
|
|
:src="fileDataUrl"
|
|
:alt="trans.methods.__('Image placeholder')"
|
|
:aria-describedby="`PreviewHelp${parameters.id}`"
|
|
>
|
|
</div>
|
|
<small
|
|
v-if="fileParamIsFile"
|
|
class="form-text text-body-secondary"
|
|
>
|
|
<span>{{ humanFileSize((fileParameters.file as File).size) }}</span>
|
|
{{ `largeur ${fileParameters.dimensions.width}px` }}
|
|
{{ `hauteur ${fileParameters.dimensions.height}px` }}.
|
|
</small>
|
|
</div>
|
|
<!-- END INLINE PREVIEW PART -->
|
|
</div>
|
|
<!-- END INPUT GROUP -->
|
|
|
|
<!-- CROP MODAL -->
|
|
<div
|
|
:id="`Modal${parameters.id}`"
|
|
class="modal"
|
|
tabindex="-1"
|
|
role="dialog"
|
|
data-bs-backdrop="static"
|
|
data-bs-keyboard="false"
|
|
@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">
|
|
{{ trans.methods.__("vue.ImageInputComponent.change_picture_before_import") }}
|
|
</h5>
|
|
<button
|
|
type="button"
|
|
class="btn-close"
|
|
data-bs-dismiss="modal"
|
|
:aria-label="trans.methods.__('vue.ImageInputComponent.close_window')"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('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="trans.methods.__('Réinitialiser l\'image')"
|
|
@click.prevent="cropperReset"
|
|
>
|
|
<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="trans.methods.__('vue.ImageInputComponent.zoom_in')"
|
|
@click.prevent="cropperInstance?.zoom(0.1)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-search-plus" />
|
|
</button>
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.zoom_out')"
|
|
@click.prevent="cropperInstance?.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="trans.methods.__('vue.ImageInputComponent.move_left')"
|
|
@click.prevent="cropperInstance?.move(-10, 0)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-left" />
|
|
</a>
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.move_right')"
|
|
@click.prevent="cropperInstance?.move(10, 0)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-right" />
|
|
</a>
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.move_up')"
|
|
@click.prevent="cropperInstance?.move(0, -10)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-up" />
|
|
</a>
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.move_down')"
|
|
@click.prevent="cropperInstance?.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="trans.methods.__('vue.ImageInputComponent.mirror_horizontal')"
|
|
@click.prevent="cropperScale('H')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrows-alt-h" />
|
|
</a>
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.mirror_vertical')"
|
|
@click.prevent="cropperScale('V')"
|
|
>
|
|
<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">{{ trans.methods.__("vue.ImageInputComponent.rotation") }} :</span>
|
|
<div class="btn-group mt-1">
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.rotate_rewind')"
|
|
@click.prevent="cropperRotate(-45)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-undo-alt" />
|
|
</a>
|
|
<a
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.rotate')"
|
|
@click.prevent="cropperRotate(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"
|
|
>
|
|
{{
|
|
trans.methods.__("vue.ImageInputComponent.degres", {
|
|
deg: String(cropperParameters.rotation),
|
|
})
|
|
}}
|
|
</label>
|
|
<input
|
|
ref="rotationSlider"
|
|
@input.stop="cropperParameters.rotation = Number.parseInt(($event.target as HTMLInputElement).value)"
|
|
@change.stop="cropperParameters.rotation = Number.parseInt(($event.target as HTMLInputElement).value)"
|
|
type="range"
|
|
class="form-range"
|
|
id="range"
|
|
min="0"
|
|
max="360"
|
|
:value="cropperParameters.rotation"
|
|
>
|
|
</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 preview-img"
|
|
>
|
|
<div
|
|
:id="`Preview${parameters.id}`"
|
|
:style="previewStyle"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary"
|
|
data-bs-dismiss="modal"
|
|
:aria-label="trans.methods.__('vue.ImageInputComponent.close_window_without_validation')"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.close_window_without_validation')"
|
|
>
|
|
{{ trans.methods.__("vue.ImageInputComponent.close_window") }}
|
|
</button>
|
|
<button
|
|
@click.prevent="exportCropperFile"
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-dismiss="modal"
|
|
:title="trans.methods.__('vue.ImageInputComponent.close_and_validate')"
|
|
>
|
|
{{ trans.methods.__("vue.ImageInputComponent.validate") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- END CROP MODAL -->
|
|
|
|
<!-- PREVIEW MODAL -->
|
|
<div
|
|
v-if="!parameters.preview && fileDataUrl"
|
|
:id="`ModalPreview${parameters.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">
|
|
{{ trans.methods.__("vue.ImageInputComponent.preview") }}
|
|
</h5>
|
|
<button
|
|
type="button"
|
|
class="btn-close"
|
|
data-bs-dismiss="modal"
|
|
:aria-label="trans.methods.__('vue.ImageInputComponent.close_window')"
|
|
data-bs-toggle="tooltip"
|
|
:title="trans.methods.__('vue.ImageInputComponent.close_window_without_validation')"
|
|
/>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="container">
|
|
<div class="row">
|
|
<div class="col-12 text-center d-flex flex-column">
|
|
<small
|
|
:id="`PreviewHelp${parameters.id}`"
|
|
class="form-text text-body-secondary d-block"
|
|
>{{ trans.methods.__("vue.ImageInputComponent.preview_picture") }}</small>
|
|
<small
|
|
v-if="fileParamIsFile"
|
|
class="form-text text-body-secondary"
|
|
>
|
|
<span>{{ humanFileSize((fileParameters.file as File).size) }}</span>
|
|
{{ `largeur ${fileParameters.dimensions.width}px` }}
|
|
{{ `hauteur ${fileParameters.dimensions.height}px` }}
|
|
</small>
|
|
<div>
|
|
<img
|
|
v-if="fileDataUrl"
|
|
class="img-fluid"
|
|
:src="fileDataUrl"
|
|
:alt="trans.methods.__('Image placeholder')"
|
|
:aria-describedby="`PreviewHelp${parameters.id}`"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- END PREVIEW MODAL -->
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
import Cropper from "cropperjs";
|
|
import { computed, defineOptions, nextTick, onMounted, reactive, ref, useAttrs, watch } from "vue";
|
|
import { Tooltips } from "./../../modules/bsTooltips";
|
|
import trans from "./../../modules/trans";
|
|
|
|
/** If string the it is an absolute file path. */
|
|
type FileOutputValue = File|string|null;
|
|
type MirrorScale = 1|-1;
|
|
|
|
type CropperParameters = {
|
|
/** Rotate the image to an absolute degree. */
|
|
rotation: number,
|
|
/** The scaling factor applies to the abscissa of the image. */
|
|
scaleX: MirrorScale,
|
|
/** The scaling factor applies to the ordinate of the image. */
|
|
scaleY: MirrorScale
|
|
};
|
|
|
|
// * CONSTANTS
|
|
const UNITS = [
|
|
"byte",
|
|
"kilobyte",
|
|
"megabyte",
|
|
"gigabyte",
|
|
"terabyte",
|
|
"petabyte",
|
|
];
|
|
const BYTES_PER_KB = 1024;
|
|
|
|
defineOptions({
|
|
name: "ImageInputComponent"
|
|
});
|
|
|
|
// * EMITS
|
|
const emits = defineEmits<{
|
|
browseFile: [intId: string],
|
|
imageFile: [file: File | null],
|
|
}>();
|
|
|
|
// * ATTRIBUTES
|
|
const attrs = useAttrs();
|
|
|
|
// * REFS
|
|
const imageInput = ref<HTMLDivElement|null>(null);
|
|
const previewBox = ref<HTMLDivElement|null>(null);
|
|
/** The inputfile to select a picture and send in form. */
|
|
const inputFileImage = ref<HTMLInputElement|null>(null);
|
|
const imgEditor = ref<HTMLImageElement|null>(null);
|
|
|
|
// * PROPS
|
|
const props = defineProps({
|
|
id: {
|
|
type: String,
|
|
default: String(Math.pow(10, 16) / Math.random())
|
|
},
|
|
name: {
|
|
type: String,
|
|
default: ""
|
|
},
|
|
value: {
|
|
type: String,
|
|
default: ""
|
|
},
|
|
helper: {
|
|
type: String,
|
|
default: ""
|
|
},
|
|
required: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** Show above input file and picture bold text. */
|
|
showLabels: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
/** Directly display the image preview or within a BS modal. */
|
|
preview: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
/** Asks the component to just emit a browse event and not open the input file picker. */
|
|
browseEvent: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
/** Picture dimmensions */
|
|
width: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
height: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
});
|
|
|
|
// * DATA
|
|
const tooltips = ref<Tooltips | null>(null);
|
|
/** If there is an actual picture file. */
|
|
const hasPicture = ref<boolean>(false);
|
|
const hasCroppedPicture = ref<boolean>(false);
|
|
const isSvg = ref<boolean>(false);
|
|
const previewStyle = ref<string>("");
|
|
|
|
const parameters = reactive<{
|
|
id: string,
|
|
name: string,
|
|
helper: string,
|
|
required: boolean,
|
|
showLabels: boolean,
|
|
preview: boolean,
|
|
browseEvent: boolean,
|
|
/** Wanted picture dimensions. */
|
|
dimensions: {
|
|
height: number;
|
|
width: number;
|
|
},
|
|
}>({
|
|
id:props.id,
|
|
name:props.name,
|
|
helper:props.helper,
|
|
required:props.required,
|
|
showLabels:props.showLabels,
|
|
preview:props.preview,
|
|
browseEvent:props.browseEvent,
|
|
/** Wanted picture dimensions. */
|
|
dimensions: {
|
|
width: props.width,
|
|
height: props.height,
|
|
},
|
|
});
|
|
|
|
/** Internal file we are working on. */
|
|
const fileParameters = reactive<{
|
|
file: File|null,
|
|
filePath: string,
|
|
fileName: string,
|
|
/** Actual picture dimensions. */
|
|
dimensions: {
|
|
height: number;
|
|
width: number;
|
|
},
|
|
}>({
|
|
file:null,
|
|
filePath: "",
|
|
fileName: "",
|
|
dimensions:{
|
|
height: 0,
|
|
width: 0,
|
|
},
|
|
});
|
|
|
|
/** The picture as data Url in order to display and crop. */
|
|
const fileDataUrl = ref<string|null>(null);
|
|
|
|
/**
|
|
* The file we shall output :
|
|
* - String when having an already stored picture
|
|
* - File when we have a File fron the input or cropped component
|
|
* - null when noting
|
|
*/
|
|
const fileOutput = ref<FileOutputValue>(null);
|
|
|
|
/** Cropper parameters and instance. */
|
|
const cropperParameters = reactive<CropperParameters>({
|
|
rotation: 0,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
});
|
|
const cropperInstance = ref<Cropper|null>(null);
|
|
|
|
/** Keep previous prop value in order to detect changes. */
|
|
const previousPropValue = ref<string>(props.value);
|
|
|
|
// * COMPUTED
|
|
const cropperOptions = computed<Cropper.Options>(() => {
|
|
return {
|
|
autoCrop: true,
|
|
background: true,
|
|
autoCropArea: 1,
|
|
viewMode: 0,
|
|
dragMode: "move",
|
|
movable: true,
|
|
rotatable: true,
|
|
scalable: true,
|
|
zoomable: true,
|
|
zoomOnTouch: false,
|
|
zoomOnWheel: false,
|
|
cropBoxMovable: true,
|
|
cropBoxResizable: false,
|
|
toggleDragModeOnDblclick: false,
|
|
aspectRatio: parameters.dimensions.width / parameters.dimensions.height,
|
|
preview: `#Preview${parameters.id}`
|
|
};
|
|
});
|
|
const isUsedWithProps = computed<boolean>(() => attrs.json === undefined);
|
|
const fileParamIsFile = computed<boolean>(() => fileParameters.file instanceof File);
|
|
|
|
// * LIFECYCLE
|
|
onMounted(() => {
|
|
const json = String(attrs.json ?? "{}"),
|
|
data = JSON.parse(json);
|
|
if (!isUsedWithProps.value) {
|
|
parameters.id = data.id;
|
|
parameters.name = data.name;
|
|
parameters.helper = data.helper ?? "";
|
|
parameters.dimensions.width = Number.parseInt(String(data.width));
|
|
parameters.dimensions.height = Number.parseInt(String(data.height));
|
|
parameters.preview = Boolean(data.preview);
|
|
parameters.required = Boolean(data.required);
|
|
parameters.showLabels = Boolean(data.showLabels);
|
|
parameters.browseEvent = Boolean(data.browseEvent);
|
|
const value = data.value;
|
|
if(typeof value === "string" && value.length) {
|
|
fileParameters.filePath = value.replace(/^\/|^/, "");
|
|
fileParameters.fileName = value.replace(/^\/|^/, "");
|
|
fileOutput.value = fileParameters.filePath;
|
|
}
|
|
} else {
|
|
assignFromProps();
|
|
}
|
|
fetchPictureFromPath(fileParameters.filePath)?.then((file) => isSvg.value = file.type === "image/svg+xml" ? true : false);
|
|
initTooltips();
|
|
});
|
|
|
|
// * WATCHERS
|
|
|
|
/** Watch props value changes. */
|
|
watch(props, (newValue: typeof props) => {
|
|
// * Ignore non prop usage.
|
|
if(!isUsedWithProps.value) {
|
|
return;
|
|
}
|
|
assignFromProps();
|
|
previousPropValue.value = newValue.value;
|
|
},{deep:true});
|
|
watch(previousPropValue, () => {
|
|
// * Ignore non prop usage.
|
|
if(!isUsedWithProps.value) {
|
|
return;
|
|
}
|
|
cropperReset();
|
|
hasCroppedPicture.value = false;
|
|
fetchPictureFromPath(fileParameters.filePath)!.then((file) => useThis(file));
|
|
});
|
|
/** Crop parameters changes. */
|
|
watch(cropperParameters, (newValue: CropperParameters) => {
|
|
if (cropperInstance.value === null) {
|
|
return;
|
|
}
|
|
cropperInstance.value.rotateTo(newValue.rotation);
|
|
}, { deep: true });
|
|
/** The output has changed, so need to change inputFileImage values. */
|
|
watch(fileOutput, (newValue: FileOutputValue) => {
|
|
// Dont use input type file if we have a string as output (already stored picture).
|
|
if (typeof newValue === "string") {
|
|
// * Reset input file.
|
|
inputFileImage.value!.value = "";
|
|
return;
|
|
}
|
|
|
|
// * Just add a file to output.
|
|
if (!(newValue instanceof File)) {
|
|
return;
|
|
}
|
|
|
|
const container = new DataTransfer();
|
|
container.items.add(newValue);
|
|
inputFileImage.value!.files = container.files;
|
|
emits("imageFile", newValue);
|
|
});
|
|
watch(fileDataUrl, (newValue) => {
|
|
if(newValue === null) {
|
|
return;
|
|
}
|
|
fileParameters.dimensions = {...parameters.dimensions};
|
|
});
|
|
|
|
// * METHODS
|
|
function assignFromProps(): void {
|
|
fileParameters.filePath = props.value.replace(/^\/|^/, "");
|
|
fileParameters.fileName = props.value.replace(/^\/|^/, "");
|
|
fileOutput.value = fileParameters.filePath;
|
|
|
|
const mapParam = <T extends typeof parameters>(dest: T, prop: keyof T, value: ValueOf<T>) => {
|
|
dest[prop] = value;
|
|
};
|
|
for(const key of ["id", "name", "helper", "required", "showLabels", "preview", "browseEvent"]) {
|
|
const paramKey = key as keyof typeof parameters;
|
|
mapParam<typeof parameters>(parameters, paramKey, props[paramKey as never]);
|
|
}
|
|
mapParam<typeof parameters>(parameters, "dimensions", {height: props.height, width: props.width});
|
|
}
|
|
|
|
/** Vertical or Horizontal scale. */
|
|
function cropperScale(direction: "H"|"V"): void {
|
|
if (cropperInstance.value === null) {
|
|
return;
|
|
}
|
|
const dir: "X"|"Y" = direction === "H" ? "X" : "Y";
|
|
cropperParameters[`scale${dir}`] = (-1 / cropperParameters[`scale${dir}`]) as MirrorScale;
|
|
cropperInstance.value[`scale${dir}`](cropperParameters[`scale${dir}`]);
|
|
}
|
|
|
|
function cropperRotate(deg: number): void {
|
|
deg += cropperParameters.rotation;
|
|
cropperParameters.rotation = Math.min(Math.max(deg, 0), 360);
|
|
}
|
|
|
|
function cropperReset(): void {
|
|
cropperInstance.value?.reset();
|
|
cropperParameters.rotation = 0;
|
|
cropperParameters.scaleX = 1;
|
|
cropperParameters.scaleY = 1;
|
|
}
|
|
|
|
/** Export cropped file from cropper. */
|
|
function exportCropperFile(): void {
|
|
if (!cropperInstance.value) {
|
|
return;
|
|
}
|
|
|
|
if (fileParameters.file?.type !== "image/svg+xml") {
|
|
hasCroppedPicture.value = true;
|
|
fileDataUrl.value =
|
|
cropperInstance.value.getCroppedCanvas({
|
|
fillColor: "rgba(0, 0, 0, 0)",
|
|
width: parameters.dimensions.width,
|
|
height: parameters.dimensions.height,
|
|
imageSmoothingEnabled: true,
|
|
imageSmoothingQuality: "high",
|
|
}).toDataURL(fileParameters.file?.type);
|
|
const file = new File(
|
|
[dataUrltoBlob(fileDataUrl.value)],
|
|
fileParameters.fileName,
|
|
{
|
|
type: fileParameters.file?.type,
|
|
lastModified: new Date().getTime(),
|
|
}
|
|
);
|
|
useThis(file);
|
|
fileOutput.value = file;
|
|
}
|
|
}
|
|
|
|
/** Click in input file picker. */
|
|
function chooseAFile(): void {
|
|
emits("browseFile", parameters.id);
|
|
if (parameters.browseEvent) {
|
|
return;
|
|
}
|
|
inputFileImage.value!.click();
|
|
}
|
|
|
|
/** Input file has changed. */
|
|
function pictureInputHasChanged(): void {
|
|
if (inputFileImage?.value?.files && inputFileImage!.value!.files[0]) {
|
|
cropperReset();
|
|
hasCroppedPicture.value = false;
|
|
useThis(inputFileImage!.value!.files[0]);
|
|
fileOutput.value = inputFileImage!.value!.files[0];
|
|
if (fileOutput.value.type === "image/svg+xml") {
|
|
isSvg.value = true;
|
|
} else {
|
|
isSvg.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Fetch the picture from file Path and save it as a File in the file input. */
|
|
function fetchPictureFromPath(filePath: string): Promise<File>|void {
|
|
if(!filePath.length) {
|
|
return;
|
|
}
|
|
return new Promise<File>((accept, deny) => nextTick(() => {
|
|
const prefixedFilePath: string = filePath.replace(/^\/|^/, "/"),
|
|
stringFileName: string = prefixedFilePath.substring(prefixedFilePath.lastIndexOf("/")).replace(/^\/|^/, "");
|
|
|
|
fetch(prefixedFilePath)
|
|
.then((response) => response.blob())
|
|
.then((blob: Blob) => {
|
|
const file = new File([blob], stringFileName, {
|
|
type: blob.type,
|
|
lastModified: new Date().getTime(),
|
|
});
|
|
setFileAsDataUrl(file);
|
|
accept(file);
|
|
}).catch(() => deny("Failed to fetch picture"));
|
|
}));
|
|
}
|
|
|
|
/** Persist a File added from input or while component init. */
|
|
function useThis(file: File): void {
|
|
setFileAsDataUrl(file);
|
|
fileParameters.fileName = file.name;
|
|
fileParameters.file = file;
|
|
hasPicture.value = true;
|
|
}
|
|
|
|
/** Set a file as DataUrl in order to use it within the component. */
|
|
function setFileAsDataUrl(file: File): void
|
|
{
|
|
const reader = new FileReader();
|
|
reader.addEventListener(
|
|
"load",
|
|
() => {
|
|
/** On convertit l'image en une chaîne de caractères base64. */
|
|
fileDataUrl.value = String(reader.result);
|
|
fileParameters.fileName = file.name;
|
|
fileParameters.file = file;
|
|
hasPicture.value = true;
|
|
},
|
|
false
|
|
);
|
|
|
|
reader.readAsDataURL(file);
|
|
reader.onloadend = () => {
|
|
fetchImageDimensions(String(reader.result));
|
|
};
|
|
}
|
|
|
|
/** Init Cropper. */
|
|
function prepareImageEditor(): void {
|
|
const createCropper = () => {
|
|
const imgEditorEl = imgEditor.value;
|
|
if (!imgEditorEl) {
|
|
throw new Error("imgEditor required");
|
|
}
|
|
if (cropperInstance.value) {
|
|
cropperInstance.value.destroy();
|
|
}
|
|
cropperInstance.value = new Cropper(imgEditorEl, cropperOptions.value);
|
|
};
|
|
|
|
if (fileParameters.file === null) {
|
|
throw new Error("File nust not be null");
|
|
}
|
|
|
|
imgEditor.value!.src = URL.createObjectURL(fileParameters.file);
|
|
|
|
nextTick(() => {
|
|
createCropper();
|
|
previewStyleCompute();
|
|
});
|
|
return;
|
|
}
|
|
|
|
/** Reset the entire component state. */
|
|
function resetComponent(): void {
|
|
cropperReset();
|
|
fileDataUrl.value = null;
|
|
fileOutput.value = null;
|
|
inputFileImage.value!.value = "";
|
|
fileParameters.fileName = trans.methods.__("vue.ImageInputComponent.choose_a_file");
|
|
fileParameters.filePath = "";
|
|
fileParameters.file = null;
|
|
fileParameters.dimensions.height = 0;
|
|
fileParameters.dimensions.width = 0;
|
|
hasPicture.value = false;
|
|
hasCroppedPicture.value = false;
|
|
isSvg.value = false;
|
|
emits("imageFile", null);
|
|
}
|
|
|
|
/** Download prefixed path picture and fetch its dimensions. */
|
|
function fetchImageDimensions(pictureSrc: string): void {
|
|
const img = new Image();
|
|
try {
|
|
img.onload = () => {
|
|
fileParameters.dimensions.width = img.width;
|
|
fileParameters.dimensions.height = img.height;
|
|
};
|
|
img.src = pictureSrc;
|
|
} catch (e) {
|
|
fileParameters.dimensions.width = img.width;
|
|
fileParameters.dimensions.height = img.height;
|
|
}
|
|
}
|
|
|
|
/** Computed the priew picture style for adaptative dimensions. */
|
|
function previewStyleCompute(): void {
|
|
if (previewBox.value === null) {
|
|
return ;
|
|
}
|
|
const style = window.getComputedStyle(previewBox.value),
|
|
targetWidth = Number.parseFloat(style.width) / 1.2,
|
|
targetHeight =
|
|
(parameters.dimensions.height * Number.parseFloat(style.width)) /
|
|
parameters.dimensions.width /
|
|
1.2;
|
|
previewStyle.value = `width: ${targetWidth.toFixed(3)}px;height:${targetHeight.toFixed(3)}px;`;
|
|
}
|
|
|
|
/** Transform data url to blob. */
|
|
function dataUrltoBlob(dataUrl: string): Blob {
|
|
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] });
|
|
}
|
|
|
|
/** Human file size change. */
|
|
function 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);
|
|
}
|
|
|
|
/** Initialise all tooltips in the component. */
|
|
function initTooltips(): void {
|
|
setTimeout(() => {
|
|
tooltips.value = Tooltips.make({
|
|
type: "dom",
|
|
elements: imageInput.value!.querySelectorAll("[data-bs-toggle=\"tooltip\"]")
|
|
});
|
|
}, 500);
|
|
}
|
|
</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%;
|
|
}
|
|
.rotate-button {
|
|
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>
|