1128 lines
35 KiB
Vue
1128 lines
35 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>{{ __("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="__('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="`${fileParamIsFile ? parameters.name : ''}`"
|
|
accept=".jpg,.jpeg,.gif,.png"
|
|
: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="__('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="__('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="__('vue.ImageInputComponent.view_source_picture')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-eye" />
|
|
</span>
|
|
</button>
|
|
<button
|
|
@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="__('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="__('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"
|
|
>
|
|
{{ __("vue.ImageInputComponent.preview_picture") }}
|
|
</p>
|
|
<div>
|
|
<img
|
|
v-if="fileDataUrl"
|
|
class="img-fluid"
|
|
:src="fileDataUrl"
|
|
:alt="__('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>
|
|
{{ __("vue.ImageInputComponent.width", { valuepx: String(fileParameters.dimensions.width) }) }}
|
|
{{ __("vue.ImageInputComponent.height", { valuepx: String(fileParameters.dimensions.height) }) }}
|
|
</small>
|
|
</div>
|
|
<!-- END INLINE PREVIEW PART -->
|
|
</div>
|
|
<!-- END INPUT GROUP -->
|
|
|
|
<!-- CROP MODAL -->
|
|
<div
|
|
:id="`Modal${parameters.id}`"
|
|
class="modal"
|
|
tabindex="-1"
|
|
data-bs-backdrop="static"
|
|
data-bs-keyboard="false"
|
|
@dragstart.prevent="() => false"
|
|
@ondrop.prevent="() => false"
|
|
@dragover.prevent="() => false"
|
|
>
|
|
<div class="modal-dialog modal-xl">
|
|
<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="__('vue.ImageInputComponent.reset_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="__('vue.ImageInputComponent.zoom_in')"
|
|
@click.prevent="cropperZoom(0.1)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-search-plus" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.zoom_out')"
|
|
@click.prevent="cropperZoom(-0.1)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-search-minus" />
|
|
</button>
|
|
</div>
|
|
<div class="btn-group me-4 mt-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.move_left')"
|
|
@click.prevent="cropperMove(-10, 0)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-left" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.move_right')"
|
|
@click.prevent="cropperMove(10, 0)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-right" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.move_up')"
|
|
@click.prevent="cropperMove(0, -10)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-up" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.move_down')"
|
|
@click.prevent="cropperMove(0, 10)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrow-down" />
|
|
</button>
|
|
</div>
|
|
<div class="btn-group me-4 mt-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.mirror_horizontal')"
|
|
@click.prevent="cropperScale('H')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrows-alt-h" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.mirror_vertical')"
|
|
@click.prevent="cropperScale('V')"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-arrows-alt-v" />
|
|
</button>
|
|
</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") }} :</span>
|
|
<div class="btn-group mt-1">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.rotate_rewind')"
|
|
@click.prevent="cropperRotate(-45)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-undo-alt" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
data-bs-toggle="tooltip"
|
|
:title="__('vue.ImageInputComponent.rotate')"
|
|
@click.prevent="cropperRotate(45)"
|
|
>
|
|
<FontAwesomeIcon icon="fa-solid fa-redo-alt" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="mt-1 ms-4 rotate-drag">
|
|
<label
|
|
:for="`range-${parameters.id}`"
|
|
class="form-label me-4"
|
|
>
|
|
{{
|
|
__("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-${parameters.id}`"
|
|
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 cropper-container-wrapper">
|
|
<img
|
|
class="image-modification"
|
|
ref="imgEditor"
|
|
src=""
|
|
:alt="__('Placeholder')"
|
|
>
|
|
</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="__('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_and_validate')"
|
|
>
|
|
{{ __("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"
|
|
data-bs-backdrop="static"
|
|
data-bs-keyboard="false"
|
|
>
|
|
<div class="modal-dialog modal-xl">
|
|
<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 d-flex flex-column">
|
|
<small
|
|
:id="`PreviewHelp${parameters.id}`"
|
|
class="form-text text-body-secondary d-block"
|
|
>{{
|
|
__("vue.ImageInputComponent.preview_picture") }}</small>
|
|
<small
|
|
v-if="fileParamIsFile"
|
|
class="form-text text-body-secondary"
|
|
>
|
|
<span>{{ humanFileSize((fileParameters.file as File).size) }}</span>
|
|
{{ __("vue.ImageInputComponent.width", { valuepx: String(fileParameters.dimensions.width) }) }}
|
|
{{ __("vue.ImageInputComponent.height", { valuepx: String(fileParameters.dimensions.height) }) }}
|
|
</small>
|
|
<div>
|
|
<img
|
|
v-if="fileDataUrl"
|
|
class="img-fluid"
|
|
:src="fileDataUrl"
|
|
:alt="__('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,
|
|
defineEmits, defineOptions, defineProps,
|
|
nextTick, onMounted, reactive, ref, useAttrs, watch
|
|
} from "vue";
|
|
|
|
import { Tooltips } from "@modules/bs-tooltips";
|
|
import { __ } from "@modules/trans";
|
|
import { uniqueId } from "@modules/uniqueid";
|
|
|
|
/** 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: () => uniqueId(),
|
|
},
|
|
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 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);
|
|
let lastAppliedRotation = 0;
|
|
|
|
/** Keep previous prop value in order to detect changes. */
|
|
const previousPropValue = ref<string>(props.value);
|
|
|
|
// * COMPUTED
|
|
const cropperTemplate = computed<string>(() => {
|
|
const ratio = parameters.dimensions.width / parameters.dimensions.height;
|
|
return "<cropper-canvas background>"
|
|
+ "<cropper-image rotatable scalable translatable></cropper-image>"
|
|
+ "<cropper-shade hidden></cropper-shade>"
|
|
+ "<cropper-handle action=\"move\" plain></cropper-handle>"
|
|
+ `<cropper-selection id="CropSelection${parameters.id}" initial-coverage="1" aspect-ratio="${ratio}" movable outlined>`
|
|
+ "<cropper-grid role=\"grid\" bordered covered></cropper-grid>"
|
|
+ "<cropper-crosshair centered></cropper-crosshair>"
|
|
+ "<cropper-handle action=\"move\" theme-color=\"rgba(255, 255, 255, 0.35)\"></cropper-handle>"
|
|
+ "</cropper-selection>"
|
|
+ "</cropper-canvas>";
|
|
});
|
|
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) {
|
|
assignFromProps();
|
|
} else {
|
|
parameters.id = data.id ?? uniqueId();
|
|
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;
|
|
}
|
|
}
|
|
fetchPictureFromPath(fileParameters.filePath);
|
|
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) => {
|
|
const cropperImage = cropperInstance.value?.getCropperImage();
|
|
if (!cropperImage) {
|
|
return;
|
|
}
|
|
const deltaRotation = newValue.rotation - lastAppliedRotation;
|
|
if (deltaRotation !== 0) {
|
|
cropperImage.$rotate(deltaRotation * Math.PI / 180);
|
|
lastAppliedRotation = 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 {
|
|
const cropperImage = cropperInstance.value?.getCropperImage();
|
|
if (!cropperImage) {
|
|
return;
|
|
}
|
|
const dir: "X" | "Y" = direction === "H" ? "X" : "Y";
|
|
cropperParameters[`scale${dir}`] = (-1 / cropperParameters[`scale${dir}`]) as MirrorScale;
|
|
if (direction === "H") {
|
|
cropperImage.$scale(-1, 1);
|
|
} else {
|
|
cropperImage.$scale(1, -1);
|
|
}
|
|
}
|
|
|
|
function cropperRotate(deg: number): void {
|
|
deg += cropperParameters.rotation;
|
|
cropperParameters.rotation = Math.min(Math.max(deg, 0), 360);
|
|
}
|
|
|
|
function cropperReset(): void {
|
|
cropperInstance.value?.getCropperImage()?.$resetTransform();
|
|
cropperInstance.value?.getCropperImage()?.$center("contain");
|
|
cropperInstance.value?.getCropperSelection()?.$reset();
|
|
cropperParameters.rotation = 0;
|
|
cropperParameters.scaleX = 1;
|
|
cropperParameters.scaleY = 1;
|
|
lastAppliedRotation = 0;
|
|
}
|
|
|
|
/** Export cropped file from cropper. */
|
|
async function exportCropperFile(): Promise<void> {
|
|
const selection = cropperInstance.value?.getCropperSelection();
|
|
if (!selection) {
|
|
return;
|
|
}
|
|
const outputWidth = Math.max(1, Math.trunc(parameters.dimensions.width));
|
|
const outputHeight = Math.max(1, Math.trunc(parameters.dimensions.height));
|
|
hasCroppedPicture.value = true;
|
|
const canvas = await selection.$toCanvas({
|
|
width: outputWidth,
|
|
height: outputHeight,
|
|
beforeDraw: (context: CanvasRenderingContext2D) => {
|
|
context.fillStyle = "rgba(0, 0, 0, 0)";
|
|
context.imageSmoothingEnabled = true;
|
|
context.imageSmoothingQuality = "high";
|
|
},
|
|
});
|
|
|
|
const normalizedCanvas = document.createElement("canvas");
|
|
normalizedCanvas.width = outputWidth;
|
|
normalizedCanvas.height = outputHeight;
|
|
const normalizedCtx = normalizedCanvas.getContext("2d");
|
|
if (!normalizedCtx) {
|
|
return;
|
|
}
|
|
normalizedCtx.imageSmoothingEnabled = true;
|
|
normalizedCtx.imageSmoothingQuality = "high";
|
|
normalizedCtx.clearRect(0, 0, outputWidth, outputHeight);
|
|
normalizedCtx.drawImage(canvas, 0, 0, outputWidth, outputHeight);
|
|
|
|
fileDataUrl.value = normalizedCanvas.toDataURL("image/png");
|
|
const file = new File(
|
|
[dataUrltoBlob(fileDataUrl.value)],
|
|
fileParameters.fileName,
|
|
{
|
|
type: "image/png",
|
|
lastModified: Date.now(),
|
|
}
|
|
);
|
|
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];
|
|
}
|
|
}
|
|
|
|
/** 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: "image/png",
|
|
lastModified: Date.now(),
|
|
});
|
|
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));
|
|
};
|
|
}
|
|
|
|
function cropperZoom(scale: number): void {
|
|
cropperInstance.value?.getCropperImage()?.$zoom(scale);
|
|
}
|
|
|
|
function cropperMove(x: number, y: number): void {
|
|
cropperInstance.value?.getCropperImage()?.$move(x, y);
|
|
}
|
|
|
|
/** Init Cropper. */
|
|
function prepareImageEditor(): void {
|
|
const recomputePreviewWhenModalShown = () => {
|
|
const modalElement = document.getElementById(`Modal${parameters.id}`);
|
|
if (!modalElement) {
|
|
return;
|
|
}
|
|
const onModalShown = () => {
|
|
requestAnimationFrame(() => {
|
|
previewStyleCompute();
|
|
setupCropperViewerWithRetry();
|
|
previewStyleCompute();
|
|
});
|
|
modalElement.removeEventListener("shown.bs.modal", onModalShown);
|
|
};
|
|
modalElement.addEventListener("shown.bs.modal", onModalShown);
|
|
};
|
|
|
|
const createCropper = () => {
|
|
const imgEditorEl = imgEditor.value;
|
|
if (!imgEditorEl) {
|
|
throw new Error("imgEditor required");
|
|
}
|
|
if (cropperInstance.value) {
|
|
cropperInstance.value.destroy();
|
|
}
|
|
cropperInstance.value = new Cropper(imgEditorEl, {template: cropperTemplate.value,});
|
|
lastAppliedRotation = 0;
|
|
};
|
|
|
|
if (fileParameters.file === null) {
|
|
throw new Error("File nust not be null");
|
|
}
|
|
|
|
const editorImg = imgEditor.value!;
|
|
const blobUrl = URL.createObjectURL(fileParameters.file);
|
|
|
|
const initCropperAfterLoad = () => {
|
|
editorImg.removeEventListener("load", initCropperAfterLoad);
|
|
nextTick(() => {
|
|
recomputePreviewWhenModalShown();
|
|
createCropper();
|
|
previewStyleCompute();
|
|
setupCropperViewerWithRetry();
|
|
previewStyleCompute();
|
|
});
|
|
};
|
|
|
|
editorImg.addEventListener("load", initCropperAfterLoad);
|
|
editorImg.src = blobUrl;
|
|
}
|
|
|
|
/** Reset the entire component state. */
|
|
function resetComponent(): void {
|
|
cropperReset();
|
|
fileDataUrl.value = null;
|
|
fileOutput.value = null;
|
|
inputFileImage.value!.value = "";
|
|
fileParameters.fileName = __("vue.ImageInputComponent.choose_a_file");
|
|
fileParameters.filePath = "";
|
|
fileParameters.file = null;
|
|
fileParameters.dimensions.height = 0;
|
|
fileParameters.dimensions.width = 0;
|
|
hasPicture.value = false;
|
|
hasCroppedPicture.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 {
|
|
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 targetContainerWidth = previewBox.value.clientWidth;
|
|
if (targetContainerWidth <= 0) {
|
|
return;
|
|
}
|
|
const targetWidth = targetContainerWidth / 1.2,
|
|
targetHeight =
|
|
(parameters.dimensions.height * targetContainerWidth) /
|
|
parameters.dimensions.width /
|
|
1.2;
|
|
previewStyle.value = `width: ${targetWidth.toFixed(3)}px;height:${targetHeight.toFixed(3)}px;`;
|
|
}
|
|
|
|
/** Set up the cropper-viewer custom element for live preview. */
|
|
function setupCropperViewer(): boolean {
|
|
const previewEl = document.getElementById(`Preview${parameters.id}`);
|
|
const cropSelection = document.getElementById(`CropSelection${parameters.id}`);
|
|
if (!previewEl || !cropSelection) {
|
|
return false;
|
|
}
|
|
previewEl.innerHTML = "";
|
|
const viewer = document.createElement("cropper-viewer");
|
|
viewer.setAttribute("selection", `#CropSelection${parameters.id}`);
|
|
viewer.style.width = "100%";
|
|
viewer.style.height = "100%";
|
|
previewEl.appendChild(viewer);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Cropper peut injecter sa sélection de manière asynchrone.
|
|
* On retente quelques frames pour éviter un preview blanc intermittent.
|
|
*/
|
|
function setupCropperViewerWithRetry(maxAttempts = 10): void {
|
|
let attempts = 0;
|
|
const trySetup = () => {
|
|
if (setupCropperViewer()) {
|
|
return;
|
|
}
|
|
attempts += 1;
|
|
if (attempts >= maxAttempts) {
|
|
return;
|
|
}
|
|
requestAnimationFrame(trySetup);
|
|
};
|
|
trySetup();
|
|
}
|
|
|
|
/** 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 mimeStart = arr[0].indexOf(":");
|
|
const mimeEnd = arr[0].indexOf(";");
|
|
if (mimeStart < 0 || mimeEnd < 0 || mimeEnd <= mimeStart + 1) {
|
|
throw new Error("invalid mime");
|
|
}
|
|
const mime = arr[0].slice(mimeStart + 1, mimeEnd);
|
|
const bstr = globalThis.atob(arr[1]);
|
|
let n = bstr.length;
|
|
const u8arr = new Uint8Array(n);
|
|
while (n--) {
|
|
u8arr[n] = bstr.codePointAt(n) ?? 0;
|
|
}
|
|
return new Blob([u8arr], { type: mime });
|
|
}
|
|
|
|
/** 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(() => {
|
|
if (imageInput.value === null) {
|
|
return;
|
|
}
|
|
tooltips.value = Tooltips.make({
|
|
type: "dom",
|
|
elements: imageInput.value!.querySelectorAll("[data-bs-toggle=\"tooltip\"]"),
|
|
options: {container: imageInput.value}
|
|
});
|
|
}, 500);
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
.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%;
|
|
}
|
|
|
|
.cropper-container-wrapper {
|
|
min-height: 400px;
|
|
|
|
:deep(cropper-canvas) {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
}
|
|
|
|
.rotate-button {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.rotate-drag {
|
|
display: flex;
|
|
flex-direction: column-reverse;
|
|
width: 100%;
|
|
|
|
&>input {
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.preview-img {
|
|
min-height: 400px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&>div {
|
|
position: static;
|
|
overflow: hidden;
|
|
border: 1px solid var(--bs-border-color, #dee2e6);
|
|
}
|
|
}
|
|
|
|
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>
|