| | |
| | | /> |
| | | </div> |
| | | </template> |
| | | <script lang="ts"> |
| | | <script lang="ts" setup> |
| | | import type { CSSProperties } from 'vue'; |
| | | import { defineComponent, onMounted, ref, unref, computed, onUnmounted } from 'vue'; |
| | | import { 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/shared'; |
| | | import { useDesign } from '@/hooks/web/useDesign'; |
| | | import { useDebounceFn } from '@vueuse/core'; |
| | | import { useAttrs } from '@vben/hooks'; |
| | | |
| | | type Options = Cropper.Options; |
| | | |
| | |
| | | rotatable: true, |
| | | }; |
| | | |
| | | const props = { |
| | | defineOptions({ name: 'CropperImage' }); |
| | | |
| | | const props = defineProps({ |
| | | src: { type: String, required: true }, |
| | | alt: { type: String }, |
| | | circled: { type: Boolean, default: false }, |
| | |
| | | }, |
| | | 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 }; |
| | | }, |
| | | }); |
| | | |
| | | const emit = defineEmits(['cropend', 'ready', 'cropendError']); |
| | | |
| | | const attrs = useAttrs(); |
| | | |
| | | 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; |
| | | } |
| | | </script> |
| | | <style lang="less"> |
| | | @prefix-cls: ~'@{namespace}-cropper-image'; |