@veltra/desktop 1.7.8 → 1.7.9
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/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/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/contextmenu/style.scss +4 -20
- 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/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'
|