Files
banu-front/src/components/form/ImageCropper.vue
T
sajjadtalkhabi 38a396ba21
Deploy banu-front / deploy (push) Successful in 1m27s
fix
2026-07-07 17:36:43 +03:30

357 lines
8.2 KiB
Vue

<template>
<div>
<div class="image-cropper" :style="{ backgroundColor: bgColor }">
<button
v-if="!isCropping && !previewUrl"
type="button"
class="image-cropper__upload-btn"
@click="triggerUpload"
>
<SvgIcon name="upload" color="" :size="64" />
</button>
<div v-if="isCropping && image" class="image-cropper__crop-stage">
<div class="image-cropper__crop-frame">
<Cropper
:src="image"
:stencil-component="circleStencil"
:stencil-props="{ aspectRatio: 1 }"
image-restriction="stencil"
:auto-zoom="true"
class="image-cropper__cropper"
@change="onCrop"
/>
</div>
<div class="image-cropper__actions">
<CircleButton
tooltip="تایید"
tooltip-position="top"
bg-color="var(--color-primary)"
size="2.5rem"
@click.stop="confirmCrop"
>
<template #icon>
<SvgIcon name="check" :size="20" color="#fff" />
</template>
</CircleButton>
<CircleButton
tooltip="انصراف"
tooltip-position="top"
bg-color="#fff"
size="2.5rem"
@click="cancelCrop"
>
<template #icon>
<SvgIcon name="close" :size="20" color="var(--color-thd-gray)" />
</template>
</CircleButton>
</div>
</div>
<div v-else-if="previewUrl && !isCropping" class="image-cropper__preview-wrap">
<div class="image-cropper__preview-frame">
<img :src="previewUrl" alt="" class="image-cropper__preview-img" />
<div class="image-cropper__preview-mask" />
</div>
<div class="image-cropper__preview-action">
<CircleButton
tooltip="بارگذاری"
tooltip-position="top"
bg-color="#fff"
size="2.5rem"
@click="triggerUpload"
>
<template #icon>
<SvgIcon name="upload" :size="20" color="var(--color-thd-gray)" />
</template>
</CircleButton>
</div>
</div>
<div class="image-cropper__hint">
<SvgIcon :name="helperIcon" color="#B8B8B8" :size="20" />
<p>{{ helperText }}</p>
</div>
</div>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
<p v-if="error" class="image-cropper__error" :class="{ 'animate-shake': vibration }">
{{ error }}
</p>
</div>
</template>
<script setup>
import 'vue-advanced-cropper/dist/style.css'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import CircleButton from '@/components/CircleButton.vue'
import { markRaw, onBeforeUnmount, ref, watch } from 'vue'
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
const props = defineProps({
modelValue: { type: [Object, String], default: null },
name: { type: String, default: '' },
vibration: { type: Boolean, default: false },
bgColor: { type: String, default: '#f3f4f6' },
maxSize: { type: Number, default: 250 * 1024 },
helperText: {
type: String,
default: 'سایز تصویر رسمی 3x4 و حجم تصویر بیشتر از 250 کیلو بایت نباشد.',
},
helperIcon: { type: String, default: 'warning' },
error: { type: String, default: '' },
})
const emit = defineEmits(['crop', 'update:modelValue', 'error'])
const fileInput = ref(null)
const image = ref(null)
const cropResult = ref(null)
const isCropping = ref(false)
const internalPreviewUrl = ref(null)
const originalFileName = ref(null)
const previewUrl = ref(null)
const circleStencil = markRaw(CircleStencil)
const cleanup = () => {
if (internalPreviewUrl.value) {
URL.revokeObjectURL(internalPreviewUrl.value)
internalPreviewUrl.value = null
}
}
watch(
() => props.modelValue,
(val) => {
if (val && typeof val === 'object' && val.url) {
previewUrl.value = val.url
} else if (typeof val === 'string') {
previewUrl.value = val
} else {
previewUrl.value = null
}
},
{ immediate: true }
)
const triggerUpload = () => fileInput.value?.click()
const onFileChange = (event) => {
cleanup()
const file = event.target.files?.[0]
if (!file) return
if (props.maxSize > 0 && file.size > props.maxSize) {
emit('error', 'حجم تصویر نباید بیشتر از ۲۵۰ کیلوبایت باشد.')
event.target.value = null
return
}
originalFileName.value = file.name
internalPreviewUrl.value = URL.createObjectURL(file)
image.value = internalPreviewUrl.value
isCropping.value = true
event.target.value = null
}
const onCrop = ({ canvas }) => {
cropResult.value = canvas
}
const confirmCrop = async () => {
if (!cropResult.value) return
const blob = await new Promise((resolve) => cropResult.value.toBlob(resolve, 'image/png'))
const croppedFile = new File([blob], originalFileName.value || 'avatar.png', {
type: 'image/png',
lastModified: Date.now(),
})
emit('crop', croppedFile)
isCropping.value = false
image.value = null
cropResult.value = null
}
const cancelCrop = () => {
isCropping.value = false
image.value = null
cleanup()
}
onBeforeUnmount(cleanup)
</script>
<style lang="scss" scoped>
.image-cropper {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-around;
border-radius: 1.5rem;
text-align: center;
padding: 0.75rem 0.5rem;
@media (min-width: 1024px) {
min-height: 20rem;
padding: 0.75rem;
}
@media (min-width: 1536px) {
min-height: 23rem;
}
&__upload-btn {
height: 8.15rem;
width: 8.15rem;
border-radius: 9999px;
background: #fff;
border: none;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-thd-gray);
@media (min-width: 1536px) {
height: 10.95rem;
width: 10.95rem;
}
}
&__crop-stage {
display: flex;
flex-direction: column;
align-items: center;
}
&__crop-frame {
height: 8.15rem;
width: 8.15rem;
border-radius: 9999px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d1d5db;
@media (min-width: 1536px) {
height: 10.95rem;
width: 10.95rem;
}
}
&__cropper {
border-radius: 9999px;
}
&__actions {
display: flex;
gap: 0.625rem;
align-items: center;
justify-content: center;
margin-top: 0.75rem;
@media (min-width: 1280px) {
margin-top: 1rem;
}
@media (min-width: 1536px) {
margin-top: 1.25rem;
}
}
&__preview-wrap {
position: relative;
width: fit-content;
margin: 0 auto;
}
&__preview-frame {
position: relative;
border-radius: 9999px;
height: 8.15rem;
width: 8.15rem;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
@media (min-width: 1536px) {
height: 10.95rem;
width: 10.95rem;
}
}
&__preview-img {
height: 100%;
width: 100%;
object-fit: cover;
border-radius: 9999px;
}
&__preview-mask {
position: absolute;
inset: 0;
border-radius: 9999px;
border: 1rem solid #000;
opacity: 0.2;
z-index: 0;
}
&__preview-action {
position: absolute;
left: 0.5rem;
bottom: -0.25rem;
z-index: 10;
}
&__hint {
background: #fff;
border-radius: 1rem;
margin-top: 1.25rem;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
p {
font-family: var(--font-family-fa);
font-size: 0.6875rem;
line-height: 1.5;
text-align: right;
margin: 0;
@media (min-width: 1024px) {
font-size: 0.7rem;
}
}
}
&__error {
margin-top: 0.25rem;
text-align: right;
font-family: var(--font-family-fa);
font-size: 0.6875rem;
color: var(--color-error);
@media (min-width: 1280px) {
font-size: 0.875rem;
}
}
&__hint-icon {
color: var(--color-prim-gray);
min-height: 1.25rem;
min-width: 1.25rem;
@media (min-width: 1024px) {
min-height: 2rem;
min-width: 2rem;
}
}
}
</style>