初始版本提交
This commit is contained in:
7
src/components/Cropper/index.ts
Normal file
7
src/components/Cropper/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { withInstall } from '/@/utils';
|
||||
import cropperImage from './src/Cropper.vue';
|
||||
import avatarCropper from './src/CropperAvatar.vue';
|
||||
|
||||
export * from './src/typing';
|
||||
export const CropperImage = withInstall(cropperImage);
|
||||
export const CropperAvatar = withInstall(avatarCropper);
|
||||
292
src/components/Cropper/src/CopperModal.vue
Normal file
292
src/components/Cropper/src/CopperModal.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<BasicModal
|
||||
v-bind="$attrs"
|
||||
@register="register"
|
||||
:title="t('头像上传')"
|
||||
width="800px"
|
||||
:canFullscreen="false"
|
||||
@ok="handleOk"
|
||||
:okText="t('确认')"
|
||||
>
|
||||
<div :class="prefixCls">
|
||||
<div :class="`${prefixCls}-left`">
|
||||
<div :class="`${prefixCls}-cropper`">
|
||||
<CropperImage
|
||||
v-if="src"
|
||||
:src="src"
|
||||
height="300px"
|
||||
:circled="circled"
|
||||
@cropend="handleCropend"
|
||||
@ready="handleReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-toolbar`">
|
||||
<Upload :fileList="[]" accept="image/*" :beforeUpload="handleBeforeUpload">
|
||||
<Tooltip :title="t('选择图片')" placement="bottom">
|
||||
<a-button size="small" type="primary">
|
||||
<template #icon><Icon icon="ant-design:upload-outlined" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
<Space>
|
||||
<Tooltip :title="t('重置')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('reset')"
|
||||
>
|
||||
<template #icon><Icon icon="ant-design:reload-outlined" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('逆时针旋转')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('rotate', -45)"
|
||||
>
|
||||
<template #icon><Icon icon="ant-design:rotate-left-outlined" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('顺时针旋转')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('rotate', 45)"
|
||||
>
|
||||
<template #icon><Icon icon="ant-design:rotate-right-outlined" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('水平翻转')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('scaleX')"
|
||||
>
|
||||
<template #icon><Icon icon="vaadin:arrows-long-h" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('垂直翻转')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('scaleY')"
|
||||
>
|
||||
<template #icon><Icon icon="vaadin:arrows-long-v" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('放大')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('zoom', 0.1)"
|
||||
>
|
||||
<template #icon><Icon icon="ant-design:zoom-in-outlined" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('缩小')" placement="bottom">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!src"
|
||||
@click="handlerToolbar('zoom', -0.1)"
|
||||
>
|
||||
<template #icon><Icon icon="ant-design:zoom-out-outlined" /></template>
|
||||
</a-button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="`${prefixCls}-right`">
|
||||
<div :class="`${prefixCls}-preview`">
|
||||
<img :src="previewSource" v-if="previewSource" :alt="t('预览')" />
|
||||
</div>
|
||||
<template v-if="previewSource">
|
||||
<div :class="`${prefixCls}-group`">
|
||||
<Avatar :src="previewSource" size="large" />
|
||||
<Avatar :src="previewSource" :size="48" />
|
||||
<Avatar :src="previewSource" :size="64" />
|
||||
<Avatar :src="previewSource" :size="80" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</BasicModal>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { CropendResult, Cropper } from './typing';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import CropperImage from './Cropper.vue';
|
||||
import { Space, Upload, Avatar, Tooltip } from 'ant-design-vue';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { BasicModal, useModalInner } from '/@/components/Modal';
|
||||
import { dataURLtoBlob } from '/@/utils/file/base64Conver';
|
||||
import { isFunction } from '/@/utils/is';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
|
||||
type apiFunParams = { file: Blob; name: string; filename: string };
|
||||
|
||||
const props = {
|
||||
circled: { type: Boolean, default: true },
|
||||
uploadApi: {
|
||||
type: Function as PropType<(params: apiFunParams) => Promise<any>>,
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CropperModal',
|
||||
components: { BasicModal, Space, CropperImage, Upload, Avatar, Tooltip, Icon },
|
||||
props,
|
||||
emits: ['uploadSuccess', 'register'],
|
||||
setup(props, { emit }) {
|
||||
let filename = '';
|
||||
const src = ref('');
|
||||
const previewSource = ref('');
|
||||
const cropper = ref<Cropper>();
|
||||
let scaleX = 1;
|
||||
let scaleY = 1;
|
||||
|
||||
const { prefixCls } = useDesign('cropper-am');
|
||||
const [register, { closeModal, setModalProps }] = useModalInner();
|
||||
const { t } = useI18n();
|
||||
|
||||
// Block upload
|
||||
function handleBeforeUpload(file: File) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
src.value = '';
|
||||
previewSource.value = '';
|
||||
reader.onload = function (e) {
|
||||
src.value = (e.target?.result as string) ?? '';
|
||||
filename = file.name;
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleCropend({ imgBase64 }: CropendResult) {
|
||||
previewSource.value = imgBase64;
|
||||
}
|
||||
|
||||
function handleReady(cropperInstance: Cropper) {
|
||||
cropper.value = cropperInstance;
|
||||
}
|
||||
|
||||
function handlerToolbar(event: string, arg?: number) {
|
||||
if (event === 'scaleX') {
|
||||
scaleX = arg = scaleX === -1 ? 1 : -1;
|
||||
}
|
||||
if (event === 'scaleY') {
|
||||
scaleY = arg = scaleY === -1 ? 1 : -1;
|
||||
}
|
||||
cropper?.value?.[event]?.(arg);
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
const uploadApi = props.uploadApi;
|
||||
if (uploadApi && isFunction(uploadApi)) {
|
||||
const blob = dataURLtoBlob(previewSource.value);
|
||||
try {
|
||||
setModalProps({ confirmLoading: true });
|
||||
const result = await uploadApi({ name: 'file', file: blob, filename });
|
||||
emit('uploadSuccess', { source: previewSource.value, data: result.data });
|
||||
closeModal();
|
||||
} finally {
|
||||
setModalProps({ confirmLoading: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
t,
|
||||
prefixCls,
|
||||
src,
|
||||
register,
|
||||
previewSource,
|
||||
handleBeforeUpload,
|
||||
handleCropend,
|
||||
handleReady,
|
||||
handlerToolbar,
|
||||
handleOk,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-cropper-am';
|
||||
|
||||
.@{prefix-cls} {
|
||||
display: flex;
|
||||
|
||||
&-left,
|
||||
&-right {
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
&-left {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
&-right {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
&-cropper {
|
||||
height: 300px;
|
||||
background: #eee;
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
rgb(0 0 0 / 25%) 25%,
|
||||
transparent 0,
|
||||
transparent 75%,
|
||||
rgb(0 0 0 / 25%) 0
|
||||
);
|
||||
background-position: 0 0, 12px 12px;
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
&-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid @border-color-base;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-group {
|
||||
display: flex;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid @border-color-base;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
188
src/components/Cropper/src/Cropper.vue
Normal file
188
src/components/Cropper/src/Cropper.vue
Normal file
@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div :class="getClass" :style="getWrapperStyle">
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imgElRef"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:crossorigin="crossorigin"
|
||||
:style="getImageStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { defineComponent, onMounted, ref, unref, computed, onUnmounted } from 'vue';
|
||||
import Cropper from 'cropperjs';
|
||||
import 'cropperjs/dist/cropper.css';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
|
||||
type Options = Cropper.Options;
|
||||
|
||||
const defaultOptions: Options = {
|
||||
aspectRatio: 1,
|
||||
zoomable: true,
|
||||
zoomOnTouch: true,
|
||||
zoomOnWheel: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
autoCrop: true,
|
||||
background: true,
|
||||
highlight: true,
|
||||
center: true,
|
||||
responsive: true,
|
||||
restore: true,
|
||||
checkCrossOrigin: true,
|
||||
checkOrientation: true,
|
||||
scalable: true,
|
||||
modal: true,
|
||||
guides: true,
|
||||
movable: true,
|
||||
rotatable: true,
|
||||
};
|
||||
|
||||
const props = {
|
||||
src: { type: String, required: true },
|
||||
alt: { type: String },
|
||||
circled: { type: Boolean, default: false },
|
||||
realTimePreview: { type: Boolean, default: true },
|
||||
height: { type: [String, Number], default: '360px' },
|
||||
crossorigin: {
|
||||
type: String as PropType<'' | 'anonymous' | 'use-credentials' | undefined>,
|
||||
default: undefined,
|
||||
},
|
||||
imageStyle: { type: Object as PropType<CSSProperties>, default: () => ({}) },
|
||||
options: { type: Object as PropType<Options>, default: () => ({}) },
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CropperImage',
|
||||
props,
|
||||
emits: ['cropend', 'ready', 'cropendError'],
|
||||
setup(props, { attrs, emit }) {
|
||||
const imgElRef = ref<ElRef<HTMLImageElement>>();
|
||||
const cropper = ref<Nullable<Cropper>>();
|
||||
const isReady = ref(false);
|
||||
|
||||
const { prefixCls } = useDesign('cropper-image');
|
||||
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80);
|
||||
|
||||
const getImageStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
height: props.height,
|
||||
maxWidth: '100%',
|
||||
...props.imageStyle,
|
||||
};
|
||||
});
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
attrs.class,
|
||||
{
|
||||
[`${prefixCls}--circled`]: props.circled,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const getWrapperStyle = computed((): CSSProperties => {
|
||||
return { height: `${props.height}`.replace(/px/, '') + 'px' };
|
||||
});
|
||||
|
||||
onMounted(init);
|
||||
|
||||
onUnmounted(() => {
|
||||
cropper.value?.destroy();
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const imgEl = unref(imgElRef);
|
||||
if (!imgEl) {
|
||||
return;
|
||||
}
|
||||
cropper.value = new Cropper(imgEl, {
|
||||
...defaultOptions,
|
||||
ready: () => {
|
||||
isReady.value = true;
|
||||
realTimeCroppered();
|
||||
emit('ready', cropper.value);
|
||||
},
|
||||
crop() {
|
||||
debounceRealTimeCroppered();
|
||||
},
|
||||
zoom() {
|
||||
debounceRealTimeCroppered();
|
||||
},
|
||||
cropmove() {
|
||||
debounceRealTimeCroppered();
|
||||
},
|
||||
...props.options,
|
||||
});
|
||||
}
|
||||
|
||||
// Real-time display preview
|
||||
function realTimeCroppered() {
|
||||
props.realTimePreview && croppered();
|
||||
}
|
||||
|
||||
// event: return base64 and width and height information after cropping
|
||||
function croppered() {
|
||||
if (!cropper.value) {
|
||||
return;
|
||||
}
|
||||
let imgInfo = cropper.value.getData();
|
||||
const canvas = props.circled ? getRoundedCanvas() : cropper.value.getCroppedCanvas();
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
let fileReader: FileReader = new FileReader();
|
||||
fileReader.readAsDataURL(blob);
|
||||
fileReader.onloadend = (e) => {
|
||||
emit('cropend', {
|
||||
imgBase64: e.target?.result ?? '',
|
||||
imgInfo,
|
||||
});
|
||||
};
|
||||
fileReader.onerror = () => {
|
||||
emit('cropendError');
|
||||
};
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
// Get a circular picture canvas
|
||||
function getRoundedCanvas() {
|
||||
const sourceCanvas = cropper.value!.getCroppedCanvas();
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d')!;
|
||||
const width = sourceCanvas.width;
|
||||
const height = sourceCanvas.height;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
context.globalCompositeOperation = 'destination-in';
|
||||
context.beginPath();
|
||||
context.arc(width / 2, height / 2, Math.min(width, height) / 2, 0, 2 * Math.PI, true);
|
||||
context.fill();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
return { getClass, imgElRef, getWrapperStyle, getImageStyle, isReady, croppered };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-cropper-image';
|
||||
|
||||
.@{prefix-cls} {
|
||||
&--circled {
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
src/components/Cropper/src/CropperAvatar.vue
Normal file
167
src/components/Cropper/src/CropperAvatar.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div :class="getClass" :style="getStyle">
|
||||
<div :class="`${prefixCls}-image-wrapper`" :style="getImageWrapperStyle" @click="openModal">
|
||||
<div :class="`${prefixCls}-image-mask`" :style="getImageWrapperStyle">
|
||||
<Icon
|
||||
icon="ant-design:cloud-upload-outlined"
|
||||
:size="getIconWidth"
|
||||
:style="getImageWrapperStyle"
|
||||
color="#d6d6d6"
|
||||
/>
|
||||
</div>
|
||||
<a-image
|
||||
:width="200"
|
||||
:height="200"
|
||||
:preview="false"
|
||||
:src="sourceValue"
|
||||
fallback="src/assets/images/header.jpg"
|
||||
/>
|
||||
</div>
|
||||
<a-button
|
||||
:class="`${prefixCls}-upload-btn`"
|
||||
@click="openModal"
|
||||
v-if="showBtn"
|
||||
v-bind="btnProps"
|
||||
>
|
||||
{{ btnText ? btnText : t('选择图片') }}
|
||||
</a-button>
|
||||
|
||||
<CopperModal
|
||||
@register="register"
|
||||
@upload-success="handleUploadSuccess"
|
||||
:uploadApi="uploadApi"
|
||||
:src="sourceValue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
computed,
|
||||
CSSProperties,
|
||||
unref,
|
||||
ref,
|
||||
watchEffect,
|
||||
watch,
|
||||
PropType,
|
||||
} from 'vue';
|
||||
import CopperModal from './CopperModal.vue';
|
||||
import { useDesign } from '/@/hooks/web/useDesign';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
import { useMessage } from '/@/hooks/web/useMessage';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
import type { ButtonProps } from '/@/components/Button';
|
||||
import Icon from '/@/components/Icon';
|
||||
|
||||
const props = {
|
||||
width: { type: [String, Number], default: '200px' },
|
||||
value: { type: String },
|
||||
showBtn: { type: Boolean, default: true },
|
||||
btnProps: { type: Object as PropType<ButtonProps> },
|
||||
btnText: { type: String, default: '' },
|
||||
uploadApi: { type: Function as PropType<({ file: Blob, name: string }) => Promise<void>> },
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CropperAvatar',
|
||||
components: { CopperModal, Icon },
|
||||
props,
|
||||
emits: ['update:value', 'change'],
|
||||
setup(props, { emit, expose }) {
|
||||
const sourceValue = ref(props.value || '');
|
||||
const { prefixCls } = useDesign('cropper-avatar');
|
||||
const [register, { openModal, closeModal }] = useModal();
|
||||
const { createMessage } = useMessage();
|
||||
const { t } = useI18n();
|
||||
|
||||
const getClass = computed(() => [prefixCls]);
|
||||
|
||||
const getWidth = computed(() => `${props.width}`.replace(/px/, '') + 'px');
|
||||
|
||||
const getIconWidth = computed(() => parseInt(`${props.width}`.replace(/px/, '')) / 2 + 'px');
|
||||
|
||||
const getStyle = computed((): CSSProperties => ({ width: unref(getWidth) }));
|
||||
|
||||
const getImageWrapperStyle = computed(
|
||||
(): CSSProperties => ({ width: unref(getWidth), height: unref(getWidth) }),
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
sourceValue.value = props.value || '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => sourceValue.value,
|
||||
(v: string) => {
|
||||
emit('update:value', v);
|
||||
},
|
||||
);
|
||||
|
||||
function handleUploadSuccess({ source }) {
|
||||
sourceValue.value = source;
|
||||
emit('change', source);
|
||||
createMessage.success(t('上传成功'));
|
||||
}
|
||||
|
||||
expose({ openModal: openModal.bind(null, true), closeModal });
|
||||
|
||||
return {
|
||||
t,
|
||||
prefixCls,
|
||||
register,
|
||||
openModal: openModal as any,
|
||||
getIconWidth,
|
||||
sourceValue,
|
||||
getClass,
|
||||
getImageWrapperStyle,
|
||||
getStyle,
|
||||
handleUploadSuccess,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-cropper-avatar';
|
||||
|
||||
.@{prefix-cls} {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
|
||||
&-image-wrapper {
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: @component-background;
|
||||
border: 1px solid @border-color-base;
|
||||
border-radius: 50%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
border-radius: inherit;
|
||||
border: inherit;
|
||||
background: rgb(0 0 0 / 40%);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.4s;
|
||||
|
||||
::v-deep(svg) {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-image-mask:hover {
|
||||
opacity: 40;
|
||||
}
|
||||
|
||||
&-upload-btn {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
8
src/components/Cropper/src/typing.ts
Normal file
8
src/components/Cropper/src/typing.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type Cropper from 'cropperjs';
|
||||
|
||||
export interface CropendResult {
|
||||
imgBase64: string;
|
||||
imgInfo: Cropper.Data;
|
||||
}
|
||||
|
||||
export type { Cropper };
|
||||
Reference in New Issue
Block a user