ldc-boutique-cnc-sable/resources/ts/bo/components/ImageInputComponent.vue
2026-10-27 09:59:02 +01:00

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") }}&nbsp;:</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>