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.
@@ -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>