diff --git a/package.json b/package.json index 376eac9a7f..3d52c60abf 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "unplugin-vue-markdown": "^0.24.3", "vite": "^4.4.11", "vitest": "^0.34.6", + "vitest-canvas-mock": "^0.3.3", "vue": "^3.3.4", "vue-tsc": "1.8.15" }, diff --git a/packages/nutui-taro-demo/project.private.config.json b/packages/nutui-taro-demo/project.private.config.json index 39f7acfbef..976aa25cb8 100644 --- a/packages/nutui-taro-demo/project.private.config.json +++ b/packages/nutui-taro-demo/project.private.config.json @@ -6,6 +6,13 @@ "condition": { "miniprogram": { "list": [ + { + "name": "AvatarCropper", + "pathName": "business/pages/avatarcropper/index", + "query": "", + "launchMode": "default", + "scene": null + }, { "name": "exhibition/pages/imagepreview/index", "pathName": "exhibition/pages/imagepreview/index", diff --git a/packages/nutui-taro-demo/src/business/pages/avatarcropper/index.config.ts b/packages/nutui-taro-demo/src/business/pages/avatarcropper/index.config.ts new file mode 100644 index 0000000000..7c77e2da51 --- /dev/null +++ b/packages/nutui-taro-demo/src/business/pages/avatarcropper/index.config.ts @@ -0,0 +1,3 @@ +export default { + navigationBarTitleText: 'AvatarCropper' +}; diff --git a/packages/nutui-taro-demo/src/business/pages/avatarcropper/index.vue b/packages/nutui-taro-demo/src/business/pages/avatarcropper/index.vue new file mode 100644 index 0000000000..3e19c2ec0a --- /dev/null +++ b/packages/nutui-taro-demo/src/business/pages/avatarcropper/index.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc53b3c74..afde6133b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: vitest: specifier: ^0.34.6 version: 0.34.6(@vitest/ui@0.34.6)(sass@1.69.3) + vitest-canvas-mock: + specifier: ^0.3.3 + version: 0.3.3(vitest@0.34.6) vue: specifier: ^3.3.4 version: 3.3.4 @@ -6543,6 +6546,10 @@ packages: engines: {node: '>=4'} hasBin: true + /cssfontparser@1.2.1: + resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==} + dev: true + /cssnano-preset-default@5.2.14(postcss@8.4.31): resolution: {integrity: sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==} engines: {node: ^10 || ^12 || >=14.0} @@ -9857,6 +9864,13 @@ packages: resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} dev: true + /jest-canvas-mock@2.5.2: + resolution: {integrity: sha512-vgnpPupjOL6+L5oJXzxTxFrlGEIbHdZqFU+LFNdtLxZ3lRDCl17FlTMM7IatoRQkrcyOTMlDinjUguqmQ6bR2A==} + dependencies: + cssfontparser: 1.2.1 + moo-color: 1.0.3 + dev: true + /jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -11136,6 +11150,12 @@ packages: yargs-unparser: 2.0.0 dev: true + /moo-color@1.0.3: + resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} + dependencies: + color-name: 1.1.4 + dev: true + /move-concurrently@1.0.1: resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==} dependencies: @@ -15093,6 +15113,15 @@ packages: fsevents: 2.3.3 dev: true + /vitest-canvas-mock@0.3.3(vitest@0.34.6): + resolution: {integrity: sha512-3P968tYBpqYyzzOaVtqnmYjqbe13576/fkjbDEJSfQAkHtC5/UjuRHOhFEN/ZV5HVZIkaROBUWgazDKJ+Ibw+Q==} + peerDependencies: + vitest: '*' + dependencies: + jest-canvas-mock: 2.5.2 + vitest: 0.34.6(@vitest/ui@0.34.6)(sass@1.69.3) + dev: true + /vitest@0.34.6(@vitest/ui@0.34.6)(sass@1.69.3): resolution: {integrity: sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==} engines: {node: '>=v14.18.0'} diff --git a/src/config.json b/src/config.json index 74e0d60a12..3bf7d2ffa3 100644 --- a/src/config.json +++ b/src/config.json @@ -1311,6 +1311,17 @@ "type": "component", "author": "ailululu", "taro": true + }, + { + "version": "1.0.0", + "name": "AvatarCropper", + "type": "component", + "tarodoc": true, + "show": true, + "cName": "头像裁剪", + "desc": "仿微信头像裁剪功能", + "taro": true, + "author": "Marvin" } ] } diff --git a/src/packages/__VUE/avatarcropper/__tests__/__snapshots__/avatarcropper.spec.ts.snap b/src/packages/__VUE/avatarcropper/__tests__/__snapshots__/avatarcropper.spec.ts.snap new file mode 100644 index 0000000000..b62e9fc16d --- /dev/null +++ b/src/packages/__VUE/avatarcropper/__tests__/__snapshots__/avatarcropper.spec.ts.snap @@ -0,0 +1,38 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`layout default slot 1`] = ` +"
+
+
+
+
+
+
+
+ + + + + 取消 + + +
+
+ +
+
+ +
+
+ + + + + 确定 + + +
+
+
+
" +`; diff --git a/src/packages/__VUE/avatarcropper/__tests__/avatarcropper.spec.ts b/src/packages/__VUE/avatarcropper/__tests__/avatarcropper.spec.ts new file mode 100644 index 0000000000..e61bfdc078 --- /dev/null +++ b/src/packages/__VUE/avatarcropper/__tests__/avatarcropper.spec.ts @@ -0,0 +1,83 @@ +import 'vitest-canvas-mock'; +import { mount } from '@vue/test-utils'; +import AvatarCropper from '../index.vue'; +import { sleep, trigger, triggerDrag } from '@/packages/utils/unit'; +import { h } from 'vue'; + +const mockFile = new File([new ArrayBuffer(10000)], 'test.jpg', { + type: 'image/jpg' +}); + +test('layout default slot', () => { + const wrapper = mount(AvatarCropper, { + slots: { + default: h('img', { + src: 'https://img12.360buyimg.com/imagetools/jfs/t1/196430/38/8105/14329/60c806a4Ed506298a/e6de9fb7b8490f38.png' + }) + } + }); + + expect(wrapper.html()).toMatchSnapshot(); + expect(wrapper.find('.nut-avatar-cropper').html()).toContain( + '' + ); +}); + +test('should render base cutAvatar and type', async () => { + const wrapper = mount(AvatarCropper); + const up_load = wrapper.find('.nut-avatar-cropper'); + expect(up_load.exists()).toBe(true); + const up_load1 = wrapper.find('.nut-avatar-cropper__input'); + expect(up_load1.attributes().type).toBe('file'); +}); + +test('AvatarCropper: Select the image to open the crop window', async () => { + const wrapper = mount(AvatarCropper); + const input: any = wrapper.find('.nut-avatar-cropper__input'); + expect(input.exists()).toBe(true); + const smallFile = new File([new ArrayBuffer(100)], 'small.jpg'); + Object.defineProperty(input.element, 'files', { + get: vi.fn().mockReturnValue([mockFile, smallFile]) + }); + expect(wrapper.find('.nut-cropper-popup').attributes()).toHaveProperty('style', 'display: none;'); + await input.trigger('change'); + await sleep(); + expect(wrapper.find('.nut-cropper-popup').attributes()).toHaveProperty('style', ''); + const canvas = wrapper.find('.nut-cropper-popup__canvas'); + expect(canvas.exists()).toBe(true); + + const track = wrapper.find('.nut-cropper-popup__highlight'); + + trigger(track, 'touchstart', 0, 0, { x: 0, y: 0 }); + trigger(track, 'touchmove', 20, 20, { x: 40, y: 60 }); + trigger(track, 'touchend', 20, 100, { x: 40, y: 60 }); + + triggerDrag(track, 50, 60); + const toolbar = wrapper.findAll('.nut-cropper-popup__toolbar-item'); + expect(toolbar.length).toBe(4); + + const cancel = toolbar[0]; + cancel.trigger('click'); + expect(wrapper.emitted('cancel')).toBeTruthy(); + expect(input.element.value).toBe(''); + await sleep(); + expect(wrapper.find('.nut-cropper-popup').attributes()).toHaveProperty('style', 'display: none;'); + + const reset = toolbar[1]; + reset.trigger('click'); + expect(wrapper.vm.angle).toBe(0); + + const rotate = toolbar[2]; + rotate.trigger('click'); + expect(wrapper.vm.angle).toBe(90); + triggerDrag(track, 1000, 2000); + rotate.trigger('click'); + expect(wrapper.vm.angle).toBe(180); + rotate.trigger('click'); + rotate.trigger('click'); + expect(wrapper.vm.angle).toBe(0); + + const confirm = toolbar[3]; + confirm.trigger('click'); + expect(wrapper.emitted('confirm')).toBeTruthy(); +}); diff --git a/src/packages/__VUE/avatarcropper/demo.vue b/src/packages/__VUE/avatarcropper/demo.vue new file mode 100644 index 0000000000..d8638a9812 --- /dev/null +++ b/src/packages/__VUE/avatarcropper/demo.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/packages/__VUE/avatarcropper/doc.en-US.md b/src/packages/__VUE/avatarcropper/doc.en-US.md new file mode 100644 index 0000000000..849493ced5 --- /dev/null +++ b/src/packages/__VUE/avatarcropper/doc.en-US.md @@ -0,0 +1,122 @@ +# AvatarCropper Head cropping + +### introduce + +Used to cut the profile picture to create a new image. + +### install + +```js +import { createApp } from 'vue'; +import { AvatarCropper } from '@nutui/nutui'; + +const app = createApp(); +app.use(AvatarCropper); +``` + +### 基础用法 + +Use the avatar component directly in the middle, and the image content will be replaced with the new one after cropping. + +:::demo + +```vue + + +``` + +::: + +### Clipping region toolbar slots + +Customize the clipping area toolbar, and toolbar-position controls the toolbar position + +:::demo + +```vue + + + + + +``` + +::: + +## API + +### AvatarCropper Props + +| Attribute | Description | Type | Default | +| ---------------- | ----------------------------------------------------------------------------------- | ------ | ------- | +| max-zoom | Maximum zoom | number | 3 | +| space | The gap reserved on both sides of the clipping area | number | 20 | +| toolbar-position | Location of the toolbar in the clipping area. The optional value is:`top` `bottom` | string | bottom | +| edit-text | The text content in the middle | string | 编辑 | +| cancel-text | Cancel button text | string | 取消 | +| cancel-confirm | Confirm button text | string | 确认 | + +### AvatarCropper Slots + +| Name | Description | +| ------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| default | The default slot for placing elements such as images, ICONS, and text | +| toolbar | After selecting the file, crop the bottom element of the pop-up window can be customized, and invoke the method of the component through ref | + +### AvatarCropper Events + +| Name | Description | Callback Arguments | +| ------- | --------------------------------------- | ---------------------- | +| confirm | Click Confirm to trigger after cropping | url:The trimmed base64 | +| cancel | Click cancel trigger | - | + +### AvatarCropper Ref + +| Event | Explain | +| ------- | ------------------ | +| cancel | uncrop | +| reset | Reset to 0 degrees | +| rotate | Rotate 90 degrees | +| confirm | Definite cut | diff --git a/src/packages/__VUE/avatarcropper/doc.md b/src/packages/__VUE/avatarcropper/doc.md new file mode 100644 index 0000000000..72d11c587c --- /dev/null +++ b/src/packages/__VUE/avatarcropper/doc.md @@ -0,0 +1,122 @@ +# AvatarCropper 头像剪切 + +### 介绍 + +用来对头像进行剪切生成一张新的图片。 + +### 安装 + +```js +import { createApp } from 'vue'; +import { AvatarCropper } from '@nutui/nutui'; + +const app = createApp(); +app.use(AvatarCropper); +``` + +### 基础用法 + +中间直接使用avatar组件,裁剪后图片内容会被替换为新的。 + +:::demo + +```vue + + +``` + +::: + +### 裁剪区域toolbar插槽 + +自定义裁剪区域工具栏,toolbar-position控制工具栏位置 + +:::demo + +```vue + + + + + +``` + +::: + +## API + +### AvatarCropper Props + +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | ------------------------------------------- | ------ | ------ | +| max-zoom | 最大缩放倍数 | number | 3 | +| space | 裁剪区域两边预留的间隙 | number | 10 | +| toolbar-position | 裁剪区域工具栏位置,可选值为:`top` `bottom` | string | bottom | +| edit-text | 中间的文字内容 | string | 编辑 | +| cancel-text | 取消按钮的文字 | string | 取消 | +| cancel-confirm | 确认按钮的文字 | string | 确认 | + +### AvatarCropper Slots + +| 名称 | 描述 | +| ------- | ----------------------------------------------------------- | +| default | 默认插槽,可放置图片、图标、文本等元素 | +| toolbar | 选择文件后裁剪弹窗底部元素可以自定义,通过ref调用组件的方法 | + +### AvatarCropper Events + +| 名称 | 描述 | 回调参数 | +| ------- | ------------------ | ------------------ | +| confirm | 裁剪后点击确认触发 | url:裁剪后的base64 | +| cancel | 点击取消触发 | - | + +### AvatarCropper Ref + +| 事件名 | 说明 | +| ------- | --------- | +| cancel | 取消裁剪 | +| reset | 重置为0度 | +| rotate | 旋转90度 | +| confirm | 确定裁剪 | diff --git a/src/packages/__VUE/avatarcropper/doc.taro.md b/src/packages/__VUE/avatarcropper/doc.taro.md new file mode 100644 index 0000000000..59611620e9 --- /dev/null +++ b/src/packages/__VUE/avatarcropper/doc.taro.md @@ -0,0 +1,124 @@ +# AvatarCropper 头像剪切 + +### 介绍 + +用来对头像进行剪切生成一张新的图片。 + +### 安装 + +```js +import { createApp } from 'vue'; +import { AvatarCropper } from '@nutui/nutui-taro'; + +const app = createApp(); +app.use(AvatarCropper); +``` + +### 基础用法 + +中间直接使用avatar组件,裁剪后图片内容会被替换为新的。 + +:::demo + +```vue + + +``` + +::: + +### 裁剪区域toolbar插槽 + +自定义裁剪区域工具栏,toolbar-position控制工具栏位置 + +:::demo + +```vue + + + + + +``` + +::: + +## API + +### AvatarCropper Props + +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | -------------------------------------------------- | ------ | -------------------------- | +| max-zoom | 最大缩放倍数 | number | 3 | +| space | 裁剪区域两边预留的间隙 | number | 10 | +| toolbar-position | 裁剪区域工具栏位置,可选值为:`top` `bottom` | string | bottom | +| edit-text | 中间的文字内容 | string | 编辑 | +| cancel-text | 取消按钮的文字 | string | 取消 | +| cancel-confirm | 确认按钮的文字 | string | 确认 | +| size-type | 所选的图片的尺寸: 可选值:`original` `compressed` | Array | ['original', 'compressed'] | +| source-type | 选择图片的来源: 可选值:`album` `camera` | Array | ['album', 'camera'] | + +### AvatarCropper Slots + +| 名称 | 描述 | +| ------- | ----------------------------------------------------------- | +| default | 默认插槽,可放置图片、图标、文本等元素 | +| toolbar | 选择文件后裁剪弹窗底部元素可以自定义,通过ref调用组件的方法 | + +### AvatarCropper Events + +| 名称 | 描述 | 回调参数 | +| ------- | ------------------ | ------------------ | +| confirm | 裁剪后点击确认触发 | url:裁剪后的base64 | +| cancel | 点击取消触发 | - | + +### AvatarCropper Ref + +| 事件名 | 说明 | +| ------- | --------- | +| cancel | 取消裁剪 | +| reset | 重置为0度 | +| rotate | 旋转90度 | +| confirm | 确定裁剪 | diff --git a/src/packages/__VUE/avatarcropper/index.scss b/src/packages/__VUE/avatarcropper/index.scss new file mode 100644 index 0000000000..016984a48e --- /dev/null +++ b/src/packages/__VUE/avatarcropper/index.scss @@ -0,0 +1,98 @@ +.nut-avatar-cropper { + position: relative; + &::after, + &__edit-text { + content: attr(data-edit-text); + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba($color: #000000, $alpha: 0.3); + z-index: 1; + color: #fff; + display: flex; + justify-content: center; + align-items: center; + } + &.taro { + &::after { + content: none; + } + } + &__input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; // 隐藏原生上传按钮 + cursor: pointer; + z-index: 2; + } +} + +.nut-cropper-popup { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--nut-overlay-bg-color, rgba(0, 0, 0, 0.7)); + z-index: 1000; + &__canvas, + &__cut-canvas { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + } + &__cut-canvas { + z-index: 0; + } + &__toolbar { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + z-index: 2; + &.top { + top: 0; + bottom: inherit; + } + .flex-sb { + width: 100%; + display: flex; + justify-content: space-between; + } + &-item { + color: #fff; + padding: 15px; + cursor: pointer; + display: flex; + align-items: center; + } + } + &__highlight { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + background-color: transparent; + .highlight { + position: absolute; + width: 365px; + height: 365px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 1; + background-color: transparent; + box-shadow: 0 0 1000px 1000px rgba(0, 0, 0, 0.6); + } + } +} diff --git a/src/packages/__VUE/avatarcropper/index.taro.vue b/src/packages/__VUE/avatarcropper/index.taro.vue new file mode 100644 index 0000000000..5eee51091a --- /dev/null +++ b/src/packages/__VUE/avatarcropper/index.taro.vue @@ -0,0 +1,657 @@ + + + diff --git a/src/packages/__VUE/avatarcropper/index.vue b/src/packages/__VUE/avatarcropper/index.vue new file mode 100644 index 0000000000..bd2031f181 --- /dev/null +++ b/src/packages/__VUE/avatarcropper/index.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/src/packages/__VUE/avatarcropper/types.ts b/src/packages/__VUE/avatarcropper/types.ts new file mode 100644 index 0000000000..155fcd07e8 --- /dev/null +++ b/src/packages/__VUE/avatarcropper/types.ts @@ -0,0 +1,3 @@ +export type AvatarCropperToolbarPosition = 'top' | 'bottom'; +export type AvatarCropperSizeType = 'original' | 'compressed'; +export type AvatarCropperSourceType = 'album' | 'camera'; diff --git a/src/packages/utils/canvas.ts b/src/packages/utils/canvas.ts new file mode 100644 index 0000000000..fdaf27c918 --- /dev/null +++ b/src/packages/utils/canvas.ts @@ -0,0 +1,107 @@ +import Taro from '@tarojs/taro'; +import CanvasContext = Taro.CanvasContext; + +const compareVersion = (v1Old: string, v2Old: string) => { + let v1 = v1Old.split('.'); + let v2 = v2Old.split('.'); + const len = Math.max(v1.length, v2.length); + + while (v1.length < len) { + v1.push('0'); + } + while (v2.length < len) { + v2.push('0'); + } + + for (let i = 0; i < len; i++) { + const num1 = parseInt(v1[i]); + const num2 = parseInt(v2[i]); + + if (num1 > num2) { + return 1; + } else if (num1 < num2) { + return -1; + } + } + + return 0; +}; + +const isWeapp = () => { + return process.env.TARO_ENV === 'weapp'; +}; + +////////////////////////////////////////////////////////////////////////////////// +//////// 微信小程序自1.9.90起废除若干个CanvasContext的函数,改为属性,以下为兼容代码 +////////////////////////////////////////////////////////////////////////////////// + +function _easyCanvasContextBase( + systemInfo: any, + lowCallback: () => void, + highCallback: () => void, + targetVersion: string = '1.9.90' +) { + if (isWeapp() && compareVersion(systemInfo.SDKVersion, targetVersion) >= 0) { + highCallback(); + } else { + lowCallback(); + } +} +/** + * + * 基础库 1.9.90 开始支持,低版本需做兼容处理。填充颜色。用法同 CanvasContext.setFillStyle()。 + * @param systemInfo + * @param canvasContext + * @param color + */ +function easySetStrokeStyle( + systemInfo: Taro.getSystemInfoSync.Result, + canvasContext: CanvasContext, + color: string | CanvasGradient +) { + _easyCanvasContextBase( + systemInfo, + () => { + canvasContext.setStrokeStyle(color); + console.log('???'); + }, + () => { + if (typeof color === 'string') { + canvasContext.strokeStyle = color; + } + console.log('2333'); + } + ); +} + +function easySetLineWidth(systemInfo: Taro.getSystemInfoSync.Result, canvasContext: CanvasContext, lineWidth: number) { + _easyCanvasContextBase( + systemInfo, + () => { + canvasContext.setLineWidth(lineWidth); + }, + () => { + canvasContext.lineWidth = lineWidth; + } + ); +} + +function easySetFillStyle( + systemInfo: Taro.getSystemInfoSync.Result, + canvasContext: CanvasContext, + color: string | CanvasGradient +) { + _easyCanvasContextBase( + systemInfo, + () => { + canvasContext.setFillStyle(color); + }, + () => { + if (typeof color === 'string') { + canvasContext.fillStyle = color; + } + } + ); +} + +export { compareVersion, easySetStrokeStyle, easySetLineWidth, easySetFillStyle };