diff --git a/CHANGELOG.md b/CHANGELOG.md index c67cec23aefe30770068aaf745384d90f1e9651e..0368cbf8afbdc1afd02175bcd4f199832be870cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## [Unreleased] +### Added + +- 新增图片裁剪上传组件 + ## [0.0.46] - 2024-12-28 ### Added diff --git a/src/common/cropping/cropping.scss b/src/common/cropping/cropping.scss new file mode 100644 index 0000000000000000000000000000000000000000..57502f83f6a35385c3af6ac5f794a9dbcba6c67c --- /dev/null +++ b/src/common/cropping/cropping.scss @@ -0,0 +1,82 @@ +@include b('cropping') { + display: flex; + flex-direction: column; + gap: getCssVar(spacing, tight); + width: 100%; + height: 100%; + overflow: hidden; + user-select: none; + @include e('content') { + display: flex; + flex: 1; + flex-direction: column; + gap: getCssVar(spacing, tight); + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; + @include m('crop') { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + } + @include m('croparea') { + position: absolute; + top: 50%; + left: 50%; + z-index: 10; + box-sizing: border-box; + overflow: hidden; + color: rgb(0 0 0 / 50%); + cursor: move; + content: ''; + background: transparent; + border: 1px solid rgb(255 255 255 / 50%); + box-shadow: 0 0 0 9999em; + transform: translate(-50%, -50%); + } + @include m('img') { + position: absolute; + inset: 0; + top: 50%; + left: 50%; + max-width: 100%; + max-height: 100%; + user-select: none; + transform: translate(-50%, -50%); + } + } + @include e('footer') { + display: flex; + flex-shrink: 0; + gap: getCssVar(spacing, base); + align-items: center; + justify-content: center; + width: 100%; + height: 50px; + overflow: hidden; + border-top: 1px solid getCssVar(color, border); + @include m('cancel') { + width: 45%; + padding: getCssVar(spacing, tight) getCssVar(spacing, base); + text-align: center; + cursor: pointer; + border: 1px solid getCssVar(color, border); + border-radius: getCssVar(border-radius, small); + + } + @include m('confirm') { + width: 45%; + padding: getCssVar(spacing, tight) getCssVar(spacing, base); + color: getCssVar(color, white); + text-align: center; + cursor: pointer; + background-color: getCssVar(color, primary); + border: 1px solid getCssVar(color, border); + border-radius: getCssVar(border-radius, small); + + } + } + } + \ No newline at end of file diff --git a/src/common/cropping/cropping.tsx b/src/common/cropping/cropping.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ab5e1fbc16c738789d923c19e006bd57d1399a29 --- /dev/null +++ b/src/common/cropping/cropping.tsx @@ -0,0 +1,320 @@ +/* eslint-disable import/no-extraneous-dependencies */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { useNamespace } from '@ibiz-template/vue3-util'; +import { computed, defineComponent, PropType, Ref, ref } from 'vue'; +import { createUUID } from 'qx-util'; +import './cropping.scss'; + +export const IBizCropping = defineComponent({ + name: 'IBizCropping', + props: { + // 传递一个文件对象进来 + img: { + type: Object as PropType, + }, + // 未传递img时,使用传递的url获取图片 + url: { + type: String, + }, + // 截取区域宽度 + cropareaWidth: { + type: Number, + default: 300, + }, + // 截取区域高度 + cropareaHeight: { + type: Number, + default: 200, + }, + }, + emits: ['change'], + setup(props, { emit }) { + const ns = useNamespace('cropping'); + // 缩放比例 + const scaleNumber = ref(1); + // 是否允许移动 + const allowMove = ref(false); + // 图片Ref + const imgRef = ref(); + // 图片移动位置 + const imgMovePosition: Ref = ref({ + x: 0, // 鼠标X轴移动距离 + y: 0, // 鼠标Y轴移动距离 + tx: 0, // 图片X轴已有偏移量 + ty: 0, // 图片Y轴已有偏移量 + }); + + // 上一次缩放时两指的距离 + let lastDistance = 0; // 最后一次距离 + // 剪切框容器 + const cropContainerRef = ref(); + // 截取区域元素id + const uuid = createUUID(); + // 计算截取图片的url + const cropImgUrl = computed(() => { + if (props.img?.raw) { + return URL.createObjectURL(props.img.raw); + } + if (props.url) { + return props.url; + } + return ''; + }); + + // 计算图片边缘与截取区域相交的边距宽度,用来限制图片拖动时在X轴上允许拖动的范围 + const spaceWidth = computed(() => { + let tempwidth = 0; + if (imgRef.value) { + const { width } = imgRef.value.getBoundingClientRect(); + tempwidth = (width - props.cropareaWidth) / 2; + } + return tempwidth; + }); + + // 计算图片边缘与截取区域相交的边距高度,用来限制图片拖动时在Y轴上允许拖动的范围 + const spaceHeight = computed(() => { + let tempheight = 0; + if (imgRef.value) { + const { height } = imgRef.value.getBoundingClientRect(); + tempheight = (height - props.cropareaHeight) / 2; + } + return tempheight; + }); + + // 缩小 + const onReduce = () => { + if (scaleNumber.value > 1) { + scaleNumber.value = (scaleNumber.value * 10 - 1) / 10; + } + }; + + // 放大 + const onAdd = () => { + if (scaleNumber.value < 3) { + scaleNumber.value = (scaleNumber.value * 10 + 1) / 10; + } + }; + + // 获取两点之间距离 + const getDistance = (start: IData, end: IData) => { + return Math.hypot(end.pageX - start.pageX, end.pageY - start.pageY); + }; + + // 图片偏移样式 + const style = computed(() => { + const imgStyle: IData = { + maxWidth: `${props.cropareaWidth}px`, + maxHeight: `${props.cropareaHeight}px`, + objectFit: 'contain', + transform: `translate(calc(${imgMovePosition.value.tx}px - 50%),calc(${imgMovePosition.value.ty}px - 50%)) scale(${scaleNumber.value})`, + }; + return imgStyle; + }); + + // 取消 + const onCancel = () => { + emit('change', ''); + }; + + // 确认 + const onConfirm = async () => { + let cropDataUrl = ''; + const croparea = document.getElementById(uuid); + if (croparea && imgRef.value) { + // 根据截取区域和图片的相对位置,计算截取位置的起始位置 + const { left: cropLeft, top: cropTop } = + croparea.getBoundingClientRect(); + const { left: imgLeft, top: imgTop } = + imgRef.value.getBoundingClientRect(); + + const distanceX = imgLeft - cropLeft; + const distanceY = imgTop - cropTop; + + const cropcanvas = await ibiz.util.html2canvas.getCanvas(imgRef.value, { + x: -distanceX, // 指定截取区域的左上角 x 坐标 + y: -distanceY, // 指定截取区域的左上角 y 坐标 + width: props.cropareaWidth, // 指定截取区域的宽度 + height: props.cropareaHeight, // 指定截取区域的高度 + }); + cropDataUrl = cropcanvas.toDataURL('image/png'); + } + // 确认,截取裁剪框的内容 + emit('change', cropDataUrl); + }; + + // 截取容器宽度 + const cropContainer = computed(() => { + let tempWidth = 200; + let tempHeight = 200; + if (cropContainerRef.value) { + const { width, height } = + cropContainerRef.value.getBoundingClientRect(); + tempWidth = width / 2; + tempHeight = height / 2; + } + return { + width: tempWidth, + height: tempHeight, + }; + }); + + // 触摸屏幕 + const onTouchStart = (event: TouchEvent) => { + event.preventDefault(); + const touches = event.touches; + if (touches.length === 2) { + // 缩放 + lastDistance = getDistance(touches[0], touches[1]); + } else if (touches.length === 1) { + const touch = event.touches[0]; + const x = touch.pageX; + const y = touch.pageY; + imgMovePosition.value.x = x; + imgMovePosition.value.y = y; + allowMove.value = true; + } + }; + + // 开始移动/缩放 + const onTouchMove = (event: TouchEvent) => { + if (!allowMove.value || !imgRef.value) { + return; + } + const touches = event.touches; + if (touches.length === 2) { + // 缩放 + const space = getDistance(touches[0], touches[1]); + if (space > lastDistance + 10) { + // 放大 + lastDistance = space; + onAdd(); + } else if (space < lastDistance - 10) { + // 缩小 + lastDistance = space; + onReduce(); + } + return; + } + // 移动 + const touch = event.touches[0]; + + const x = touch.pageX; + const y = touch.pageY; + + const spaceX = x - imgMovePosition.value.x + imgMovePosition.value.tx; + const spaceY = y - imgMovePosition.value.y + imgMovePosition.value.ty; + const { width, height } = cropContainer.value; + + if ( + spaceX <= spaceWidth.value + width * (scaleNumber.value - 1) && + spaceX >= -spaceWidth.value - width * (scaleNumber.value - 1) + ) { + imgMovePosition.value.tx = spaceX; + } + if ( + spaceY >= -spaceHeight.value - height * (scaleNumber.value - 1) && + spaceY <= spaceHeight.value + height * (scaleNumber.value - 1) + ) { + imgMovePosition.value.ty = spaceY; + } + + imgMovePosition.value.x = x; + imgMovePosition.value.y = y; + }; + // 移动结束 + const onTouchEnd = (_event: TouchEvent) => { + allowMove.value = false; + // 结束之后要归位 + const spaceX = imgMovePosition.value.tx; + const spaceY = imgMovePosition.value.ty; + + if ( + spaceX >= + spaceWidth.value + (props.cropareaWidth / 2) * (scaleNumber.value - 1) + ) { + imgMovePosition.value.tx = + spaceWidth.value + + (props.cropareaWidth / 2) * (scaleNumber.value - 1); + } + if ( + spaceX <= + -spaceWidth.value - (props.cropareaWidth / 2) * (scaleNumber.value - 1) + ) { + imgMovePosition.value.tx = + -spaceWidth.value - + (props.cropareaWidth / 2) * (scaleNumber.value - 1); + } + if ( + spaceY <= + -spaceHeight.value - + (props.cropareaHeight / 2) * (scaleNumber.value - 1) + ) { + imgMovePosition.value.ty = + -spaceHeight.value - + (props.cropareaHeight / 2) * (scaleNumber.value - 1); + } + if ( + spaceY >= + spaceHeight.value + (props.cropareaHeight / 2) * (scaleNumber.value - 1) + ) { + imgMovePosition.value.ty = + spaceHeight.value + + (props.cropareaHeight / 2) * (scaleNumber.value - 1); + } + }; + + return { + ns, + cropImgUrl, + scaleNumber, + style, + uuid, + imgRef, + cropContainerRef, + onReduce, + onAdd, + onCancel, + onConfirm, + onTouchStart, + onTouchMove, + onTouchEnd, + imgMovePosition, + }; + }, + render() { + return ( +
+
+
+
+ +
+
+
+
+ {ibiz.i18n.t('editor.common.cancel')} +
+
+ {ibiz.i18n.t('editor.common.confirm')} +
+
+
+ ); + }, +}); diff --git a/src/common/index.ts b/src/common/index.ts index 30c1abcd70bba1f7bd560d26173866cc045658dc..7afdfeee73db4ba6d5506fbcd085519b1a1753e7 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -23,6 +23,7 @@ import { IBizEmojiSelect } from './emoji-select/emoji-select'; import { IBizMdCtrlSetting } from './md-ctrl-setting/md-ctrl-setting'; import { IBizPreviewImage } from './preview-image/preview-image'; import { IBizDateRangeCalendar } from './date-range-picker/date-range-picker'; +import { IBizCropping } from './cropping/cropping'; export * from './col/col'; export * from './row/row'; @@ -52,6 +53,7 @@ export const IBizCommonComponents = { v.component(IBizBadge.name, IBizBadge); v.component(IBizMdCtrlSetting.name, IBizMdCtrlSetting); v.component(IBizPreviewImage.name, IBizPreviewImage); + v.component(IBizCropping.name, IBizCropping); }, }; diff --git a/src/editor/index.ts b/src/editor/index.ts index 6fe9e64a8e6b1cbaf9e8836b9221220be7fb033b..6677329125869ef33d70c6e93717f36b7ab60874 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -28,6 +28,7 @@ import { IBizImageUpload, FileUploaderEditorProvider, IBizEditorCarousel, + IBizImageCropping, } from './upload'; import { IBizNumberRangePicker, @@ -71,6 +72,7 @@ export const IBizEditor = { v.component(IBizPickerSelectView.name, IBizPickerSelectView); v.component(IBizEditorCarousel.name, IBizEditorCarousel); v.component(IBizQrcode.name, IBizQrcode); + v.component(IBizImageCropping.name, IBizImageCropping); v.component( 'IBizMarkDown', @@ -204,6 +206,10 @@ export const IBizEditor = { 'MOBPICTURE_RAW', () => new FileUploaderEditorProvider('MOBPICTURE_RAW'), ); + registerEditorProvider( + 'MOBPICTURE_CROPPING', + () => new FileUploaderEditorProvider('MOBPICTURE_CROPPING'), + ); // 数值范围 registerEditorProvider( 'MOBNUMBERRANGE', diff --git a/src/editor/upload/ibiz-image-cropping/ibiz-image-cropping.scss b/src/editor/upload/ibiz-image-cropping/ibiz-image-cropping.scss new file mode 100644 index 0000000000000000000000000000000000000000..7be9bb06400bcc0d58b069e78ee52b09047c5ce7 --- /dev/null +++ b/src/editor/upload/ibiz-image-cropping/ibiz-image-cropping.scss @@ -0,0 +1,92 @@ +@include b('image-upload-cropping') { + .van-uploader__preview { + .van-uploader__file-icon { + display: none; + } + + .van-uploader__file-name { + display: none; + } + } + @include e(crop-popup){ + width: 100%; + max-width: 100%; + height: 100%; + } + @include b(image-upload-cropping-item-cover) { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + font-size: rem(12px); + line-height: initial; + color: #fff; + text-align: center; + background: transparent; + + img { + width: 100%; + height: 100%; + border-radius: rem(4px); + } + } + + .van-uploader { + display: flex; + flex-direction: row; + justify-content: getCssVar(form-item-container, editor-align); + } +} + +.van-uploader__preview-delete--shadow { + top: rem(-5px); + right: rem(-5px); + display: flex; + align-items: center; + justify-content: center; + width: var(--van-uploader-delete-icon-size); + height: var(--van-uploader-delete-icon-size); + text-align: center; + background: var(--van-uploader-delete-background); + border-radius: 50%; + opacity: 0.6; +} + +.van-uploader__preview-delete-icon { + position: absolute; + top: 0; + right: 0; + font-size: var(--van-uploader-delete-icon-size); + color: var(--van-uploader-delete-color); + transform: scale(0.7) translate(0%, -10%); +} + +.van-uploader__upload { + background: transparent; + border: getCssVar(color, border) rem(1px) solid; + border-radius: rem(4px); +} + +.van-uploader__upload--readonly { + display: none; +} + +@include b(panel-field) { + @include b(panel-field-content) { + height: 100%; + + // 特殊适配 + .van-uploader { + width: 100%; + height: 100%; + } + + .van-uploader__wrapper { + height: 100%; + } + } +} diff --git a/src/editor/upload/ibiz-image-cropping/ibiz-image-cropping.tsx b/src/editor/upload/ibiz-image-cropping/ibiz-image-cropping.tsx new file mode 100644 index 0000000000000000000000000000000000000000..024eb1cf481a94c345ec4b395f30bef4e328fb4a --- /dev/null +++ b/src/editor/upload/ibiz-image-cropping/ibiz-image-cropping.tsx @@ -0,0 +1,171 @@ +/* eslint-disable no-param-reassign */ +import { computed, defineComponent, Ref, ref } from 'vue'; +import { + getEditorEmits, + getUploadProps, + useNamespace, +} from '@ibiz-template/vue3-util'; +import './ibiz-image-cropping.scss'; +import { showImagePreview } from 'vant'; +import { useVanUpload } from '../use/use-van-upload'; +import { UploadEditorController } from '../upload-editor.controller'; + +export const IBizImageCropping = defineComponent({ + name: 'IBizImageCropping', + props: getUploadProps(), + emits: getEditorEmits(), + setup(props, { emit }) { + const ns = useNamespace('image-upload-cropping'); + + const c = props.controller; + + // 裁剪框是否显示 + const show = ref(false); + + // 待上传的文件 + const tempFile: Ref = ref(); + + // 是否阻止默认点击 + const result = c.editorParams?.STOPPROPAGATION !== 'false'; + + const { + uploadUrl, + headers, + files, + onRemove, + beforeUpload, + afterRead, + limit, + onDownload, + } = useVanUpload( + props, + value => { + emit('change', value); + }, + c, + ); + + // 预览 + const onPreview = (_file: IData) => { + const index = files.value.findIndex(item => item.id === _file.id); + showImagePreview({ + images: files.value.map(item => item.url) as string[], + startPosition: index, + }); + }; + + const onClick = (file: IData, event: MouseEvent) => { + if (result) { + event.stopPropagation(); + onPreview(file); + } + }; + + const cropReadedFile = (file: IData) => { + tempFile.value = file; + show.value = true; + }; + + const cropImgUrl = computed(() => { + if (tempFile.value) { + return tempFile.value.content; + } + return ''; + }); + + const dataURLtoBlob = (dataURL: string) => { + const byteString = atob(dataURL.split(',')[1]); + const mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0]; + const arrayBuffer = new ArrayBuffer(byteString.length); + const intArray = new Uint8Array(arrayBuffer); + for (let i = 0; i < byteString.length; i++) { + intArray[i] = byteString.charCodeAt(i); + } + return new Blob([arrayBuffer], { type: mimeString }); + }; + + const cropChange = (url: string) => { + show.value = false; + if (!url) { + ibiz.message.info(ibiz.i18n.t('editor.upload.cancelUpload')); + return; + } + const blob = dataURLtoBlob(url); + const _tempFile = new File([blob], 'cropimg.png', { + type: blob.type, + }); + if (_tempFile && tempFile.value) { + tempFile.value.file = _tempFile; + afterRead(tempFile.value); + } + }; + + return { + ns, + c, + files, + limit, + headers, + uploadUrl, + result, + beforeUpload, + onRemove, + onPreview, + cropReadedFile, + onDownload, + onClick, + show, + tempFile, + cropImgUrl, + cropChange, + }; + }, + render() { + // 编辑态展示 + return ( +
+ + {{ + 'preview-cover': (file: IData) => { + return ( +
this.onClick(file, event)} + > + +
+ ); + }, + }} +
+ + + +
+ ); + }, +}); diff --git a/src/editor/upload/index.ts b/src/editor/upload/index.ts index 288d3603a9c3299d50d4759ccff332668ab927c2..58f06cd655cd3e95b60dd1ec97675500ae02c469 100644 --- a/src/editor/upload/index.ts +++ b/src/editor/upload/index.ts @@ -2,5 +2,6 @@ export { IBizFileUpload } from './ibiz-file-upload/ibiz-file-upload'; export { IBizImageUpload } from './ibiz-image-upload/ibiz-image-upload'; export { IBizImageSelect } from './ibiz-image-select/ibiz-image-select'; export { IBizEditorCarousel } from './ibiz-carousel/ibiz-carousel'; +export { IBizImageCropping } from './ibiz-image-cropping/ibiz-image-cropping'; export * from './upload-editor.controller'; export * from './upload-editor.provider'; diff --git a/src/editor/upload/upload-editor.provider.ts b/src/editor/upload/upload-editor.provider.ts index e42ef02283334c6a43866655dd6be5d3d62c8d0e..8b066f61a56527a6185b1dff9bbe8f348d41d759 100644 --- a/src/editor/upload/upload-editor.provider.ts +++ b/src/editor/upload/upload-editor.provider.ts @@ -29,6 +29,9 @@ export class FileUploaderEditorProvider implements IEditorProvider { case 'MOBPICTURE_RAW': componentName = 'IBizImageSelect'; break; + case 'MOBPICTURE_CROPPING': + componentName = 'IBizImageCropping'; + break; case 'CAROUSEL': componentName = 'IBizEditorCarousel'; break;