Merge branch 'main' of ssh://18.167.251.121:10002/aidlab/FiDA_Front

This commit is contained in:
2026-03-09 14:02:10 +08:00
14 changed files with 3162 additions and 423 deletions

View File

@@ -24,7 +24,7 @@
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import { TOOLS } from '../manager/ToolManager'
import { OperationType } from '../tools/layerHelper'
const props = defineProps({
zoom: { default: 1, type: Number },
step: { default: 0.1, type: Number }
@@ -32,29 +32,29 @@
const emit = defineEmits(['export', 'import'])
const stateManager = inject('stateManager') as any
const toolManager = inject('toolManager') as any
const tool = computed(() => stateManager.tool.value)
const tool = computed(() => toolManager.currentTool.value)
const historyIndex = computed(() => stateManager.historyIndex.value)
const historyList = computed(() => stateManager.historyList.value)
const isUndo = computed(() => !historyList.value[historyIndex.value - 1])
const isRedo = computed(() => !historyList.value[historyIndex.value + 1])
const tools = ref([
{ name: TOOLS.SELECT, icon: 'dc-select', iconSize: 16, disabled: ref(false) },
{ name: TOOLS.MOVE, icon: 'dc-move', iconSize: 18, disabled: ref(false) },
{ name: TOOLS.BRUSH, icon: 'dc-brush', iconSize: 18, disabled: ref(false) },
{ name: TOOLS.ERASER, icon: 'dc-eraser', iconSize: 18, disabled: ref(false) },
{ name: TOOLS.IMAGE, icon: 'dc-image', iconSize: 17, disabled: ref(false) },
{ name: TOOLS.SELECTBOX, icon: 'dc-selectbox', iconSize: 16, disabled: ref(false) },
{ name: TOOLS.RECTANGLE, icon: 'dc-rectangle', iconSize: 16, disabled: ref(false) },
{ name: OperationType.SELECT, icon: 'dc-select', iconSize: 16, disabled: ref(false) },
{ name: OperationType.PAN, icon: 'dc-move', iconSize: 18, disabled: ref(false) },
{ name: OperationType.DRAW, icon: 'dc-brush', iconSize: 18, disabled: ref(false) },
{ name: OperationType.ERASER, icon: 'dc-eraser', iconSize: 18, disabled: ref(false) },
{ name: OperationType.IMAGE, icon: 'dc-image', iconSize: 17, disabled: ref(false) },
{ name: OperationType.SELECTBOX, icon: 'dc-selectbox', iconSize: 16, disabled: ref(false) },
{ name: OperationType.RECTANGLE, icon: 'dc-rectangle', iconSize: 16, disabled: ref(false) },
{ type: 'line' },
{
name: TOOLS.UNDO,
name: OperationType.UNDO,
icon: 'dc-undo',
iconSize: 18,
disabled: isUndo,
on: () => stateManager.undoState()
},
{
name: TOOLS.REDO,
name: OperationType.REDO,
icon: 'dc-redo',
iconSize: 18,
disabled: isRedo,

View File

@@ -6,7 +6,12 @@
<layer-panel />
<details-panel />
<header-tools />
<zoom :zoom="1" :step="0.1" is-home />
<zoom
:zoom="canvasManager.currentZoom.value / 100"
:step="0.1"
is-home
@home="() => canvasManager.resetZoom()"
/>
</div>
</template>
@@ -23,9 +28,8 @@
// 管理器
import { StateManager } from './manager/StateManager'
import { EventManager } from './manager/EventManager'
import { FlowManager } from './manager/FlowManager'
import { NodeManager } from './manager/NodeManager'
import { ToolManager, TOOLS } from './manager/ToolManager'
import { CanvasManager } from './manager/CanvasManager'
import { ToolManager } from './manager/ToolManager'
const canvasContainerRef = ref(null)
const canvasRef = ref(null)
@@ -41,77 +45,51 @@
const stateManager = new StateManager({})
provide('stateManager', stateManager)
// 画布管理器
const canvasManager = new CanvasManager({ stateManager })
stateManager.setManager({ canvasManager, canvasRef })
provide('canvasManager', canvasManager)
// 事件管理器
const eventManager = new EventManager({ stateManager })
stateManager.setManager({ eventManager })
provide('eventManager', eventManager)
// 流程管理器
const flowManager = new FlowManager({ stateManager })
stateManager.setManager({ flowManager })
provide('flowManager', flowManager)
// 节点管理器
const nodeManager = new NodeManager({ stateManager })
stateManager.setManager({ nodeManager })
provide('nodeManager', nodeManager)
// 工具管理器
const toolManager = new ToolManager({ stateManager })
const toolManager = new ToolManager({ stateManager, canvasManager })
stateManager.setManager({ toolManager })
provide('toolManager', toolManager)
const initCanvas = () => {
console.log('OverallCanvas: initCanvas')
const canvasViewWidth = canvasContainerRef.value.clientWidth
const canvasViewHeight = canvasContainerRef.value.clientHeight
const canvasWidth = 750
const canvasHeight = 600
const canvas = new fabric.Canvas(canvasRef.value, {
selection: true,
width: canvasViewWidth,
height: canvasViewHeight,
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
imageSmoothingQuality: 'high', // 设置高质量图像平滑
preserveObjectStacking: true,
enableRetinaScaling: true,
stopContextMenu: true,
fireRightClick: true,
backgroundColor: '#fff'
})
canvas.clipPath = new fabric.Rect({
left: 0,
top: 0,
width: canvasWidth,
height: canvasHeight
})
// 画布居中
const canvasX = canvasViewWidth / 2 - canvasWidth / 2
const canvasY = canvasViewHeight / 2 - canvasHeight / 2
canvas.viewportTransform = [1, 0, 0, 1, canvasX, canvasY]
// 创建矩形
const rect = new fabric.Rect({
left: 20,
top: 20,
width: 100,
height: 100,
fill: '#f00'
})
canvas.add(rect)
//创建圆形
const circle = new fabric.Circle({
left: 200,
top: 200,
radius: 50,
fill: '#0f0'
})
canvas.add(circle)
}
const observer = ref(null)
onMounted(() => {
initCanvas()
canvasManager.initCanvas({
canvasRef,
canvasViewWidth: canvasContainerRef.value.clientWidth,
canvasViewHeight: canvasContainerRef.value.clientHeight,
canvasWidth: 750,
canvasHeight: 600
})
const trailingTimeout = ref(null)
observer.value = new ResizeObserver((entries) => {
clearTimeout(trailingTimeout.value)
trailingTimeout.value = setTimeout(async () => {
handleWindowResize()
}, 100)
})
observer.value.observe(canvasContainerRef.value)
})
onBeforeMount(() => {
// eventManager.removeEvents() // 移除事件
})
async function handleWindowResize() {
console.log('==========画布窗口大小变化==========')
canvasManager.setCanvasViewSize({
canvasViewWidth: canvasContainerRef.value.clientWidth,
canvasViewHeight: canvasContainerRef.value.clientHeight
})
canvasManager.resetZoom()
}
</script>
<style lang="less">
@import '@vue-flow/core/dist/style.css';

View File

@@ -0,0 +1,850 @@
import { gsap } from "gsap";
/**
* 画布动画管理器
* 负责处理画布平移、缩放等动画效果
*/
export class AnimationManager {
/**
* 创建动画管理器
* @param {fabric.Canvas} canvas fabric.js画布实例
* @param {Object} options 配置选项
*/
constructor(canvas, options = {}) {
this.canvasManager = options.canvasManager;
this.canvas = canvas;
this.currentZoom = options.currentZoom || { value: 100 };
// 动画相关属性
this._zoomAnimation = null;
this._panAnimation = null;
this._lastWheelTime = 0;
this._lastWheelProcessTime = 0; // 上次处理wheel事件的时间
this._wheelEvents = [];
// 检测设备类型Mac设备使用更短的节流时间确保响应性
this._isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
this._wheelThrottleTime = this._isMac
? options.wheelThrottleTime || 8 // Mac设备使用更短的节流时间
: options.wheelThrottleTime || 30;
this._accumulatedWheelDelta = 0; // 累积滚轮增量
this._wheelAccumulationTimeout = null; // 滚轮累积超时
// Mac设备使用更短的累积时间窗口确保及时响应
this._wheelAccumulationTime = this._isMac ? 60 : 120; // 滚轮累积时间窗口(毫秒)
// 添加新的状态跟踪变量
this._wasPanning = false; // 是否有平移动画正在进行
this._wasZooming = false; // 是否有缩放动画正在进行
this._combinedAnimation = null; // 组合动画引用
// Mac特有的动画优化变量 - 使用最小防抖机制
if (this._isMac) {
this._lastMacAnimationTime = 0; // 上次Mac动画时间
this._macAnimationCooldown = 2; // 最小的动画冷却时间,确保最大响应性
}
// 初始化GSAP默认配置
gsap.defaults({
ease: options.defaultEase || (this._isMac ? "power2.out" : "power2.out"), // Mac使用简单高效的缓动
duration: options.defaultDuration || (this._isMac ? 0.3 : 0.3), // Mac使用标准持续时间
overwrite: "auto", // 自动覆盖同一对象上的动画
});
}
/**
* 使用 GSAP 实现平滑缩放动画
* @param {Object} point 缩放中心点 {x, y}
* @param {Number} targetZoom 目标缩放值
* @param {Object} options 动画选项
*/
animateZoom(point, targetZoom, options = {}) {
if (!this.canvas) return;
// 限制缩放范围
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
// 当前缩放值
const currentZoom = this.canvas.getZoom();
// 如果变化太小,直接应用缩放
if (Math.abs(targetZoom - currentZoom) < 0.01) {
this._applyZoom(point, targetZoom);
return;
}
// 停止任何进行中的缩放动画
if (this._zoomAnimation) {
// 不是直接 kill而是获取当前进度值作为新的起点
const currentProgress = this._zoomAnimation.progress();
const currentZoomValue = this._zoomAnimation.targets()[0].value;
this._zoomAnimation.kill();
this._zoomAnimation = null;
// 从当前过渡中的值开始新动画,而不是从最初的值
const zoomObj = { value: currentZoomValue };
const currentVpt = [...this.canvas.viewportTransform];
// 计算过渡动画持续时间 - 根据当前值到目标值的距离比例
const progressRatio =
Math.abs(targetZoom - currentZoomValue) / Math.abs(targetZoom - currentZoom);
const duration = options.duration || 0.3 * progressRatio;
// 计算缩放后目标位置需要的修正,保持缩放点不变
const animOptions = {
value: targetZoom,
duration: duration,
ease: options.ease || "power2.out",
onUpdate: () => {
// 更新缩放值显示
this.currentZoom.value = Math.round(zoomObj.value * 100);
// 计算过渡中的变换矩阵
const zoom = zoomObj.value;
const scale = zoom / currentZoomValue;
const currentScaleFactor = scale;
// 应用变换
const vpt = this.canvas.viewportTransform;
vpt[0] = currentVpt[0] * scale;
vpt[3] = currentVpt[3] * scale;
// 应用平移修正以保持缩放点
const adjustX = (1 - currentScaleFactor) * point.x;
const adjustY = (1 - currentScaleFactor) * point.y;
vpt[4] = currentVpt[4] * scale + adjustX;
vpt[5] = currentVpt[5] * scale + adjustY;
this.canvas.renderAll();
},
onComplete: () => {
this._zoomAnimation = null;
// 确保最终状态准确
this._applyZoom(point, targetZoom, true);
},
};
// 启动 GSAP 动画
this._zoomAnimation = gsap.to(zoomObj, animOptions);
return;
}
// 如果没有正在进行的动画,创建新的缩放动画
const zoomObj = { value: currentZoom };
const currentVpt = [...this.canvas.viewportTransform];
// 计算缩放后目标位置需要的修正,保持缩放点不变
const scaleFactor = targetZoom / currentZoom;
const invertedScaleFactor = 1 / scaleFactor;
// 这个数学公式确保缩放点在屏幕上的位置保持不变
const dx = point.x - point.x * invertedScaleFactor;
const dy = point.y - point.y * invertedScaleFactor;
// 创建动画配置
const animOptions = {
value: targetZoom,
duration: options.duration || 0.3,
ease: options.ease || (this._isMac ? "expo.out" : "power2.out"), // Mac使用更平滑的缓动
onUpdate: () => {
// 更新缩放值显示
this.currentZoom.value = Math.round(zoomObj.value * 100);
// 计算过渡中的变换矩阵
const zoom = zoomObj.value;
const scale = zoom / currentZoom;
const currentScaleFactor = scale;
// 应用变换
const vpt = this.canvas.viewportTransform;
vpt[0] = currentVpt[0] * scale;
vpt[3] = currentVpt[3] * scale;
// 应用平移修正以保持缩放点
const adjustX = (1 - currentScaleFactor) * point.x;
const adjustY = (1 - currentScaleFactor) * point.y;
vpt[4] = currentVpt[4] * scale + adjustX;
vpt[5] = currentVpt[5] * scale + adjustY;
this.canvas.renderAll();
},
onComplete: () => {
this._zoomAnimation = null;
// 确保最终状态准确
this._applyZoom(point, targetZoom, true);
},
};
// 启动 GSAP 动画
this._zoomAnimation = gsap.to(zoomObj, animOptions);
}
/**
* 应用缩放(内部使用)
* @private
*/
_applyZoom(point, zoom, skipUpdate = false) {
if (!skipUpdate) {
this.currentZoom.value = Math.round(zoom * 100);
}
this.canvas.zoomToPoint(point, zoom);
}
/**
* 使用 GSAP 实现平滑平移动画
* @param {Object} targetPosition 目标位置 {x, y}
* @param {Object} options 动画选项
*/
animatePan(targetPosition, options = {}) {
if (!this.canvas) return;
// 停止任何进行中的平移动画
if (this._panAnimation) {
this._panAnimation.kill();
}
const currentVpt = [...this.canvas.viewportTransform];
const position = {
x: -currentVpt[4],
y: -currentVpt[5],
};
// 计算平移距离
const dx = targetPosition.x - position.x;
const dy = targetPosition.y - position.y;
// 如果距离太小,直接应用平移
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
this._applyPan(targetPosition.x, targetPosition.y);
return;
}
// 创建动画配置
const animOptions = {
x: targetPosition.x,
y: targetPosition.y,
duration: options.duration || 0.3,
ease: options.ease || (this._isMac ? "circ.out" : "power2.out"), // Mac使用更柔和的缓动
onUpdate: () => {
this._applyPan(position.x, position.y);
},
onComplete: () => {
this._panAnimation = null;
// 确保最终位置准确
this._applyPan(targetPosition.x, targetPosition.y);
},
};
// 启动 GSAP 动画
this._panAnimation = gsap.to(position, animOptions);
}
/**
* 应用平移(内部使用)
* @private
*/
_applyPan(x, y) {
if (!this.canvas) return;
const vpt = this.canvas.viewportTransform;
vpt[4] = -x;
vpt[5] = -y;
this.canvas.renderAll();
}
/**
* 使用动画平移到指定元素
* @param {Object} elementId 元素ID
*/
panToElement(elementId) {
if (!this.canvas) return;
const obj = this.canvas.getObjects().find((obj) => obj.id === elementId);
if (!obj) return;
const zoom = this.canvas.getZoom();
const center = obj.getCenterPoint();
// 计算目标中心位置
const targetX = center.x * zoom - this.canvas.width / 2;
const targetY = center.y * zoom - this.canvas.height / 2;
// 动画平移
this.animatePan(
{ x: targetX, y: targetY },
{
duration: 0.6,
ease: this._isMac ? "back.out(0.3)" : "power3.out", // Mac使用轻微回弹效果
}
);
}
/**
* 重置缩放(带平滑动画)
* @param {Boolean} animated 是否使用动画
*/
async resetZoom(animated = true) {
const canvasViewWidth = this.canvasManager.canvasViewWidth;
const canvasViewHeight = this.canvasManager.canvasViewHeight;
const canvasWidth = this.canvasManager.canvasWidth;
const canvasHeight = this.canvasManager.canvasHeight;
const panX = canvasViewWidth / 2 - canvasWidth / 2
const panY = canvasViewHeight / 2 - canvasHeight / 2
return new Promise((resolve) => {
if (animated) {
// 停止任何进行中的动画
if (this._zoomAnimation) {
this._zoomAnimation.kill();
}
if (this._panAnimation) {
this._panAnimation.kill();
}
const center = {
x: this.canvas.width / 2,
y: this.canvas.height / 2,
};
// 获取当前变换矩阵
const currentVpt = [...this.canvas.viewportTransform];
const currentZoom = this.canvas.getZoom();
// 创建一个对象来动画整个视图变换
const viewTransform = {
zoom: currentZoom,
panX: currentVpt[4],
panY: currentVpt[5],
};
// 使用GSAP同时动画缩放和平移
gsap.to(viewTransform, {
zoom: 1,
panX: panX,
panY: panY,
duration: 0.5,
ease: this._isMac ? "back.out(0.2)" : "power3.out", // Mac使用轻微回弹效果
onUpdate: () => {
// 更新缩放显示值
this.currentZoom.value = Math.round(viewTransform.zoom * 100);
// 应用新的变换
const vpt = this.canvas.viewportTransform;
vpt[0] = viewTransform.zoom;
vpt[3] = viewTransform.zoom;
vpt[4] = viewTransform.panX;
vpt[5] = viewTransform.panY;
this.canvas.renderAll();
},
onComplete: () => {
// 确保最终状态准确
this.canvas.setViewportTransform([1, 0, 0, 1, panX, panY]);
this.currentZoom.value = 100;
this._zoomAnimation = null;
this._panAnimation = null;
resolve();
},
});
} else {
this.canvas.setViewportTransform([1, 0, 0, 1, panX, panY]);
this.currentZoom.value = 100;
resolve();
}
});
}
/**
* 处理鼠标滚轮缩放
* @param {Object} opt 事件对象
*/
handleMouseWheel(opt) {
const now = Date.now();
let delta = opt.e.deltaY;
// 记录事件用于计算速度和惯性
this._wheelEvents.push({
delta: delta,
point: { x: opt.e.offsetX, y: opt.e.offsetY },
time: now,
hasPanAnimation: this._wasPanning,
hasZoomAnimation: this._wasZooming,
});
// 保留最近的事件记录
if (this._wheelEvents.length > 10) {
this._wheelEvents.shift();
}
// 检查是否是第一个事件或者距离上次处理已经过了足够时间
const isFirstEvent = !this._wheelAccumulationTimeout;
const timeSinceLastProcess = now - (this._lastWheelProcessTime || 0);
if (isFirstEvent || timeSinceLastProcess > this._wheelAccumulationTime) {
// 立即处理第一个事件或长时间没有处理的事件,确保响应性
this._processAccumulatedWheel(opt);
this._lastWheelProcessTime = now;
// 清理之前的累积
this._accumulatedWheelDelta = 0;
// 如果有pending的timeout清除它
if (this._wheelAccumulationTimeout) {
clearTimeout(this._wheelAccumulationTimeout);
this._wheelAccumulationTimeout = null;
}
} else {
// 累积后续事件
this._accumulatedWheelDelta += delta;
// 如果正在累积中,清除之前的定时器
if (this._wheelAccumulationTimeout) {
clearTimeout(this._wheelAccumulationTimeout);
}
// 设置新的定时器,处理累积的事件
this._wheelAccumulationTimeout = setTimeout(() => {
this._processAccumulatedWheel(opt);
this._lastWheelProcessTime = Date.now();
// 清理
this._accumulatedWheelDelta = 0;
this._wheelAccumulationTimeout = null;
}, this._wheelThrottleTime);
}
opt.e.preventDefault();
opt.e.stopPropagation();
}
/**
* 处理累积的滚轮事件并应用缩放
* @private
* @param {Object} lastOpt 最后一个滚轮事件
*/
_processAccumulatedWheel(lastOpt) {
if (!this._wheelEvents.length) return;
const now = Date.now();
// Mac设备的轻量防抖检查 - 进一步减少冷却时间,确保响应性
if (this._isMac && now - this._lastMacAnimationTime < this._macAnimationCooldown) {
// 如果距离上次动画时间太短,只延迟很短时间,不阻塞太久
if (this._wheelAccumulationTimeout) {
clearTimeout(this._wheelAccumulationTimeout);
}
this._wheelAccumulationTimeout = setTimeout(
() => {
this._processAccumulatedWheel(lastOpt);
},
Math.min(this._macAnimationCooldown, 3)
); // 最多延迟3ms
return;
}
const currentZoom = this.canvas.getZoom();
// 分析滚轮事件模式,计算平均增量、速度和加速度
let sumDelta = 0;
let count = 0;
let earliestTime = now;
let latestTime = 0;
let point = {
x: lastOpt.e.offsetX,
y: lastOpt.e.offsetY,
};
// 判断是否在事件收集期间有平移或缩放动画
let hadPanAnimation = false;
let hadZoomAnimation = false;
// 计算平均增量和速度
this._wheelEvents.forEach((event) => {
sumDelta += event.delta;
count++;
earliestTime = Math.min(earliestTime, event.time);
latestTime = Math.max(latestTime, event.time);
// 使用最后记录的点作为缩放中心
if (event.time > latestTime) {
point = event.point;
}
// 检查是否有动画状态
if (event.hasPanAnimation) hadPanAnimation = true;
if (event.hasZoomAnimation) hadZoomAnimation = true;
});
// 计算平均增量
const avgDelta = sumDelta / count;
// 计算滚动速度 - 基于事件频率和时间跨度
const timeSpan = latestTime - earliestTime + 1; // 避免除以零
const eventsPerSecond = (count / timeSpan) * 1000;
// 速度系数: 速度越快,缩放越敏感
let speedFactor = Math.min(3, Math.max(0.5, eventsPerSecond / 10));
// 计算缩放因子,应用速度系数
// 针对Mac设备优化Mac触控板的deltaY值通常较小需要适度增加敏感度
let zoomFactorBase = 0.999;
if (this._isMac) {
// Mac设备的触控板需要适度的敏感度避免过度反应
zoomFactorBase = 0.995; // 适度降低基数,增加缩放敏感度
// 检测是否为触控板滚动(小幅度、高频次的特征)
const avgAbsDelta = Math.abs(avgDelta);
if (avgAbsDelta < 50 && count > 2) {
// 触控板滚动,适度增加敏感度
speedFactor *= 1.6; // 适度增加敏感度倍数
zoomFactorBase = 0.993; // 进一步调整基数
}
}
const zoomFactor = zoomFactorBase ** (avgDelta * speedFactor);
let targetZoom = currentZoom * zoomFactor;
// 限制缩放范围
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
// 根据滚动速度和缩放幅度计算动画持续时间
// 速度快时缩短动画时间,缩放幅度大时延长动画时间
const zoomRatio = Math.abs(targetZoom - currentZoom) / currentZoom;
let duration;
if (this._isMac) {
// Mac设备使用平衡的动画时间控制
if (speedFactor > 2) {
// 快速操作:快速但平滑
duration = Math.min(0.18, Math.max(0.08, (zoomRatio * 0.3) / Math.sqrt(speedFactor)));
} else if (speedFactor > 1.2) {
// 中等速度:标准响应
duration = Math.min(0.25, Math.max(0.1, (zoomRatio * 0.4) / Math.sqrt(speedFactor)));
} else {
// 慢速精确操作:确保平滑
duration = Math.min(0.3, Math.max(0.12, (zoomRatio * 0.5) / Math.sqrt(speedFactor)));
}
} else {
duration = Math.min(0.5, Math.max(0.15, (zoomRatio * 0.8) / Math.sqrt(speedFactor)));
}
// 根据滚动速度选择不同的缓动效果
let easeType;
if (this._isMac) {
// Mac设备使用更简单、性能更好的缓动函数
// 避免复杂的指数和回弹效果,减少计算量
if (speedFactor > 2) {
// 快速滚动:使用简单的缓出效果
easeType = "power2.out";
} else if (speedFactor > 1.2) {
// 中等速度:使用平滑的缓出
easeType = "power1.out";
} else {
// 慢速精确操作:使用线性过渡
easeType = "power1.out";
}
} else {
// 非Mac设备保持原有的缓动
easeType = speedFactor > 1.5 ? "power1.out" : "power2.out";
}
// 根据是否有其他动画正在进行,选择合适的动画方法
if (hadPanAnimation || this._wasPanning) {
// 如果有平移动画,使用组合动画以保持平滑过渡
this.animateCombinedTransform(point, targetZoom, {
duration: duration,
ease: easeType,
});
} else {
// 如果没有其他动画,使用标准缩放动画
this.animateZoom(point, targetZoom, {
duration: duration,
ease: easeType,
});
}
// 更新Mac设备的最后动画时间
if (this._isMac) {
this._lastMacAnimationTime = now;
}
// 清理事件记录
this._wheelEvents = [];
}
/**
* 计算并应用拖动结束后的惯性效果
* @param {Array} positions 拖动过程中记录的位置数组
* @param {Boolean} isTouchDevice 是否是触摸设备
*/
applyInertiaEffect(positions, isTouchDevice) {
if (!positions || positions.length <= 1) return;
const lastPos = positions[positions.length - 1];
const firstPos = positions[0];
const deltaTime = lastPos.time - firstPos.time;
if (deltaTime <= 0) return;
// 计算速度向量 (像素/毫秒)
const velocityX = (lastPos.x - firstPos.x) / deltaTime;
const velocityY = (lastPos.y - firstPos.y) / deltaTime;
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
// 仅当速度足够大时应用惯性效果
if (speed > 0.2) {
// 计算惯性距离,基于速度和衰减因子
const decayFactor = 300; // 调整此值以改变惯性效果的强度
const inertiaDistanceX = velocityX * decayFactor;
const inertiaDistanceY = velocityY * decayFactor;
// 计算目标位置
const vpt = this.canvas.viewportTransform;
const currentPos = {
x: -vpt[4],
y: -vpt[5],
};
const targetPos = {
x: currentPos.x - inertiaDistanceX,
y: currentPos.y - inertiaDistanceY,
};
// 应用惯性动画,速度越大,动画时间越长
const animationDuration = Math.min(1.2, Math.max(0.6, speed * 2));
// 应用惯性动画
this.animatePan(targetPos, {
duration: animationDuration, // 动态计算持续时间
ease: this._isMac ? "quart.out" : "power3.out", // Mac使用更自然的减速效果
});
}
}
/**
* 平滑过渡停止所有动画
* 用于在需要中断当前动画时提供更自然的过渡,而不是硬性中断
* @param {Object} options 过渡选项
*/
smoothStopAnimations(options = {}) {
const duration = options.duration || 0.15; // 默认短暂过渡时间
// 处理缩放动画
if (this._zoomAnimation) {
const zoomObj = this._zoomAnimation.targets()[0];
const currentZoom = this.canvas.getZoom();
// 创建短暂的过渡动画到当前值
gsap.to(zoomObj, {
value: currentZoom,
duration: duration,
ease: this._isMac ? "circ.out" : "power1.out", // Mac使用更平滑的缓动
onUpdate: () => {
this.currentZoom.value = Math.round(zoomObj.value * 100);
this.canvas.renderAll();
},
onComplete: () => {
if (this._zoomAnimation) {
this._zoomAnimation.kill();
this._zoomAnimation = null;
}
},
});
}
// 处理平移动画
if (this._panAnimation) {
const panObj = this._panAnimation.targets()[0];
const vpt = this.canvas.viewportTransform;
const currentPos = { x: -vpt[4], y: -vpt[5] };
// 创建短暂的过渡动画到当前位置
gsap.to(panObj, {
x: currentPos.x,
y: currentPos.y,
duration: duration,
ease: this._isMac ? "circ.out" : "power1.out", // Mac使用更平滑的缓动
onUpdate: () => {
this._applyPan(panObj.x, panObj.y);
},
onComplete: () => {
if (this._panAnimation) {
this._panAnimation.kill();
this._panAnimation = null;
}
},
});
}
}
/**
* 设置画布交互动画
* 为对象交互添加流畅的动画效果
*/
setupInteractionAnimations() {
if (!this.canvas) return;
// 启用对象旋转的流畅动画
this._setupRotationAnimation();
}
/**
* 设置旋转动画
* @private
*/
_setupRotationAnimation() {
if (!fabric) return;
// 保存原始旋转方法
const originalRotate = fabric.Object.prototype.rotate;
const isMac = this._isMac; // 保存Mac检测结果
// 覆盖旋转方法以添加动画
fabric.Object.prototype.rotate = function (angle) {
const currentAngle = this.angle || 0;
if (Math.abs(angle - currentAngle) > 0.1) {
gsap.to(this, {
angle: angle,
duration: 0.3,
ease: isMac ? "back.out(0.3)" : "power2.out", // Mac使用轻微回弹
onUpdate: () => {
this.canvas && this.canvas.renderAll();
},
});
return this;
}
// 如果角度差异很小,使用原始方法
return originalRotate.call(this, angle);
};
}
/**
* 处理滚轮缩放,同时兼容正在进行的平移动画
* @param {Object} point 缩放中心点
* @param {Number} targetZoom 目标缩放值
* @param {Object} options 动画选项
*/
animateCombinedTransform(point, targetZoom, options = {}) {
if (!this.canvas) return;
// 限制缩放范围
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
// 当前状态
const currentZoom = this.canvas.getZoom();
const currentVpt = [...this.canvas.viewportTransform];
const currentPos = { x: -currentVpt[4], y: -currentVpt[5] };
// 如果有正在进行的动画,先停止它们
if (this._combinedAnimation) {
this._combinedAnimation.kill();
this._combinedAnimation = null;
}
if (this._zoomAnimation) {
this._zoomAnimation.kill();
this._zoomAnimation = null;
}
if (this._panAnimation) {
this._panAnimation.kill();
this._panAnimation = null;
}
// 创建一个统一的变换对象来动画
const transform = {
zoom: currentZoom,
panX: currentVpt[4],
panY: currentVpt[5],
progress: 0, // 用于动画进度跟踪
};
// 获取平移目标位置(如果有的话)
let panTarget = { x: currentPos.x, y: currentPos.y };
if (this._wasPanning) {
// 如果之前有平移动画,尝试获取平移的目标位置
const vpt = this.canvas.viewportTransform;
panTarget = {
x: currentPos.x,
y: currentPos.y,
};
}
// 计算新的变换矩阵,同时考虑平移和缩放
const scaleFactor = targetZoom / currentZoom;
// 创建动画
this._combinedAnimation = gsap.to(transform, {
zoom: targetZoom,
progress: 1,
duration: options.duration || 0.3,
ease: options.ease || (this._isMac ? "expo.out" : "power2.out"), // Mac使用更平滑的缓动
onUpdate: () => {
// 计算当前动画阶段的混合变换
const currentScaleFactor = transform.zoom / currentZoom;
// 应用缩放
const vpt = this.canvas.viewportTransform;
vpt[0] = currentVpt[0] * (transform.zoom / currentZoom);
vpt[3] = currentVpt[3] * (transform.zoom / currentZoom);
// 平滑混合平移和缩放调整
const adjustX = (1 - currentScaleFactor) * point.x;
const adjustY = (1 - currentScaleFactor) * point.y;
// 如果存在平移目标,进行插值
if (this._wasPanning) {
const t = transform.progress;
const interpolatedX = currentPos.x * (1 - t) + panTarget.x * t;
const interpolatedY = currentPos.y * (1 - t) + panTarget.y * t;
// 结合缩放和平移的调整
vpt[4] = -interpolatedX * currentScaleFactor + adjustX;
vpt[5] = -interpolatedY * currentScaleFactor + adjustY;
} else {
// 只有缩放,保持中心点
vpt[4] = currentVpt[4] * currentScaleFactor + adjustX;
vpt[5] = currentVpt[5] * currentScaleFactor + adjustY;
}
// 更新缩放值显示
this.currentZoom.value = Math.round(transform.zoom * 100);
this.canvas.renderAll();
},
onComplete: () => {
this._combinedAnimation = null;
this._zoomAnimation = null;
this._panAnimation = null;
this._wasPanning = false;
this._wasZooming = false;
// 确保最终状态准确
this._applyZoom(point, targetZoom, true);
},
});
}
/**
* 清理资源
*/
dispose() {
if (this._zoomAnimation) {
this._zoomAnimation.kill();
this._zoomAnimation = null;
}
if (this._panAnimation) {
this._panAnimation.kill();
this._panAnimation = null;
}
this._wheelEvents = [];
this.canvas = null;
this.currentZoom = null;
}
}

View File

@@ -0,0 +1,104 @@
import { fabric } from 'fabric-with-all'
import { ref } from 'vue'
import { createCanvas } from '../tools/canvasFactory'
import { AnimationManager } from './animationManager'
import { detectDeviceType } from '../tools/index'
import { CanvasEventManager } from "./events/CanvasEventManager";
import { OperationType } from '../tools/layerHelper'
interface CanvasInitOptions {
canvasRef: any
canvasViewWidth?: number
canvasViewHeight?: number
canvasWidth?: number
canvasHeight?: number
}
export class CanvasManager {
stateManager: any
deviceInfo: any
canvas: any
canvasViewWidth: number
canvasViewHeight: number
canvasWidth: number
canvasHeight: number
currentZoom: any
animationManager: any
eventManager: any
constructor(options) {
this.stateManager = options.stateManager;
this.deviceInfo = detectDeviceType();
this.currentZoom = ref(100)
}
setCanvasViewSize(options) {
this.canvasViewWidth = options.canvasViewWidth || 1920
this.canvasViewHeight = options.canvasViewHeight || 1080
}
initCanvas(options: CanvasInitOptions) {
this.setCanvasViewSize(options)
this.canvasWidth = options.canvasWidth || 750
this.canvasHeight = options.canvasHeight || 600
this.canvas = createCanvas(options.canvasRef.value, {
width: this.canvasViewWidth,
height: this.canvasViewHeight,
preserveObjectStacking: true,
enableRetinaScaling: true,
stopContextMenu: true,
fireRightClick: true,
backgroundColor: '#fff',
})
this.canvas.clipPath = new fabric.Rect({
left: 0,
top: 0,
width: this.canvasWidth,
height: this.canvasHeight
})
// 画布居中
const canvasX = this.canvasViewWidth / 2 - this.canvasWidth / 2
const canvasY = this.canvasViewHeight / 2 - this.canvasHeight / 2
this.canvas.viewportTransform = [1, 0, 0, 1, canvasX, canvasY]
// 创建矩形
const rect = new fabric.Rect({
left: 20,
top: 20,
width: 100,
height: 100,
fill: '#f00'
})
this.canvas.add(rect)
//创建圆形
const circle = new fabric.Circle({
left: 200,
top: 200,
radius: 50,
fill: '#0f0'
})
this.canvas.add(circle)
this.animationManager = new AnimationManager(this.canvas, {
currentZoom: this.currentZoom,
canvasManager: this,
wheelThrottleTime: 15, // 降低滚轮事件节流时间,提高响应性
defaultEase: "power2.lin",
defaultDuration: 0.3, // 缩短默认动画时间
});
this.setupCanvasEvents()
this.stateManager.toolManager.setTool(OperationType.PAN)
}
setupCanvasEvents() {
// 创建画布事件管理器
this.eventManager = new CanvasEventManager(this.canvas, {
canvasManager: this,
animationManager: this.animationManager,
toolManager: this.stateManager.toolManager,
});
// 设置动画交互效果
this.animationManager.setupInteractionAnimations();
}
resetZoom() {
this.animationManager.resetZoom()
}
}

View File

@@ -1,4 +1,4 @@
import { TOOLS } from "./ToolManager"
import { OperationType } from "../tools/layerHelper"
export class EventManager {
stateManager: any
vueFlow: any
@@ -30,14 +30,14 @@ export class EventManager {
handleClick(event: any) {
this.stateManager.setActiveNodeID("")
const tool = this.stateManager.tool.value
if (tool === TOOLS.TEXT) {
if (tool === OperationType.TEXT) {
const { x, y, zoom } = this.vueFlow.value.viewport
const position = {
x: (event.offsetX - x) / zoom,
y: (event.offsetY - y) / zoom
}
this.stateManager.nodeManager.createTextNode({ position })
this.stateManager.toolManager.setTool(TOOLS.SELECT)
this.stateManager.toolManager.setTool(OperationType.SELECT)
}
}

View File

@@ -1,26 +0,0 @@
export class FlowManager {
stateManager: any
vueFlow: any
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
}
setZoom(zoom: number) {
this.stateManager.zoom.value = zoom
this.vueFlow.value.zoomTo(zoom)
}
getNodeById(id: string) {
return this.vueFlow.value.getNode(id)
}
getLastNode() {
const lastNode = this.stateManager.getLastNode()
if (lastNode?.id) {
return this.vueFlow.value.getNode(lastNode.id)
}
return null;
}
getSubordNodeByID(id: string) {
return this.vueFlow.value.getNodes?.find((v) => v.data.superiorID === id)
}
}

View File

@@ -1,133 +0,0 @@
import { createId } from '../../tools/tools'
import { NODE_DATATYPE, NODE_COMPONENT, NODE_DATATIER } from '../tools/index.d'
interface NodeData {
type?: string
component?: any// 节点组件
data?: object// 节点数据
tier?: string// 节点层级
isHeader?: boolean// 是否显示头
superiorID?: string// 上级节点ID
disableDelete?: boolean// 是否禁用删除
disableCopy?: boolean// 是否禁用复制
}
interface NodeOptions {
id?: string
position?: { x: number, y: number }
positionX?: number
positionY?: number
component?: any
data?: NodeData
}// 不可传入type class (内部使用)
export class NodeManager {
stateManager: any
vueFlow: any
nodesep = 100 // 节点间距
ranksep = 100 // 层级间距
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
}
/** 删除节点 */
deleteNode(id: string) {
this.stateManager.deleteNode(id)
}
/** 添加节点 */
addNode(node: any) {
this.stateManager.addNode(node)
}
/** 创建节点 */
createNode(options: NodeOptions) {
const superiorID = options?.data?.superiorID
const snode = superiorID ? this.stateManager.flowManager.getNodeById(superiorID) : this.stateManager.flowManager.getLastNode();
const id = options.id || createId()
const positionX = options.positionX || 0
const positionY = options.positionY || 0
const position = options.position ||
(!snode ?
{ x: positionX, y: positionY } :
{
x: snode.position.x + snode.dimensions.width + this.nodesep + positionX,
y: snode.position.y + positionY
})
const data = options?.data || {}
data['component'] = options.component
const options_ = {
id,
position,
data
}
this.addNode(options_)
return options_;
}
/** 创建结果节点 */
createResultNode(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.RESULT_IMAGE,
data: {
tier: NODE_DATATIER.RESULT_IMAGE,
type: NODE_DATATYPE.RESULT_IMAGE,
isHeader: true,
...(options?.data || {}),
},
}
return this.createNode(options_)
}
/** 创建卡片选择节点 */
createCardsSelect(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.CARD,
positionY: 50,
data: {
tier: NODE_DATATIER.CARDS_SELECT,
type: NODE_DATATYPE.CARDS_SELECT,
...(options?.data || {}),
},
}
return this.createNode(options_)
}
/** 创建卡片节点 */
createCardNode(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.CARD,
data: {
...(options?.data || {}),
}
}
return this.createNode(options_)
}
/** 创建文本节点 */
createTextNode(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.TEXT,
data: {
...(options?.data || {}),
}
}
return this.createNode(options_)
}
copyNodeById(id: string) {
const node = this.stateManager.getNodeById(id)
const flowNode = this.stateManager.flowManager.getNodeById(id)
if (!node) return console.warn(`${id}找不到对应节点`)
if (node.data?.disableCopy) return console.warn(`${id}节点已禁用复制`)
const node_ = {
...JSON.parse(JSON.stringify(node)),
id: createId(),
position: {
x: node.position.x,
y: node.position.y + (flowNode?.dimensions?.height || 0) + this.ranksep,
}
}
delete node_.data?.superiorID
delete node_.data?.disableDelete
this.stateManager.addNode(node_)
}
}

View File

@@ -4,27 +4,8 @@ import { ElMessageBox } from 'element-plus'
import i18n from '@/lang'
const t = i18n.global.t
export interface NodesItem {
id: string
type: string
class: string
position: { x: number, y: number }
data: { component: any, type: string, superiorID?: string }
}
export class StateManager {
vueFlow: any
activeNodeID: any
nodes: any
nodes_: any
edges: any
zoom: any
tool: any
cursor: any
// 节点是否可拖动
nodesDraggable: any
// 拖动时是否可以平移画布
panOnDrag: any
export class StateManager {
// 历史记录-撤回/重做
mxHistory: any
historyList: any
@@ -32,155 +13,56 @@ export class StateManager {
// 管理器
eventManager: any
flowManager: any
nodeManager: any
canvasManager: any
toolManager: any
// 设置管理器
setManager(options) {
options.eventManager && (this.eventManager = options.eventManager)
options.flowManager && (this.flowManager = options.flowManager)
options.nodeManager && (this.nodeManager = options.nodeManager)
options.canvasManager && (this.canvasManager = options.canvasManager)
options.toolManager && (this.toolManager = options.toolManager)
}
constructor(options) {
this.vueFlow = options.vueFlow
this.zoom = ref(1)
this.tool = ref("")
this.cursor = ref("")
this.nodesDraggable = ref(false)
this.panOnDrag = ref(false)
this.mxHistory = ref(50)
this.historyList = ref([])
this.historyIndex = ref(0)
this.activeNodeID = ref("")
this.nodes = ref<NodesItem[]>([]);
this.nodes_ = computed(() => {
return this.nodes.value.map((node, index) => {
const obj = node;
const superiorID = node.data.superiorID;
const isSuperior = this.nodes.value.some((v) => v.id === superiorID)
const isSubord = this.nodes.value.some((v) => v.data.superiorID === node.id)
if (!isSuperior && isSubord) {// 没有上级 有下级
obj.type = NODE_TYPE.INPUT;
} else if (isSuperior && isSubord) {// 有上级 有下级
obj.type = NODE_TYPE.SECONDARY;
} else if (isSuperior && !isSubord) {// 有上级 没有下级
obj.type = NODE_TYPE.OUTPUT;
} else {// 其他情况-没有上级 没有下级
obj.type = NODE_TYPE.ALONE;
}
return obj
})
})
this.edges = computed(() => {
const arr = []
this.nodes.value.forEach((node, index) => {
const superiorID = node.data.superiorID;
const isSuperior = this.nodes.value.some((v) => v.id === superiorID)
if (superiorID && isSuperior) {
const source = node.data.superiorID
const target = node.id
arr.push({
id: `el-${source}-${target}`,
source: source,
target: target,
selectable: false,
type: 'default'
})
}
})
return arr
})
}
/** 设置激活节点 */
setActiveNodeID(id: string) { this.activeNodeID.value = id }
/** 添加节点 */
addNode(node: NodesItem) {
this.nodes.value.push(node);
this.recordState()
}
/** 删除节点 */
async deleteNode(id: string, { isElMessageBox } = { isElMessageBox: false }) {
const node = this.getNodeById(id)
if (!node) return console.warn(`没有找到指定id:${id}`)
if (node.data.disableDelete) return console.warn('该节点禁用删除')
let deletePromise: any = true
if (isElMessageBox) {
deletePromise = await new Promise<void>((resolve, reject) => {
ElMessageBox.confirm(
t('flowCanvas.deleteCardConfirm'),
'',
{
confirmButtonText: t('flowCanvas.confirm'),
cancelButtonText: t('flowCanvas.cancel'),
}
).then(() => {
resolve(true)
}).catch(() => {
resolve(false)
})
})
}
if (!deletePromise) return console.log('删除操作被取消')
this.nodes.value = this.nodes.value.filter((node: NodesItem) => node.id !== id)
this.recordState()
}
/** 获取节点 */
getNodeById(id: string) { return this.nodes.value.find((node: NodesItem) => node.id === id) }
/** 获取下级节点 */
getSubordNodeByID(id: string) { return this.nodes.value.find((node: NodesItem) => node.data.superiorID === id) }
getLastNode() { return this.nodes.value[this.nodes.value.length - 1] }
/** 设置工具 */
setTool(tool: string) { this.tool.value = tool }
/** 设置光标 */
setCursor(v: string) { this.cursor.value = v }
/** 设置节点是否可拖动 */
setNodesDraggable(v: boolean) { this.nodesDraggable.value = v }
/** 设置是否可以平移画布 */
setPanOnDrag(v: boolean) { this.panOnDrag.value = v }
/** 设置节点层级至最顶部 */
bringToFont(id) {
const fromIndex = this.nodes.value.findIndex(item => item.id === id)
if (fromIndex === -1) return console.warn(`没有找到指定id:${id}`)
this.nodes.value.splice(this.nodes.value.length - 1, 0, ...this.nodes.value.splice(fromIndex, 1))
}
/** 设置节点层级至最低部 */
sendToBack(id) {
const fromIndex = this.nodes.value.findIndex(item => item.id === id)
if (fromIndex === -1) return console.warn(`没有找到指定id:${id}`)
this.nodes.value.splice(0, 0, ...this.nodes.value.splice(fromIndex, 1))
}
/** 记录状态 */
recordState() {
if (this.historyIndex.value < this.historyList.value.length - 1) {
this.historyList.value.splice(this.historyIndex.value + 1)
}
const state = {
nodes: JSON.stringify(this.nodes.value)
}
this.historyList.value.push(state)
const size = this.historyList.value.length - this.mxHistory.value
if (size > 0) this.historyList.value.splice(0, size)
this.historyIndex.value = this.historyList.value.length - 1
// if (this.historyIndex.value < this.historyList.value.length - 1) {
// this.historyList.value.splice(this.historyIndex.value + 1)
// }
// const state = {
// nodes: JSON.stringify(this.nodes.value)
// }
// this.historyList.value.push(state)
// const size = this.historyList.value.length - this.mxHistory.value
// if (size > 0) this.historyList.value.splice(0, size)
// this.historyIndex.value = this.historyList.value.length - 1
}
/** 撤回状态 */
undoState() {
var index = this.historyIndex.value - 1
const state = this.historyList.value[index]
if (!state) return
this.historyIndex.value = index
this.nodes.value = JSON.parse(state.nodes)
// var index = this.historyIndex.value - 1
// const state = this.historyList.value[index]
// if (!state) return
// this.historyIndex.value = index
// this.nodes.value = JSON.parse(state.nodes)
}
/** 重做状态 */
redoState() {
var index = this.historyIndex.value + 1
const state = this.historyList.value[index]
if (!state) return
this.historyIndex.value = index
this.nodes.value = JSON.parse(state.nodes)
// var index = this.historyIndex.value + 1
// const state = this.historyList.value[index]
// if (!state) return
// this.historyIndex.value = index
// this.nodes.value = JSON.parse(state.nodes)
}
}

View File

@@ -1,52 +1,69 @@
export const TOOLS = {
SELECT: "SELECT",
MOVE: "MOVE",
BRUSH: "BRUSH",
ERASER: "ERASER",
IMAGE: "IMAGE",
SELECTBOX: "SELECTBOX",
RECTANGLE: "RECTANGLE",
TEXT: "TEXT",
UNDO: "UNDO",
REDO: "REDO",
}
export const tools = [
/** 选择工具 */
{
name: TOOLS.SELECT,
nodesDraggable: true,
panOnDrag: false,
},
/** 移动工具 */
{
name: TOOLS.MOVE,
nodesDraggable: false,
panOnDrag: true,
},
/** 文本工具 */
{
name: TOOLS.TEXT,
cursor: "text",
nodesDraggable: false,
panOnDrag: false,
},
]
import { ref } from 'vue'
import { OperationType } from '../tools/layerHelper'
export class ToolManager {
stateManager: any
vueFlow: any
canvasManager: any
currentTool: any
tools: any[]
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
this.setTool(TOOLS.SELECT)
this.canvasManager = options.canvasManager;
this.currentTool = ref(null)
this.tools = [
/** 选择工具 */
{
name: OperationType.SELECT,
cursor: "default",
setup: this.setupSelectTool.bind(this),
selection: true,
},
/** 移动工具 */
{
name: OperationType.PAN,
cursor: "grab",
setup: this.setupMoveTool.bind(this),
},
/** 画笔工具 */
{
name: OperationType.DRAW,
cursor: "crosshair",
},
/** 橡皮擦工具 */
{
name: OperationType.ERASER,
cursor: "crosshair",
},
/** 智能选框工具 */
{
name: OperationType.SELECTBOX,
cursor: "crosshair",
},
/** 矩形工具 */
{
name: OperationType.RECTANGLE,
cursor: "crosshair",
},
]
}
setTool(value: string) {
const tool = tools.find((t) => t.name === value)
const tool = this.tools.find((t) => t.name === value)
if (!tool) return console.warn(`工具${tool}不存在`)
this.stateManager.tool.value = tool.name
this.stateManager.setNodesDraggable(!!tool.nodesDraggable)
this.stateManager.setPanOnDrag(!!tool.panOnDrag)
this.stateManager.setCursor(tool.cursor || "")
}
this.currentTool.value = tool.name
this.canvasManager.canvas.defaultCursor = tool.cursor
this.setCanvasEvented(!!tool.selection)
this.canvasManager.canvas.isDragging = !!tool.isDragging
if (tool.setup) tool.setup()
}
// 切换工具时,设置画布事件
setCanvasEvented(value: boolean) {
this.canvasManager.canvas.selection = value
this.canvasManager.canvas.getObjects().forEach((v) => v.evented = value)
}
/** 选择工具 */
setupSelectTool() {
}
/** 移动工具 */
setupMoveTool() {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,762 @@
/**
* 键盘管理器
* 负责处理编辑器中的键盘事件和快捷键
* 支持PC、Mac和iPad三端适配
*/
export class KeyboardManager {
/**
* 创建键盘管理器
* @param {Object} options 配置选项
* @param {Object} options.toolManager 工具管理器实例
* @param {Object} options.commandManager 命令管理器实例
* @param {Object} options.layerManager 图层管理器实例
* @param {Object} options.canvasManager 画布管理器实例
* @param {Function} options.pasteText 粘贴文本回调函数
* @param {Function} options.pasteImage 粘贴图片回调函数
* @param {Ref<Boolean>} options.isRedGreenMode 是否为红绿模式
* @param {HTMLElement} options.container 容器元素,用于添加事件监听
*/
constructor(options = {}) {
this.toolManager = options.toolManager;
this.commandManager = options.commandManager;
this.layerManager = options.layerManager;
this.canvasManager = options.canvasManager;
this.container = options.container || document;
this.pasteText = options.pasteText || (() => { });
this.pasteImage = options.pasteImage || (() => { });
this.isRedGreenMode = options.isRedGreenMode;
// 检测平台类型
this.platform = this.detectPlatform();
this.isTouchDevice = this.detectTouchDevice();
// 快捷键的平台特定键名
this.modifierKeys = {
ctrl: this.platform === "mac" ? "meta" : "ctrl",
cmdOrCtrl: this.platform === "mac" ? "meta" : "ctrl",
alt: "alt",
shift: "shift",
option: "alt", // Mac 特有,等同于 alt
cmd: "meta", // Mac 特有,等同于 Command
};
// 快捷键显示的平台特定符号
this.keySymbols = {
ctrl: this.platform === "mac" ? "⌃" : "Ctrl",
meta: this.platform === "mac" ? "⌘" : "Win",
alt: this.platform === "mac" ? "⌥" : "Alt",
shift: this.platform === "mac" ? "⇧" : "Shift",
escape: "Esc",
space: "空格",
};
// 快捷键映射表 - 可通过配置进行扩展
this.shortcuts = this.initShortcuts();
// 触摸相关状态
this.touchState = {
pinchStartDistance: 0,
pinchStartBrushSize: 0,
touchStartX: 0,
touchStartY: 0,
isTwoFingerTouch: false,
};
// 临时工具状态
this.tempToolState = {
active: false,
originalTool: null,
};
// 事件绑定
this._handleKeyDown = this.handleKeyDown.bind(this);
this._handleKeyUp = this.handleKeyUp.bind(this);
this._handlePaste = this.handlePaste.bind(this);
this._handleTouchStart = this.handleTouchStart.bind(this);
this._handleTouchMove = this.handleTouchMove.bind(this);
this._handleTouchEnd = this.handleTouchEnd.bind(this);
// 已注册的自定义事件处理程序
this.customHandlers = {};
}
/**
* 检测当前平台
* @returns {'mac'|'windows'|'ios'|'android'|'other'} 平台类型
*/
detectPlatform() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.indexOf("mac") !== -1) return "mac";
if (userAgent.indexOf("win") !== -1) return "windows";
if (/(iphone|ipad|ipod)/.test(userAgent)) return "ios";
if (userAgent.indexOf("android") !== -1) return "android";
return "other";
}
/**
* 检测是否为触摸设备
* @returns {boolean} 是否为触摸设备
*/
detectTouchDevice() {
return (
"ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
);
}
/**
* 初始化快捷键配置
* @returns {Object} 快捷键配置
*/
initShortcuts() {
const cmdOrCtrl = this.modifierKeys.cmdOrCtrl;
// 基本快捷键映射,将在构建时根据平台类型自动调整
return {
// 撤销/重做
[`${cmdOrCtrl}+z`]: { action: "undo", description: "撤销" },
[`${cmdOrCtrl}+shift+z`]: { action: "redo", description: "重做" },
[`${cmdOrCtrl}+y`]: { action: "redo", description: "重做" },
// 复制/粘贴
[`${cmdOrCtrl}+c`]: { action: "copy", description: "复制" },
[`${cmdOrCtrl}+v`]: { action: "paste", description: "粘贴", noStop: true },
[`${cmdOrCtrl}+x`]: { action: "cut", description: "剪切" },
// 删除
delete: { action: "delete", description: "删除" },
backspace: { action: "delete", description: "删除" },
up: { action: "up", description: "上" },
down: { action: "down", description: "下" },
left: { action: "left", description: "左" },
right: { action: "right", description: "右" },
// 选择
[`${cmdOrCtrl}+a`]: { action: "selectAll", description: "全选" },
escape: { action: "clearSelection", description: "取消选择" },
// 保存
[`${cmdOrCtrl}+s`]: { action: "save", description: "保存" },
// 工具切换 (这些会由工具管理器处理)
v: { action: "selectTool", param: "select", description: "选择工具" },
b: { action: "selectTool", param: "draw", description: "画笔工具" },
e: { action: "selectTool", param: "eraser", description: "橡皮擦" },
i: { action: "selectTool", param: "eyedropper", description: "吸色工具" },
h: { action: "selectTool", param: "pan", description: "移动画布" },
l: { action: "selectTool", param: "lasso", description: "套索工具" },
m: {
action: "selectTool",
param: "area_custom",
description: "自由选区工具",
},
w: { action: "selectTool", param: "wave", description: "波浪工具" },
j: { action: "selectTool", param: "liquify", description: "液化工具" },
// 数值调整
"shift+[": {
action: "decreaseTextureScale",
description: "减小材质图片大小",
},
"shift+]": {
action: "increaseTextureScale",
description: "增大材质图片大小",
},
"[": { action: "decreaseBrushSize", param: 1, description: "减小画笔" },
"]": { action: "increaseBrushSize", param: 1, description: "增大画笔" },
",": {
action: "decreaseBrushOpacity",
param: 0.01,
description: "减小透明度",
},
".": {
action: "increaseBrushOpacity",
param: 0.01,
description: "增大透明度",
},
// 空格 - 临时切换到手型工具
space: {
action: "toggleTempTool",
param: "pan",
description: "临时切换到手形工具",
},
// 图层操作
[`${cmdOrCtrl}+shift+n`]: { action: "newLayer", description: "新建图层" },
[`${cmdOrCtrl}+g`]: { action: "groupLayers", description: "组合图层" },
[`${cmdOrCtrl}+o`]: {
action: "addImageToNewLayer",
description: "上传图片到新图层",
},
[`${cmdOrCtrl}+shift+g`]: {
action: "ungroupLayers",
description: "取消组合",
},
[`${cmdOrCtrl}+j`]: { action: "mergeLayers", description: "合并图层" },
// iPad特有的快捷键(当无法使用键盘时)
...(this.platform === "ios" && {
two_finger_tap: {
action: "contextMenu",
description: "显示上下文菜单",
},
three_finger_swipe_left: { action: "undo", description: "撤销" },
three_finger_swipe_right: { action: "redo", description: "重做" },
}),
};
}
/**
* 处理粘贴事件
* @param {ClipboardEvent} event 粘贴事件
*/
handlePaste(event) {
event.preventDefault(); // 阻止默认粘贴行为
if (this.isRedGreenMode.value) return;
const text = event.clipboardData?.getData("text/plain") || "";
if (/^aida_copy_canvas_layer/.test(text)) return;
const items = event.clipboardData?.items || [];
// console.log(this);
for (const item of items) {
if (item.type.indexOf("text/plain") !== -1) {
item.getAsString((text) => {
this.pasteText(text);
});
} else if (item.type.indexOf("image") !== -1) {
const blob = item.getAsFile();
this.pasteImage(blob);
}
}
}
/**
* 初始化并开始监听键盘事件
*/
init() {
// 添加键盘事件监听
this.container.addEventListener("keydown", this._handleKeyDown);
this.container.addEventListener("keyup", this._handleKeyUp);
this.container.addEventListener("paste", this._handlePaste);
// 如果是触摸设备,添加触摸事件监听
if (this.isTouchDevice) {
this.container.addEventListener("touchstart", this._handleTouchStart);
this.container.addEventListener("touchmove", this._handleTouchMove);
this.container.addEventListener("touchend", this._handleTouchEnd);
this.container.addEventListener("touchcancel", this._handleTouchEnd);
}
// console.log(`键盘管理器已初始化,平台: ${this.platform}, 触摸设备: ${this.isTouchDevice}`);
}
/**
* hide模式下关闭快捷键
*/
removeEvents() {
// 移除键盘事件监听
this.container.removeEventListener("keydown", this._handleKeyDown);
this.container.removeEventListener("keyup", this._handleKeyUp);
this.container.removeEventListener("paste", this._handlePaste);
// 如果是触摸设备,移除触摸事件监听
if (this.isTouchDevice) {
this.container.removeEventListener("touchstart", this._handleTouchStart);
this.container.removeEventListener("touchmove", this._handleTouchMove);
this.container.removeEventListener("touchend", this._handleTouchEnd);
this.container.removeEventListener("touchcancel", this._handleTouchEnd);
}
}
/**
* 处理键盘按下事件
* @param {KeyboardEvent} event 键盘事件
*/
handleKeyDown(event) {
// 如果当前焦点在输入框内,不处理大部分快捷键
if (this.isInputActive() && !["Escape", "Tab"].includes(event.key)) {
return;
}
// 构建快捷键标识符
const shortcutKey = this.buildShortcutKey(event);
// 查找并执行快捷键动作
const shortcut = this.shortcuts[shortcutKey];
if (shortcut) {
// 阻止默认行为,例如浏览器的保存对话框等
if (shortcutKey.includes(`${this.modifierKeys.cmdOrCtrl}+`) && !shortcut.noStop) {
event.preventDefault();
}
this.executeAction(shortcut.action, shortcut.param, event);
return;
}
// 工具快捷键处理
if (this.toolManager && !event.ctrlKey && !event.metaKey && !event.altKey) {
this.toolManager.handleKeyboardShortcut(event);
}
}
/**
* 处理键盘释放事件
* @param {KeyboardEvent} event 键盘事件
*/
handleKeyUp(event) {
// 当空格键释放时,如果是临时工具,切回原始工具
if (event.key === " " && this.tempToolState.active) {
this.restoreTempTool();
}
// 调用自定义处理程序
const key = event.key.toLowerCase();
if (this.customHandlers[key] && typeof this.customHandlers[key].onKeyUp === "function") {
this.customHandlers[key].onKeyUp(event);
}
}
/**
* 处理触摸开始事件
* @param {TouchEvent} event 触摸事件
*/
handleTouchStart(event) {
const touches = event.touches;
// 存储初始状态以便后续计算
if (touches.length === 2) {
// 双指触摸 - 可用于缩放或调整画笔大小
this.touchState.isTwoFingerTouch = true;
this.touchState.pinchStartDistance = this.getDistanceBetweenTouches(touches[0], touches[1]);
// 如果有画笔管理器,记录起始画笔大小
if (this.toolManager && this.toolManager.brushManager) {
this.touchState.pinchStartBrushSize = this.toolManager.brushManager.brushSize.value;
}
} else if (touches.length === 3) {
// 三指触摸 - 可用于撤销/重做
this.touchState.touchStartX = touches[0].clientX;
}
}
/**
* 处理触摸移动事件
* @param {TouchEvent} event 触摸事件
*/
handleTouchMove(event) {
const touches = event.touches;
// 阻止默认行为(例如滚动)
if (touches.length >= 2) {
event.preventDefault();
}
// 双指缩放处理 - 调整画笔大小
if (touches.length === 2 && this.touchState.isTwoFingerTouch) {
const currentDistance = this.getDistanceBetweenTouches(touches[0], touches[1]);
const scale = currentDistance / this.touchState.pinchStartDistance;
// 调整画笔大小
if (this.toolManager && this.toolManager.brushManager && scale !== 1) {
const newSize = this.touchState.pinchStartBrushSize * scale;
this.toolManager.brushManager.setBrushSize(newSize);
}
}
// 三指滑动处理 - 撤销/重做
else if (touches.length === 3) {
const deltaX = touches[0].clientX - this.touchState.touchStartX;
// 滑动超过50px认为是有效的手势
if (Math.abs(deltaX) > 50) {
if (deltaX < 0) {
// 向左滑动 - 撤销
this.executeAction("undo");
} else {
// 向右滑动 - 重做
this.executeAction("redo");
}
// 更新起始位置,防止连续触发
this.touchState.touchStartX = touches[0].clientX;
}
}
}
/**
* 处理触摸结束事件
* @param {TouchEvent} event 触摸事件
*/
handleTouchEnd(event) {
// 检测双指轻拍 (两个手指几乎同时按下,又几乎同时抬起)
if (this.touchState.isTwoFingerTouch && event.touches.length === 0) {
if (new Date().getTime() - this.touchState.touchStartTime < 300) {
// 双指轻拍 - 可以触发上下文菜单
this.executeAction("contextMenu");
}
}
// 重置触摸状态
this.touchState.isTwoFingerTouch = false;
}
/**
* 计算两个触摸点之间的距离
* @param {Touch} touch1 第一个触摸点
* @param {Touch} touch2 第二个触摸点
* @returns {number} 两点间距离
*/
getDistanceBetweenTouches(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* 执行快捷键对应的动作
* @param {string} action 动作名称
* @param {*} param 动作参数
* @param {Event} event 原始事件
*/
executeAction(action, param, event) {
switch (action) {
case "undo":
if (this.commandManager) {
this.commandManager.undo();
}
break;
case "redo":
if (this.commandManager) {
this.commandManager.redo();
}
break;
case "copy":
// 复制逻辑
// console.log("复制当前选中图层");
if (this.isRedGreenMode.value) return;
this.layerManager.copyLayer(this.layerManager.activeLayerId.value);
break;
case "paste":
// 粘贴逻辑
// console.log("粘贴");
if (this.isRedGreenMode.value) return;
this.layerManager.pasteLayer();
break;
case "cut":
// 剪切逻辑
// console.log("剪切");
if (this.isRedGreenMode.value) return;
this.layerManager.cutLayer(this.layerManager.activeLayerId.value);
break;
case "delete":
// 删除逻辑
// console.log("删除");
if (this.isRedGreenMode.value) return;
this.layerManager.removeLayer(this.layerManager.activeLayerId.value);
break;
case "selectAll":
// 全选逻辑
// console.log("全选");
if (this.isRedGreenMode.value) return;
// 这里需要实现全选逻辑 TODO: 是否在选择模式下才可以全选?
if (this.layerManager) {
this.layerManager.selectAll();
}
break;
case "clearSelection":
// 清除选择逻辑
// console.log("清除选择");
// 这里需要实现清除选择逻辑
if (this.layerManager) {
this.layerManager.clearSelection();
}
break;
case "save":
// 保存逻辑
// console.log("保存");
break;
case "selectTool":
// 选择工具
if (this.toolManager && param) {
this.toolManager.setToolWithCommand(param);
}
break;
case "up":
case "down":
case "left":
case "right":
// 方向键逻辑
this.canvasManager.moveActiveObject(action);
break;
case "increaseBrushSize":
// 增大画笔尺寸
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 5;
this.toolManager.brushManager.increaseBrushSize(amount);
}
break;
case "decreaseBrushSize":
// 减小画笔尺寸
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 5;
this.toolManager.brushManager.decreaseBrushSize(amount);
}
break;
case "increaseBrushOpacity":
// 增大画笔透明度
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 0.01;
this.toolManager.brushManager.increaseBrushOpacity(amount);
}
break;
case "decreaseTextureScale":
// 减小画笔材质图片大小
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 5;
this.toolManager.brushManager.decreaseBrushSize(amount);
}
break;
case "increaseTextureScale":
// 增大画笔材质图片大小
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 0.01;
this.toolManager.brushManager.increaseTextureScale(amount);
}
break;
case "decreaseBrushOpacity":
// 减小画笔透明度
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 0.01;
this.toolManager.brushManager.decreaseBrushOpacity(amount);
}
break;
case "toggleTempTool":
// 临时切换工具
if (param && this.toolManager) {
this.setTempTool(param);
}
break;
case "newLayer":
// 创建新图层
if (this.layerManager) {
this.layerManager.createNewLayer();
}
break;
case "addImageToNewLayer":
this.toolManager?.openFile?.();
break;
case "groupLayers":
// 组合图层
if (this.layerManager) {
this.layerManager.groupSelectedLayers();
}
break;
case "ungroupLayers":
// 解组图层
if (this.layerManager) {
this.layerManager.ungroupSelectedLayer();
}
break;
case "mergeLayers":
// 合并图层
if (this.layerManager) {
this.layerManager.mergeSelectedLayers();
}
break;
case "contextMenu":
// 上下文菜单(通常由右击或触控设备上的特定手势触发)
// console.log("显示上下文菜单");
// 这里需要实现显示上下文菜单的逻辑
break;
default:
// 调用自定义注册的动作处理
if (this.customHandlers[action]) {
this.customHandlers[action].execute(param, event);
}
}
}
/**
* 设置临时工具
* @param {string} toolId 临时工具ID
*/
setTempTool(toolId) {
if (!this.toolManager || this.tempToolState.active) return;
// 保存当前工具
this.tempToolState.originalTool = this.toolManager.getCurrentTool();
this.tempToolState.active = true;
// 切换到临时工具
this.toolManager.setTool(toolId);
}
/**
* 恢复临时工具切换前的工具
*/
restoreTempTool() {
if (!this.toolManager || !this.tempToolState.active) return;
// 恢复到原始工具
if (this.tempToolState.originalTool) {
this.toolManager.setTool(this.tempToolState.originalTool);
}
// 重置状态
this.tempToolState.active = false;
this.tempToolState.originalTool = null;
}
/**
* 构建快捷键标识符
* @param {KeyboardEvent} event 键盘事件
* @returns {string} 快捷键标识符
*/
buildShortcutKey(event) {
let shortcutKey = "";
// 统一处理Mac和PC的修饰键
if ((this.platform === "mac" && event.metaKey) || (this.platform !== "mac" && event.ctrlKey)) {
shortcutKey += `${this.modifierKeys.cmdOrCtrl}+`;
} else if (event.ctrlKey) {
shortcutKey += "ctrl+";
}
if (event.shiftKey) shortcutKey += "shift+";
if (event.altKey) shortcutKey += "alt+";
const key = event.key.toLowerCase();
// 特殊键处理
switch (key) {
case " ":
shortcutKey += "space";
break;
case "arrowup":
shortcutKey += "up";
break;
case "arrowdown":
shortcutKey += "down";
break;
case "arrowleft":
shortcutKey += "left";
break;
case "arrowright":
shortcutKey += "right";
break;
default:
shortcutKey += key;
}
return shortcutKey;
}
/**
* 检查当前是否有输入框处于活动状态
* @returns {boolean} 是否有输入框处于活动状态
*/
isInputActive() {
const activeElement = document.activeElement;
const tagName = activeElement.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
activeElement.getAttribute("contenteditable") === "true"
);
}
/**
* 获取所有可用的快捷键
* @returns {Array} 快捷键列表
*/
getShortcuts() {
return Object.entries(this.shortcuts).map(([key, value]) => ({
key,
displayKey: this.formatShortcutForDisplay(key),
...value,
}));
}
/**
* 格式化快捷键以便显示
* @param {string} shortcut 快捷键标识符
* @returns {string} 格式化后的快捷键显示
*/
formatShortcutForDisplay(shortcut) {
// 将快捷键格式化为适合当前平台显示的形式
return shortcut
.split("+")
.map((key) => {
// 将键名转换为显示符号
return this.keySymbols[key.toLowerCase()] || key.toUpperCase();
})
.join("+");
}
/**
* 注册自定义快捷键处理程序
* @param {string} action 动作名称
* @param {Object} handler 处理程序对象
* @param {Function} handler.execute 执行函数
* @param {Function} handler.onKeyUp 键释放处理函数(可选)
* @param {string} description 描述
*/
registerCustomHandler(action, handler, description = "") {
if (!action || typeof handler.execute !== "function") {
console.error("无效的自定义处理程序");
return;
}
this.customHandlers[action] = handler;
// 如果提供了快捷键,添加到快捷键映射
if (handler.shortcut) {
this.shortcuts[handler.shortcut] = {
action,
description: description || handler.description || action,
};
}
}
/**
* 清理资源
*/
dispose() {
// 移除事件监听
this.removeEvents();
// 清除引用
this.toolManager = null;
this.commandManager = null;
this.layerManager = null;
this.container = null;
this.customHandlers = {};
this.tempToolState = { active: false, originalTool: null };
this.touchState = {};
}
}

View File

@@ -0,0 +1,34 @@
import { fabric } from "fabric-with-all";
/**
* Factory for creating optimized fabric canvas instances
*/
export const createCanvas = (elementId, options = {}) => {
// Create the canvas instance
const canvas = new fabric.Canvas(elementId, {
enableRetinaScaling: true,
renderOnAddRemove: false,
preserveObjectStacking: true, // 保持对象堆叠顺序
// skipOffscreen: true, // 跳过离屏渲染
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
imageSmoothingQuality: "high", // 设置高质量图像平滑
...options,
});
return canvas;
};
/**
* Utility to create a static canvas (for improved performance when interaction is not needed)
*/
export const createStaticCanvas = (elementId, options = {}) => {
const canvas = new fabric.StaticCanvas(elementId, {
enableRetinaScaling: true,
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
imageSmoothingQuality: "high", // 设置高质量图像平滑
skipOffscreen: false, // 不跳过离屏渲染
...options,
});
return canvas;
};

View File

@@ -0,0 +1,61 @@
/**
* 精确检测设备类型,区分 PC、Mac、平板和移动设备
* @private
* @returns {Object} 设备信息对象
*/
export const detectDeviceType = () => {
const userAgent = navigator.userAgent.toLowerCase();
const platform = navigator.platform.toLowerCase();
const hasTouchSupport =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
// 检测操作系统
const isMac = /mac|darwin/.test(platform) || /macintosh/.test(userAgent);
const isWindows = /win/.test(platform);
const isLinux = /linux/.test(platform) && !/android/.test(userAgent);
// 检测设备类型 - 修复iPad检测逻辑
const isMobile = /mobile|phone|android.*mobile|iphone/.test(userAgent);
// 修复iPad检测包括iOS iPad和Android平板
const isTablet =
/tablet|ipad|android(?!.*mobile)/.test(userAgent) ||
/ipad/.test(userAgent) ||
(navigator.maxTouchPoints &&
navigator.maxTouchPoints > 1 &&
/mac/.test(userAgent));
const isDesktop = !isMobile && !isTablet;
// 检测浏览器类型(用于特定优化)
const isSafari = /safari/.test(userAgent) && !/chrome/.test(userAgent);
const isChrome = /chrome/.test(userAgent);
const isFirefox = /firefox/.test(userAgent);
// 调试日志 - 仅在开发环境输出
if (process.env.NODE_ENV === "development") {
// console.log("设备检测结果:", {
// userAgent,
// platform,
// isMobile,
// isTablet,
// isDesktop,
// hasTouchSupport,
// maxTouchPoints: navigator.maxTouchPoints,
// });
}
return {
isMac,
isWindows,
isLinux,
isMobile,
isTablet,
isDesktop,
isSafari,
isChrome,
isFirefox,
hasTouchSupport,
// 判断是否应该使用触摸事件作为主要交互方式
preferTouchEvents: (isMobile || isTablet) && !isDesktop,
// 判断是否需要特殊的 Mac 触控板处理
needsMacTrackpadOptimization: isMac && isDesktop && hasTouchSupport,
};
}

View File

@@ -0,0 +1,66 @@
/**
* 图层类型枚举
*/
export const LayerType = {
EMPTY: "empty", // 空图层
BITMAP: "bitmap", // 位图图层
VECTOR: "vector", // 矢量图层
TEXT: "text", // 文字图层
GROUP: "group", // 组图层
ADJUSTMENT: "adjustment", // 调整图层
SMART_OBJECT: "smartObject", // 智能对象
SHAPE: "shape", // 形状图层
VIDEO: "video", // 视频图层 (预留)
AUDIO: "audio", // 音频图层 (预留)
FIXED: "fixed", // 固定图层 - 位于背景图层之上,普通图层之下
BACKGROUND: "background", // 背景图层 - 位于固定图层之、普通图层之下
};
/**
* 画布操作模式枚举draw(绘画)、select(选择)、pan(拖拽)....
*/
export const OperationType = {
// 编辑器模式
DISABLED: "disabled", // 禁用
SELECT: "select",// 选择工具模式
PAN: "pan", // 拖拽模式
DRAW: "draw", // 绘画模式
ERASER: "eraser", // 橡皮擦模式
IMAGE: "image",// 图片工具模式
SELECTBOX: "selectbox",// 选择框工具模式
RECTANGLE: "rectangle",// 矩形工具模式
TEXT: "text",// 文字工具模式
UNDO: "undo",// 撤销工具模式
REDO: "redo",// 重做工具模式
};
// 所有操作模式类型列表
export const OperationTypes = Object.values(OperationType);
/**
* 混合模式枚举
* 与 fabricjs 和 CSS3 的 globalCompositeOperation 对应
*/
export const BlendMode = {
NORMAL: "source-over", // 正常模式
MULTIPLY: "multiply", // 正片叠底
SCREEN: "screen", // 滤色
OVERLAY: "overlay", // 叠加
DARKEN: "darken", // 变暗
LIGHTEN: "lighten", // 变亮
COLOR_DODGE: "color-dodge", // 颜色减淡
COLOR_BURN: "color-burn", // 颜色加深
HARD_LIGHT: "hard-light", // 强光
SOFT_LIGHT: "soft-light", // 柔光
DIFFERENCE: "difference", // 差值
EXCLUSION: "exclusion", // 排除
HUE: "hue", // 色相
SATURATION: "saturation", // 饱和度
COLOR: "color", // 颜色
LUMINOSITY: "luminosity", // 明度
DESTINATION_IN: "destination-in", // 目标内
DESTINATION_OUT: "destination-out", // 目标外
};