Browse Source
- Add `cropper.js ` component integration. - Add Component locales. - Reconstruct personal Settings for uploading avatars and support avatar cropping.pull/1338/head
16 changed files with 814 additions and 32 deletions
@ -0,0 +1,200 @@ |
|||
<script lang="ts" setup> |
|||
import type { CSSProperties, PropType } from 'vue'; |
|||
|
|||
import type { Nullable } from '@vben/types'; |
|||
|
|||
import { |
|||
computed, |
|||
onMounted, |
|||
onUnmounted, |
|||
ref, |
|||
unref, |
|||
useAttrs, |
|||
useTemplateRef, |
|||
} from 'vue'; |
|||
|
|||
import { useNamespace } from '@vben/hooks'; |
|||
|
|||
import { useDebounceFn } from '@vueuse/core'; |
|||
import Cropper from 'cropperjs'; |
|||
|
|||
import 'cropperjs/dist/cropper.css'; |
|||
|
|||
type Options = Cropper.Options; |
|||
type ElRef<T extends HTMLElement = HTMLDivElement> = Nullable<T>; |
|||
|
|||
const props = defineProps({ |
|||
src: { type: String, required: true }, |
|||
alt: { type: String, default: '' }, |
|||
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: () => ({}) }, |
|||
}); |
|||
const emits = defineEmits(['cropend', 'ready', 'cropendError']); |
|||
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 attrs = useAttrs(); |
|||
|
|||
const imgElRef = useTemplateRef<ElRef<HTMLImageElement>>('imgElRef'); |
|||
const cropper = ref<Nullable<Cropper>>(); |
|||
const isReady = ref(false); |
|||
|
|||
const { b, is } = useNamespace('cropper-image'); |
|||
const debounceRealTimeCroppered = useDebounceFn(realTimeCroppered, 80); |
|||
|
|||
const getImageStyle = computed((): CSSProperties => { |
|||
return { |
|||
height: props.height, |
|||
maxWidth: '100%', |
|||
...props.imageStyle, |
|||
}; |
|||
}); |
|||
|
|||
const getClass = computed(() => { |
|||
return [b(), attrs.class, is('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(); |
|||
emits('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; |
|||
} |
|||
const imgInfo = cropper.value.getData(); |
|||
const canvas = props.circled |
|||
? getRoundedCanvas() |
|||
: cropper.value.getCroppedCanvas(); |
|||
canvas.toBlob((blob) => { |
|||
if (!blob) { |
|||
return; |
|||
} |
|||
const fileReader: FileReader = new FileReader(); |
|||
fileReader.readAsDataURL(blob); |
|||
fileReader.onloadend = (e) => { |
|||
emits('cropend', { |
|||
imgBase64: e.target?.result ?? '', |
|||
imgInfo, |
|||
}); |
|||
}; |
|||
// eslint-disable-next-line unicorn/prefer-add-event-listener |
|||
fileReader.onerror = () => { |
|||
emits('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; |
|||
} |
|||
</script> |
|||
<template> |
|||
<div :class="getClass" :style="getWrapperStyle"> |
|||
<img |
|||
v-show="isReady" |
|||
ref="imgElRef" |
|||
:src="src" |
|||
:alt="alt" |
|||
:crossorigin="crossorigin" |
|||
:style="getImageStyle" |
|||
/> |
|||
</div> |
|||
</template> |
|||
<style scoped lang="scss"> |
|||
$namespace: vben; |
|||
|
|||
.#{$namespace}-cropper-image { |
|||
&.is-circled { |
|||
.cropper-view-box, |
|||
.cropper-face { |
|||
border-radius: 50%; |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,159 @@ |
|||
<script setup lang="ts"> |
|||
import type { ButtonProps } from 'ant-design-vue/es/button'; |
|||
|
|||
import type { CSSProperties, PropType } from 'vue'; |
|||
|
|||
import { computed, ref, unref, watch, watchEffect } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { useNamespace } from '@vben/hooks'; |
|||
import { createIconifyIcon } from '@vben/icons'; |
|||
import { useI18n } from '@vben/locales'; |
|||
|
|||
import { Button } from 'ant-design-vue'; |
|||
|
|||
import CropperModal from './CropperModal.vue'; |
|||
|
|||
interface File { |
|||
file: Blob; |
|||
fileName?: string; |
|||
name: string; |
|||
} |
|||
|
|||
const props = defineProps({ |
|||
width: { type: [String, Number], default: '200px' }, |
|||
value: { type: String, default: '' }, |
|||
showBtn: { type: Boolean, default: true }, |
|||
btnProps: { type: Object as PropType<ButtonProps>, default: undefined }, |
|||
btnText: { type: String, default: '' }, |
|||
uploadApi: { |
|||
type: Function as PropType<(file: File) => Promise<void>>, |
|||
default: undefined, |
|||
}, |
|||
}); |
|||
|
|||
const emits = defineEmits(['update:value', 'change']); |
|||
|
|||
const UploadIcon = createIconifyIcon('ant-design:cloud-upload-outlined'); |
|||
|
|||
const sourceValue = ref(props.value || ''); |
|||
const { b, e } = useNamespace('cropper-avatar'); |
|||
const [Modal, modalApi] = useVbenModal({ |
|||
connectedComponent: CropperModal, |
|||
}); |
|||
const { t } = useI18n(); |
|||
|
|||
const getWidth = computed(() => `${`${props.width}`.replace(/px/, '')}px`); |
|||
|
|||
const getIconWidth = computed( |
|||
() => `${Number.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) => { |
|||
emits('update:value', v); |
|||
}, |
|||
); |
|||
|
|||
function handleUploadSuccess(url: string) { |
|||
sourceValue.value = url; |
|||
emits('change', url); |
|||
} |
|||
function openModal() { |
|||
modalApi.open(); |
|||
} |
|||
function closeModal() { |
|||
modalApi.close(); |
|||
} |
|||
|
|||
defineExpose({ openModal, closeModal }); |
|||
</script> |
|||
|
|||
<template> |
|||
<div :class="b()" :style="getStyle"> |
|||
<div |
|||
:class="e(`image-wrapper`)" |
|||
:style="getImageWrapperStyle" |
|||
@click="openModal" |
|||
> |
|||
<div :class="e(`image-mask`)" :style="getImageWrapperStyle"> |
|||
<UploadIcon |
|||
:width="getIconWidth" |
|||
:style="getImageWrapperStyle" |
|||
color="#d6d6d6" |
|||
/> |
|||
</div> |
|||
<img :src="sourceValue" v-if="sourceValue" alt="avatar" /> |
|||
</div> |
|||
<Button |
|||
:class="e(`upload-btn`)" |
|||
@click="openModal" |
|||
v-if="showBtn" |
|||
v-bind="btnProps" |
|||
> |
|||
{{ btnText ? btnText : t('cropper.selectImage') }} |
|||
</Button> |
|||
|
|||
<Modal |
|||
@upload-success="handleUploadSuccess" |
|||
:upload-api="uploadApi" |
|||
:src="sourceValue" |
|||
/> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped lang="scss"> |
|||
$namespace: vben; |
|||
|
|||
.#{$namespace}-cropper-avatar { |
|||
display: inline-block; |
|||
text-align: center; |
|||
|
|||
&__image-wrapper { |
|||
overflow: hidden; |
|||
cursor: pointer; |
|||
border: var(--border); |
|||
border-radius: 50%; |
|||
|
|||
img { |
|||
width: 100%; |
|||
// height: 100%; |
|||
} |
|||
} |
|||
|
|||
&__image-mask { |
|||
position: absolute; |
|||
width: inherit; |
|||
height: inherit; |
|||
cursor: pointer; |
|||
background: rgb(0 0 0 / 40%); |
|||
border: inherit; |
|||
border-radius: inherit; |
|||
opacity: 0; |
|||
transition: opacity 0.4s; |
|||
|
|||
::v-deep(svg) { |
|||
margin: auto; |
|||
} |
|||
} |
|||
|
|||
&__image-mask:hover { |
|||
opacity: 40; |
|||
} |
|||
|
|||
&__upload-btn { |
|||
margin: 10px auto; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,309 @@ |
|||
<script setup lang="ts"> |
|||
import type { UploadProps } from 'ant-design-vue'; |
|||
|
|||
import type { PropType } from 'vue'; |
|||
|
|||
import type { CropendResult, Cropper } from './types'; |
|||
|
|||
import { h, ref } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { useNamespace } from '@vben/hooks'; |
|||
import { createIconifyIcon } from '@vben/icons'; |
|||
import { $t } from '@vben/locales'; |
|||
import { isFunction } from '@vben/utils'; |
|||
|
|||
import { dataURLtoBlob } from '@abp/core'; |
|||
import { Avatar, Button, Space, Tooltip, Upload } from 'ant-design-vue'; |
|||
|
|||
import CropperImage from './Cropper.vue'; |
|||
|
|||
type ApiFunParams = { file: Blob; fileName: string; name: string }; |
|||
|
|||
const props = defineProps({ |
|||
circled: { type: Boolean, default: true }, |
|||
uploadApi: { |
|||
type: Function as PropType<(params: ApiFunParams) => Promise<any>>, |
|||
default: undefined, |
|||
}, |
|||
src: { type: String, default: '' }, |
|||
}); |
|||
|
|||
const emits = defineEmits<{ |
|||
(event: 'uploadSuccess', url: string): void; |
|||
}>(); |
|||
|
|||
const UploadIcon = createIconifyIcon('ant-design:upload-outlined'); |
|||
const ResetIcon = createIconifyIcon('ant-design:reload-outlined'); |
|||
const RotateLeftIcon = createIconifyIcon('ant-design:rotate-left-outlined'); |
|||
const RotateRightIcon = createIconifyIcon('ant-design:rotate-right-outlined'); |
|||
const ScaleXIcon = createIconifyIcon('vaadin:arrows-long-h'); |
|||
const ScaleYIcon = createIconifyIcon('vaadin:arrows-long-v'); |
|||
const ZoomInIcon = createIconifyIcon('ant-design:zoom-in-outlined'); |
|||
const ZoomOutIcon = createIconifyIcon('ant-design:zoom-out-outlined'); |
|||
|
|||
let fileName = ''; |
|||
const src = ref(props.src || ''); |
|||
const previewSource = ref(''); |
|||
const fileList = ref<UploadProps['fileList']>([]); |
|||
const cropper = ref<Cropper>(); |
|||
let scaleX = 1; |
|||
let scaleY = 1; |
|||
|
|||
const { b, e } = useNamespace('cropper-am'); |
|||
const [Modal, modalApi] = useVbenModal({ |
|||
class: 'w-[800px]', |
|||
fullscreen: false, |
|||
fullscreenButton: false, |
|||
confirmText: $t('cropper.confirmText'), |
|||
onConfirm: handleOk, |
|||
title: $t('cropper.title'), |
|||
}); |
|||
function handleBeforeUpload(file: File) { |
|||
const reader = new FileReader(); |
|||
reader.readAsDataURL(file); |
|||
src.value = ''; |
|||
previewSource.value = ''; |
|||
reader.addEventListener('load', (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 (!cropper.value) { |
|||
return; |
|||
} |
|||
if (event === 'scaleX') { |
|||
scaleX = arg = scaleX === -1 ? 1 : -1; |
|||
} |
|||
if (event === 'scaleY') { |
|||
scaleY = arg = scaleY === -1 ? 1 : -1; |
|||
} |
|||
switch (event) { |
|||
case 'reset': { |
|||
return cropper.value.reset(); |
|||
} |
|||
case 'rotate': { |
|||
return cropper.value.rotate(arg!); |
|||
} |
|||
case 'scaleX': { |
|||
return cropper.value.scaleX(scaleX); |
|||
} |
|||
case 'scaleY': { |
|||
return cropper.value.scaleY(scaleY); |
|||
} |
|||
case 'zoom': { |
|||
return cropper.value.zoom(arg!); |
|||
} |
|||
} |
|||
} |
|||
async function handleOk() { |
|||
const uploadApi = props.uploadApi; |
|||
if (uploadApi && isFunction(uploadApi)) { |
|||
const blob = dataURLtoBlob(previewSource.value); |
|||
try { |
|||
modalApi.setState({ submitting: true }); |
|||
await uploadApi({ name: 'file', file: blob, fileName }); |
|||
emits('uploadSuccess', previewSource.value); |
|||
modalApi.close(); |
|||
} finally { |
|||
modalApi.setState({ submitting: false }); |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Modal> |
|||
<div :class="b()"> |
|||
<div :class="e('left')"> |
|||
<div :class="e('cropper')"> |
|||
<CropperImage |
|||
v-if="src" |
|||
:src="src" |
|||
height="300px" |
|||
:circled="circled" |
|||
@cropend="handleCropend" |
|||
@ready="handleReady" |
|||
/> |
|||
</div> |
|||
<div :class="e('toolbar')"> |
|||
<Upload |
|||
:file-list="fileList" |
|||
accept="image/*" |
|||
:before-upload="handleBeforeUpload" |
|||
> |
|||
<Tooltip :title="$t('cropper.selectImage')" placement="bottom"> |
|||
<Button size="small" :icon="h(UploadIcon)" type="primary" /> |
|||
</Tooltip> |
|||
</Upload> |
|||
<Space> |
|||
<Tooltip :title="$t('cropper.btn_reset')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(ResetIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('reset')" |
|||
/> |
|||
</Tooltip> |
|||
<Tooltip :title="$t('cropper.btn_rotate_left')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(RotateLeftIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('rotate', -45)" |
|||
/> |
|||
</Tooltip> |
|||
<Tooltip :title="$t('cropper.btn_rotate_right')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(RotateRightIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('rotate', 45)" |
|||
/> |
|||
</Tooltip> |
|||
<Tooltip :title="$t('cropper.btn_scale_x')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(ScaleXIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('scaleX')" |
|||
/> |
|||
</Tooltip> |
|||
<Tooltip :title="$t('cropper.btn_scale_y')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(ScaleYIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('scaleY')" |
|||
/> |
|||
</Tooltip> |
|||
<Tooltip :title="$t('cropper.btn_zoom_in')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(ZoomInIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('zoom', 0.1)" |
|||
/> |
|||
</Tooltip> |
|||
<Tooltip :title="$t('cropper.btn_zoom_out')" placement="bottom"> |
|||
<Button |
|||
type="primary" |
|||
:icon="h(ZoomOutIcon)" |
|||
size="small" |
|||
:disabled="!src" |
|||
@click="handlerToolbar('zoom', -0.1)" |
|||
/> |
|||
</Tooltip> |
|||
</Space> |
|||
</div> |
|||
</div> |
|||
<div :class="e('right')"> |
|||
<div :class="e(`preview`)"> |
|||
<img |
|||
:src="previewSource" |
|||
v-if="previewSource" |
|||
:alt="$t('cropper.preview')" |
|||
/> |
|||
</div> |
|||
<template v-if="previewSource"> |
|||
<div :class="e(`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> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<style scoped lang="scss"> |
|||
$namespace: vben; |
|||
|
|||
.#{$namespace}-cropper-am { |
|||
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; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
margin-top: 10px; |
|||
} |
|||
|
|||
&__preview { |
|||
width: 220px; |
|||
height: 220px; |
|||
margin: 0 auto; |
|||
overflow: hidden; |
|||
border: var(--border); |
|||
border-radius: 50%; |
|||
|
|||
img { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
} |
|||
|
|||
&__group { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-around; |
|||
padding-top: 8px; |
|||
margin-top: 8px; |
|||
border-top: var(--border); |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,2 @@ |
|||
export { default as CropperAvatar } from './CropperAvatar.vue'; |
|||
export { default as CropperModal } from './CropperModal.vue'; |
|||
@ -0,0 +1,8 @@ |
|||
import type Cropper from 'cropperjs'; |
|||
|
|||
export interface CropendResult { |
|||
imgBase64: string; |
|||
imgInfo: Cropper.Data; |
|||
} |
|||
|
|||
export type { Cropper }; |
|||
@ -0,0 +1,20 @@ |
|||
import type { SupportedLanguagesType } from '@vben/locales'; |
|||
|
|||
import { loadLocalesMapFromDir } from '@vben/locales'; |
|||
|
|||
const modules = import.meta.glob('./langs/**/*.json'); |
|||
|
|||
const localesMap = loadLocalesMapFromDir( |
|||
/\.\/langs\/([^/]+)\/(.*)\.json$/, |
|||
modules, |
|||
); |
|||
|
|||
/** |
|||
* 加载自定义组件本地化资源 |
|||
* @param lang 当前语言 |
|||
* @returns 资源集合 |
|||
*/ |
|||
export async function loadComponentMessages(lang: SupportedLanguagesType) { |
|||
const locales = localesMap[lang]?.(); |
|||
return locales; |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
{ |
|||
"confirmText": "Confirm and upload", |
|||
"title": "Avatar upload", |
|||
"selectImage": "Select Image", |
|||
"btn_rotate_left": "Counterclockwise rotation", |
|||
"btn_rotate_right": "Clockwise rotation", |
|||
"btn_scale_x": "Flip horizontal", |
|||
"btn_scale_y": "Flip vertical", |
|||
"btn_zoom_in": "Zoom in", |
|||
"btn_zoom_out": "Zoom out", |
|||
"btn_reset": "Reset", |
|||
"preview": "Preivew", |
|||
"uploadSuccess": "Uploaded success!" |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
{ |
|||
"confirmText": "确认并上传", |
|||
"title": "头像上传", |
|||
"selectImage": "选择图片", |
|||
"btn_rotate_left": "逆时针旋转", |
|||
"btn_rotate_right": "顺时针旋转", |
|||
"btn_scale_x": "水平翻转", |
|||
"btn_scale_y": "垂直翻转", |
|||
"btn_zoom_in": "放大", |
|||
"btn_zoom_out": "缩小", |
|||
"btn_reset": "重置", |
|||
"preview": "预览", |
|||
"uploadSuccess": "上传成功!" |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */ |
|||
/** |
|||
* @description: base64 to blob |
|||
*/ |
|||
export function dataURLtoBlob(base64Buf: string): Blob { |
|||
const arr = base64Buf.split(','); |
|||
const typeItem = arr[0]; |
|||
const mime = typeItem?.match(/:(.*?);/)?.[1]; |
|||
const bstr = window.atob(arr[1]!); |
|||
let n = bstr.length; |
|||
const u8arr = new Uint8Array(n); |
|||
while (n--) { |
|||
u8arr[n] = bstr.codePointAt(n)!; |
|||
} |
|||
return new Blob([u8arr], { type: mime }); |
|||
} |
|||
|
|||
/** |
|||
* img url to base64 |
|||
* @param url |
|||
*/ |
|||
export function urlToBase64(url: string, mineType?: string): Promise<string> { |
|||
return new Promise((resolve, reject) => { |
|||
let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null; |
|||
const ctx = canvas!.getContext('2d'); |
|||
|
|||
const img = new Image(); |
|||
img.crossOrigin = ''; |
|||
img.addEventListener('load', () => { |
|||
if (!canvas || !ctx) { |
|||
return reject(new Error('canvas or ctx is null!')); |
|||
} |
|||
canvas.height = img.height; |
|||
canvas.width = img.width; |
|||
ctx.drawImage(img, 0, 0); |
|||
const dataURL = canvas.toDataURL(mineType || 'image/png'); |
|||
canvas = null; |
|||
resolve(dataURL); |
|||
}); |
|||
img.src = url; |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue