2025-03-18 23:08:47 +01:00
|
|
|
<script setup>
|
|
|
|
|
|
2025-05-20 14:48:18 +02:00
|
|
|
import { useTemplateRef, ref, watch } from 'vue';
|
|
|
|
|
import { Button } from 'pankow';
|
2025-03-18 23:08:47 +01:00
|
|
|
|
|
|
|
|
const fileInput = useTemplateRef('fileInput');
|
|
|
|
|
|
2025-05-20 14:48:18 +02:00
|
|
|
const props = defineProps(['src', 'fallbackSrc', 'size', 'maxSize', 'displayHeight', 'displayWidth', 'disabled', 'saveHandler', 'unsetHandler']);
|
2025-03-18 23:08:47 +01:00
|
|
|
|
2025-03-19 01:09:17 +01:00
|
|
|
const image = useTemplateRef('image');
|
2025-03-18 23:08:47 +01:00
|
|
|
const internalSrc = ref('');
|
|
|
|
|
const isChanged = ref(false);
|
2025-05-20 14:48:18 +02:00
|
|
|
const busy = ref(false);
|
2025-03-18 23:08:47 +01:00
|
|
|
|
2025-05-20 14:48:18 +02:00
|
|
|
watch(() => {
|
|
|
|
|
internalSrc.value = props.src;
|
|
|
|
|
});
|
2025-03-18 23:08:47 +01:00
|
|
|
|
|
|
|
|
function dataURLtoFile(dataURL, filename) {
|
|
|
|
|
// Split the data URL to get the MIME type and the base64 data
|
|
|
|
|
const [metadata, base64Data] = dataURL.split(',');
|
|
|
|
|
|
|
|
|
|
// Extract the MIME type from the metadata
|
|
|
|
|
const mimeType = metadata.match(/:(.*?);/)[1];
|
|
|
|
|
|
|
|
|
|
// Decode the base64 data to binary
|
|
|
|
|
const binaryData = atob(base64Data);
|
|
|
|
|
|
|
|
|
|
// Create an array buffer from the binary data
|
|
|
|
|
const arrayBuffer = new ArrayBuffer(binaryData.length);
|
|
|
|
|
const view = new Uint8Array(arrayBuffer);
|
|
|
|
|
for (let i = 0; i < binaryData.length; i++) {
|
2025-03-19 01:09:17 +01:00
|
|
|
view[i] = binaryData.charCodeAt(i);
|
2025-03-18 23:08:47 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a Blob from the array buffer
|
|
|
|
|
const blob = new Blob([arrayBuffer], { type: mimeType });
|
|
|
|
|
|
|
|
|
|
// Create a File object from the Blob
|
|
|
|
|
const file = new File([blob], filename, { type: mimeType });
|
|
|
|
|
|
|
|
|
|
return file;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-20 14:48:18 +02:00
|
|
|
async function onSave() {
|
|
|
|
|
if (typeof props.saveHandler !== 'function') return console.error('saveHandler must be a function in ImagePicker');
|
|
|
|
|
|
|
|
|
|
busy.value = true;
|
|
|
|
|
|
|
|
|
|
const error = await props.saveHandler(dataURLtoFile(internalSrc.value, 'image.png'));
|
|
|
|
|
if (!error) isChanged.value = false;
|
|
|
|
|
|
|
|
|
|
busy.value = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onCancel() {
|
|
|
|
|
internalSrc.value = props.src || props.fallbackSrc;
|
|
|
|
|
isChanged.value = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onEdit() {
|
|
|
|
|
fileInput.value.click();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onUnset() {
|
|
|
|
|
if (typeof props.unsetHandler !== 'function') return console.error('unsetHandler must be a function in ImagePicker');
|
|
|
|
|
|
|
|
|
|
busy.value = true;
|
|
|
|
|
|
|
|
|
|
const error = await props.unsetHandler();
|
|
|
|
|
if (!error) isChanged.value = false;
|
|
|
|
|
|
|
|
|
|
busy.value = false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-18 23:08:47 +01:00
|
|
|
function onChanged(event) {
|
|
|
|
|
const fr = new FileReader();
|
|
|
|
|
fr.onload = function () {
|
|
|
|
|
const image = new Image();
|
|
|
|
|
image.onload = function () {
|
2025-03-19 01:22:13 +01:00
|
|
|
const size = props.size ? parseInt(props.size) : 512;
|
2025-03-18 23:08:47 +01:00
|
|
|
const maxSize = props.maxSize ? parseInt(props.maxSize) : 0;
|
|
|
|
|
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
|
|
|
|
|
if (maxSize) {
|
|
|
|
|
if (image.naturalWidth > maxSize) {
|
|
|
|
|
canvas.width = maxSize;
|
|
|
|
|
canvas.height = (image.naturalHeight / image.naturalWidth) * maxSize;
|
|
|
|
|
} else {
|
|
|
|
|
canvas.width = image.naturalWidth;
|
|
|
|
|
canvas.height = image.naturalHeight;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
canvas.width = size;
|
|
|
|
|
canvas.height = size;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const imageDimensionRatio = image.width / image.height;
|
|
|
|
|
const canvasDimensionRatio = canvas.width / canvas.height;
|
|
|
|
|
let renderableHeight, renderableWidth, xStart, yStart;
|
|
|
|
|
|
|
|
|
|
if (imageDimensionRatio > canvasDimensionRatio) {
|
|
|
|
|
renderableHeight = canvas.height;
|
|
|
|
|
renderableWidth = image.width * (renderableHeight / image.height);
|
|
|
|
|
xStart = (canvas.width - renderableWidth) / 2;
|
|
|
|
|
yStart = 0;
|
|
|
|
|
} else if (imageDimensionRatio < canvasDimensionRatio) {
|
|
|
|
|
renderableWidth = canvas.width;
|
|
|
|
|
renderableHeight = image.height * (renderableWidth / image.width);
|
|
|
|
|
xStart = 0;
|
|
|
|
|
yStart = (canvas.height - renderableHeight) / 2;
|
|
|
|
|
} else {
|
|
|
|
|
renderableHeight = canvas.height;
|
|
|
|
|
renderableWidth = canvas.width;
|
|
|
|
|
xStart = 0;
|
|
|
|
|
yStart = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var ctx = canvas.getContext('2d');
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
ctx.drawImage(image, xStart, yStart, renderableWidth, renderableHeight);
|
|
|
|
|
|
|
|
|
|
internalSrc.value = canvas.toDataURL('image/png');
|
|
|
|
|
isChanged.value = true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
image.src = fr.result;
|
|
|
|
|
};
|
|
|
|
|
fr.readAsDataURL(event.target.files[0]);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-19 01:09:17 +01:00
|
|
|
function onError() {
|
|
|
|
|
internalSrc.value = props.fallbackSrc;
|
|
|
|
|
}
|
2025-03-18 23:08:47 +01:00
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div>
|
2025-05-20 14:48:18 +02:00
|
|
|
<div class="image-picker-container">
|
2025-03-18 23:08:47 +01:00
|
|
|
<input @change="onChanged($event)" type="file" ref="fileInput" style="display: none" accept="image/*"/>
|
|
|
|
|
|
2025-05-20 14:48:18 +02:00
|
|
|
<div ref="image" :disabled="disabled || null" class="image-picker" @click="!disabled && onEdit()">
|
|
|
|
|
<img :src="internalSrc || src" @error="onError" class="image-picker-image" :style="{ height: displayHeight || null, width: displayWidth || null }">
|
|
|
|
|
<div v-if="isChanged" class="image-picker-actions" style="visibility: visible;">
|
|
|
|
|
<Button @click.stop="onCancel" secondary tool small icon="fa fa-undo" :disabled="busy"/>
|
|
|
|
|
<Button @click.stop="onSave" success tool small :loading="busy" :disabled="busy" icon="fa fa-floppy-disk"/>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else-if="!disabled" class="image-picker-actions">
|
|
|
|
|
<Button @click.stop="onEdit" tool small icon="fa fa-pencil-alt" :disabled="busy"/>
|
|
|
|
|
<Button @click.stop="onUnset" tool small icon="fa fa-trash" :loading="busy" :disabled="busy" v-if="unsetHandler"/>
|
|
|
|
|
</div>
|
2025-03-18 23:08:47 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
2025-05-20 14:48:18 +02:00
|
|
|
.image-picker-container {
|
|
|
|
|
position: relative;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-picker-actions {
|
|
|
|
|
position: absolute;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
bottom: 6px;
|
|
|
|
|
width: 100%;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
visibility: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (hover: none) {
|
|
|
|
|
.image-picker-actions {
|
|
|
|
|
visibility: visible;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-picker-actions:hover,
|
|
|
|
|
.image-picker:hover .image-picker-actions {
|
|
|
|
|
visibility: visible;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-picker-image {
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-18 23:08:47 +01:00
|
|
|
.image-picker {
|
|
|
|
|
display: inline-block;
|
|
|
|
|
position: relative;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-13 18:11:10 +02:00
|
|
|
.image-picker[disabled] {
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-18 23:08:47 +01:00
|
|
|
.image-picker > img {
|
|
|
|
|
display: block;
|
2025-03-25 15:05:08 +01:00
|
|
|
/* height: 320px;*/
|
2025-03-18 23:08:47 +01:00
|
|
|
border-radius: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-picker-edit-indicator {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 4px;
|
|
|
|
|
right: 4px;
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
padding: 5px;
|
|
|
|
|
color: var(--pankow-text-color);
|
|
|
|
|
background-color: var(--pankow-input-background-color);
|
|
|
|
|
transition: all 250ms;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.image-picker:hover .image-picker-edit-indicator {
|
|
|
|
|
color: white;
|
|
|
|
|
background: var(--pankow-color-primary);
|
|
|
|
|
transform: scale(1.2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
</style>
|