@veltra/desktop 1.7.8 → 1.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/batch-edit/batch-edit-form.js +7 -3
- package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
- package/dist/components/batch-edit/batch-edit.js +1 -0
- package/dist/components/batch-edit/batch-edit.js.map +1 -1
- package/dist/components/batch-edit/use-edit-state.js.map +1 -1
- package/dist/components/form/form.js.map +1 -1
- package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +31 -6
- package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/is-field-modified.js +12 -0
- package/dist/components/form/is-field-modified.js.map +1 -0
- package/dist/components/form/style2.css +37 -1
- package/dist/components/form/use-form-fields.js +11 -6
- package/dist/components/form/use-form-fields.js.map +1 -1
- package/dist/components/form-item/form-item.js.map +1 -1
- package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +18 -5
- package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/image-cropper/draw-crop.js +63 -0
- package/dist/components/image-cropper/draw-crop.js.map +1 -0
- package/dist/components/image-cropper/image-cropper.js +344 -0
- package/dist/components/image-cropper/image-cropper.js.map +1 -0
- package/dist/components/image-cropper/style.d.ts +1 -0
- package/dist/components/image-cropper/style.js +1 -0
- package/dist/components/image-cropper/style2.css +205 -0
- package/dist/components/image-cropper/use-image-loader.js +59 -0
- package/dist/components/image-cropper/use-image-loader.js.map +1 -0
- package/dist/components/image-cropper/use-preview.js +44 -0
- package/dist/components/image-cropper/use-preview.js.map +1 -0
- package/dist/components/image-cropper/use-selection.js +183 -0
- package/dist/components/image-cropper/use-selection.js.map +1 -0
- package/dist/components/image-cropper/use-transform.js +182 -0
- package/dist/components/image-cropper/use-transform.js.map +1 -0
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.js +3 -1
- package/dist/components/index.js.map +1 -1
- package/dist/index.d.ts +47 -45
- package/dist/index.js +46 -45
- package/dist/types/batch-edit.d.ts +2 -0
- package/dist/types/form-item.d.ts +4 -1
- package/dist/types/form.d.ts +10 -1
- package/dist/types/image-cropper.d.ts +68 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +6 -6
- package/src/components/auto-complete/style.scss +3 -15
- package/src/components/batch-edit/__test__/batch-edit.test.ts +138 -0
- package/src/components/batch-edit/batch-edit-form.vue +18 -4
- package/src/components/batch-edit/use-edit-state.ts +1 -1
- package/src/components/contextmenu/style.scss +4 -20
- package/src/components/form/__test__/form.test.ts +80 -0
- package/src/components/form/__test__/is-field-modified.test.ts +37 -0
- package/src/components/form/form.vue +28 -4
- package/src/components/form/is-field-modified.ts +16 -0
- package/src/components/form/style.scss +53 -2
- package/src/components/form/use-form-fields.ts +20 -6
- package/src/components/form-item/__test__/form-item.test.ts +90 -0
- package/src/components/form-item/form-item.vue +42 -7
- package/src/components/image-cropper/__test__/draw-crop.test.ts +131 -0
- package/src/components/image-cropper/__test__/image-cropper.test.ts +744 -0
- package/src/components/image-cropper/__test__/use-transform.test.ts +229 -0
- package/src/components/image-cropper/draw-crop.ts +112 -0
- package/src/components/image-cropper/image-cropper.vue +302 -0
- package/src/components/image-cropper/index.ts +1 -0
- package/src/components/image-cropper/style.scss +232 -0
- package/src/components/image-cropper/style.ts +1 -0
- package/src/components/image-cropper/use-image-loader.ts +79 -0
- package/src/components/image-cropper/use-preview.ts +58 -0
- package/src/components/image-cropper/use-selection.ts +255 -0
- package/src/components/image-cropper/use-transform.ts +256 -0
- package/src/components/index.ts +2 -0
- package/src/components/select/style.scss +3 -15
- package/src/types/batch-edit.ts +2 -0
- package/src/types/form-item.ts +4 -1
- package/src/types/form.ts +10 -2
- package/src/types/image-cropper.ts +73 -0
- package/src/types/index.ts +1 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createApp, shallowRef } from 'vue'
|
|
3
|
+
|
|
4
|
+
import { useTransform } from '../use-transform'
|
|
5
|
+
|
|
6
|
+
/** 挂载一个空组件以提供组合式函数所需的组件上下文 */
|
|
7
|
+
function mountTransform(options?: { onReset?: () => void }) {
|
|
8
|
+
const target = shallowRef<HTMLElement>()
|
|
9
|
+
let api!: ReturnType<typeof useTransform>
|
|
10
|
+
|
|
11
|
+
const app = createApp({
|
|
12
|
+
setup() {
|
|
13
|
+
api = useTransform({
|
|
14
|
+
target,
|
|
15
|
+
// 画布 400×300,图片 800×600:适应缩放为 0.5
|
|
16
|
+
canvasSize: () => ({ width: 400, height: 300 }),
|
|
17
|
+
imageWidth: () => 800,
|
|
18
|
+
imageHeight: () => 600,
|
|
19
|
+
onReset: options?.onReset ?? (() => {})
|
|
20
|
+
})
|
|
21
|
+
return () => null
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const host = document.createElement('div')
|
|
26
|
+
document.body.appendChild(host)
|
|
27
|
+
app.mount(host)
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
api,
|
|
31
|
+
target,
|
|
32
|
+
unmount() {
|
|
33
|
+
app.unmount()
|
|
34
|
+
host.remove()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('useTransform', () => {
|
|
40
|
+
let unmount: (() => void) | undefined
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
unmount?.()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('fit:按容器适应缩放并居中', () => {
|
|
47
|
+
const { api, unmount: u } = mountTransform()
|
|
48
|
+
unmount = u
|
|
49
|
+
|
|
50
|
+
api.fit()
|
|
51
|
+
expect(api.transform.scale).toBe(0.5)
|
|
52
|
+
expect(api.transform.translateX).toBe(-200)
|
|
53
|
+
expect(api.transform.translateY).toBe(-150)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('zoomTo:钳制在适应缩放与其 10 倍之间', () => {
|
|
57
|
+
const { api, unmount: u } = mountTransform()
|
|
58
|
+
unmount = u
|
|
59
|
+
api.fit()
|
|
60
|
+
|
|
61
|
+
api.zoomTo(0.1)
|
|
62
|
+
expect(api.transform.scale).toBe(0.5)
|
|
63
|
+
|
|
64
|
+
api.zoomTo(100)
|
|
65
|
+
expect(api.transform.scale).toBe(5)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('zoomIn / zoomOut:默认以画布中心为锚,中心点平移不变', () => {
|
|
69
|
+
const { api, unmount: u } = mountTransform()
|
|
70
|
+
unmount = u
|
|
71
|
+
api.fit()
|
|
72
|
+
|
|
73
|
+
api.zoomIn()
|
|
74
|
+
expect(api.transform.scale).toBeCloseTo(0.6, 10)
|
|
75
|
+
expect(api.transform.translateX).toBeCloseTo(-200, 10)
|
|
76
|
+
expect(api.transform.translateY).toBeCloseTo(-150, 10)
|
|
77
|
+
|
|
78
|
+
api.zoomOut()
|
|
79
|
+
expect(api.transform.scale).toBeCloseTo(0.5, 10)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('zoomIn:以指定锚点缩放时锚点下的图片点保持不动', () => {
|
|
83
|
+
const { api, unmount: u } = mountTransform()
|
|
84
|
+
unmount = u
|
|
85
|
+
api.fit()
|
|
86
|
+
|
|
87
|
+
api.zoomIn({ x: 0, y: 0 })
|
|
88
|
+
expect(api.transform.scale).toBeCloseTo(0.6, 10)
|
|
89
|
+
expect(api.transform.translateX).toBeCloseTo(-160, 8)
|
|
90
|
+
expect(api.transform.translateY).toBeCloseTo(-120, 8)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('rotate:90° 步进并归一化,90° / 270° 按交换宽高重新适应', () => {
|
|
94
|
+
const { api, unmount: u } = mountTransform()
|
|
95
|
+
unmount = u
|
|
96
|
+
api.fit()
|
|
97
|
+
|
|
98
|
+
api.rotate(1)
|
|
99
|
+
expect(api.transform.rotation).toBe(90)
|
|
100
|
+
// 交换宽高后:min(400/600, 300/800) = 0.375
|
|
101
|
+
expect(api.transform.scale).toBe(0.375)
|
|
102
|
+
|
|
103
|
+
api.rotate(-1)
|
|
104
|
+
expect(api.transform.rotation).toBe(0)
|
|
105
|
+
expect(api.transform.scale).toBe(0.5)
|
|
106
|
+
|
|
107
|
+
api.rotate(-1)
|
|
108
|
+
expect(api.transform.rotation).toBe(270)
|
|
109
|
+
api.rotate(1)
|
|
110
|
+
api.rotate(1)
|
|
111
|
+
expect(api.transform.rotation).toBe(90)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('flip:水平 / 垂直翻转互不影响', () => {
|
|
115
|
+
const { api, unmount: u } = mountTransform()
|
|
116
|
+
unmount = u
|
|
117
|
+
|
|
118
|
+
api.flip('horizontal')
|
|
119
|
+
expect(api.transform.flipX).toBe(true)
|
|
120
|
+
expect(api.transform.flipY).toBe(false)
|
|
121
|
+
|
|
122
|
+
api.flip('vertical')
|
|
123
|
+
expect(api.transform.flipX).toBe(true)
|
|
124
|
+
expect(api.transform.flipY).toBe(true)
|
|
125
|
+
|
|
126
|
+
api.flip('horizontal')
|
|
127
|
+
expect(api.transform.flipX).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('resetTransform:恢复初始变换、重新适应并复位选区', () => {
|
|
131
|
+
const onReset = vi.fn()
|
|
132
|
+
const { api, unmount: u } = mountTransform({ onReset })
|
|
133
|
+
unmount = u
|
|
134
|
+
api.fit()
|
|
135
|
+
|
|
136
|
+
api.zoomIn()
|
|
137
|
+
api.rotate(1)
|
|
138
|
+
api.flip('horizontal')
|
|
139
|
+
|
|
140
|
+
api.resetTransform()
|
|
141
|
+
expect(api.transform).toMatchObject({
|
|
142
|
+
scale: 0.5,
|
|
143
|
+
translateX: -200,
|
|
144
|
+
translateY: -150,
|
|
145
|
+
rotation: 0,
|
|
146
|
+
flipX: false,
|
|
147
|
+
flipY: false
|
|
148
|
+
})
|
|
149
|
+
expect(onReset).toHaveBeenCalledTimes(1)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('toImageDelta:按缩放 / 旋转 / 翻转换算屏幕位移', () => {
|
|
153
|
+
const { api, unmount: u } = mountTransform()
|
|
154
|
+
unmount = u
|
|
155
|
+
api.fit()
|
|
156
|
+
|
|
157
|
+
expect(api.toImageDelta(60, 30)).toEqual({ x: 120, y: 60 })
|
|
158
|
+
|
|
159
|
+
// 旋转 90°(顺时针):屏幕右移对应图片 -y 方向
|
|
160
|
+
api.rotate(1)
|
|
161
|
+
const rotated = api.toImageDelta(10, 0)
|
|
162
|
+
expect(rotated.x).toBeCloseTo(0, 10)
|
|
163
|
+
expect(rotated.y).toBeCloseTo(-10 / 0.375, 10)
|
|
164
|
+
|
|
165
|
+
api.resetTransform()
|
|
166
|
+
api.flip('horizontal')
|
|
167
|
+
expect(api.toImageDelta(10, 0).x).toBeCloseTo(-20, 10)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('图片未超出容器时选区外拖动不平移,超出后拖动平移', async () => {
|
|
171
|
+
const { api, target, unmount: u } = mountTransform()
|
|
172
|
+
unmount = u
|
|
173
|
+
api.fit()
|
|
174
|
+
|
|
175
|
+
const canvas = document.createElement('div')
|
|
176
|
+
// happy-dom 的 DOMRect 字段为 undefined,补零保证锚点计算
|
|
177
|
+
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect
|
|
178
|
+
target.value = canvas
|
|
179
|
+
await Promise.resolve()
|
|
180
|
+
|
|
181
|
+
// 适应尺寸 400×300 未超出容器:拖动不平移
|
|
182
|
+
canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 100, clientY: 100 }))
|
|
183
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 130, clientY: 115 }))
|
|
184
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
185
|
+
expect(api.transform.translateX).toBe(-200)
|
|
186
|
+
expect(api.transform.translateY).toBe(-150)
|
|
187
|
+
|
|
188
|
+
// 放大到 0.6 后显示尺寸 480×360 超出容器:拖动平移
|
|
189
|
+
api.zoomIn()
|
|
190
|
+
canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 100, clientY: 100 }))
|
|
191
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 130, clientY: 115 }))
|
|
192
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
193
|
+
expect(api.transform.translateX).toBeCloseTo(-170, 10)
|
|
194
|
+
expect(api.transform.translateY).toBeCloseTo(-135, 10)
|
|
195
|
+
|
|
196
|
+
// 抬起后再次移动不再平移
|
|
197
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 200, clientY: 200 }))
|
|
198
|
+
expect(api.transform.translateX).toBeCloseTo(-170, 10)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('wheel:以指针位置为锚缩放', async () => {
|
|
202
|
+
const { api, target, unmount: u } = mountTransform()
|
|
203
|
+
unmount = u
|
|
204
|
+
api.fit()
|
|
205
|
+
|
|
206
|
+
const canvas = document.createElement('div')
|
|
207
|
+
// happy-dom 的 DOMRect 字段为 undefined,补零保证锚点计算
|
|
208
|
+
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect
|
|
209
|
+
target.value = canvas
|
|
210
|
+
await Promise.resolve()
|
|
211
|
+
|
|
212
|
+
// happy-dom 的 WheelEvent 不携带 clientX / clientY,用 MouseEvent 模拟并补 deltaY
|
|
213
|
+
const wheel = (deltaY: number) => {
|
|
214
|
+
const e = new MouseEvent('wheel', { clientX: 0, clientY: 0 })
|
|
215
|
+
Object.defineProperty(e, 'deltaY', { value: deltaY })
|
|
216
|
+
canvas.dispatchEvent(e)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
wheel(-100)
|
|
220
|
+
expect(api.transform.scale).toBeCloseTo(0.6, 10)
|
|
221
|
+
expect(api.transform.translateX).toBeCloseTo(-160, 8)
|
|
222
|
+
expect(api.transform.translateY).toBeCloseTo(-120, 8)
|
|
223
|
+
|
|
224
|
+
// 缩小被钳制在适应缩放
|
|
225
|
+
wheel(100)
|
|
226
|
+
wheel(100)
|
|
227
|
+
expect(api.transform.scale).toBe(0.5)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ImageCropperResult } from '../../types'
|
|
2
|
+
import type { SelectionRect } from './use-selection'
|
|
3
|
+
|
|
4
|
+
/** 影响裁剪输出的变换(缩放 / 平移只影响画布展示,与输出无关) */
|
|
5
|
+
export interface CropTransform {
|
|
6
|
+
/** 90° 步进角度:0 / 90 / 180 / 270 */
|
|
7
|
+
rotation: number
|
|
8
|
+
flipX: boolean
|
|
9
|
+
flipY: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 输出尺寸,缺省维度按选区比例推算,都不传则为原图选区像素尺寸 */
|
|
13
|
+
export interface CropOutputSize {
|
|
14
|
+
width?: number
|
|
15
|
+
height?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 把选区按当前旋转 / 翻转变换绘制到目标 canvas,预览(P4)与 getResult()(P5)共用。
|
|
20
|
+
* 输出为屏幕上看到的裁剪结果:先旋转后翻转,与舞台 transform 链一致
|
|
21
|
+
*/
|
|
22
|
+
export function drawCropToCanvas(
|
|
23
|
+
img: HTMLImageElement,
|
|
24
|
+
selection: SelectionRect,
|
|
25
|
+
transform: CropTransform,
|
|
26
|
+
canvas: HTMLCanvasElement,
|
|
27
|
+
outSize?: CropOutputSize
|
|
28
|
+
): void {
|
|
29
|
+
const { rotation, flipX, flipY } = transform
|
|
30
|
+
// 旋转 90° / 270° 后输出的宽为选区高、高为选区宽
|
|
31
|
+
const swapped = rotation % 180 !== 0
|
|
32
|
+
const baseW = swapped ? selection.height : selection.width
|
|
33
|
+
const baseH = swapped ? selection.width : selection.height
|
|
34
|
+
|
|
35
|
+
const outW = Math.max(
|
|
36
|
+
1,
|
|
37
|
+
Math.round(outSize?.width ?? ((outSize?.height ?? baseH) / baseH) * baseW)
|
|
38
|
+
)
|
|
39
|
+
const outH = Math.max(
|
|
40
|
+
1,
|
|
41
|
+
Math.round(outSize?.height ?? ((outSize?.width ?? baseW) / baseW) * baseH)
|
|
42
|
+
)
|
|
43
|
+
canvas.width = outW
|
|
44
|
+
canvas.height = outH
|
|
45
|
+
|
|
46
|
+
const ctx = canvas.getContext('2d')
|
|
47
|
+
if (!ctx) return
|
|
48
|
+
|
|
49
|
+
ctx.translate(outW / 2, outH / 2)
|
|
50
|
+
ctx.rotate((rotation * Math.PI) / 180)
|
|
51
|
+
ctx.scale(((flipX ? -1 : 1) * outW) / baseW, ((flipY ? -1 : 1) * outH) / baseH)
|
|
52
|
+
// 以画布中心为锚绘制选区,旋转 / 翻转由上面的变换承担
|
|
53
|
+
ctx.drawImage(
|
|
54
|
+
img,
|
|
55
|
+
selection.x,
|
|
56
|
+
selection.y,
|
|
57
|
+
selection.width,
|
|
58
|
+
selection.height,
|
|
59
|
+
-selection.width / 2,
|
|
60
|
+
-selection.height / 2,
|
|
61
|
+
selection.width,
|
|
62
|
+
selection.height
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* canvas 导出为 Blob。
|
|
68
|
+
* 跨域图片污染画布时 toBlob 同步抛 SecurityError,包装为可捕获的 Promise 拒绝
|
|
69
|
+
*/
|
|
70
|
+
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
try {
|
|
73
|
+
canvas.toBlob((blob) => {
|
|
74
|
+
if (blob) resolve(blob)
|
|
75
|
+
else reject(new Error('Export crop result failed: canvas.toBlob got null'))
|
|
76
|
+
}, 'image/png')
|
|
77
|
+
} catch (err) {
|
|
78
|
+
reject(
|
|
79
|
+
new Error('Export crop result failed: canvas is tainted by cross-origin image', {
|
|
80
|
+
cause: err
|
|
81
|
+
})
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Blob 读取为 dataURL 形式的 base64 */
|
|
88
|
+
function blobToBase64(blob: Blob): Promise<string> {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const reader = new FileReader()
|
|
91
|
+
reader.onload = () => resolve(reader.result as string)
|
|
92
|
+
reader.onerror = () =>
|
|
93
|
+
reject(reader.error ?? new Error('Export crop result failed: read blob as base64 failed'))
|
|
94
|
+
reader.readAsDataURL(blob)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 输出选区裁剪结果:离屏 canvas 经 drawCropToCanvas 按原图像素绘制后导出 Blob + base64。
|
|
100
|
+
* outSize 缺省为原图选区像素尺寸,传入则按该尺寸缩放输出
|
|
101
|
+
*/
|
|
102
|
+
export async function cropToResult(
|
|
103
|
+
img: HTMLImageElement,
|
|
104
|
+
selection: SelectionRect,
|
|
105
|
+
transform: CropTransform,
|
|
106
|
+
outSize?: CropOutputSize
|
|
107
|
+
): Promise<ImageCropperResult> {
|
|
108
|
+
const canvas = document.createElement('canvas')
|
|
109
|
+
drawCropToCanvas(img, selection, transform, canvas, outSize)
|
|
110
|
+
const blob = await canvasToBlob(canvas)
|
|
111
|
+
return { blob, base64: await blobToBase64(blob) }
|
|
112
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="cls.b">
|
|
3
|
+
<div v-if="showToolbar" :class="cls.e('toolbar')">
|
|
4
|
+
<slot
|
|
5
|
+
name="toolbar"
|
|
6
|
+
:zoom-in="zoomIn"
|
|
7
|
+
:zoom-out="zoomOut"
|
|
8
|
+
:rotate="rotate"
|
|
9
|
+
:flip="flip"
|
|
10
|
+
:reset="resetTransform"
|
|
11
|
+
:set-aspect-ratio="setAspectRatio"
|
|
12
|
+
:aspect-ratio="currentRatio"
|
|
13
|
+
>
|
|
14
|
+
<!-- 宽高比预设 -->
|
|
15
|
+
<div :class="cls.e('tool-group')">
|
|
16
|
+
<button
|
|
17
|
+
v-for="preset in RATIO_PRESETS"
|
|
18
|
+
:key="preset.label"
|
|
19
|
+
type="button"
|
|
20
|
+
:class="[cls.e('tool'), bem.is('active', preset.value === currentRatio)]"
|
|
21
|
+
@click="setAspectRatio(preset.value)"
|
|
22
|
+
>
|
|
23
|
+
{{ preset.label }}
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- 缩放 / 旋转 / 翻转 / 重置 -->
|
|
28
|
+
<div :class="cls.e('tool-group')">
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
32
|
+
aria-label="缩小"
|
|
33
|
+
title="缩小"
|
|
34
|
+
@click="zoomOut()"
|
|
35
|
+
>
|
|
36
|
+
<u-icon :size="15"><ZoomOut /></u-icon>
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
41
|
+
aria-label="放大"
|
|
42
|
+
title="放大"
|
|
43
|
+
@click="zoomIn()"
|
|
44
|
+
>
|
|
45
|
+
<u-icon :size="15"><ZoomIn /></u-icon>
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
50
|
+
aria-label="逆时针旋转 90°"
|
|
51
|
+
title="逆时针旋转 90°"
|
|
52
|
+
@click="rotate(-1)"
|
|
53
|
+
>
|
|
54
|
+
<u-icon :size="15"><RotateLeft /></u-icon>
|
|
55
|
+
</button>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
59
|
+
aria-label="顺时针旋转 90°"
|
|
60
|
+
title="顺时针旋转 90°"
|
|
61
|
+
@click="rotate(1)"
|
|
62
|
+
>
|
|
63
|
+
<u-icon :size="15"><RotateRight /></u-icon>
|
|
64
|
+
</button>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
68
|
+
aria-label="水平翻转"
|
|
69
|
+
title="水平翻转"
|
|
70
|
+
@click="flip('horizontal')"
|
|
71
|
+
>
|
|
72
|
+
<u-icon :size="15" :class="cls.e('flip-icon')"><ArrowUpdown /></u-icon>
|
|
73
|
+
</button>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
77
|
+
aria-label="垂直翻转"
|
|
78
|
+
title="垂直翻转"
|
|
79
|
+
@click="flip('vertical')"
|
|
80
|
+
>
|
|
81
|
+
<u-icon :size="15"><ArrowUpdown /></u-icon>
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
:class="[cls.e('tool'), cls.em('tool', 'icon')]"
|
|
86
|
+
aria-label="重置"
|
|
87
|
+
title="重置"
|
|
88
|
+
@click="resetTransform()"
|
|
89
|
+
>
|
|
90
|
+
<u-icon :size="15"><Refresh /></u-icon>
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</slot>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div :class="cls.e('canvas')" ref="canvasRef">
|
|
97
|
+
<div v-if="loaded" :class="cls.e('stage')" :style="stageStyle">
|
|
98
|
+
<img :class="cls.e('image')" :src="imageUrl" draggable="false" alt="" />
|
|
99
|
+
|
|
100
|
+
<template v-if="selection">
|
|
101
|
+
<!-- 选区外半透明遮罩 -->
|
|
102
|
+
<div v-for="(style, i) in maskStyles" :key="i" :class="cls.e('mask')" :style="style" />
|
|
103
|
+
|
|
104
|
+
<!-- 裁剪选区:整体拖动 + 8 个手柄调整大小 -->
|
|
105
|
+
<div :class="cls.e('selection')" ref="selectionRef" :style="selectionStyle">
|
|
106
|
+
<!-- 3×3 网格线,顺序对应 style.scss 中 nth-child 的定位 -->
|
|
107
|
+
<i v-for="i in 4" :key="i" :class="cls.e('grid-line')" />
|
|
108
|
+
<i
|
|
109
|
+
v-for="handle in SELECTION_HANDLES"
|
|
110
|
+
:key="handle"
|
|
111
|
+
:class="cls.e('handle')"
|
|
112
|
+
:data-handle="handle"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div v-if="showPreview && loaded" :class="cls.e('preview')">
|
|
120
|
+
<canvas :class="cls.e('preview-canvas')" ref="previewCanvasRef" />
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</template>
|
|
124
|
+
|
|
125
|
+
<script lang="ts" setup>
|
|
126
|
+
import { useResizeObserver } from '@veltra/compositions'
|
|
127
|
+
import {
|
|
128
|
+
ArrowUpdown,
|
|
129
|
+
Refresh,
|
|
130
|
+
RotateLeft,
|
|
131
|
+
RotateRight,
|
|
132
|
+
ZoomIn,
|
|
133
|
+
ZoomOut
|
|
134
|
+
} from '@veltra/icons/normal'
|
|
135
|
+
import { bem } from '@veltra/utils'
|
|
136
|
+
import { computed, reactive, shallowRef, watch } from 'vue'
|
|
137
|
+
|
|
138
|
+
import type {
|
|
139
|
+
ImageCropperProps,
|
|
140
|
+
ImageCropperEmits,
|
|
141
|
+
ImageCropperResult,
|
|
142
|
+
ImageCropperResultOptions,
|
|
143
|
+
_ImageCropperExposed
|
|
144
|
+
} from '../../types'
|
|
145
|
+
import { UIcon } from '../icon'
|
|
146
|
+
import { cropToResult } from './draw-crop'
|
|
147
|
+
import { useImageLoader } from './use-image-loader'
|
|
148
|
+
import { usePreview } from './use-preview'
|
|
149
|
+
import { SELECTION_HANDLES, useSelection } from './use-selection'
|
|
150
|
+
import { useTransform } from './use-transform'
|
|
151
|
+
|
|
152
|
+
defineOptions({ name: 'ImageCropper' })
|
|
153
|
+
|
|
154
|
+
const props = withDefaults(defineProps<ImageCropperProps>(), {
|
|
155
|
+
showToolbar: true,
|
|
156
|
+
showPreview: true
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const emit = defineEmits<ImageCropperEmits>()
|
|
160
|
+
|
|
161
|
+
const cls = bem('image-cropper')
|
|
162
|
+
|
|
163
|
+
const canvasRef = shallowRef<HTMLElement>()
|
|
164
|
+
const selectionRef = shallowRef<HTMLElement>()
|
|
165
|
+
const previewCanvasRef = shallowRef<HTMLCanvasElement>()
|
|
166
|
+
|
|
167
|
+
const { imageUrl, loaded, naturalWidth, naturalHeight, imageEl } = useImageLoader({
|
|
168
|
+
src: () => props.src
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const canvasSize = reactive({ width: 0, height: 0 })
|
|
172
|
+
|
|
173
|
+
/** 宽高比预设,value 为 undefined 表示自由比例 */
|
|
174
|
+
const RATIO_PRESETS: { label: string; value?: number }[] = [
|
|
175
|
+
{ label: '自由', value: undefined },
|
|
176
|
+
{ label: '1:1', value: 1 },
|
|
177
|
+
{ label: '4:3', value: 4 / 3 },
|
|
178
|
+
{ label: '16:9', value: 16 / 9 }
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
/** 当前生效的宽高比:工具栏预设可覆盖 prop,prop 变化时同步回来 */
|
|
182
|
+
const currentRatio = shallowRef(props.aspectRatio)
|
|
183
|
+
|
|
184
|
+
function setAspectRatio(ratio: number | undefined) {
|
|
185
|
+
currentRatio.value = ratio
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { transform, fit, reset, resetTransform, zoomIn, zoomOut, rotate, flip, toImageDelta } =
|
|
189
|
+
useTransform({
|
|
190
|
+
target: canvasRef,
|
|
191
|
+
canvasSize: () => canvasSize,
|
|
192
|
+
imageWidth: () => naturalWidth.value,
|
|
193
|
+
imageHeight: () => naturalHeight.value,
|
|
194
|
+
onReset: () => initSelection()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const { selection, initSelection, clearSelection } = useSelection({
|
|
198
|
+
target: selectionRef,
|
|
199
|
+
imageWidth: () => naturalWidth.value,
|
|
200
|
+
imageHeight: () => naturalHeight.value,
|
|
201
|
+
toImageDelta,
|
|
202
|
+
aspectRatio: () => currentRatio.value
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
usePreview({
|
|
206
|
+
canvas: previewCanvasRef,
|
|
207
|
+
image: imageEl,
|
|
208
|
+
selection,
|
|
209
|
+
transform,
|
|
210
|
+
canvasSize: () => canvasSize
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
useResizeObserver({
|
|
214
|
+
targets: canvasRef,
|
|
215
|
+
onResize([entry]) {
|
|
216
|
+
if (!entry) return
|
|
217
|
+
canvasSize.width = entry.contentRect.width
|
|
218
|
+
canvasSize.height = entry.contentRect.height
|
|
219
|
+
if (loaded.value) fit()
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
watch(
|
|
224
|
+
() => props.src,
|
|
225
|
+
() => {
|
|
226
|
+
reset()
|
|
227
|
+
clearSelection()
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
watch(
|
|
232
|
+
() => props.aspectRatio,
|
|
233
|
+
(ratio) => {
|
|
234
|
+
currentRatio.value = ratio
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
watch(loaded, (isLoaded) => {
|
|
239
|
+
if (!isLoaded) return
|
|
240
|
+
fit()
|
|
241
|
+
initSelection()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// 选区或图片变换变化时发出裁剪变化事件,载荷为当前选区(图片像素坐标)与变换摘要
|
|
245
|
+
watch([selection, () => ({ ...transform })], () => {
|
|
246
|
+
const sel = selection.value
|
|
247
|
+
if (!sel) return
|
|
248
|
+
emit('crop-change', {
|
|
249
|
+
selection: { ...sel },
|
|
250
|
+
transform: { rotation: transform.rotation, flipX: transform.flipX, flipY: transform.flipY }
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
/** 输出当前选区裁剪结果:默认原图选区像素,options 传入目标宽 / 高时按该尺寸缩放 */
|
|
255
|
+
async function getResult(options?: ImageCropperResultOptions): Promise<ImageCropperResult> {
|
|
256
|
+
const img = imageEl.value
|
|
257
|
+
const sel = selection.value
|
|
258
|
+
if (!img || !sel) throw new Error('Export crop result failed: image not loaded or no selection')
|
|
259
|
+
return cropToResult(img, sel, transform, options)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
defineExpose<_ImageCropperExposed>({ getResult })
|
|
263
|
+
|
|
264
|
+
/** 舞台样式:图片与选区层共用同一变换,选区以图片像素坐标定位 */
|
|
265
|
+
const stageStyle = computed(() => {
|
|
266
|
+
const { translateX, translateY, rotation, scale, flipX, flipY } = transform
|
|
267
|
+
return {
|
|
268
|
+
width: `${naturalWidth.value}px`,
|
|
269
|
+
height: `${naturalHeight.value}px`,
|
|
270
|
+
transform: `translate(${translateX}px, ${translateY}px) rotate(${rotation}deg) scale(${(flipX ? -1 : 1) * scale}, ${(flipY ? -1 : 1) * scale})`,
|
|
271
|
+
// 逆缩放系数:选区边框 / 网格线 / 手柄在缩放后保持屏幕恒定尺寸
|
|
272
|
+
'--u-image-cropper-inv-scale': String(1 / scale)
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const selectionStyle = computed(() => {
|
|
277
|
+
const sel = selection.value
|
|
278
|
+
if (!sel) return {}
|
|
279
|
+
return {
|
|
280
|
+
left: `${sel.x}px`,
|
|
281
|
+
top: `${sel.y}px`,
|
|
282
|
+
width: `${sel.width}px`,
|
|
283
|
+
height: `${sel.height}px`
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
/** 选区外上 / 下 / 左 / 右四向遮罩 */
|
|
288
|
+
const maskStyles = computed(() => {
|
|
289
|
+
const sel = selection.value
|
|
290
|
+
if (!sel) return []
|
|
291
|
+
const W = naturalWidth.value
|
|
292
|
+
const H = naturalHeight.value
|
|
293
|
+
const right = sel.x + sel.width
|
|
294
|
+
const bottom = sel.y + sel.height
|
|
295
|
+
return [
|
|
296
|
+
{ left: '0px', top: '0px', width: `${W}px`, height: `${sel.y}px` },
|
|
297
|
+
{ left: '0px', top: `${bottom}px`, width: `${W}px`, height: `${H - bottom}px` },
|
|
298
|
+
{ left: '0px', top: `${sel.y}px`, width: `${sel.x}px`, height: `${sel.height}px` },
|
|
299
|
+
{ left: `${right}px`, top: `${sel.y}px`, width: `${W - right}px`, height: `${sel.height}px` }
|
|
300
|
+
]
|
|
301
|
+
})
|
|
302
|
+
</script>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as UImageCropper } from './image-cropper.vue'
|