ldc-legaulois/resources/ts/bo/components/ImageInputComponent.vue
2026-06-10 15:25:40 +02:00

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