v3-comf-dm 1.0.0
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/README.md +175 -0
- package/dist/components/ImageCropper/index.vue.d.ts +67 -0
- package/dist/components/ImageCropper/types.d.ts +33 -0
- package/dist/components/ScaleContainer/index.vue.d.ts +48 -0
- package/dist/components/ScaleContainer/types.d.ts +8 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.esm.js +718 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/install.d.ts +7 -0
- package/dist/style.css +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/package.json +68 -0
- package/packages/components/ImageCropper/index.vue +826 -0
- package/packages/components/ImageCropper/types.ts +47 -0
- package/packages/components/ScaleContainer/index.vue +129 -0
- package/packages/components/ScaleContainer/types.ts +10 -0
- package/packages/components/index.ts +9 -0
- package/packages/index.ts +26 -0
- package/packages/install.ts +21 -0
- package/packages/styles/index.scss +8 -0
- package/packages/types/index.ts +3 -0
- package/packages/utils/index.ts +56 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="containerRef" class="vc-image-cropper" :style="containerStyle" tabindex="0">
|
|
3
|
+
<div v-if="!imageSrc" class="vc-image-cropper-placeholder">
|
|
4
|
+
<div class="placeholder-content">
|
|
5
|
+
<svg
|
|
6
|
+
width="64"
|
|
7
|
+
height="64"
|
|
8
|
+
viewBox="0 0 24 24"
|
|
9
|
+
fill="none"
|
|
10
|
+
stroke="currentColor"
|
|
11
|
+
stroke-width="2"
|
|
12
|
+
>
|
|
13
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
14
|
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
|
15
|
+
<polyline points="21 15 16 10 5 21"></polyline>
|
|
16
|
+
</svg>
|
|
17
|
+
<p>请选择图片</p>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div v-else class="vc-image-cropper-content" ref="contentRef">
|
|
22
|
+
<div
|
|
23
|
+
ref="imageWrapperRef"
|
|
24
|
+
class="vc-image-wrapper"
|
|
25
|
+
:style="imageWrapperStyle"
|
|
26
|
+
@mousedown="handleMouseDown"
|
|
27
|
+
@wheel.prevent="handleWheel"
|
|
28
|
+
>
|
|
29
|
+
<img
|
|
30
|
+
ref="imageRef"
|
|
31
|
+
:src="imageSrc"
|
|
32
|
+
:style="imageStyle"
|
|
33
|
+
@load="handleImageLoad"
|
|
34
|
+
draggable="false"
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
<!-- 裁剪框 -->
|
|
38
|
+
<div
|
|
39
|
+
v-if="imageLoaded"
|
|
40
|
+
ref="cropBoxRef"
|
|
41
|
+
class="vc-crop-box"
|
|
42
|
+
:style="cropBoxStyle"
|
|
43
|
+
@mousedown.stop="handleCropBoxMouseDown"
|
|
44
|
+
>
|
|
45
|
+
<!-- 网格线 -->
|
|
46
|
+
<div v-if="showGrid" class="vc-crop-grid">
|
|
47
|
+
<div class="grid-line grid-line-h1"></div>
|
|
48
|
+
<div class="grid-line grid-line-h2"></div>
|
|
49
|
+
<div class="grid-line grid-line-v1"></div>
|
|
50
|
+
<div class="grid-line grid-line-v2"></div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<!-- 控制点 -->
|
|
54
|
+
<div
|
|
55
|
+
v-for="(handle, index) in cropHandles"
|
|
56
|
+
:key="index"
|
|
57
|
+
class="vc-crop-handle"
|
|
58
|
+
:class="handle.class"
|
|
59
|
+
:style="handle.style"
|
|
60
|
+
@mousedown.stop="handleResizeStart($event, handle.type)"
|
|
61
|
+
></div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<!-- 工具栏 -->
|
|
66
|
+
<div v-if="imageLoaded" class="vc-cropper-toolbar">
|
|
67
|
+
<div v-if="zoomable" class="toolbar-group">
|
|
68
|
+
<label class="toolbar-label">缩放:</label>
|
|
69
|
+
<input
|
|
70
|
+
v-model.number="zoom"
|
|
71
|
+
type="range"
|
|
72
|
+
:min="minZoom"
|
|
73
|
+
:max="maxZoom"
|
|
74
|
+
:step="0.1"
|
|
75
|
+
class="toolbar-slider"
|
|
76
|
+
@input="handleZoomChange"
|
|
77
|
+
/>
|
|
78
|
+
<span class="toolbar-value">{{ Math.round(zoom * 100) }}%</span>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div v-if="rotatable" class="toolbar-group">
|
|
82
|
+
<button class="toolbar-btn" @click="rotateImage(-90)" title="逆时针旋转">↺</button>
|
|
83
|
+
<button class="toolbar-btn" @click="rotateImage(90)" title="顺时针旋转">↻</button>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div class="toolbar-group">
|
|
87
|
+
<button class="toolbar-btn toolbar-btn-primary" @click="handleCrop">确认裁剪</button>
|
|
88
|
+
<button class="toolbar-btn" @click="handleCancel">取消</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<script setup lang="ts">
|
|
96
|
+
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
|
97
|
+
import type { ImageCropperProps, ImageCropperEmits, CropArea } from './types'
|
|
98
|
+
|
|
99
|
+
const props = withDefaults(defineProps<ImageCropperProps>(), {
|
|
100
|
+
cropMode: 'free',
|
|
101
|
+
minWidth: 50,
|
|
102
|
+
minHeight: 50,
|
|
103
|
+
showGrid: true,
|
|
104
|
+
rotatable: true,
|
|
105
|
+
zoomable: true,
|
|
106
|
+
initialZoom: 1,
|
|
107
|
+
containerWidth: '100%',
|
|
108
|
+
containerHeight: '600px',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const emit = defineEmits<ImageCropperEmits>()
|
|
112
|
+
|
|
113
|
+
// Refs
|
|
114
|
+
const containerRef = ref<HTMLElement | null>(null)
|
|
115
|
+
const contentRef = ref<HTMLElement | null>(null)
|
|
116
|
+
const imageWrapperRef = ref<HTMLElement | null>(null)
|
|
117
|
+
const imageRef = ref<HTMLImageElement | null>(null)
|
|
118
|
+
const cropBoxRef = ref<HTMLElement | null>(null)
|
|
119
|
+
|
|
120
|
+
// 状态
|
|
121
|
+
const imageLoaded = ref(false)
|
|
122
|
+
const imageNaturalWidth = ref(0)
|
|
123
|
+
const imageNaturalHeight = ref(0)
|
|
124
|
+
const imageDisplayWidth = ref(0)
|
|
125
|
+
const imageDisplayHeight = ref(0)
|
|
126
|
+
const rotation = ref(0)
|
|
127
|
+
const zoom = ref(props.initialZoom)
|
|
128
|
+
const minZoom = ref(0.1)
|
|
129
|
+
const maxZoom = ref(5)
|
|
130
|
+
|
|
131
|
+
// 裁剪区域(相对于图片的坐标)
|
|
132
|
+
const cropArea = ref<CropArea>({
|
|
133
|
+
x: 0,
|
|
134
|
+
y: 0,
|
|
135
|
+
width: 0,
|
|
136
|
+
height: 0,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// 拖拽状态
|
|
140
|
+
const isDragging = ref(false)
|
|
141
|
+
const isResizing = ref(false)
|
|
142
|
+
const dragType = ref<string>('')
|
|
143
|
+
const dragStart = ref({ x: 0, y: 0 })
|
|
144
|
+
const cropStart = ref<CropArea>({ x: 0, y: 0, width: 0, height: 0 })
|
|
145
|
+
|
|
146
|
+
// 容器样式
|
|
147
|
+
const containerStyle = computed(() => ({
|
|
148
|
+
width:
|
|
149
|
+
typeof props.containerWidth === 'number' ? `${props.containerWidth}px` : props.containerWidth,
|
|
150
|
+
height:
|
|
151
|
+
typeof props.containerHeight === 'number'
|
|
152
|
+
? `${props.containerHeight}px`
|
|
153
|
+
: props.containerHeight,
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
// 图片包装器样式
|
|
157
|
+
const imageWrapperStyle = computed(() => ({
|
|
158
|
+
transform: `rotate(${rotation.value}deg) scale(${zoom.value})`,
|
|
159
|
+
transformOrigin: 'center center',
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
// 图片样式
|
|
163
|
+
const imageStyle = computed(() => ({
|
|
164
|
+
width: `${imageDisplayWidth.value}px`,
|
|
165
|
+
height: `${imageDisplayHeight.value}px`,
|
|
166
|
+
display: 'block',
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
// 裁剪框样式
|
|
170
|
+
const cropBoxStyle = computed(() => ({
|
|
171
|
+
left: `${cropArea.value.x}px`,
|
|
172
|
+
top: `${cropArea.value.y}px`,
|
|
173
|
+
width: `${cropArea.value.width}px`,
|
|
174
|
+
height: `${cropArea.value.height}px`,
|
|
175
|
+
}))
|
|
176
|
+
|
|
177
|
+
// 裁剪控制点
|
|
178
|
+
const cropHandles = computed(() => {
|
|
179
|
+
return [
|
|
180
|
+
{ type: 'nw', class: 'handle-nw', style: { top: '-4px', left: '-4px', cursor: 'nwse-resize' } },
|
|
181
|
+
{
|
|
182
|
+
type: 'ne',
|
|
183
|
+
class: 'handle-ne',
|
|
184
|
+
style: { top: '-4px', right: '-4px', cursor: 'nesw-resize' },
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
type: 'sw',
|
|
188
|
+
class: 'handle-sw',
|
|
189
|
+
style: { bottom: '-4px', left: '-4px', cursor: 'nesw-resize' },
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
type: 'se',
|
|
193
|
+
class: 'handle-se',
|
|
194
|
+
style: { bottom: '-4px', right: '-4px', cursor: 'nwse-resize' },
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: 'n',
|
|
198
|
+
class: 'handle-n',
|
|
199
|
+
style: { top: '-4px', left: '50%', transform: 'translateX(-50%)', cursor: 'ns-resize' },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
type: 's',
|
|
203
|
+
class: 'handle-s',
|
|
204
|
+
style: { bottom: '-4px', left: '50%', transform: 'translateX(-50%)', cursor: 'ns-resize' },
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
type: 'w',
|
|
208
|
+
class: 'handle-w',
|
|
209
|
+
style: { top: '50%', left: '-4px', transform: 'translateY(-50%)', cursor: 'ew-resize' },
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
type: 'e',
|
|
213
|
+
class: 'handle-e',
|
|
214
|
+
style: { top: '50%', right: '-4px', transform: 'translateY(-50%)', cursor: 'ew-resize' },
|
|
215
|
+
},
|
|
216
|
+
]
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// 图片加载完成
|
|
220
|
+
const handleImageLoad = () => {
|
|
221
|
+
if (!imageRef.value) return
|
|
222
|
+
|
|
223
|
+
imageNaturalWidth.value = imageRef.value.naturalWidth
|
|
224
|
+
imageNaturalHeight.value = imageRef.value.naturalHeight
|
|
225
|
+
|
|
226
|
+
nextTick(() => {
|
|
227
|
+
updateImageDisplaySize()
|
|
228
|
+
initCropArea()
|
|
229
|
+
imageLoaded.value = true
|
|
230
|
+
emit('ready')
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 更新图片显示尺寸
|
|
235
|
+
const updateImageDisplaySize = () => {
|
|
236
|
+
if (!contentRef.value || !imageRef.value) return
|
|
237
|
+
|
|
238
|
+
// 使用内容区域的尺寸作为参考
|
|
239
|
+
const containerWidth = contentRef.value.clientWidth
|
|
240
|
+
const containerHeight = contentRef.value.clientHeight
|
|
241
|
+
|
|
242
|
+
if (containerWidth === 0 || containerHeight === 0) return
|
|
243
|
+
|
|
244
|
+
const naturalAspect = imageNaturalWidth.value / imageNaturalHeight.value
|
|
245
|
+
const containerAspect = containerWidth / containerHeight
|
|
246
|
+
|
|
247
|
+
if (naturalAspect > containerAspect) {
|
|
248
|
+
// 图片较宽,以容器宽度为准
|
|
249
|
+
imageDisplayWidth.value = containerWidth * 0.9
|
|
250
|
+
imageDisplayHeight.value = (containerWidth * 0.9) / naturalAspect
|
|
251
|
+
} else {
|
|
252
|
+
// 图片较高,以容器高度为准
|
|
253
|
+
imageDisplayHeight.value = containerHeight * 0.9
|
|
254
|
+
imageDisplayWidth.value = containerHeight * 0.9 * naturalAspect
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 初始化裁剪区域
|
|
259
|
+
const initCropArea = () => {
|
|
260
|
+
const width = imageDisplayWidth.value
|
|
261
|
+
const height = imageDisplayHeight.value
|
|
262
|
+
const size = Math.min(width, height) * 0.8
|
|
263
|
+
|
|
264
|
+
let cropWidth = size
|
|
265
|
+
let cropHeight = size
|
|
266
|
+
|
|
267
|
+
if (props.cropMode === 'fixed') {
|
|
268
|
+
cropWidth = props.fixedWidth || size
|
|
269
|
+
cropHeight = props.fixedHeight || size
|
|
270
|
+
} else if (props.cropMode === 'ratio' && props.aspectRatio) {
|
|
271
|
+
if (width / height > props.aspectRatio) {
|
|
272
|
+
cropHeight = size
|
|
273
|
+
cropWidth = size * props.aspectRatio
|
|
274
|
+
} else {
|
|
275
|
+
cropWidth = size
|
|
276
|
+
cropHeight = size / props.aspectRatio
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
cropArea.value = {
|
|
281
|
+
x: (width - cropWidth) / 2,
|
|
282
|
+
y: (height - cropHeight) / 2,
|
|
283
|
+
width: cropWidth,
|
|
284
|
+
height: cropHeight,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 鼠标按下(拖拽图片)
|
|
289
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
290
|
+
if (e.target !== imageWrapperRef.value) return
|
|
291
|
+
isDragging.value = true
|
|
292
|
+
dragStart.value = { x: e.clientX, y: e.clientY }
|
|
293
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
294
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
295
|
+
e.preventDefault()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 裁剪框鼠标按下
|
|
299
|
+
const handleCropBoxMouseDown = (e: MouseEvent) => {
|
|
300
|
+
if (e.target === cropBoxRef.value) {
|
|
301
|
+
isDragging.value = true
|
|
302
|
+
dragType.value = 'crop-box'
|
|
303
|
+
dragStart.value = { x: e.clientX, y: e.clientY }
|
|
304
|
+
cropStart.value = { ...cropArea.value }
|
|
305
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
306
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
307
|
+
e.preventDefault()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 开始调整大小
|
|
312
|
+
const handleResizeStart = (e: MouseEvent, type: string) => {
|
|
313
|
+
isResizing.value = true
|
|
314
|
+
dragType.value = type
|
|
315
|
+
dragStart.value = { x: e.clientX, y: e.clientY }
|
|
316
|
+
cropStart.value = { ...cropArea.value }
|
|
317
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
318
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
319
|
+
e.preventDefault()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 鼠标移动
|
|
323
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
324
|
+
if (!contentRef.value) return
|
|
325
|
+
|
|
326
|
+
// 计算鼠标在屏幕上的移动距离,并除以缩放倍数
|
|
327
|
+
const screenDeltaX = (e.clientX - dragStart.value.x) / zoom.value
|
|
328
|
+
const screenDeltaY = (e.clientY - dragStart.value.y) / zoom.value
|
|
329
|
+
|
|
330
|
+
// 【核心修复】将屏幕移动坐标转换回旋转后的本地坐标系
|
|
331
|
+
// 公式:x' = x*cos(θ) + y*sin(θ), y' = -x*sin(θ) + y*cos(θ)
|
|
332
|
+
// 这里 θ 取负值,因为我们要从屏幕坐标转回组件本地坐标
|
|
333
|
+
const rad = (-rotation.value * Math.PI) / 180
|
|
334
|
+
const cos = Math.cos(rad)
|
|
335
|
+
const sin = Math.sin(rad)
|
|
336
|
+
|
|
337
|
+
const deltaX = screenDeltaX * cos - screenDeltaY * sin
|
|
338
|
+
const deltaY = screenDeltaX * sin + screenDeltaY * cos
|
|
339
|
+
|
|
340
|
+
if (isDragging.value && dragType.value === 'crop-box') {
|
|
341
|
+
// 拖拽裁剪框
|
|
342
|
+
// 【核心修复】由于裁剪框在旋转容器内部,边界限制应始终使用图片的原始显示尺寸
|
|
343
|
+
const width = imageDisplayWidth.value
|
|
344
|
+
const height = imageDisplayHeight.value
|
|
345
|
+
const newX = Math.max(0, Math.min(cropStart.value.x + deltaX, width - cropArea.value.width))
|
|
346
|
+
const newY = Math.max(0, Math.min(cropStart.value.y + deltaY, height - cropArea.value.height))
|
|
347
|
+
|
|
348
|
+
cropArea.value.x = newX
|
|
349
|
+
cropArea.value.y = newY
|
|
350
|
+
} else if (isResizing.value) {
|
|
351
|
+
// 调整裁剪框大小
|
|
352
|
+
resizeCropBox(deltaX, deltaY)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 调整裁剪框大小
|
|
357
|
+
const resizeCropBox = (deltaX: number, deltaY: number) => {
|
|
358
|
+
const width = imageDisplayWidth.value
|
|
359
|
+
const height = imageDisplayHeight.value
|
|
360
|
+
const { x, y, width: w, height: h } = cropStart.value
|
|
361
|
+
const type = dragType.value
|
|
362
|
+
|
|
363
|
+
let newX = x
|
|
364
|
+
let newY = y
|
|
365
|
+
let newWidth = w
|
|
366
|
+
let newHeight = h
|
|
367
|
+
|
|
368
|
+
// 根据拖拽类型调整
|
|
369
|
+
if (type.includes('e')) {
|
|
370
|
+
newWidth = Math.max(props.minWidth, Math.min(w + deltaX, width - x, props.maxWidth || Infinity))
|
|
371
|
+
}
|
|
372
|
+
if (type.includes('w')) {
|
|
373
|
+
const maxWidth = x + w
|
|
374
|
+
newWidth = Math.max(props.minWidth, Math.min(w - deltaX, maxWidth, props.maxWidth || Infinity))
|
|
375
|
+
newX = Math.max(0, x + w - newWidth)
|
|
376
|
+
}
|
|
377
|
+
if (type.includes('s')) {
|
|
378
|
+
newHeight = Math.max(
|
|
379
|
+
props.minHeight,
|
|
380
|
+
Math.min(h + deltaY, height - y, props.maxHeight || Infinity)
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
if (type.includes('n')) {
|
|
384
|
+
const maxHeight = y + h
|
|
385
|
+
newHeight = Math.max(
|
|
386
|
+
props.minHeight,
|
|
387
|
+
Math.min(h - deltaY, maxHeight, props.maxHeight || Infinity)
|
|
388
|
+
)
|
|
389
|
+
newY = Math.max(0, y + h - newHeight)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 处理固定比例
|
|
393
|
+
if (props.cropMode === 'ratio' && props.aspectRatio) {
|
|
394
|
+
const currentAspect = newWidth / newHeight
|
|
395
|
+
if (Math.abs(currentAspect - props.aspectRatio) > 0.01) {
|
|
396
|
+
if (type.includes('e') || type.includes('w')) {
|
|
397
|
+
newHeight = newWidth / props.aspectRatio
|
|
398
|
+
if (type.includes('n')) {
|
|
399
|
+
newY = y + h - newHeight
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
newWidth = newHeight * props.aspectRatio
|
|
403
|
+
if (type.includes('w')) {
|
|
404
|
+
newX = x + w - newWidth
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 边界检查
|
|
411
|
+
if (newX + newWidth > width) {
|
|
412
|
+
newWidth = width - newX
|
|
413
|
+
if (props.cropMode === 'ratio' && props.aspectRatio) {
|
|
414
|
+
newHeight = newWidth / props.aspectRatio
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (newY + newHeight > height) {
|
|
418
|
+
newHeight = height - newY
|
|
419
|
+
if (props.cropMode === 'ratio' && props.aspectRatio) {
|
|
420
|
+
newWidth = newHeight * props.aspectRatio
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
cropArea.value = { x: newX, y: newY, width: newWidth, height: newHeight }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 鼠标释放
|
|
428
|
+
const handleMouseUp = () => {
|
|
429
|
+
isDragging.value = false
|
|
430
|
+
isResizing.value = false
|
|
431
|
+
dragType.value = ''
|
|
432
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
433
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 缩放变化
|
|
437
|
+
const handleZoomChange = () => {
|
|
438
|
+
// 缩放时保持裁剪区域相对位置
|
|
439
|
+
// 这里可以添加更复杂的缩放逻辑
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 鼠标滚轮缩放(电脑端优化)
|
|
443
|
+
const handleWheel = (e: WheelEvent) => {
|
|
444
|
+
if (!props.zoomable || !imageLoaded.value) return
|
|
445
|
+
|
|
446
|
+
e.preventDefault()
|
|
447
|
+
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
|
448
|
+
const newZoom = Math.max(minZoom.value, Math.min(maxZoom.value, zoom.value + delta))
|
|
449
|
+
zoom.value = Math.round(newZoom * 10) / 10
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 旋转图片
|
|
453
|
+
const rotateImage = (angle: number) => {
|
|
454
|
+
rotation.value = (rotation.value + angle) % 360
|
|
455
|
+
nextTick(() => {
|
|
456
|
+
updateImageDisplaySize()
|
|
457
|
+
initCropArea()
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// 执行裁剪
|
|
462
|
+
const handleCrop = () => {
|
|
463
|
+
if (!imageRef.value || !props.imageSrc) return
|
|
464
|
+
|
|
465
|
+
const canvas = document.createElement('canvas')
|
|
466
|
+
const ctx = canvas.getContext('2d')
|
|
467
|
+
if (!ctx) return
|
|
468
|
+
|
|
469
|
+
// 1. 获取缩放比例(显示尺寸 vs 原始尺寸)
|
|
470
|
+
const scaleX = imageNaturalWidth.value / imageDisplayWidth.value
|
|
471
|
+
const scaleY = imageNaturalHeight.value / imageDisplayHeight.value
|
|
472
|
+
|
|
473
|
+
// 2. 计算裁剪框在原始图片坐标系下的尺寸(不计旋转)
|
|
474
|
+
const { x, y, width, height } = cropArea.value
|
|
475
|
+
const cropW = width * scaleX
|
|
476
|
+
const cropH = height * scaleY
|
|
477
|
+
|
|
478
|
+
// 3. 设置目标 Canvas 尺寸
|
|
479
|
+
// 注意:如果旋转了 90/270 度,裁剪结果的视觉尺寸应该互换
|
|
480
|
+
const is90Deg = Math.abs(rotation.value % 180) === 90
|
|
481
|
+
canvas.width = is90Deg ? cropH : cropW
|
|
482
|
+
canvas.height = is90Deg ? cropW : cropH
|
|
483
|
+
|
|
484
|
+
ctx.save()
|
|
485
|
+
|
|
486
|
+
// 4. 将画布原点移至中心并旋转
|
|
487
|
+
ctx.translate(canvas.width / 2, canvas.height / 2)
|
|
488
|
+
ctx.rotate((rotation.value * Math.PI) / 180)
|
|
489
|
+
|
|
490
|
+
// 5. 关键步骤:计算图片在旋转后的画布上应该绘制的位置
|
|
491
|
+
// 我们需要将图片的“裁剪框中心点”对齐到画布的“中心点”
|
|
492
|
+
const imgCenterX = imageNaturalWidth.value / 2
|
|
493
|
+
const imgCenterY = imageNaturalHeight.value / 2
|
|
494
|
+
const cropCenterX = (x + width / 2) * scaleX
|
|
495
|
+
const cropCenterY = (y + height / 2) * scaleY
|
|
496
|
+
|
|
497
|
+
// 绘制偏移量 = 图片中心 - 裁剪框中心
|
|
498
|
+
const drawOffsetX = imgCenterX - cropCenterX
|
|
499
|
+
const drawOffsetY = imgCenterY - cropCenterY
|
|
500
|
+
|
|
501
|
+
// 6. 绘制图片
|
|
502
|
+
ctx.drawImage(
|
|
503
|
+
imageRef.value,
|
|
504
|
+
-imageNaturalWidth.value / 2 + drawOffsetX,
|
|
505
|
+
-imageNaturalHeight.value / 2 + drawOffsetY,
|
|
506
|
+
imageNaturalWidth.value,
|
|
507
|
+
imageNaturalHeight.value
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
ctx.restore()
|
|
511
|
+
|
|
512
|
+
// 7. 输出结果
|
|
513
|
+
canvas.toBlob(blob => {
|
|
514
|
+
if (blob) {
|
|
515
|
+
const dataUrl = canvas.toDataURL('image/png')
|
|
516
|
+
emit('crop', {
|
|
517
|
+
blob,
|
|
518
|
+
dataUrl,
|
|
519
|
+
cropArea: {
|
|
520
|
+
x: x * scaleX,
|
|
521
|
+
y: y * scaleY,
|
|
522
|
+
width: cropW,
|
|
523
|
+
height: cropH,
|
|
524
|
+
},
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
}, 'image/png')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 取消
|
|
531
|
+
const handleCancel = () => {
|
|
532
|
+
emit('cancel')
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 监听图片源变化
|
|
536
|
+
watch(
|
|
537
|
+
() => props.imageSrc,
|
|
538
|
+
(newSrc: string | undefined) => {
|
|
539
|
+
if (newSrc) {
|
|
540
|
+
imageLoaded.value = false
|
|
541
|
+
rotation.value = 0
|
|
542
|
+
zoom.value = props.initialZoom
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
// 监听容器大小变化
|
|
548
|
+
const resizeObserver = ref<ResizeObserver | null>(null)
|
|
549
|
+
|
|
550
|
+
// 键盘快捷键处理(电脑端优化)
|
|
551
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
552
|
+
// 只在组件可见且有图片时响应
|
|
553
|
+
if (!imageLoaded.value || !props.imageSrc) return
|
|
554
|
+
// 如果用户在输入框中,不响应快捷键
|
|
555
|
+
const target = e.target as HTMLElement
|
|
556
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
// Ctrl/Cmd + 滚轮缩放,或方向键旋转
|
|
560
|
+
if (e.ctrlKey || e.metaKey) {
|
|
561
|
+
if (e.key === '=' || e.key === '+') {
|
|
562
|
+
e.preventDefault()
|
|
563
|
+
const newZoom = Math.min(maxZoom.value, zoom.value + 0.1)
|
|
564
|
+
zoom.value = Math.round(newZoom * 10) / 10
|
|
565
|
+
} else if (e.key === '-' || e.key === '_') {
|
|
566
|
+
e.preventDefault()
|
|
567
|
+
const newZoom = Math.max(minZoom.value, zoom.value - 0.1)
|
|
568
|
+
zoom.value = Math.round(newZoom * 10) / 10
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// R 键旋转(需要组件获得焦点)
|
|
573
|
+
if (props.rotatable && (e.key === 'r' || e.key === 'R')) {
|
|
574
|
+
e.preventDefault()
|
|
575
|
+
rotateImage(90)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Enter 确认裁剪
|
|
579
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
580
|
+
e.preventDefault()
|
|
581
|
+
handleCrop()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Esc 取消
|
|
585
|
+
if (e.key === 'Escape') {
|
|
586
|
+
e.preventDefault()
|
|
587
|
+
handleCancel()
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
onMounted(() => {
|
|
592
|
+
if (containerRef.value && window.ResizeObserver) {
|
|
593
|
+
resizeObserver.value = new ResizeObserver(() => {
|
|
594
|
+
updateImageDisplaySize()
|
|
595
|
+
})
|
|
596
|
+
resizeObserver.value.observe(containerRef.value)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// 添加键盘事件监听(电脑端优化)
|
|
600
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
onBeforeUnmount(() => {
|
|
604
|
+
handleMouseUp()
|
|
605
|
+
if (resizeObserver.value) {
|
|
606
|
+
resizeObserver.value.disconnect()
|
|
607
|
+
}
|
|
608
|
+
// 移除键盘事件监听
|
|
609
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
610
|
+
})
|
|
611
|
+
</script>
|
|
612
|
+
|
|
613
|
+
<script lang="ts">
|
|
614
|
+
export default {
|
|
615
|
+
name: 'VcImageCropper',
|
|
616
|
+
}
|
|
617
|
+
</script>
|
|
618
|
+
|
|
619
|
+
<style lang="scss" scoped>
|
|
620
|
+
.vc-image-cropper {
|
|
621
|
+
position: relative;
|
|
622
|
+
background: #1a1a1a;
|
|
623
|
+
border-radius: 8px;
|
|
624
|
+
overflow: hidden;
|
|
625
|
+
user-select: none;
|
|
626
|
+
outline: none;
|
|
627
|
+
|
|
628
|
+
&:focus {
|
|
629
|
+
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.5);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.vc-image-cropper-placeholder {
|
|
634
|
+
display: flex;
|
|
635
|
+
align-items: center;
|
|
636
|
+
justify-content: center;
|
|
637
|
+
height: 100%;
|
|
638
|
+
color: #666;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.placeholder-content {
|
|
642
|
+
text-align: center;
|
|
643
|
+
|
|
644
|
+
svg {
|
|
645
|
+
margin-bottom: 16px;
|
|
646
|
+
opacity: 0.5;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
p {
|
|
650
|
+
margin: 0;
|
|
651
|
+
font-size: 14px;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.vc-image-cropper-content {
|
|
656
|
+
position: relative;
|
|
657
|
+
width: 100%;
|
|
658
|
+
height: calc(100% - 60px);
|
|
659
|
+
overflow: hidden;
|
|
660
|
+
display: flex;
|
|
661
|
+
align-items: center;
|
|
662
|
+
justify-content: center;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.vc-image-wrapper {
|
|
666
|
+
position: relative;
|
|
667
|
+
transition: transform 0.1s ease-out;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.vc-image-wrapper img {
|
|
671
|
+
max-width: none;
|
|
672
|
+
max-height: none;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.vc-crop-box {
|
|
676
|
+
position: absolute;
|
|
677
|
+
border: 2px solid #fff;
|
|
678
|
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
|
679
|
+
cursor: move;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.vc-crop-grid {
|
|
683
|
+
position: absolute;
|
|
684
|
+
top: 0;
|
|
685
|
+
left: 0;
|
|
686
|
+
width: 100%;
|
|
687
|
+
height: 100%;
|
|
688
|
+
pointer-events: none;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.grid-line {
|
|
692
|
+
position: absolute;
|
|
693
|
+
background: rgba(255, 255, 255, 0.3);
|
|
694
|
+
|
|
695
|
+
&.grid-line-h1 {
|
|
696
|
+
top: 33.33%;
|
|
697
|
+
left: 0;
|
|
698
|
+
width: 100%;
|
|
699
|
+
height: 1px;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
&.grid-line-h2 {
|
|
703
|
+
top: 66.66%;
|
|
704
|
+
left: 0;
|
|
705
|
+
width: 100%;
|
|
706
|
+
height: 1px;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
&.grid-line-v1 {
|
|
710
|
+
left: 33.33%;
|
|
711
|
+
top: 0;
|
|
712
|
+
width: 1px;
|
|
713
|
+
height: 100%;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
&.grid-line-v2 {
|
|
717
|
+
left: 66.66%;
|
|
718
|
+
top: 0;
|
|
719
|
+
width: 1px;
|
|
720
|
+
height: 100%;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.vc-crop-handle {
|
|
725
|
+
position: absolute;
|
|
726
|
+
width: 8px;
|
|
727
|
+
height: 8px;
|
|
728
|
+
background: #fff;
|
|
729
|
+
border: 1px solid #333;
|
|
730
|
+
box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
|
|
731
|
+
z-index: 10;
|
|
732
|
+
|
|
733
|
+
&:hover {
|
|
734
|
+
background: #667eea;
|
|
735
|
+
border-color: #667eea;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.vc-cropper-toolbar {
|
|
740
|
+
position: absolute;
|
|
741
|
+
bottom: 0;
|
|
742
|
+
left: 0;
|
|
743
|
+
right: 0;
|
|
744
|
+
height: 60px;
|
|
745
|
+
background: rgba(0, 0, 0, 0.8);
|
|
746
|
+
display: flex;
|
|
747
|
+
align-items: center;
|
|
748
|
+
justify-content: center;
|
|
749
|
+
gap: 24px;
|
|
750
|
+
padding: 0 24px;
|
|
751
|
+
backdrop-filter: blur(10px);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.toolbar-group {
|
|
755
|
+
display: flex;
|
|
756
|
+
align-items: center;
|
|
757
|
+
gap: 12px;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.toolbar-label {
|
|
761
|
+
color: #fff;
|
|
762
|
+
font-size: 14px;
|
|
763
|
+
white-space: nowrap;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.toolbar-slider {
|
|
767
|
+
width: 150px;
|
|
768
|
+
height: 4px;
|
|
769
|
+
border-radius: 2px;
|
|
770
|
+
background: #333;
|
|
771
|
+
outline: none;
|
|
772
|
+
-webkit-appearance: none;
|
|
773
|
+
appearance: none;
|
|
774
|
+
|
|
775
|
+
&::-webkit-slider-thumb {
|
|
776
|
+
-webkit-appearance: none;
|
|
777
|
+
appearance: none;
|
|
778
|
+
width: 16px;
|
|
779
|
+
height: 16px;
|
|
780
|
+
border-radius: 50%;
|
|
781
|
+
background: #667eea;
|
|
782
|
+
cursor: pointer;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
&::-moz-range-thumb {
|
|
786
|
+
width: 16px;
|
|
787
|
+
height: 16px;
|
|
788
|
+
border-radius: 50%;
|
|
789
|
+
background: #667eea;
|
|
790
|
+
cursor: pointer;
|
|
791
|
+
border: none;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
.toolbar-value {
|
|
796
|
+
color: #fff;
|
|
797
|
+
font-size: 14px;
|
|
798
|
+
min-width: 40px;
|
|
799
|
+
text-align: right;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
.toolbar-btn {
|
|
803
|
+
padding: 8px 16px;
|
|
804
|
+
border: 1px solid #444;
|
|
805
|
+
background: #333;
|
|
806
|
+
color: #fff;
|
|
807
|
+
border-radius: 4px;
|
|
808
|
+
cursor: pointer;
|
|
809
|
+
font-size: 14px;
|
|
810
|
+
transition: all 0.2s;
|
|
811
|
+
|
|
812
|
+
&:hover {
|
|
813
|
+
background: #444;
|
|
814
|
+
border-color: #667eea;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
&.toolbar-btn-primary {
|
|
818
|
+
background: #667eea;
|
|
819
|
+
border-color: #667eea;
|
|
820
|
+
|
|
821
|
+
&:hover {
|
|
822
|
+
background: #5568d3;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
</style>
|