@veltra/desktop 1.7.7 → 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/cascade/use-check.js +5 -2
- package/dist/components/cascade/use-check.js.map +1 -1
- package/dist/components/cascade/use-select.js +5 -1
- package/dist/components/cascade/use-select.js.map +1 -1
- package/dist/components/code-editor/langs/bash/index.js +1 -1
- package/dist/components/code-editor/langs/powershell/index.js +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/components/table/style2.css +1 -1
- package/dist/index.d.ts +47 -45
- package/dist/index.js +46 -45
- package/dist/node_modules/.bun/{@codemirror_legacy-modes@6.5.3 → @codemirror_legacy-modes@6.5.4}/node_modules/@codemirror/legacy-modes/mode/powershell.js +1 -1
- package/dist/node_modules/.bun/{@codemirror_legacy-modes@6.5.3 → @codemirror_legacy-modes@6.5.4}/node_modules/@codemirror/legacy-modes/mode/powershell.js.map +1 -1
- package/dist/node_modules/.bun/{@codemirror_legacy-modes@6.5.3 → @codemirror_legacy-modes@6.5.4}/node_modules/@codemirror/legacy-modes/mode/shell.js +1 -1
- package/dist/node_modules/.bun/{@codemirror_legacy-modes@6.5.3 → @codemirror_legacy-modes@6.5.4}/node_modules/@codemirror/legacy-modes/mode/shell.js.map +1 -1
- package/dist/types/cascade.d.ts +6 -2
- package/dist/types/image-cropper.d.ts +68 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +15 -15
- package/src/components/auto-complete/style.scss +3 -15
- package/src/components/cascade/__test__/cascade.test.ts +103 -0
- package/src/components/cascade/use-check.ts +5 -2
- package/src/components/cascade/use-select.ts +2 -1
- 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/components/table/style.scss +1 -1
- package/src/types/cascade.ts +10 -2
- package/src/types/image-cropper.ts +73 -0
- package/src/types/index.ts +1 -0
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createApp, h, nextTick, ref, shallowRef } from 'vue'
|
|
3
|
+
|
|
4
|
+
import type { ImageCropperChangePayload, ImageCropperExposed } from '../../../types'
|
|
5
|
+
import UImageCropper from '../image-cropper.vue'
|
|
6
|
+
|
|
7
|
+
/** 可控的 Image 桩,set src 后异步触发 onload / onerror */
|
|
8
|
+
class MockImage {
|
|
9
|
+
static instances: MockImage[] = []
|
|
10
|
+
static failNext = false
|
|
11
|
+
|
|
12
|
+
onload: (() => void) | null = null
|
|
13
|
+
onerror: (() => void) | null = null
|
|
14
|
+
crossOrigin: string | null = null
|
|
15
|
+
naturalWidth = 800
|
|
16
|
+
naturalHeight = 600
|
|
17
|
+
|
|
18
|
+
#src = ''
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
MockImage.instances.push(this)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
set src(value: string) {
|
|
25
|
+
this.#src = value
|
|
26
|
+
const fail = MockImage.failNext
|
|
27
|
+
MockImage.failNext = false
|
|
28
|
+
queueMicrotask(() => (fail ? this.onerror?.() : this.onload?.()))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get src() {
|
|
32
|
+
return this.#src
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function flush() {
|
|
37
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
38
|
+
await nextTick()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 让画布拿到 400×300 的容器尺寸(happy-dom 的 ResizeObserver 不触发回调) */
|
|
42
|
+
class MockResizeObserver {
|
|
43
|
+
constructor(private cb: ResizeObserverCallback) {}
|
|
44
|
+
|
|
45
|
+
observe() {
|
|
46
|
+
this.cb(
|
|
47
|
+
[{ contentRect: { width: 400, height: 300 } } as ResizeObserverEntry],
|
|
48
|
+
this as unknown as ResizeObserver
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
unobserve() {}
|
|
53
|
+
|
|
54
|
+
disconnect() {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mountCropper(initialSrc?: File | Blob | string, options?: { aspectRatio?: number }) {
|
|
58
|
+
const host = document.createElement('div')
|
|
59
|
+
document.body.appendChild(host)
|
|
60
|
+
|
|
61
|
+
const src = ref<File | Blob | string | undefined>(initialSrc)
|
|
62
|
+
const aspectRatio = ref<number | undefined>(options?.aspectRatio)
|
|
63
|
+
const app = createApp({
|
|
64
|
+
render() {
|
|
65
|
+
return h(UImageCropper, { src: src.value, aspectRatio: aspectRatio.value })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
app.mount(host)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
host,
|
|
72
|
+
async setSrc(value: File | Blob | string | undefined) {
|
|
73
|
+
src.value = value
|
|
74
|
+
await flush()
|
|
75
|
+
},
|
|
76
|
+
async setAspectRatio(value: number | undefined) {
|
|
77
|
+
aspectRatio.value = value
|
|
78
|
+
await flush()
|
|
79
|
+
},
|
|
80
|
+
unmount() {
|
|
81
|
+
app.unmount()
|
|
82
|
+
host.remove()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('UImageCropper 图片源加载', () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
MockImage.instances = []
|
|
90
|
+
MockImage.failNext = false
|
|
91
|
+
vi.stubGlobal('Image', MockImage)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
vi.unstubAllGlobals()
|
|
96
|
+
vi.restoreAllMocks()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('URL 源以 anonymous 跨域加载并渲染', async () => {
|
|
100
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
101
|
+
await flush()
|
|
102
|
+
|
|
103
|
+
const img = host.querySelector('img')
|
|
104
|
+
expect(img).toBeTruthy()
|
|
105
|
+
expect(img!.src).toBe('https://example.com/a.png')
|
|
106
|
+
expect(MockImage.instances[0]!.crossOrigin).toBe('anonymous')
|
|
107
|
+
unmount()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('Blob 源经 createObjectURL 渲染,切换与卸载时释放', async () => {
|
|
111
|
+
const createObjectURL = vi.fn(() => 'blob:mock-1')
|
|
112
|
+
const revokeObjectURL = vi.fn()
|
|
113
|
+
URL.createObjectURL = createObjectURL
|
|
114
|
+
URL.revokeObjectURL = revokeObjectURL
|
|
115
|
+
|
|
116
|
+
const { host, setSrc, unmount } = mountCropper(new Blob(['a']))
|
|
117
|
+
await flush()
|
|
118
|
+
|
|
119
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
|
120
|
+
expect(host.querySelector('img')?.src).toBe('blob:mock-1')
|
|
121
|
+
|
|
122
|
+
await setSrc('https://example.com/b.png')
|
|
123
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-1')
|
|
124
|
+
expect(host.querySelector('img')?.src).toBe('https://example.com/b.png')
|
|
125
|
+
|
|
126
|
+
await setSrc(new Blob(['b']))
|
|
127
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1)
|
|
128
|
+
unmount()
|
|
129
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(2)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('加载失败置空态,不渲染图片', async () => {
|
|
133
|
+
MockImage.failNext = true
|
|
134
|
+
const { host, unmount } = mountCropper('https://example.com/broken.png')
|
|
135
|
+
await flush()
|
|
136
|
+
|
|
137
|
+
expect(host.querySelector('img')).toBeNull()
|
|
138
|
+
unmount()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('src 置空时不加载图片', async () => {
|
|
142
|
+
const { host, unmount } = mountCropper()
|
|
143
|
+
await flush()
|
|
144
|
+
|
|
145
|
+
expect(host.querySelector('img')).toBeNull()
|
|
146
|
+
expect(MockImage.instances.length).toBe(0)
|
|
147
|
+
unmount()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('UImageCropper 选区交互', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
MockImage.instances = []
|
|
154
|
+
MockImage.failNext = false
|
|
155
|
+
vi.stubGlobal('Image', MockImage)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
afterEach(() => {
|
|
159
|
+
vi.unstubAllGlobals()
|
|
160
|
+
vi.restoreAllMocks()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
function getSelection(host: HTMLElement) {
|
|
164
|
+
return host.querySelector('.u-image-cropper__selection') as HTMLElement | null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function selectionRect(host: HTMLElement) {
|
|
168
|
+
const el = getSelection(host)!
|
|
169
|
+
return {
|
|
170
|
+
x: parseFloat(el.style.left),
|
|
171
|
+
y: parseFloat(el.style.top),
|
|
172
|
+
width: parseFloat(el.style.width),
|
|
173
|
+
height: parseFloat(el.style.height)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 从 (100, 100) 按下目标元素并拖动 (dx, dy) 后抬起 */
|
|
178
|
+
async function drag(target: Element, dx: number, dy: number) {
|
|
179
|
+
target.dispatchEvent(
|
|
180
|
+
new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 100 })
|
|
181
|
+
)
|
|
182
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 + dx, clientY: 100 + dy }))
|
|
183
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
184
|
+
await nextTick()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
it('图片加载后初始选区为居中 80% 区域,含遮罩 / 网格线 / 8 个手柄', async () => {
|
|
188
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
189
|
+
await flush()
|
|
190
|
+
|
|
191
|
+
// MockImage 为 800×600:选区 640×480,位于 (80, 60)
|
|
192
|
+
expect(selectionRect(host)).toEqual({ x: 80, y: 60, width: 640, height: 480 })
|
|
193
|
+
expect(host.querySelectorAll('.u-image-cropper__mask')).toHaveLength(4)
|
|
194
|
+
expect(host.querySelectorAll('.u-image-cropper__grid-line')).toHaveLength(4)
|
|
195
|
+
expect(host.querySelectorAll('[data-handle]')).toHaveLength(8)
|
|
196
|
+
unmount()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('选区整体拖动并钳制在图片范围内', async () => {
|
|
200
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
201
|
+
await flush()
|
|
202
|
+
|
|
203
|
+
const sel = getSelection(host)!
|
|
204
|
+
await drag(sel, 50, 50)
|
|
205
|
+
expect(selectionRect(host)).toEqual({ x: 130, y: 110, width: 640, height: 480 })
|
|
206
|
+
|
|
207
|
+
// 拖出左上 / 右下边界均被钳制
|
|
208
|
+
await drag(sel, -9999, -9999)
|
|
209
|
+
expect(selectionRect(host)).toEqual({ x: 0, y: 0, width: 640, height: 480 })
|
|
210
|
+
|
|
211
|
+
await drag(sel, 9999, 9999)
|
|
212
|
+
expect(selectionRect(host)).toEqual({ x: 160, y: 120, width: 640, height: 480 })
|
|
213
|
+
unmount()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('手柄调整选区大小并钳制在图片范围内', async () => {
|
|
217
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
218
|
+
await flush()
|
|
219
|
+
|
|
220
|
+
const se = host.querySelector('[data-handle="se"]')!
|
|
221
|
+
await drag(se, 50, 40)
|
|
222
|
+
expect(selectionRect(host)).toEqual({ x: 80, y: 60, width: 690, height: 520 })
|
|
223
|
+
|
|
224
|
+
// 越界调整被钳制到图片边缘
|
|
225
|
+
await drag(se, 9999, 9999)
|
|
226
|
+
expect(selectionRect(host)).toEqual({ x: 80, y: 60, width: 720, height: 540 })
|
|
227
|
+
|
|
228
|
+
// 西北角手柄:位置随动、对侧不动,且不小于最小尺寸
|
|
229
|
+
const nw = host.querySelector('[data-handle="nw"]')!
|
|
230
|
+
await drag(nw, 9999, 9999)
|
|
231
|
+
expect(selectionRect(host)).toEqual({ x: 780, y: 580, width: 20, height: 20 })
|
|
232
|
+
unmount()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('aspectRatio 约束初始选区与角手柄调整', async () => {
|
|
236
|
+
const { host, unmount } = mountCropper('https://example.com/a.png', { aspectRatio: 1 })
|
|
237
|
+
await flush()
|
|
238
|
+
|
|
239
|
+
// 1:1 内接居中 80% 框:480×480,位于 (160, 60)
|
|
240
|
+
expect(selectionRect(host)).toEqual({ x: 160, y: 60, width: 480, height: 480 })
|
|
241
|
+
|
|
242
|
+
// 角手柄按比例约束:宽度方向位移更大,由宽度驱动
|
|
243
|
+
const se = host.querySelector('[data-handle="se"]')!
|
|
244
|
+
await drag(se, 60, 10)
|
|
245
|
+
expect(selectionRect(host)).toEqual({ x: 160, y: 60, width: 540, height: 540 })
|
|
246
|
+
|
|
247
|
+
// 仅垂直方向拖动也由高度换算出宽度,保持比例
|
|
248
|
+
const ne = host.querySelector('[data-handle="ne"]')!
|
|
249
|
+
await drag(ne, 0, -30)
|
|
250
|
+
expect(selectionRect(host)).toEqual({ x: 160, y: 30, width: 570, height: 570 })
|
|
251
|
+
unmount()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('aspectRatio 下边中手柄按比例绕中心联动并钳制', async () => {
|
|
255
|
+
const { host, unmount } = mountCropper('https://example.com/a.png', { aspectRatio: 1 })
|
|
256
|
+
await flush()
|
|
257
|
+
|
|
258
|
+
// 东边缘手柄:宽度驱动,高度绕中心随动;受中心锚定边界钳制
|
|
259
|
+
const e = host.querySelector('[data-handle="e"]')!
|
|
260
|
+
await drag(e, 9999, 0)
|
|
261
|
+
expect(selectionRect(host)).toEqual({ x: 160, y: 0, width: 600, height: 600 })
|
|
262
|
+
unmount()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('aspectRatio 变化时以选区中心为锚按比例重算', async () => {
|
|
266
|
+
const { host, setAspectRatio, unmount } = mountCropper('https://example.com/a.png')
|
|
267
|
+
await flush()
|
|
268
|
+
|
|
269
|
+
await setAspectRatio(1)
|
|
270
|
+
// 中心 (400, 300):640 宽对应高 640 超出图片,按高 600 重算
|
|
271
|
+
expect(selectionRect(host)).toEqual({ x: 100, y: 0, width: 600, height: 600 })
|
|
272
|
+
|
|
273
|
+
await setAspectRatio(16 / 9)
|
|
274
|
+
expect(selectionRect(host).height).toBeCloseTo(337.5)
|
|
275
|
+
expect(selectionRect(host).y).toBeCloseTo(131.25)
|
|
276
|
+
expect(selectionRect(host).width).toBe(600)
|
|
277
|
+
expect(selectionRect(host).x).toBe(100)
|
|
278
|
+
|
|
279
|
+
// 恢复自由比例后选区保持现状
|
|
280
|
+
await setAspectRatio(undefined)
|
|
281
|
+
expect(selectionRect(host).width).toBe(600)
|
|
282
|
+
unmount()
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('切换图片源后选区重置为初始', async () => {
|
|
286
|
+
const { host, setSrc, unmount } = mountCropper('https://example.com/a.png')
|
|
287
|
+
await flush()
|
|
288
|
+
|
|
289
|
+
await drag(getSelection(host)!, 100, 50)
|
|
290
|
+
// 拖动越界被钳制(最大 x = 800 - 640 = 160)
|
|
291
|
+
expect(selectionRect(host).x).toBe(160)
|
|
292
|
+
|
|
293
|
+
await setSrc('https://example.com/b.png')
|
|
294
|
+
expect(selectionRect(host)).toEqual({ x: 80, y: 60, width: 640, height: 480 })
|
|
295
|
+
unmount()
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('UImageCropper 图片变换', () => {
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
MockImage.instances = []
|
|
302
|
+
MockImage.failNext = false
|
|
303
|
+
vi.stubGlobal('Image', MockImage)
|
|
304
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
afterEach(() => {
|
|
308
|
+
vi.unstubAllGlobals()
|
|
309
|
+
vi.restoreAllMocks()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
function getCanvas(host: HTMLElement) {
|
|
313
|
+
const canvas = host.querySelector('.u-image-cropper__canvas') as HTMLElement
|
|
314
|
+
// happy-dom 的 DOMRect 字段为 undefined,补零保证锚点计算
|
|
315
|
+
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }) as DOMRect
|
|
316
|
+
return canvas
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function stageTransform(host: HTMLElement) {
|
|
320
|
+
const stage = host.querySelector('.u-image-cropper__stage') as HTMLElement
|
|
321
|
+
const m = stage.style.transform.match(
|
|
322
|
+
/translate\((-?[\d.]+)px, (-?[\d.]+)px\) rotate\((-?[\d.]+)deg\) scale\((-?[\d.]+), (-?[\d.]+)\)/
|
|
323
|
+
)
|
|
324
|
+
if (!m) throw new Error(`无法解析 transform: ${stage.style.transform}`)
|
|
325
|
+
return { tx: +m[1]!, ty: +m[2]!, rotation: +m[3]!, sx: +m[4]!, sy: +m[5]! }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function wheel(host: HTMLElement, deltaY: number) {
|
|
329
|
+
// happy-dom 的 WheelEvent 不携带 clientX / clientY,用 MouseEvent 模拟并补 deltaY
|
|
330
|
+
const e = new MouseEvent('wheel', { clientX: 0, clientY: 0 })
|
|
331
|
+
Object.defineProperty(e, 'deltaY', { value: deltaY })
|
|
332
|
+
getCanvas(host).dispatchEvent(e)
|
|
333
|
+
await nextTick()
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** 从 (100, 100) 按下目标元素并拖动 (dx, dy) 后抬起 */
|
|
337
|
+
async function drag(target: Element, dx: number, dy: number) {
|
|
338
|
+
target.dispatchEvent(
|
|
339
|
+
new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 100 })
|
|
340
|
+
)
|
|
341
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 + dx, clientY: 100 + dy }))
|
|
342
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
343
|
+
await nextTick()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
it('图片加载后按容器适应缩放并居中', async () => {
|
|
347
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
348
|
+
await flush()
|
|
349
|
+
|
|
350
|
+
// 容器 400×300、图片 800×600:fit 缩放 0.5,平移使图片中心对齐画布中心
|
|
351
|
+
expect(stageTransform(host)).toEqual({ tx: -200, ty: -150, rotation: 0, sx: 0.5, sy: 0.5 })
|
|
352
|
+
unmount()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('滚轮以指针为锚缩放,缩小钳制在适应缩放', async () => {
|
|
356
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
357
|
+
await flush()
|
|
358
|
+
|
|
359
|
+
await wheel(host, -100)
|
|
360
|
+
let t = stageTransform(host)
|
|
361
|
+
expect(t.sx).toBeCloseTo(0.6, 10)
|
|
362
|
+
expect(t.tx).toBeCloseTo(-160, 8)
|
|
363
|
+
expect(t.ty).toBeCloseTo(-120, 8)
|
|
364
|
+
|
|
365
|
+
await wheel(host, 100)
|
|
366
|
+
await wheel(host, 100)
|
|
367
|
+
t = stageTransform(host)
|
|
368
|
+
expect(t.sx).toBe(0.5)
|
|
369
|
+
expect(t.tx).toBe(-200)
|
|
370
|
+
unmount()
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('图片未超出容器时选区外拖动不平移', async () => {
|
|
374
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
375
|
+
await flush()
|
|
376
|
+
|
|
377
|
+
// fit 状态显示尺寸 400×300 未超出容器
|
|
378
|
+
await drag(getCanvas(host), 30, 15)
|
|
379
|
+
expect(stageTransform(host)).toEqual({ tx: -200, ty: -150, rotation: 0, sx: 0.5, sy: 0.5 })
|
|
380
|
+
unmount()
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('放大后选区外拖动平移图片、选区内拖动移动选区,两者互不干扰', async () => {
|
|
384
|
+
const { host, unmount } = mountCropper('https://example.com/a.png')
|
|
385
|
+
await flush()
|
|
386
|
+
|
|
387
|
+
// 放大到 0.6:显示尺寸 480×360 超出容器
|
|
388
|
+
await wheel(host, -100)
|
|
389
|
+
|
|
390
|
+
// 选区外(画布空白处)拖动:平移图片
|
|
391
|
+
await drag(getCanvas(host), 30, 15)
|
|
392
|
+
const t = stageTransform(host)
|
|
393
|
+
expect(t.tx).toBeCloseTo(-130, 8)
|
|
394
|
+
expect(t.ty).toBeCloseTo(-105, 8)
|
|
395
|
+
|
|
396
|
+
// 选区内拖动:移动选区(屏幕 30px / 缩放 0.6 = 图片 50px),图片不平移
|
|
397
|
+
const sel = host.querySelector('.u-image-cropper__selection')!
|
|
398
|
+
await drag(sel, 30, 0)
|
|
399
|
+
expect(parseFloat((sel as HTMLElement).style.left)).toBeCloseTo(130, 10)
|
|
400
|
+
const after = stageTransform(host)
|
|
401
|
+
expect(after.tx).toBeCloseTo(t.tx, 10)
|
|
402
|
+
expect(after.ty).toBeCloseTo(t.ty, 10)
|
|
403
|
+
unmount()
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
describe('UImageCropper 工具栏与预览', () => {
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
MockImage.instances = []
|
|
410
|
+
MockImage.failNext = false
|
|
411
|
+
vi.stubGlobal('Image', MockImage)
|
|
412
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
afterEach(() => {
|
|
416
|
+
vi.unstubAllGlobals()
|
|
417
|
+
vi.restoreAllMocks()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
function mountWithProps(
|
|
421
|
+
props: Record<string, unknown>,
|
|
422
|
+
slots?: Record<string, (...args: any[]) => any>
|
|
423
|
+
) {
|
|
424
|
+
const host = document.createElement('div')
|
|
425
|
+
document.body.appendChild(host)
|
|
426
|
+
const app = createApp({
|
|
427
|
+
render() {
|
|
428
|
+
return h(UImageCropper, props, slots)
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
app.mount(host)
|
|
432
|
+
return {
|
|
433
|
+
host,
|
|
434
|
+
unmount() {
|
|
435
|
+
app.unmount()
|
|
436
|
+
host.remove()
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function toolButton(host: HTMLElement, label: string) {
|
|
442
|
+
const buttons = [...host.querySelectorAll<HTMLElement>('.u-image-cropper__tool')]
|
|
443
|
+
const btn = buttons.find(
|
|
444
|
+
(el) => el.textContent?.trim() === label || el.getAttribute('aria-label') === label
|
|
445
|
+
)
|
|
446
|
+
if (!btn) throw new Error(`未找到工具栏按钮: ${label}`)
|
|
447
|
+
return btn
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function stageTransform(host: HTMLElement) {
|
|
451
|
+
const stage = host.querySelector('.u-image-cropper__stage') as HTMLElement
|
|
452
|
+
const m = stage.style.transform.match(
|
|
453
|
+
/translate\((-?[\d.]+)px, (-?[\d.]+)px\) rotate\((-?[\d.]+)deg\) scale\((-?[\d.]+), (-?[\d.]+)\)/
|
|
454
|
+
)
|
|
455
|
+
if (!m) throw new Error(`无法解析 transform: ${stage.style.transform}`)
|
|
456
|
+
return { tx: +m[1]!, ty: +m[2]!, rotation: +m[3]!, sx: +m[4]!, sy: +m[5]! }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function selectionRect(host: HTMLElement) {
|
|
460
|
+
const el = host.querySelector('.u-image-cropper__selection') as HTMLElement
|
|
461
|
+
return {
|
|
462
|
+
x: parseFloat(el.style.left),
|
|
463
|
+
y: parseFloat(el.style.top),
|
|
464
|
+
width: parseFloat(el.style.width),
|
|
465
|
+
height: parseFloat(el.style.height)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
it('宽高比预设切换后选区立即按比例重算,并高亮当前预设', async () => {
|
|
470
|
+
const { host, unmount } = mountWithProps({ src: 'https://example.com/a.png' })
|
|
471
|
+
await flush()
|
|
472
|
+
|
|
473
|
+
expect(selectionRect(host)).toEqual({ x: 80, y: 60, width: 640, height: 480 })
|
|
474
|
+
|
|
475
|
+
toolButton(host, '1:1').click()
|
|
476
|
+
await nextTick()
|
|
477
|
+
// 以选区中心 (400, 300) 为锚按 1:1 重算
|
|
478
|
+
expect(selectionRect(host)).toEqual({ x: 100, y: 0, width: 600, height: 600 })
|
|
479
|
+
expect(toolButton(host, '1:1').classList.contains('is-active')).toBe(true)
|
|
480
|
+
|
|
481
|
+
toolButton(host, '自由').click()
|
|
482
|
+
await nextTick()
|
|
483
|
+
// 恢复自由比例:选区保持现状,仅解除比例约束
|
|
484
|
+
expect(selectionRect(host)).toEqual({ x: 100, y: 0, width: 600, height: 600 })
|
|
485
|
+
expect(toolButton(host, '自由').classList.contains('is-active')).toBe(true)
|
|
486
|
+
unmount()
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('缩放 / 旋转 / 翻转 / 重置按钮即时驱动画布变换', async () => {
|
|
490
|
+
const { host, unmount } = mountWithProps({ src: 'https://example.com/a.png' })
|
|
491
|
+
await flush()
|
|
492
|
+
|
|
493
|
+
// 初始 fit:缩放 0.5 居中
|
|
494
|
+
expect(stageTransform(host)).toEqual({ tx: -200, ty: -150, rotation: 0, sx: 0.5, sy: 0.5 })
|
|
495
|
+
|
|
496
|
+
toolButton(host, '放大').click()
|
|
497
|
+
await nextTick()
|
|
498
|
+
expect(stageTransform(host).sx).toBeCloseTo(0.6, 10)
|
|
499
|
+
|
|
500
|
+
toolButton(host, '缩小').click()
|
|
501
|
+
await nextTick()
|
|
502
|
+
expect(stageTransform(host).sx).toBe(0.5)
|
|
503
|
+
|
|
504
|
+
toolButton(host, '顺时针旋转 90°').click()
|
|
505
|
+
await nextTick()
|
|
506
|
+
expect(stageTransform(host).rotation).toBe(90)
|
|
507
|
+
|
|
508
|
+
toolButton(host, '逆时针旋转 90°').click()
|
|
509
|
+
await nextTick()
|
|
510
|
+
expect(stageTransform(host).rotation).toBe(0)
|
|
511
|
+
|
|
512
|
+
toolButton(host, '水平翻转').click()
|
|
513
|
+
toolButton(host, '垂直翻转').click()
|
|
514
|
+
await nextTick()
|
|
515
|
+
expect(stageTransform(host).sx).toBe(-0.5)
|
|
516
|
+
expect(stageTransform(host).sy).toBe(-0.5)
|
|
517
|
+
|
|
518
|
+
toolButton(host, '重置').click()
|
|
519
|
+
await nextTick()
|
|
520
|
+
expect(stageTransform(host)).toEqual({ tx: -200, ty: -150, rotation: 0, sx: 0.5, sy: 0.5 })
|
|
521
|
+
expect(selectionRect(host)).toEqual({ x: 80, y: 60, width: 640, height: 480 })
|
|
522
|
+
unmount()
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
it('showToolbar 隐藏内置工具栏,toolbar 插槽完全自定义并暴露作用域方法', async () => {
|
|
526
|
+
const { host, unmount } = mountWithProps(
|
|
527
|
+
{ src: 'https://example.com/a.png' },
|
|
528
|
+
{
|
|
529
|
+
toolbar: (scope: { rotate: (d: 1 | -1) => void }) =>
|
|
530
|
+
h('button', { class: 'my-rotate', onClick: () => scope.rotate(1) }, '自定义旋转')
|
|
531
|
+
}
|
|
532
|
+
)
|
|
533
|
+
await flush()
|
|
534
|
+
|
|
535
|
+
// 插槽替代内置工具:预设按钮不存在
|
|
536
|
+
expect(host.querySelector('.u-image-cropper__tool')).toBeNull()
|
|
537
|
+
const custom = host.querySelector('.my-rotate') as HTMLElement
|
|
538
|
+
expect(custom).toBeTruthy()
|
|
539
|
+
|
|
540
|
+
custom.click()
|
|
541
|
+
await nextTick()
|
|
542
|
+
expect(stageTransform(host).rotation).toBe(90)
|
|
543
|
+
unmount()
|
|
544
|
+
|
|
545
|
+
const hidden = mountWithProps({ src: 'https://example.com/a.png', showToolbar: false })
|
|
546
|
+
await flush()
|
|
547
|
+
expect(hidden.host.querySelector('.u-image-cropper__toolbar')).toBeNull()
|
|
548
|
+
hidden.unmount()
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('默认显示实时预览画布,showPreview 关闭预览区', async () => {
|
|
552
|
+
const { host, unmount } = mountWithProps({ src: 'https://example.com/a.png' })
|
|
553
|
+
await flush()
|
|
554
|
+
expect(host.querySelector('.u-image-cropper__preview-canvas')).toBeTruthy()
|
|
555
|
+
unmount()
|
|
556
|
+
|
|
557
|
+
const hidden = mountWithProps({ src: 'https://example.com/a.png', showPreview: false })
|
|
558
|
+
await flush()
|
|
559
|
+
expect(hidden.host.querySelector('.u-image-cropper__preview')).toBeNull()
|
|
560
|
+
hidden.unmount()
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('预览画布按选区等比缩放到画布区 30% 以内,旋转后宽高互换', async () => {
|
|
564
|
+
const { host, unmount } = mountWithProps({ src: 'https://example.com/a.png' })
|
|
565
|
+
await flush()
|
|
566
|
+
|
|
567
|
+
const canvas = host.querySelector<HTMLCanvasElement>('.u-image-cropper__preview-canvas')!
|
|
568
|
+
// 选区 640×480,画布区 400×300 的 30% 上限为 120×90 → 等比缩放 0.1875
|
|
569
|
+
expect(canvas.width).toBe(120)
|
|
570
|
+
expect(canvas.height).toBe(90)
|
|
571
|
+
|
|
572
|
+
// 旋转 90° 后基准尺寸互换为 480×640 → 67.5×90(宽度取整 68)
|
|
573
|
+
toolButton(host, '顺时针旋转 90°').click()
|
|
574
|
+
await flush()
|
|
575
|
+
expect(canvas.width).toBe(68)
|
|
576
|
+
expect(canvas.height).toBe(90)
|
|
577
|
+
|
|
578
|
+
unmount()
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
describe('UImageCropper getResult 与裁剪变化事件', () => {
|
|
583
|
+
beforeEach(() => {
|
|
584
|
+
MockImage.instances = []
|
|
585
|
+
MockImage.failNext = false
|
|
586
|
+
vi.stubGlobal('Image', MockImage)
|
|
587
|
+
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
afterEach(() => {
|
|
591
|
+
vi.unstubAllGlobals()
|
|
592
|
+
vi.restoreAllMocks()
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
/** 挂载并拿到组件暴露的 ref,同时收集 crop-change 事件载荷 */
|
|
596
|
+
function mountExposed(props: Record<string, unknown> = {}) {
|
|
597
|
+
const host = document.createElement('div')
|
|
598
|
+
document.body.appendChild(host)
|
|
599
|
+
|
|
600
|
+
const cropperRef = shallowRef<ImageCropperExposed>()
|
|
601
|
+
const payloads: ImageCropperChangePayload[] = []
|
|
602
|
+
const app = createApp({
|
|
603
|
+
render() {
|
|
604
|
+
return h(UImageCropper, {
|
|
605
|
+
src: 'https://example.com/a.png',
|
|
606
|
+
...props,
|
|
607
|
+
ref: cropperRef,
|
|
608
|
+
onCropChange: (payload: ImageCropperChangePayload) => payloads.push(payload)
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
})
|
|
612
|
+
app.mount(host)
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
host,
|
|
616
|
+
cropperRef,
|
|
617
|
+
payloads,
|
|
618
|
+
unmount() {
|
|
619
|
+
app.unmount()
|
|
620
|
+
host.remove()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** getResult 最近一次实际导出的离屏画布(toBlob 的调用方) */
|
|
626
|
+
function exportedCanvas(toBlobSpy: ReturnType<typeof vi.spyOn>) {
|
|
627
|
+
return toBlobSpy.mock.instances.at(-1) as unknown as HTMLCanvasElement
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 从 (100, 100) 按下目标元素并拖动 (dx, dy) 后抬起 */
|
|
631
|
+
async function drag(target: Element, dx: number, dy: number) {
|
|
632
|
+
target.dispatchEvent(
|
|
633
|
+
new MouseEvent('mousedown', { bubbles: true, button: 0, clientX: 100, clientY: 100 })
|
|
634
|
+
)
|
|
635
|
+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100 + dx, clientY: 100 + dy }))
|
|
636
|
+
document.dispatchEvent(new MouseEvent('mouseup'))
|
|
637
|
+
await nextTick()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
it('getResult() 默认按原图选区像素输出 Blob + base64', async () => {
|
|
641
|
+
const { cropperRef, unmount } = mountExposed()
|
|
642
|
+
await flush()
|
|
643
|
+
|
|
644
|
+
const toBlobSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob')
|
|
645
|
+
const result = await cropperRef.value!.getResult()
|
|
646
|
+
|
|
647
|
+
// 初始选区 640×480(MockImage 800×600 的居中 80%)
|
|
648
|
+
expect(exportedCanvas(toBlobSpy).width).toBe(640)
|
|
649
|
+
expect(exportedCanvas(toBlobSpy).height).toBe(480)
|
|
650
|
+
expect(result.blob).toBeInstanceOf(Blob)
|
|
651
|
+
expect(result.blob.type).toBe('image/png')
|
|
652
|
+
expect(result.base64.startsWith('data:image/png;base64,')).toBe(true)
|
|
653
|
+
unmount()
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('getResult() 传入目标宽 / 高时按该尺寸缩放输出,缺省维度按比例推算', async () => {
|
|
657
|
+
const { cropperRef, unmount } = mountExposed()
|
|
658
|
+
await flush()
|
|
659
|
+
|
|
660
|
+
const toBlobSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob')
|
|
661
|
+
await cropperRef.value!.getResult({ width: 320 })
|
|
662
|
+
expect(exportedCanvas(toBlobSpy).width).toBe(320)
|
|
663
|
+
expect(exportedCanvas(toBlobSpy).height).toBe(240)
|
|
664
|
+
|
|
665
|
+
await cropperRef.value!.getResult({ width: 160, height: 160 })
|
|
666
|
+
expect(exportedCanvas(toBlobSpy).width).toBe(160)
|
|
667
|
+
expect(exportedCanvas(toBlobSpy).height).toBe(160)
|
|
668
|
+
unmount()
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('旋转 90° 后 getResult 输出与画布视觉一致(输出尺寸交换宽高)', async () => {
|
|
672
|
+
const { host, cropperRef, unmount } = mountExposed()
|
|
673
|
+
await flush()
|
|
674
|
+
|
|
675
|
+
const rotateBtn = host.querySelector<HTMLElement>('[aria-label="顺时针旋转 90°"]')!
|
|
676
|
+
rotateBtn.click()
|
|
677
|
+
await nextTick()
|
|
678
|
+
|
|
679
|
+
const toBlobSpy = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob')
|
|
680
|
+
await cropperRef.value!.getResult()
|
|
681
|
+
expect(exportedCanvas(toBlobSpy).width).toBe(480)
|
|
682
|
+
expect(exportedCanvas(toBlobSpy).height).toBe(640)
|
|
683
|
+
unmount()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('图片未加载时 getResult 抛出可捕获错误', async () => {
|
|
687
|
+
const { cropperRef, unmount } = mountExposed({ src: undefined })
|
|
688
|
+
await flush()
|
|
689
|
+
|
|
690
|
+
await expect(cropperRef.value!.getResult()).rejects.toThrow(/not loaded/)
|
|
691
|
+
unmount()
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('画布被跨域图片污染时 getResult 拒绝,不静默失败', async () => {
|
|
695
|
+
const { cropperRef, unmount } = mountExposed()
|
|
696
|
+
await flush()
|
|
697
|
+
|
|
698
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(() => {
|
|
699
|
+
throw new DOMException('The canvas has been tainted by cross-origin data.', 'SecurityError')
|
|
700
|
+
})
|
|
701
|
+
await expect(cropperRef.value!.getResult()).rejects.toThrow(/tainted/)
|
|
702
|
+
unmount()
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('图片加载并初始化选区后发出 crop-change,载荷含选区与变换摘要', async () => {
|
|
706
|
+
const { payloads, unmount } = mountExposed()
|
|
707
|
+
await flush()
|
|
708
|
+
|
|
709
|
+
expect(payloads).toEqual([
|
|
710
|
+
{
|
|
711
|
+
selection: { x: 80, y: 60, width: 640, height: 480 },
|
|
712
|
+
transform: { rotation: 0, flipX: false, flipY: false }
|
|
713
|
+
}
|
|
714
|
+
])
|
|
715
|
+
unmount()
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
it('选区拖动后发出 crop-change,载荷为最新选区(图片像素坐标)', async () => {
|
|
719
|
+
const { host, payloads, unmount } = mountExposed()
|
|
720
|
+
await flush()
|
|
721
|
+
|
|
722
|
+
const count = payloads.length
|
|
723
|
+
// fit 缩放 0.5:屏幕 30px = 图片 60px
|
|
724
|
+
await drag(host.querySelector('.u-image-cropper__selection')!, 30, 0)
|
|
725
|
+
|
|
726
|
+
expect(payloads.length).toBe(count + 1)
|
|
727
|
+
expect(payloads.at(-1)!.selection).toEqual({ x: 140, y: 60, width: 640, height: 480 })
|
|
728
|
+
unmount()
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('旋转 / 翻转等图片变换变化时发出 crop-change,变换摘要随之更新', async () => {
|
|
732
|
+
const { host, payloads, unmount } = mountExposed()
|
|
733
|
+
await flush()
|
|
734
|
+
|
|
735
|
+
host.querySelector<HTMLElement>('[aria-label="顺时针旋转 90°"]')!.click()
|
|
736
|
+
await nextTick()
|
|
737
|
+
expect(payloads.at(-1)!.transform).toEqual({ rotation: 90, flipX: false, flipY: false })
|
|
738
|
+
|
|
739
|
+
host.querySelector<HTMLElement>('[aria-label="水平翻转"]')!.click()
|
|
740
|
+
await nextTick()
|
|
741
|
+
expect(payloads.at(-1)!.transform).toEqual({ rotation: 90, flipX: true, flipY: false })
|
|
742
|
+
unmount()
|
|
743
|
+
})
|
|
744
|
+
})
|