|
@@ -0,0 +1,1345 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <van-popup
|
|
|
|
|
+ v-model:show="visible"
|
|
|
|
|
+ position="bottom"
|
|
|
|
|
+ closeable
|
|
|
|
|
+ close-icon="close"
|
|
|
|
|
+ :style="{ height: '100%' }"
|
|
|
|
|
+ @close="handleCancel"
|
|
|
|
|
+ >
|
|
|
|
|
+ <div class="edit-image-container">
|
|
|
|
|
+ <!-- 顶部标题栏 -->
|
|
|
|
|
+ <div class="header">
|
|
|
|
|
+ <div class="title">图片编辑</div>
|
|
|
|
|
+ <div class="actions">
|
|
|
|
|
+ <van-button size="small" @click="handleCancel">取消</van-button>
|
|
|
|
|
+ <van-button type="primary" size="small" @click="handleSave">保存</van-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Canvas区域 -->
|
|
|
|
|
+ <div ref="canvasContainerEl" class="canvas-container">
|
|
|
|
|
+ <canvas ref="canvasEl" class="image-canvas"></canvas>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 底部工具栏 -->
|
|
|
|
|
+ <div class="toolbar">
|
|
|
|
|
+ <div class="tool-group">
|
|
|
|
|
+ <button
|
|
|
|
|
+ v-for="tool in tools"
|
|
|
|
|
+ :key="tool.id"
|
|
|
|
|
+ :class="['tool-btn', { active: currentTool === tool.id }]"
|
|
|
|
|
+ @click="selectTool(tool.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <van-icon v-if="tool.vanIcon" :name="tool.vanIcon" class="tool-icon" />
|
|
|
|
|
+ <span v-else class="tool-icon tool-text-icon">{{ tool.icon }}</span>
|
|
|
|
|
+ <span class="tool-label">{{ tool.label }}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 操作按钮组 -->
|
|
|
|
|
+ <div class="action-group">
|
|
|
|
|
+ <van-button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @click="handleUndo"
|
|
|
|
|
+ :disabled="!canUndo"
|
|
|
|
|
+ plain
|
|
|
|
|
+ class="action-btn"
|
|
|
|
|
+ >
|
|
|
|
|
+ 撤销
|
|
|
|
|
+ </van-button>
|
|
|
|
|
+ <van-button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @click="handleRedo"
|
|
|
|
|
+ :disabled="!canRedo"
|
|
|
|
|
+ plain
|
|
|
|
|
+ class="action-btn"
|
|
|
|
|
+ >
|
|
|
|
|
+ 重做
|
|
|
|
|
+ </van-button>
|
|
|
|
|
+ <van-button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @click="handleReset"
|
|
|
|
|
+ plain
|
|
|
|
|
+ class="action-btn"
|
|
|
|
|
+ >
|
|
|
|
|
+ 重置
|
|
|
|
|
+ </van-button>
|
|
|
|
|
+ <van-button
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ round
|
|
|
|
|
+ class="settings-btn"
|
|
|
|
|
+ @click="showPropertyPanel = !showPropertyPanel"
|
|
|
|
|
+ >
|
|
|
|
|
+ <van-icon name="setting-o" />
|
|
|
|
|
+ </van-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 工具属性面板 -->
|
|
|
|
|
+ <div v-if="showPropertyPanel" class="property-panel">
|
|
|
|
|
+ <div class="property-group">
|
|
|
|
|
+ <div class="property-item">
|
|
|
|
|
+ <span class="property-label">颜色:</span>
|
|
|
|
|
+ <div class="color-picker">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="color in colors"
|
|
|
|
|
+ :key="color"
|
|
|
|
|
+ :class="['color-option', { active: currentColor === color }]"
|
|
|
|
|
+ :style="{ backgroundColor: color }"
|
|
|
|
|
+ @click="currentColor = color"
|
|
|
|
|
+ ></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="property-item">
|
|
|
|
|
+ <span class="property-label">粗细:</span>
|
|
|
|
|
+ <van-slider
|
|
|
|
|
+ v-model="strokeWidth"
|
|
|
|
|
+ :min="1"
|
|
|
|
|
+ :max="10"
|
|
|
|
|
+ :step="1"
|
|
|
|
|
+ bar-height="4px"
|
|
|
|
|
+ active-color="#1989fa"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span class="slider-value">{{ strokeWidth }}px</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="property-item" v-if="currentTool === 'text'">
|
|
|
|
|
+ <span class="property-label">字体大小:</span>
|
|
|
|
|
+ <van-slider
|
|
|
|
|
+ v-model="fontSize"
|
|
|
|
|
+ :min="8"
|
|
|
|
|
+ :max="72"
|
|
|
|
|
+ :step="2"
|
|
|
|
|
+ bar-height="4px"
|
|
|
|
|
+ active-color="#1989fa"
|
|
|
|
|
+ />
|
|
|
|
|
+ <span class="slider-value">{{ fontSize }}px</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </van-popup>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { ref, shallowRef, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
|
|
|
+import { showNotify } from 'vant'
|
|
|
|
|
+import * as fabric from 'fabric'
|
|
|
|
|
+import {
|
|
|
|
|
+ ZOOM_CONFIG,
|
|
|
|
|
+ HISTORY_CONFIG,
|
|
|
|
|
+ SHAPE_CONFIG,
|
|
|
|
|
+ TEXT_CONFIG,
|
|
|
|
|
+ COLOR_LIST,
|
|
|
|
|
+ MOBILE_LONGPRESS_DELAY,
|
|
|
|
|
+ CANVAS_CONFIG,
|
|
|
|
|
+ SLIDER_RANGES,
|
|
|
|
|
+} from '@/constants/editImageConfig'
|
|
|
|
|
+import type {
|
|
|
|
|
+ ImageItem,
|
|
|
|
|
+ EditImageExpose,
|
|
|
|
|
+ ToolMode,
|
|
|
|
|
+ ToolDefinition,
|
|
|
|
|
+} from '@/types/editImage'
|
|
|
|
|
+import { TimerManager } from '@/utils/timerManager'
|
|
|
|
|
+
|
|
|
|
|
+// 响应式数据
|
|
|
|
|
+const visible = ref(false)
|
|
|
|
|
+const canvasEl = ref<HTMLCanvasElement | null>(null)
|
|
|
|
|
+const canvasContainerEl = ref<HTMLElement | null>(null)
|
|
|
|
|
+const canvas = shallowRef<fabric.Canvas | null>(null)
|
|
|
|
|
+const currentImage = ref<ImageItem | null>(null)
|
|
|
|
|
+const currentType = ref<'outer' | 'inner'>('outer')
|
|
|
|
|
+
|
|
|
|
|
+// 工具状态
|
|
|
|
|
+const currentTool = ref<ToolMode>('select')
|
|
|
|
|
+const currentColor = ref('#ff3b30')
|
|
|
|
|
+const strokeWidth = ref(3)
|
|
|
|
|
+const fontSize = ref(16)
|
|
|
|
|
+const showPropertyPanel = ref(false)
|
|
|
|
|
+
|
|
|
|
|
+// 缩放和平移状态
|
|
|
|
|
+const zoomLevel = ref(1)
|
|
|
|
|
+const minZoom = ZOOM_CONFIG.MIN_ZOOM
|
|
|
|
|
+const maxZoom = ZOOM_CONFIG.MAX_ZOOM
|
|
|
|
|
+const isPanning = ref(false)
|
|
|
|
|
+let panStartX = 0
|
|
|
|
|
+let panStartY = 0
|
|
|
|
|
+// 历史记录
|
|
|
|
|
+const historyStack = ref<string[]>([])
|
|
|
|
|
+const historyIndex = ref(-1)
|
|
|
|
|
+const canUndo = ref(false)
|
|
|
|
|
+const canRedo = ref(false)
|
|
|
|
|
+// 正在从历史记录恢复时设为 true,阻止 saveHistory 被 object:added/removed 事件重复触发
|
|
|
|
|
+let isRestoring = false
|
|
|
|
|
+
|
|
|
|
|
+// 历史记录防抖定时器:避免 slider 拖动产生大量历史条目
|
|
|
|
|
+let watchSaveHistoryTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
+
|
|
|
|
|
+// 键盘快捷键处理函数(模块级别定义,确保 onUnmounted 能正确移除)
|
|
|
|
|
+const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
+ if (!visible.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // Ctrl+Z / Cmd+Z - 撤销
|
|
|
|
|
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (canUndo.value) handleUndo()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Ctrl+Y / Ctrl+Shift+Z - 重做
|
|
|
|
|
+ if ((e.ctrlKey && e.key === 'y') || ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey)) {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ if (canRedo.value) handleRedo()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 监听属性变化,更新选中的对象(历史记录保存使用防抖,避免 slider 拖动产生大量历史条目)
|
|
|
|
|
+watch([currentColor, strokeWidth, fontSize], () => {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ const activeObject = canvas.value.getActiveObject()
|
|
|
|
|
+ if (activeObject) {
|
|
|
|
|
+ // 立即更新对象属性
|
|
|
|
|
+ if (activeObject.stroke !== undefined) {
|
|
|
|
|
+ activeObject.set('stroke', currentColor.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (activeObject.fill !== undefined) {
|
|
|
|
|
+ activeObject.set('fill', currentColor.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (activeObject.strokeWidth !== undefined) {
|
|
|
|
|
+ activeObject.set('strokeWidth', strokeWidth.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (activeObject.type === 'i-text' || activeObject.type === 'textbox') {
|
|
|
|
|
+ const textObj = activeObject as fabric.IText
|
|
|
|
|
+ textObj.set('fontSize', fontSize.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ canvas.value.renderAll()
|
|
|
|
|
+
|
|
|
|
|
+ // 延迟 500ms 保存历史,合并连续拖动操作
|
|
|
|
|
+ if (watchSaveHistoryTimer) clearTimeout(watchSaveHistoryTimer)
|
|
|
|
|
+ watchSaveHistoryTimer = setTimeout(() => {
|
|
|
|
|
+ saveHistory()
|
|
|
|
|
+ watchSaveHistoryTimer = null
|
|
|
|
|
+ }, 500)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 工具定义
|
|
|
|
|
+const tools = [
|
|
|
|
|
+ { id: 'select' as ToolMode, icon: '', vanIcon: 'like-o', label: '选择' },
|
|
|
|
|
+ { id: 'pan' as ToolMode, icon: '', vanIcon: 'replay', label: '移动' },
|
|
|
|
|
+ { id: 'rect' as ToolMode, icon: '', vanIcon: 'stop-o', label: '矩形' },
|
|
|
|
|
+ { id: 'circle' as ToolMode, icon: '', vanIcon: 'circle-o', label: '圆形' },
|
|
|
|
|
+ { id: 'arrow' as ToolMode, icon: '', vanIcon: 'share-o', label: '箭头' },
|
|
|
|
|
+ { id: 'text' as ToolMode, icon: 'T', vanIcon: '', label: '文字' },
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+// 颜色选项
|
|
|
|
|
+const colors = [
|
|
|
|
|
+ '#ff3b30', // 红色 - 用于标记问题
|
|
|
|
|
+ '#007aff', // 蓝色 - 用于标记注意
|
|
|
|
|
+ '#34c759', // 绿色 - 用于标记正常
|
|
|
|
|
+ '#ff9500', // 橙色 - 用于标记警告
|
|
|
|
|
+ '#5856d6', // 紫色 - 用于标记其他
|
|
|
|
|
+ '#000000', // 黑色
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+// 事件定义
|
|
|
|
|
+const emit = defineEmits<{
|
|
|
|
|
+ 'save-image': [dataURL: string, type: 'outer' | 'inner']
|
|
|
|
|
+ cancel: []
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+// 暴露给父组件的方法
|
|
|
|
|
+defineExpose<EditImageExpose>({
|
|
|
|
|
+ editImage: (imageObj: ImageItem, type: 'outer' | 'inner') => {
|
|
|
|
|
+ currentImage.value = imageObj
|
|
|
|
|
+ currentType.value = type
|
|
|
|
|
+ visible.value = true
|
|
|
|
|
+
|
|
|
|
|
+ // 取图片来源:优先已上传 url,其次 van-uploader 生成的 base64 content,兜底从 file 生成 ObjectURL
|
|
|
|
|
+ const imageSource =
|
|
|
|
|
+ imageObj.url ||
|
|
|
|
|
+ imageObj.content ||
|
|
|
|
|
+ (imageObj.file ? URL.createObjectURL(imageObj.file) : null)
|
|
|
|
|
+
|
|
|
|
|
+ if (!imageSource) {
|
|
|
|
|
+ console.warn('[EditImage] 无可用图片来源')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 popup 动画完成(约 300ms)后再加载,防止 canvas 尺寸为 0
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ if (!canvas.value) {
|
|
|
|
|
+ // canvas 未初始化时重新初始化
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ initCanvas()
|
|
|
|
|
+ loadImageToCanvas(imageSource)
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ loadImageToCanvas(imageSource)
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 350)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 初始化Canvas
|
|
|
|
|
+
|
|
|
|
|
+// 清理资源
|
|
|
|
|
+onUnmounted(() => {
|
|
|
|
|
+ if (canvas.value) {
|
|
|
|
|
+ canvas.value.dispose()
|
|
|
|
|
+ canvas.value = null
|
|
|
|
|
+ }
|
|
|
|
|
+ // 移除全局键盘事件监听,防止内存泄漏
|
|
|
|
|
+ window.removeEventListener('keydown', handleKeyDown)
|
|
|
|
|
+ // 清理防抖定时器
|
|
|
|
|
+ if (watchSaveHistoryTimer) clearTimeout(watchSaveHistoryTimer)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 初始化Canvas
|
|
|
|
|
+function initCanvas() {
|
|
|
|
|
+ if (!canvasEl.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 优先用容器实际尺寸,如果读不到则先用屏幕尺寸兑底
|
|
|
|
|
+ const containerW = canvasContainerEl.value?.clientWidth || window.innerWidth
|
|
|
|
|
+ const containerH = canvasContainerEl.value?.clientHeight || (window.innerHeight - 200)
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value = new fabric.Canvas(canvasEl.value, {
|
|
|
|
|
+ width: containerW,
|
|
|
|
|
+ height: containerH,
|
|
|
|
|
+ backgroundColor: '#f5f5f5',
|
|
|
|
|
+ preserveObjectStacking: true,
|
|
|
|
|
+ // 移动端优化配置
|
|
|
|
|
+ allowTouchScrolling: false,
|
|
|
|
|
+ enablePointerEvents: true,
|
|
|
|
|
+ fireRightClick: true,
|
|
|
|
|
+ stopContextMenu: true,
|
|
|
|
|
+ touchCornerSize: 24,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 设置画布事件监听
|
|
|
|
|
+ setupCanvasEvents()
|
|
|
|
|
+ setupZoom()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置画布事件
|
|
|
|
|
+function setupCanvasEvents() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 对象选择变化
|
|
|
|
|
+ canvas.value.on('selection:created', () => {
|
|
|
|
|
+ updatePropertyPanel()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('selection:updated', () => {
|
|
|
|
|
+ updatePropertyPanel()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('selection:cleared', () => {
|
|
|
|
|
+ updatePropertyPanel()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 对象修改后保存历史
|
|
|
|
|
+ canvas.value.on('object:modified', () => {
|
|
|
|
|
+ saveHistory()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('object:added', () => {
|
|
|
|
|
+ saveHistory()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('object:removed', () => {
|
|
|
|
|
+ saveHistory()
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 加载图片到Canvas
|
|
|
|
|
+async function loadImageToCanvas(imageUrl: string) {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 清除现有内容(Fabric v6+ 已移除 setBackgroundColor / setBackgroundImage 回调形式)
|
|
|
|
|
+ canvas.value.clear()
|
|
|
|
|
+ canvas.value.backgroundColor = '#f5f5f5'
|
|
|
|
|
+
|
|
|
|
|
+ // 重置视口变换(缩放/平移),确保重新进入时画板居中
|
|
|
|
|
+ canvas.value.setViewportTransform([1, 0, 0, 1, 0, 0])
|
|
|
|
|
+ zoomLevel.value = 1
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.requestRenderAll()
|
|
|
|
|
+
|
|
|
|
|
+ // 创建图片元素,crossOrigin 必须在 src 赋值前设置,避免远程 URL CORS 问题
|
|
|
|
|
+ const imgElement = document.createElement('img')
|
|
|
|
|
+ imgElement.crossOrigin = 'anonymous'
|
|
|
|
|
+
|
|
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
|
|
+ imgElement.onload = () => resolve()
|
|
|
|
|
+ imgElement.onerror = (err) => reject(err)
|
|
|
|
|
+ imgElement.src = imageUrl
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // canvas 可能在等待期间被销毁
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 计算适合画布的尺寸
|
|
|
|
|
+ const canvasWidth = canvas.value.width!
|
|
|
|
|
+ const canvasHeight = canvas.value.height!
|
|
|
|
|
+ const imgWidth = imgElement.naturalWidth || imgElement.width
|
|
|
|
|
+ const imgHeight = imgElement.naturalHeight || imgElement.height
|
|
|
|
|
+
|
|
|
|
|
+ if (!imgWidth || !imgHeight) {
|
|
|
|
|
+ throw new Error('图片尺寸无效')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 计算等比缩放比例
|
|
|
|
|
+ const scale = Math.min(
|
|
|
|
|
+ canvasWidth / imgWidth,
|
|
|
|
|
+ canvasHeight / imgHeight,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const scaledWidth = imgWidth * scale
|
|
|
|
|
+ const scaledHeight = imgHeight * scale
|
|
|
|
|
+
|
|
|
|
|
+ // Fabric v7:直接 new fabric.Image,然后用属性赋值替代 setBackgroundImage 回调
|
|
|
|
|
+ const fabricImage = new fabric.Image(imgElement, {
|
|
|
|
|
+ left: (canvasWidth - scaledWidth) / 2,
|
|
|
|
|
+ top: (canvasHeight - scaledHeight) / 2,
|
|
|
|
|
+ scaleX: scale,
|
|
|
|
|
+ scaleY: scale,
|
|
|
|
|
+ selectable: false,
|
|
|
|
|
+ evented: false,
|
|
|
|
|
+ originX: 'left',
|
|
|
|
|
+ originY: 'top',
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.backgroundImage = fabricImage
|
|
|
|
|
+ canvas.value.requestRenderAll()
|
|
|
|
|
+
|
|
|
|
|
+ // 重置历史记录
|
|
|
|
|
+ historyStack.value = []
|
|
|
|
|
+ historyIndex.value = -1
|
|
|
|
|
+ updateUndoRedoState()
|
|
|
|
|
+
|
|
|
|
|
+ // 初始保存历史
|
|
|
|
|
+ saveHistory()
|
|
|
|
|
+
|
|
|
|
|
+ showNotify({ type: 'success', message: '图片加载成功' })
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('图片加载失败:', error)
|
|
|
|
|
+ showNotify({ type: 'danger', message: '图片加载失败,请重试' })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 选择工具
|
|
|
|
|
+function selectTool(tool: ToolMode) {
|
|
|
|
|
+ currentTool.value = tool
|
|
|
|
|
+ showPropertyPanel.value = tool !== 'select' && tool !== 'pan'
|
|
|
|
|
+
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 切换工具前移除旧的绘图鼠标事件监听,防止多次调用导致监听器堆叠
|
|
|
|
|
+ canvas.value.off('mouse:down')
|
|
|
|
|
+ canvas.value.off('mouse:move')
|
|
|
|
|
+ canvas.value.off('mouse:up')
|
|
|
|
|
+
|
|
|
|
|
+ // 根据工具设置画布模式
|
|
|
|
|
+ switch (tool) {
|
|
|
|
|
+ case 'select':
|
|
|
|
|
+ canvas.value.isDrawingMode = false
|
|
|
|
|
+ canvas.value.selection = true
|
|
|
|
|
+ canvas.value.defaultCursor = 'default'
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ case 'pan':
|
|
|
|
|
+ canvas.value.isDrawingMode = false
|
|
|
|
|
+ canvas.value.selection = false
|
|
|
|
|
+ canvas.value.defaultCursor = 'grab'
|
|
|
|
|
+ setupPanTool()
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'rect':
|
|
|
|
|
+ canvas.value.isDrawingMode = false
|
|
|
|
|
+ canvas.value.selection = false
|
|
|
|
|
+ canvas.value.defaultCursor = 'crosshair'
|
|
|
|
|
+ setupRectTool()
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ case 'circle':
|
|
|
|
|
+ canvas.value.isDrawingMode = false
|
|
|
|
|
+ canvas.value.selection = false
|
|
|
|
|
+ canvas.value.defaultCursor = 'crosshair'
|
|
|
|
|
+ setupCircleTool()
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ case 'arrow':
|
|
|
|
|
+ canvas.value.isDrawingMode = false
|
|
|
|
|
+ canvas.value.selection = false
|
|
|
|
|
+ canvas.value.defaultCursor = 'crosshair'
|
|
|
|
|
+ setupArrowTool()
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ case 'text':
|
|
|
|
|
+ canvas.value.isDrawingMode = false
|
|
|
|
|
+ canvas.value.selection = false
|
|
|
|
|
+ canvas.value.defaultCursor = 'text'
|
|
|
|
|
+ setupTextTool()
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置平移工具
|
|
|
|
|
+function setupPanTool() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ isPanning.value = false
|
|
|
|
|
+
|
|
|
|
|
+ // 鼠标/触摸按下事件
|
|
|
|
|
+ canvas.value.on('mouse:down', (options) => {
|
|
|
|
|
+ if (currentTool.value !== 'pan') return
|
|
|
|
|
+
|
|
|
|
|
+ isPanning.value = true
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+ panStartX = pointer.x
|
|
|
|
|
+ panStartY = pointer.y
|
|
|
|
|
+ canvas.value!.defaultCursor = 'grabbing'
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 鼠标/触摸移动事件
|
|
|
|
|
+ canvas.value.on('mouse:move', (options) => {
|
|
|
|
|
+ if (!isPanning.value || currentTool.value !== 'pan') return
|
|
|
|
|
+
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+ const deltaX = pointer.x - panStartX
|
|
|
|
|
+ const deltaY = pointer.y - panStartY
|
|
|
|
|
+
|
|
|
|
|
+ // 移动所有对象
|
|
|
|
|
+ canvas.value!.getObjects().forEach((obj) => {
|
|
|
|
|
+ obj.left! += deltaX
|
|
|
|
|
+ obj.top! += deltaY
|
|
|
|
|
+ obj.setCoords()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 移动背景图片
|
|
|
|
|
+ const bgImage = canvas.value!.backgroundImage
|
|
|
|
|
+ if (bgImage) {
|
|
|
|
|
+ bgImage.left! += deltaX
|
|
|
|
|
+ bgImage.top! += deltaY
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ panStartX = pointer.x
|
|
|
|
|
+ panStartY = pointer.y
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 鼠标/触摸释放事件
|
|
|
|
|
+ canvas.value.on('mouse:up', () => {
|
|
|
|
|
+ if (currentTool.value !== 'pan') return
|
|
|
|
|
+
|
|
|
|
|
+ isPanning.value = false
|
|
|
|
|
+ canvas.value!.defaultCursor = 'grab'
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// 设置缩放功能
|
|
|
|
|
+function setupZoom() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 鼠标滚轮缩放
|
|
|
|
|
+ canvas.value.on('mouse:wheel', (options) => {
|
|
|
|
|
+ const delta = options.e.deltaY
|
|
|
|
|
+ let zoom = canvas.value!.getZoom()
|
|
|
|
|
+
|
|
|
|
|
+ // 计算新的缩放级别
|
|
|
|
|
+ zoom *= 0.999 ** delta
|
|
|
|
|
+ zoom = Math.max(minZoom, Math.min(maxZoom, zoom))
|
|
|
|
|
+
|
|
|
|
|
+ // 应用缩放
|
|
|
|
|
+ canvas.value!.zoomToPoint(
|
|
|
|
|
+ { x: options.e.offsetX, y: options.e.offsetY },
|
|
|
|
|
+ zoom
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ zoomLevel.value = zoom
|
|
|
|
|
+ options.e.preventDefault()
|
|
|
|
|
+ options.e.stopPropagation()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 触摸缩放(双指捏合)
|
|
|
|
|
+ const canvasElement = canvas.value.getElement()
|
|
|
|
|
+ let lastDistance = 0
|
|
|
|
|
+
|
|
|
|
|
+ canvasElement.addEventListener('touchstart', (e: TouchEvent) => {
|
|
|
|
|
+ if (e.touches.length === 2) {
|
|
|
|
|
+ lastDistance = Math.hypot(
|
|
|
|
|
+ e.touches[0].clientX - e.touches[1].clientX,
|
|
|
|
|
+ e.touches[0].clientY - e.touches[1].clientY
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvasElement.addEventListener('touchmove', (e: TouchEvent) => {
|
|
|
|
|
+ if (e.touches.length === 2 && currentTool.value === 'pan') {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+
|
|
|
|
|
+ const currentDistance = Math.hypot(
|
|
|
|
|
+ e.touches[0].clientX - e.touches[1].clientX,
|
|
|
|
|
+ e.touches[0].clientY - e.touches[1].clientY
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if (lastDistance > 0) {
|
|
|
|
|
+ const zoom = canvas.value!.getZoom() * (currentDistance / lastDistance)
|
|
|
|
|
+ const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
|
|
|
|
+ const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
|
|
|
|
+
|
|
|
|
|
+ const boundedZoom = Math.max(minZoom, Math.min(maxZoom, zoom))
|
|
|
|
|
+ canvas.value!.zoomToPoint(
|
|
|
|
|
+ { x: centerX, y: centerY },
|
|
|
|
|
+ boundedZoom
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ zoomLevel.value = boundedZoom
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ lastDistance = currentDistance
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvasElement.addEventListener('touchend', () => {
|
|
|
|
|
+ lastDistance = 0
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置矩形工具
|
|
|
|
|
+function setupRectTool() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ let rect: fabric.Rect | null = null
|
|
|
|
|
+ let isDrawing = false
|
|
|
|
|
+ let startX = 0
|
|
|
|
|
+ let startY = 0
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:down', (options) => {
|
|
|
|
|
+ if (currentTool.value !== 'rect') return;
|
|
|
|
|
+ // 如果点在已有对象上,不绘制新图形
|
|
|
|
|
+ if (options.target) return;
|
|
|
|
|
+
|
|
|
|
|
+ isDrawing = true;
|
|
|
|
|
+ const pointer = options.scenePoint;
|
|
|
|
|
+ startX = pointer.x;
|
|
|
|
|
+ startY = pointer.y;
|
|
|
|
|
+
|
|
|
|
|
+ rect = new fabric.Rect({
|
|
|
|
|
+ left: startX,
|
|
|
|
|
+ top: startY,
|
|
|
|
|
+ width: 0,
|
|
|
|
|
+ height: 0,
|
|
|
|
|
+ // 绘制中用半透明填充提供视觉反馈,mouseup 后改为透明
|
|
|
|
|
+ fill: 'rgba(255,255,255,0.15)',
|
|
|
|
|
+ stroke: currentColor.value,
|
|
|
|
|
+ strokeWidth: strokeWidth.value,
|
|
|
|
|
+ selectable: false,
|
|
|
|
|
+ evented: false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.add(rect);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:move', (options) => {
|
|
|
|
|
+ if (!isDrawing || !rect || currentTool.value !== 'rect') return
|
|
|
|
|
+
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+ const rawW = pointer.x - startX
|
|
|
|
|
+ const rawH = pointer.y - startY
|
|
|
|
|
+
|
|
|
|
|
+ rect.set({
|
|
|
|
|
+ width: Math.abs(rawW),
|
|
|
|
|
+ height: Math.abs(rawH),
|
|
|
|
|
+ left: rawW >= 0 ? startX : pointer.x,
|
|
|
|
|
+ top: rawH >= 0 ? startY : pointer.y,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:up', () => {
|
|
|
|
|
+ if (currentTool.value !== 'rect') return
|
|
|
|
|
+
|
|
|
|
|
+ isDrawing = false
|
|
|
|
|
+ if (rect) {
|
|
|
|
|
+ if (rect.width! < 5 || rect.height! < 5) {
|
|
|
|
|
+ // 太小则移除
|
|
|
|
|
+ canvas.value!.remove(rect)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 绘制完成:去除半透明填充,恢复为可选中状态
|
|
|
|
|
+ rect.set({
|
|
|
|
|
+ fill: 'transparent',
|
|
|
|
|
+ selectable: true,
|
|
|
|
|
+ evented: true,
|
|
|
|
|
+ })
|
|
|
|
|
+ rect.setCoords()
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rect = null
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置圆形工具
|
|
|
|
|
+function setupCircleTool() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ let circle: fabric.Circle | null = null
|
|
|
|
|
+ let isDrawing = false
|
|
|
|
|
+ let startX = 0
|
|
|
|
|
+ let startY = 0
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:down', (options) => {
|
|
|
|
|
+ if (currentTool.value !== 'circle') return;
|
|
|
|
|
+ // 如果点在已有对象上,不绘制新图形
|
|
|
|
|
+ if (options.target) return;
|
|
|
|
|
+
|
|
|
|
|
+ isDrawing = true;
|
|
|
|
|
+ const pointer = options.scenePoint;
|
|
|
|
|
+ startX = pointer.x;
|
|
|
|
|
+ startY = pointer.y;
|
|
|
|
|
+
|
|
|
|
|
+ circle = new fabric.Circle({
|
|
|
|
|
+ left: startX,
|
|
|
|
|
+ top: startY,
|
|
|
|
|
+ radius: 0,
|
|
|
|
|
+ // 绘制中用半透明填充提供视觉反馈,mouseup 后改为透明
|
|
|
|
|
+ fill: 'rgba(255,255,255,0.15)',
|
|
|
|
|
+ stroke: currentColor.value,
|
|
|
|
|
+ strokeWidth: strokeWidth.value,
|
|
|
|
|
+ selectable: false,
|
|
|
|
|
+ evented: false,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.add(circle);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:move', (options) => {
|
|
|
|
|
+ if (!isDrawing || !circle || currentTool.value !== 'circle') return
|
|
|
|
|
+
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+ const rawW = pointer.x - startX
|
|
|
|
|
+ const rawH = pointer.y - startY
|
|
|
|
|
+ // 取宽高最小值确保始终是圆形(内切于拖拽包围盒)
|
|
|
|
|
+ const size = Math.min(Math.abs(rawW), Math.abs(rawH))
|
|
|
|
|
+ const radius = size / 2
|
|
|
|
|
+
|
|
|
|
|
+ circle.set({
|
|
|
|
|
+ radius,
|
|
|
|
|
+ left: rawW >= 0 ? startX : startX - size,
|
|
|
|
|
+ top: rawH >= 0 ? startY : startY - size,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:up', () => {
|
|
|
|
|
+ if (currentTool.value !== 'circle') return
|
|
|
|
|
+
|
|
|
|
|
+ isDrawing = false
|
|
|
|
|
+ if (circle) {
|
|
|
|
|
+ if (circle.radius! < 5) {
|
|
|
|
|
+ // 太小则移除
|
|
|
|
|
+ canvas.value!.remove(circle)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 绘制完成:去除半透明填充,恢复为可选中状态
|
|
|
|
|
+ circle.set({
|
|
|
|
|
+ fill: 'transparent',
|
|
|
|
|
+ selectable: true,
|
|
|
|
|
+ evented: true,
|
|
|
|
|
+ })
|
|
|
|
|
+ circle.setCoords()
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ circle = null
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置箭头工具
|
|
|
|
|
+function setupArrowTool() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ let line: fabric.Line | null = null
|
|
|
|
|
+ let arrowHead1: fabric.Line | null = null
|
|
|
|
|
+ let arrowHead2: fabric.Line | null = null
|
|
|
|
|
+ let isDrawing = false
|
|
|
|
|
+ let startX = 0
|
|
|
|
|
+ let startY = 0
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:down', (options) => {
|
|
|
|
|
+ if (currentTool.value !== 'arrow') return
|
|
|
|
|
+ // 如果点在已有对象上,不绘制新图形
|
|
|
|
|
+ if (options.target) return
|
|
|
|
|
+
|
|
|
|
|
+ isDrawing = true
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+ startX = pointer.x
|
|
|
|
|
+ startY = pointer.y
|
|
|
|
|
+
|
|
|
|
|
+ // 直接将三条线加到画布(避免在 Group 内使用绝对坐标的问题)
|
|
|
|
|
+ line = new fabric.Line([startX, startY, startX, startY], {
|
|
|
|
|
+ stroke: currentColor.value,
|
|
|
|
|
+ strokeWidth: strokeWidth.value,
|
|
|
|
|
+ selectable: false,
|
|
|
|
|
+ evented: false,
|
|
|
|
|
+ })
|
|
|
|
|
+ arrowHead1 = new fabric.Line([startX, startY, startX, startY], {
|
|
|
|
|
+ stroke: currentColor.value,
|
|
|
|
|
+ strokeWidth: strokeWidth.value,
|
|
|
|
|
+ selectable: false,
|
|
|
|
|
+ evented: false,
|
|
|
|
|
+ })
|
|
|
|
|
+ arrowHead2 = new fabric.Line([startX, startY, startX, startY], {
|
|
|
|
|
+ stroke: currentColor.value,
|
|
|
|
|
+ strokeWidth: strokeWidth.value,
|
|
|
|
|
+ selectable: false,
|
|
|
|
|
+ evented: false,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.add(line, arrowHead1, arrowHead2)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:move', (options) => {
|
|
|
|
|
+ if (!isDrawing || !line || !arrowHead1 || !arrowHead2 || currentTool.value !== 'arrow') return
|
|
|
|
|
+
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+
|
|
|
|
|
+ // 更新主线终点
|
|
|
|
|
+ line.set({ x2: pointer.x, y2: pointer.y })
|
|
|
|
|
+
|
|
|
|
|
+ // 计算箭头角度和头部坐标
|
|
|
|
|
+ const dx = pointer.x - startX
|
|
|
|
|
+ const dy = pointer.y - startY
|
|
|
|
|
+ const angle = Math.atan2(dy, dx)
|
|
|
|
|
+ const length = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
|
+
|
|
|
|
|
+ if (length > 0) {
|
|
|
|
|
+ const arrowHeadSize = SHAPE_CONFIG.ARROW_HEAD_SIZE
|
|
|
|
|
+ const arrowAngle = Math.PI / 6 // 30度
|
|
|
|
|
+
|
|
|
|
|
+ // 箭头头部点1
|
|
|
|
|
+ const angle1 = angle + arrowAngle
|
|
|
|
|
+ const hx1 = pointer.x - arrowHeadSize * Math.cos(angle1)
|
|
|
|
|
+ const hy1 = pointer.y - arrowHeadSize * Math.sin(angle1)
|
|
|
|
|
+
|
|
|
|
|
+ // 箭头头部点2
|
|
|
|
|
+ const angle2 = angle - arrowAngle
|
|
|
|
|
+ const hx2 = pointer.x - arrowHeadSize * Math.cos(angle2)
|
|
|
|
|
+ const hy2 = pointer.y - arrowHeadSize * Math.sin(angle2)
|
|
|
|
|
+
|
|
|
|
|
+ arrowHead1.set({ x1: pointer.x, y1: pointer.y, x2: hx1, y2: hy1 })
|
|
|
|
|
+ arrowHead2.set({ x1: pointer.x, y1: pointer.y, x2: hx2, y2: hy2 })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:up', () => {
|
|
|
|
|
+ if (currentTool.value !== 'arrow') return
|
|
|
|
|
+
|
|
|
|
|
+ isDrawing = false
|
|
|
|
|
+ if (line && arrowHead1 && arrowHead2) {
|
|
|
|
|
+ const dx = line.x2! - line.x1!
|
|
|
|
|
+ const dy = line.y2! - line.y1!
|
|
|
|
|
+ const length = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
|
+
|
|
|
|
|
+ if (length < 10) {
|
|
|
|
|
+ // 太短则移除
|
|
|
|
|
+ canvas.value!.remove(line, arrowHead1, arrowHead2)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 从画布移除后组合成 Group,使箭头可作为整体被选中/移动
|
|
|
|
|
+ canvas.value!.remove(line, arrowHead1, arrowHead2)
|
|
|
|
|
+ const group = new fabric.Group([line, arrowHead1, arrowHead2], {
|
|
|
|
|
+ selectable: true,
|
|
|
|
|
+ evented: true,
|
|
|
|
|
+ })
|
|
|
|
|
+ canvas.value!.add(group)
|
|
|
|
|
+ canvas.value!.renderAll()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ line = null
|
|
|
|
|
+ arrowHead1 = null
|
|
|
|
|
+ arrowHead2 = null
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 设置文字工具
|
|
|
|
|
+function setupTextTool() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value.on('mouse:down', (options) => {
|
|
|
|
|
+ if (currentTool.value !== 'text') return
|
|
|
|
|
+
|
|
|
|
|
+ const pointer = options.scenePoint
|
|
|
|
|
+
|
|
|
|
|
+ const text = new fabric.IText('点击编辑文字', {
|
|
|
|
|
+ left: pointer.x,
|
|
|
|
|
+ top: pointer.y,
|
|
|
|
|
+ fontSize: fontSize.value,
|
|
|
|
|
+ fill: currentColor.value,
|
|
|
|
|
+ backgroundColor: 'transparent',
|
|
|
|
|
+ padding: 8,
|
|
|
|
|
+ selectable: true,
|
|
|
|
|
+ editable: true,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvas.value!.add(text)
|
|
|
|
|
+ canvas.value!.setActiveObject(text)
|
|
|
|
|
+
|
|
|
|
|
+ // 进入编辑模式
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ text.enterEditing()
|
|
|
|
|
+ text.selectAll()
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新属性面板
|
|
|
|
|
+function updatePropertyPanel() {
|
|
|
|
|
+ if (!canvas.value) return
|
|
|
|
|
+
|
|
|
|
|
+ const activeObject = canvas.value.getActiveObject()
|
|
|
|
|
+ if (activeObject) {
|
|
|
|
|
+ // 更新当前颜色和线条粗细
|
|
|
|
|
+ if (activeObject.stroke) {
|
|
|
|
|
+ currentColor.value = activeObject.stroke as string
|
|
|
|
|
+ }
|
|
|
|
|
+ if (activeObject.strokeWidth) {
|
|
|
|
|
+ strokeWidth.value = activeObject.strokeWidth
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 更新字体大小(如果是文本对象)
|
|
|
|
|
+ if (activeObject.type === 'i-text' || activeObject.type === 'textbox') {
|
|
|
|
|
+ const textObj = activeObject as fabric.IText
|
|
|
|
|
+ if (textObj.fontSize) {
|
|
|
|
|
+ fontSize.value = textObj.fontSize
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 保存历史记录
|
|
|
|
|
+function saveHistory() {
|
|
|
|
|
+ if (!canvas.value || isRestoring) return
|
|
|
|
|
+
|
|
|
|
|
+ const json = JSON.stringify(canvas.value.toJSON())
|
|
|
|
|
+
|
|
|
|
|
+ // 截断重做部分
|
|
|
|
|
+ if (historyIndex.value < historyStack.value.length - 1) {
|
|
|
|
|
+ historyStack.value = historyStack.value.slice(0, historyIndex.value + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ historyStack.value.push(json)
|
|
|
|
|
+
|
|
|
|
|
+ // 超出历史记录上限时,移除最旧的记录,防止内存占用无限增长
|
|
|
|
|
+ if (historyStack.value.length > HISTORY_CONFIG.MAX_HISTORY) {
|
|
|
|
|
+ historyStack.value.shift()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ historyIndex.value = historyStack.value.length - 1
|
|
|
|
|
+ updateUndoRedoState()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 更新撤销/重做状态
|
|
|
|
|
+function updateUndoRedoState() {
|
|
|
|
|
+ canUndo.value = historyIndex.value > 0
|
|
|
|
|
+ canRedo.value = historyIndex.value < historyStack.value.length - 1
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 撤销操作
|
|
|
|
|
+async function handleUndo() {
|
|
|
|
|
+ if (!canvas.value || !canUndo.value) return
|
|
|
|
|
+
|
|
|
|
|
+ isRestoring = true
|
|
|
|
|
+ historyIndex.value--
|
|
|
|
|
+ const json = historyStack.value[historyIndex.value]
|
|
|
|
|
+ try {
|
|
|
|
|
+ await canvas.value.loadFromJSON(json)
|
|
|
|
|
+ canvas.value.renderAll()
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isRestoring = false
|
|
|
|
|
+ }
|
|
|
|
|
+ updateUndoRedoState()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 重做操作
|
|
|
|
|
+async function handleRedo() {
|
|
|
|
|
+ if (!canvas.value || !canRedo.value) return
|
|
|
|
|
+
|
|
|
|
|
+ isRestoring = true
|
|
|
|
|
+ historyIndex.value++
|
|
|
|
|
+ const json = historyStack.value[historyIndex.value]
|
|
|
|
|
+ try {
|
|
|
|
|
+ await canvas.value.loadFromJSON(json)
|
|
|
|
|
+ canvas.value.renderAll()
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isRestoring = false
|
|
|
|
|
+ }
|
|
|
|
|
+ updateUndoRedoState()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 重置画布
|
|
|
|
|
+function handleReset() {
|
|
|
|
|
+ if (!canvas.value || !currentImage.value) return
|
|
|
|
|
+
|
|
|
|
|
+ const imageSource =
|
|
|
|
|
+ currentImage.value.url ||
|
|
|
|
|
+ currentImage.value.content ||
|
|
|
|
|
+ (currentImage.value.file instanceof File ? URL.createObjectURL(currentImage.value.file) : null)
|
|
|
|
|
+
|
|
|
|
|
+ if (!imageSource) return
|
|
|
|
|
+ loadImageToCanvas(imageSource)
|
|
|
|
|
+ showNotify({ type: 'primary', message: '已重置' })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 保存图片
|
|
|
|
|
+async function handleSave() {
|
|
|
|
|
+ if (!canvas.value || !currentImage.value) return
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 隐藏所有控制点
|
|
|
|
|
+ canvas.value.discardActiveObject()
|
|
|
|
|
+
|
|
|
|
|
+ // 获取背景图及原图尺寸
|
|
|
|
|
+ const bgImg = canvas.value.backgroundImage as fabric.Image | null
|
|
|
|
|
+ if (!bgImg) throw new Error('未找到背景图片')
|
|
|
|
|
+
|
|
|
|
|
+ const imgWidth = bgImg.width!
|
|
|
|
|
+ const imgHeight = bgImg.height!
|
|
|
|
|
+ if (!imgWidth || !imgHeight) throw new Error('原图尺寸无效')
|
|
|
|
|
+
|
|
|
|
|
+ const bgScaleX = bgImg.scaleX ?? 1
|
|
|
|
|
+ const bgScaleY = bgImg.scaleY ?? 1
|
|
|
|
|
+ const bgLeft = bgImg.left ?? 0
|
|
|
|
|
+ const bgTop = bgImg.top ?? 0
|
|
|
|
|
+ const scaledW = imgWidth * bgScaleX
|
|
|
|
|
+ const scaledH = imgHeight * bgScaleY
|
|
|
|
|
+
|
|
|
|
|
+ // 临时重置 viewport(消除用户缩放/平移),保证渲染坐标与 scene 坐标一致
|
|
|
|
|
+ const savedVpt = [...canvas.value.viewportTransform!] as [number, number, number, number, number, number]
|
|
|
|
|
+ canvas.value.setViewportTransform([1, 0, 0, 1, 0, 0])
|
|
|
|
|
+ canvas.value.renderAll()
|
|
|
|
|
+
|
|
|
|
|
+ // 直接从 Fabric 画布 HTML 元素上裁剪图片区域,缩放到原图尺寸
|
|
|
|
|
+ // 这样完全避免手动坐标变换,所有标注由 Fabric 自己渲染,位置天然正确
|
|
|
|
|
+ const fabricEl = canvas.value.getElement() as HTMLCanvasElement
|
|
|
|
|
+ const tempCanvas = document.createElement('canvas')
|
|
|
|
|
+ tempCanvas.width = imgWidth
|
|
|
|
|
+ tempCanvas.height = imgHeight
|
|
|
|
|
+ const tempCtx = tempCanvas.getContext('2d')!
|
|
|
|
|
+
|
|
|
|
|
+ // Fabric 内部画布按 devicePixelRatio 放大实际分辨率,而 bgLeft/bgTop/scaledW/scaledH
|
|
|
|
|
+ // 均为 Fabric 的 CSS 坐标(逻辑像素),读取 fabricEl 时需换算成物理像素,
|
|
|
|
|
+ // 否则在高 DPR 设备(移动端 DPR=2/3)上裁剪区域偏移,导致输出白板。
|
|
|
|
|
+ const dpr = window.devicePixelRatio || 1
|
|
|
|
|
+ tempCtx.drawImage(
|
|
|
|
|
+ fabricEl,
|
|
|
|
|
+ bgLeft * dpr, bgTop * dpr, scaledW * dpr, scaledH * dpr, // 物理像素坐标
|
|
|
|
|
+ 0, 0, imgWidth, imgHeight, // 缩放到原图尺寸输出
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ const dataURL = tempCanvas.toDataURL('image/png', 1)
|
|
|
|
|
+
|
|
|
|
|
+ // 恢复 viewport
|
|
|
|
|
+ canvas.value.setViewportTransform(savedVpt)
|
|
|
|
|
+ canvas.value.renderAll()
|
|
|
|
|
+
|
|
|
|
|
+ // 触发保存事件
|
|
|
|
|
+ emit('save-image', dataURL, currentType.value)
|
|
|
|
|
+ visible.value = false
|
|
|
|
|
+
|
|
|
|
|
+ showNotify({ type: 'success', message: '图片保存成功' })
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('保存失败:', error)
|
|
|
|
|
+ // 出现异常时也尝试恢复 viewport
|
|
|
|
|
+ if (canvas.value) canvas.value.renderAll()
|
|
|
|
|
+ showNotify({ type: 'danger', message: '保存失败,请重试' })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 取消编辑
|
|
|
|
|
+function handleCancel() {
|
|
|
|
|
+ visible.value = false
|
|
|
|
|
+ emit('cancel')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 添加移动端触摸事件优化
|
|
|
|
|
+let mobileTouchOptimized = false
|
|
|
|
|
+function setupMobileTouchOptimization() {
|
|
|
|
|
+ if (!canvas.value || mobileTouchOptimized) return
|
|
|
|
|
+ mobileTouchOptimized = true
|
|
|
|
|
+
|
|
|
|
|
+ const canvasElement = canvas.value.getElement()
|
|
|
|
|
+
|
|
|
|
|
+ // 阻止默认的触摸行为(如滚动)
|
|
|
|
|
+ canvasElement.addEventListener('touchstart', (e) => {
|
|
|
|
|
+ if (e.touches.length === 1 && currentTool.value === 'pan') {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ }
|
|
|
|
|
+ }, { passive: false })
|
|
|
|
|
+
|
|
|
|
|
+ // 长按提示
|
|
|
|
|
+ let longPressTimer: number | null = null
|
|
|
|
|
+
|
|
|
|
|
+ canvasElement.addEventListener('touchstart', (e) => {
|
|
|
|
|
+ if (e.touches.length === 1) {
|
|
|
|
|
+ longPressTimer = window.setTimeout(() => {
|
|
|
|
|
+ showNotify({ type: 'info', message: '长按可进入平移模式' })
|
|
|
|
|
+ }, MOBILE_LONGPRESS_DELAY)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvasElement.addEventListener('touchend', () => {
|
|
|
|
|
+ if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ canvasElement.addEventListener('touchmove', () => {
|
|
|
|
|
+ if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null }
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 在 Canvas 初始化后调用移动端优化
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ if (canvasEl.value) {
|
|
|
|
|
+ initCanvas()
|
|
|
|
|
+ nextTick(() => {
|
|
|
|
|
+ setupMobileTouchOptimization()
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ // 在正确的生命周期中注册全局键盘快捷键(由 onUnmounted 统一清理)
|
|
|
|
|
+ window.addEventListener('keydown', handleKeyDown)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="sass">
|
|
|
|
|
+.edit-image-container
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ flex-direction: column
|
|
|
|
|
+ height: 100vh
|
|
|
|
|
+ background: #f0f0f0
|
|
|
|
|
+ overflow: hidden
|
|
|
|
|
+
|
|
|
|
|
+// ── 顶部标题栏 ──────────────────────────
|
|
|
|
|
+.header
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ justify-content: space-between
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+ padding: 0 12px
|
|
|
|
|
+ height: 50px
|
|
|
|
|
+ background: #fff
|
|
|
|
|
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12)
|
|
|
|
|
+ flex-shrink: 0
|
|
|
|
|
+ z-index: 10
|
|
|
|
|
+
|
|
|
|
|
+ .title
|
|
|
|
|
+ font-size: 17px
|
|
|
|
|
+ font-weight: 600
|
|
|
|
|
+ color: #1a1a1a
|
|
|
|
|
+
|
|
|
|
|
+ .actions
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ gap: 8px
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+
|
|
|
|
|
+ .van-button
|
|
|
|
|
+ min-width: 62px
|
|
|
|
|
+ height: 32px
|
|
|
|
|
+ border-radius: 16px
|
|
|
|
|
+ font-size: 14px
|
|
|
|
|
+
|
|
|
|
|
+// ── Canvas 区域 ──────────────────────────
|
|
|
|
|
+.canvas-container
|
|
|
|
|
+ flex: 1
|
|
|
|
|
+ overflow: hidden
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ align-items: stretch
|
|
|
|
|
+ justify-content: stretch
|
|
|
|
|
+ background: #7a7a7a
|
|
|
|
|
+ position: relative
|
|
|
|
|
+
|
|
|
|
|
+ // canvas 元素和 Fabric 创建的 .upper-canvas 都充满容器
|
|
|
|
|
+ :deep(canvas),
|
|
|
|
|
+ :deep(.upper-canvas)
|
|
|
|
|
+ display: block !important
|
|
|
|
|
+ width: 100% !important
|
|
|
|
|
+ height: 100% !important
|
|
|
|
|
+
|
|
|
|
|
+// ── 底部工具栏 ──────────────────────────
|
|
|
|
|
+.toolbar
|
|
|
|
|
+ background: #fff
|
|
|
|
|
+ padding: 10px 12px 8px
|
|
|
|
|
+ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08)
|
|
|
|
|
+ flex-shrink: 0
|
|
|
|
|
+
|
|
|
|
|
+ .tool-group
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ gap: 6px
|
|
|
|
|
+ justify-content: center
|
|
|
|
|
+ margin-bottom: 10px
|
|
|
|
|
+ flex-wrap: wrap
|
|
|
|
|
+
|
|
|
|
|
+ .tool-btn
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ flex-direction: column
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+ justify-content: center
|
|
|
|
|
+ padding: 7px 10px 5px
|
|
|
|
|
+ border: none
|
|
|
|
|
+ background: #f7f7f7
|
|
|
|
|
+ border-radius: 10px
|
|
|
|
|
+ cursor: pointer
|
|
|
|
|
+ transition: background 0.15s, color 0.15s
|
|
|
|
|
+ min-width: 56px
|
|
|
|
|
+ line-height: 1
|
|
|
|
|
+
|
|
|
|
|
+ &:active
|
|
|
|
|
+ background: #e8e8e8
|
|
|
|
|
+
|
|
|
|
|
+ &.active
|
|
|
|
|
+ background: #1989fa
|
|
|
|
|
+
|
|
|
|
|
+ .tool-icon,
|
|
|
|
|
+ .tool-text-icon
|
|
|
|
|
+ color: #fff !important
|
|
|
|
|
+
|
|
|
|
|
+ .tool-label
|
|
|
|
|
+ color: #fff
|
|
|
|
|
+
|
|
|
|
|
+ .tool-icon
|
|
|
|
|
+ font-size: 22px
|
|
|
|
|
+ color: #f0943a
|
|
|
|
|
+ margin-bottom: 4px
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+ justify-content: center
|
|
|
|
|
+
|
|
|
|
|
+ .tool-text-icon
|
|
|
|
|
+ font-size: 20px
|
|
|
|
|
+ font-weight: 700
|
|
|
|
|
+ color: #f0943a
|
|
|
|
|
+ margin-bottom: 4px
|
|
|
|
|
+ height: 22px
|
|
|
|
|
+ line-height: 22px
|
|
|
|
|
+
|
|
|
|
|
+ .tool-label
|
|
|
|
|
+ font-size: 11px
|
|
|
|
|
+ color: #555
|
|
|
|
|
+ white-space: nowrap
|
|
|
|
|
+
|
|
|
|
|
+ // ── 操作按钮行 ──
|
|
|
|
|
+ .action-group
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ gap: 8px
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+
|
|
|
|
|
+ .action-btn
|
|
|
|
|
+ flex: 1
|
|
|
|
|
+ height: 36px
|
|
|
|
|
+ border-radius: 8px
|
|
|
|
|
+ font-size: 14px
|
|
|
|
|
+ border-color: #ddd
|
|
|
|
|
+ color: #555
|
|
|
|
|
+
|
|
|
|
|
+ &:disabled
|
|
|
|
|
+ opacity: 0.4
|
|
|
|
|
+
|
|
|
|
|
+ .settings-btn
|
|
|
|
|
+ flex-shrink: 0
|
|
|
|
|
+ width: 36px
|
|
|
|
|
+ height: 36px
|
|
|
|
|
+ padding: 0
|
|
|
|
|
+ border-radius: 50%
|
|
|
|
|
+ border-color: #ddd
|
|
|
|
|
+ color: #666
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+ justify-content: center
|
|
|
|
|
+ font-size: 18px
|
|
|
|
|
+
|
|
|
|
|
+// ── 属性面板 ──────────────────────────
|
|
|
|
|
+.property-panel
|
|
|
|
|
+ background: #fff
|
|
|
|
|
+ padding: 10px 14px
|
|
|
|
|
+ border-top: 1px solid #ebebeb
|
|
|
|
|
+ flex-shrink: 0
|
|
|
|
|
+
|
|
|
|
|
+ .property-group
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ flex-direction: column
|
|
|
|
|
+ gap: 12px
|
|
|
|
|
+
|
|
|
|
|
+ .property-item
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ align-items: center
|
|
|
|
|
+ gap: 10px
|
|
|
|
|
+
|
|
|
|
|
+ .property-label
|
|
|
|
|
+ font-size: 13px
|
|
|
|
|
+ color: #444
|
|
|
|
|
+ min-width: 52px
|
|
|
|
|
+ flex-shrink: 0
|
|
|
|
|
+
|
|
|
|
|
+ .color-picker
|
|
|
|
|
+ display: flex
|
|
|
|
|
+ gap: 8px
|
|
|
|
|
+ flex-wrap: wrap
|
|
|
|
|
+
|
|
|
|
|
+ .color-option
|
|
|
|
|
+ width: 22px
|
|
|
|
|
+ height: 22px
|
|
|
|
|
+ border-radius: 50%
|
|
|
|
|
+ cursor: pointer
|
|
|
|
|
+ border: 2px solid transparent
|
|
|
|
|
+ transition: transform 0.15s
|
|
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2)
|
|
|
|
|
+
|
|
|
|
|
+ &:active
|
|
|
|
|
+ transform: scale(1.15)
|
|
|
|
|
+
|
|
|
|
|
+ &.active
|
|
|
|
|
+ border-color: #1a1a1a
|
|
|
|
|
+ transform: scale(1.15)
|
|
|
|
|
+
|
|
|
|
|
+ .van-slider
|
|
|
|
|
+ flex: 1
|
|
|
|
|
+
|
|
|
|
|
+ .slider-value
|
|
|
|
|
+ font-size: 13px
|
|
|
|
|
+ color: #888
|
|
|
|
|
+ min-width: 38px
|
|
|
|
|
+ text-align: right
|
|
|
|
|
+ flex-shrink: 0
|
|
|
|
|
+
|
|
|
|
|
+// ── 移动端适配 ──────────────────────────
|
|
|
|
|
+@media (max-width: 480px)
|
|
|
|
|
+ .toolbar
|
|
|
|
|
+ .tool-group
|
|
|
|
|
+ .tool-btn
|
|
|
|
|
+ min-width: 50px
|
|
|
|
|
+ padding: 6px 8px 4px
|
|
|
|
|
+
|
|
|
|
|
+ .tool-icon
|
|
|
|
|
+ font-size: 20px
|
|
|
|
|
+
|
|
|
|
|
+ .tool-label
|
|
|
|
|
+ font-size: 10px
|
|
|
|
|
+
|
|
|
|
|
+ .property-item
|
|
|
|
|
+ flex-wrap: wrap
|
|
|
|
|
+
|
|
|
|
|
+ .property-label
|
|
|
|
|
+ min-width: auto !important
|
|
|
|
|
+
|
|
|
|
|
+ .color-picker
|
|
|
|
|
+ justify-content: flex-start
|
|
|
|
|
+</style>
|