first commit
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
<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" :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="warning" class="image-cropper__hint-icon" />
|
||||
<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 { markRaw, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
|
||||
import CircleButton from '@/components/CircleButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
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 کیلو بایت نباشد.',
|
||||
},
|
||||
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.75rem;
|
||||
|
||||
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.75rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__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>
|
||||
Reference in New Issue
Block a user