接入画布
This commit is contained in:
589
src/component/Canvas/CanvasEditor/commands/BackgroundCommands.js
Normal file
589
src/component/Canvas/CanvasEditor/commands/BackgroundCommands.js
Normal file
@@ -0,0 +1,589 @@
|
||||
import { Command } from "./Command";
|
||||
//import { fabric } from "fabric-with-all";
|
||||
|
||||
/**
|
||||
* 创建背景图层命令
|
||||
*/
|
||||
export class CreateBackgroundLayerCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "创建背景图层",
|
||||
saveState: true,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layers = options.layers;
|
||||
this.backgroundLayer = options.backgroundLayer;
|
||||
this.canvasManager = options.canvasManager;
|
||||
this.historyManager = options.historyManager;
|
||||
|
||||
this.beforeLayers = [...this.layers.value]; // 备份原图层列表
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 检查是否已经存在背景图层
|
||||
const existingBgLayer = this.layers.value.find(
|
||||
(layer) => layer.isBackground
|
||||
);
|
||||
if (existingBgLayer) {
|
||||
console.warn("已存在背景层,不重复创建");
|
||||
return existingBgLayer.id;
|
||||
}
|
||||
|
||||
// 创建背景矩形对象
|
||||
const bgObject = this._createBackgroundObject();
|
||||
|
||||
// 将背景对象添加到图层中
|
||||
this.backgroundLayer.fabricObject = bgObject;
|
||||
|
||||
// 添加图层到最底部
|
||||
this.layers.value.push(this.backgroundLayer);
|
||||
|
||||
// 添加到画布
|
||||
this.canvas.add(bgObject);
|
||||
|
||||
// 渲染画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return this.backgroundLayer.id;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// 从图层列表中删除背景图层
|
||||
const bgLayerIndex = this.layers.value.findIndex(
|
||||
(layer) => layer.isBackground
|
||||
);
|
||||
if (bgLayerIndex !== -1) {
|
||||
this.layers.value.splice(bgLayerIndex, 1);
|
||||
}
|
||||
|
||||
// 从画布中移除背景对象
|
||||
if (this.backgroundLayer.fabricObject) {
|
||||
this.canvas.remove(this.backgroundLayer.fabricObject);
|
||||
}
|
||||
|
||||
// 渲染画布
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建背景矩形对象
|
||||
* @returns {Object} fabric.js 矩形对象
|
||||
* @private
|
||||
*/
|
||||
_createBackgroundObject() {
|
||||
// 计算画布尺寸
|
||||
const canvasWidth = this.canvas.width;
|
||||
const canvasHeight = this.canvas.height;
|
||||
|
||||
// 确保背景色为白色,如果没有设置或者是透明的话
|
||||
const backgroundColor =
|
||||
this.backgroundLayer.backgroundColor &&
|
||||
this.backgroundLayer.backgroundColor !== "transparent"
|
||||
? this.backgroundLayer.backgroundColor
|
||||
: "#ffffff";
|
||||
|
||||
const rect = new fabric.Rect({
|
||||
left: canvasWidth / 2,
|
||||
top: canvasHeight / 2,
|
||||
width: this.backgroundLayer.canvasWidth,
|
||||
height: this.backgroundLayer.canvasHeight,
|
||||
fill: backgroundColor,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hoverCursor: "default",
|
||||
id: `bg_object_${this.backgroundLayer.id}`,
|
||||
layerId: this.backgroundLayer.id,
|
||||
layerName: this.backgroundLayer.name,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
isBackground: true, // 标记为背景对象
|
||||
});
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
layerId: this.backgroundLayer.id,
|
||||
layerName: this.backgroundLayer.name,
|
||||
width: this.backgroundLayer.canvasWidth,
|
||||
height: this.backgroundLayer.canvasHeight,
|
||||
backgroundColor: this.backgroundLayer.backgroundColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新背景属性命令(如背景颜色)
|
||||
*/
|
||||
export class UpdateBackgroundCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "更新背景属性",
|
||||
saveState: true,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layers = options.layers;
|
||||
this.backgroundColor = options.backgroundColor;
|
||||
this.historyManager = options.historyManager;
|
||||
|
||||
// 查找背景图层
|
||||
this.bgLayer = this.layers.value.find((layer) => layer.isBackground);
|
||||
this.oldBackgroundColor = this.bgLayer
|
||||
? this.bgLayer.backgroundColor
|
||||
: "#ffffff";
|
||||
}
|
||||
|
||||
execute() {
|
||||
if (!this.bgLayer) {
|
||||
console.error("未找到背景图层");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新背景图层属性
|
||||
this.bgLayer.backgroundColor = this.backgroundColor;
|
||||
|
||||
// 更新背景对象属性
|
||||
if (this.bgLayer.fabricObject) {
|
||||
this.bgLayer.fabricObject.set("fill", this.backgroundColor);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.bgLayer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 恢复背景图层属性
|
||||
this.bgLayer.backgroundColor = this.oldBackgroundColor;
|
||||
|
||||
// 恢复背景对象属性
|
||||
if (this.bgLayer.fabricObject) {
|
||||
this.bgLayer.fabricObject.set("fill", this.oldBackgroundColor);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
layerId: this.bgLayer?.id,
|
||||
oldColor: this.oldBackgroundColor,
|
||||
newColor: this.backgroundColor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整画布和背景大小命令
|
||||
*/
|
||||
export class BackgroundSizeCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "调整背景大小",
|
||||
saveState: true,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layers = options.layers;
|
||||
this.canvasManager = options.canvasManager;
|
||||
this.newWidth = options.newWidth;
|
||||
this.newHeight = options.newHeight;
|
||||
this.historyManager = options.historyManager;
|
||||
|
||||
// 记录原尺寸
|
||||
this.oldWidth = this.canvas.width;
|
||||
this.oldHeight = this.canvas.height;
|
||||
|
||||
// 查找背景图层
|
||||
this.bgLayer = this.layers.value.find((layer) => layer.isBackground);
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 调整画布大小
|
||||
this.canvas.setWidth(this.newWidth);
|
||||
this.canvas.setHeight(this.newHeight);
|
||||
|
||||
// 如果使用 CanvasManager,通知它画布大小变化
|
||||
if (
|
||||
this.canvasManager &&
|
||||
typeof this.canvasManager.updateCanvasSize === "function"
|
||||
) {
|
||||
this.canvasManager.updateCanvasSize(this.newWidth, this.newHeight);
|
||||
}
|
||||
|
||||
// 调整背景对象大小
|
||||
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||
// 保持原有的背景颜色,如果没有设置则使用白色
|
||||
const currentFill =
|
||||
this.bgLayer.fabricObject.fill ||
|
||||
this.bgLayer.backgroundColor ||
|
||||
"#ffffff";
|
||||
|
||||
this.bgLayer.fabricObject.set({
|
||||
width: this.newWidth,
|
||||
height: this.newHeight,
|
||||
fill: currentFill, // 保持原有颜色
|
||||
});
|
||||
|
||||
// 更新图层记录的尺寸
|
||||
this.bgLayer.canvasWidth = this.newWidth;
|
||||
this.bgLayer.canvasHeight = this.newHeight;
|
||||
}
|
||||
|
||||
// 渲染画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// 恢复画布大小
|
||||
this.canvas.setWidth(this.oldWidth);
|
||||
this.canvas.setHeight(this.oldHeight);
|
||||
|
||||
// 如果使用 CanvasManager,通知它画布大小恢复
|
||||
if (
|
||||
this.canvasManager &&
|
||||
typeof this.canvasManager.updateCanvasSize === "function"
|
||||
) {
|
||||
this.canvasManager.updateCanvasSize(this.oldWidth, this.oldHeight);
|
||||
}
|
||||
|
||||
// 恢复背景对象大小
|
||||
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||
this.bgLayer.fabricObject.set({
|
||||
width: this.oldWidth,
|
||||
height: this.oldHeight,
|
||||
});
|
||||
|
||||
// 恢复图层记录的尺寸
|
||||
this.bgLayer.canvasWidth = this.oldWidth;
|
||||
this.bgLayer.canvasHeight = this.oldHeight;
|
||||
}
|
||||
|
||||
// 渲染画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
oldWidth: this.oldWidth,
|
||||
oldHeight: this.oldHeight,
|
||||
newWidth: this.newWidth,
|
||||
newHeight: this.newHeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整背景大小并等比缩放所有其他元素的命令
|
||||
*/
|
||||
export class BackgroundSizeWithScaleCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "调整背景大小并缩放元素",
|
||||
saveState: true,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layers = options.layers;
|
||||
this.canvasManager = options.canvasManager;
|
||||
this.newWidth = options.newWidth;
|
||||
this.newHeight = options.newHeight;
|
||||
this.historyManager = options.historyManager;
|
||||
|
||||
// 缩放策略:'uniform' | 'fill' | 'fit' | 'stretch'
|
||||
this.scaleStrategy = options.scaleStrategy || "uniform";
|
||||
|
||||
// 记录原尺寸
|
||||
this.oldWidth = this.canvas.width;
|
||||
this.oldHeight = this.canvas.height;
|
||||
|
||||
// 查找背景图层
|
||||
this.bgLayer = this.layers.value.find((layer) => layer.isBackground);
|
||||
|
||||
// 计算缩放比例
|
||||
const scaleXRatio = this.newWidth / this.oldWidth;
|
||||
const scaleYRatio = this.newHeight / this.oldHeight;
|
||||
|
||||
// 根据策略计算缩放比例和偏移
|
||||
this._calculateScaleAndOffset(scaleXRatio, scaleYRatio);
|
||||
|
||||
// 存储所有非背景对象的原始状态
|
||||
this.objectStates = [];
|
||||
this._saveOriginalStates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存所有非背景对象的原始状态
|
||||
* @private
|
||||
*/
|
||||
_saveOriginalStates() {
|
||||
this.canvas.getObjects().forEach((obj) => {
|
||||
if (!obj.isBackground) {
|
||||
// 检查对象是否已经有原始状态记录
|
||||
if (!obj._originalState) {
|
||||
// 第一次记录原始状态
|
||||
obj._originalState = {
|
||||
left: obj.left,
|
||||
top: obj.top,
|
||||
scaleX: obj.scaleX || 1,
|
||||
scaleY: obj.scaleY || 1,
|
||||
width: obj.width,
|
||||
height: obj.height,
|
||||
// 记录基准画布尺寸
|
||||
baseCanvasWidth: this.oldWidth,
|
||||
baseCanvasHeight: this.oldHeight,
|
||||
};
|
||||
}
|
||||
|
||||
this.objectStates.push({
|
||||
obj: obj,
|
||||
// 使用原始状态而不是当前状态
|
||||
left: obj._originalState.left,
|
||||
top: obj._originalState.top,
|
||||
scaleX: obj._originalState.scaleX,
|
||||
scaleY: obj._originalState.scaleY,
|
||||
width: obj._originalState.width,
|
||||
height: obj._originalState.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据缩放策略计算缩放比例和偏移量
|
||||
* @param {number} scaleXRatio X轴缩放比例
|
||||
* @param {number} scaleYRatio Y轴缩放比例
|
||||
* @private
|
||||
*/
|
||||
_calculateScaleAndOffset(scaleXRatio, scaleYRatio) {
|
||||
switch (this.scaleStrategy) {
|
||||
case "uniform":
|
||||
// 统一缩放:使用平均值,保持相对比例的同时允许适度的形变
|
||||
this.uniformScale = Math.sqrt(scaleXRatio * scaleYRatio);
|
||||
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||
this.offsetY =
|
||||
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||
break;
|
||||
|
||||
case "fit":
|
||||
// 适应模式:使用较小值,确保所有内容都在画布内,可能有留白
|
||||
this.uniformScale = Math.min(scaleXRatio, scaleYRatio);
|
||||
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||
this.offsetY =
|
||||
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||
break;
|
||||
|
||||
case "fill":
|
||||
// 填充模式:使用较大值,填满画布,可能有部分内容被裁切
|
||||
this.uniformScale = Math.max(scaleXRatio, scaleYRatio);
|
||||
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||
this.offsetY =
|
||||
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||
break;
|
||||
|
||||
case "stretch":
|
||||
// 拉伸模式:不保持宽高比,完全适应新尺寸
|
||||
this.scaleX = scaleXRatio;
|
||||
this.scaleY = scaleYRatio;
|
||||
this.offsetX = 0;
|
||||
this.offsetY = 0;
|
||||
break;
|
||||
|
||||
default:
|
||||
// 默认使用uniform模式
|
||||
this.uniformScale = Math.sqrt(scaleXRatio * scaleYRatio);
|
||||
this.offsetX = (this.newWidth - this.oldWidth * this.uniformScale) / 2;
|
||||
this.offsetY =
|
||||
(this.newHeight - this.oldHeight * this.uniformScale) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 调整画布大小
|
||||
this.canvas.setWidth(this.newWidth);
|
||||
this.canvas.setHeight(this.newHeight);
|
||||
|
||||
// 如果使用 CanvasManager,通知它画布大小变化
|
||||
if (
|
||||
this.canvasManager &&
|
||||
typeof this.canvasManager.updateCanvasSize === "function"
|
||||
) {
|
||||
this.canvasManager.updateCanvasSize(this.newWidth, this.newHeight);
|
||||
}
|
||||
|
||||
// 调整背景对象大小和位置
|
||||
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||
// 保持原有的背景颜色,如果没有设置则使用白色
|
||||
const currentFill =
|
||||
this.bgLayer.fabricObject.fill ||
|
||||
this.bgLayer.backgroundColor ||
|
||||
"#ffffff";
|
||||
|
||||
this.bgLayer.fabricObject.set({
|
||||
width: this.newWidth,
|
||||
height: this.newHeight,
|
||||
left: this.newWidth / 2,
|
||||
top: this.newHeight / 2,
|
||||
fill: currentFill, // 保持原有颜色
|
||||
});
|
||||
|
||||
// 更新图层记录的尺寸
|
||||
this.bgLayer.canvasWidth = this.newWidth;
|
||||
this.bgLayer.canvasHeight = this.newHeight;
|
||||
}
|
||||
|
||||
// 计算基于原始画布的缩放比例
|
||||
const baseScaleX =
|
||||
this.newWidth /
|
||||
this.objectStates[0]?.obj._originalState?.baseCanvasWidth ||
|
||||
this.newWidth / this.oldWidth;
|
||||
const baseScaleY =
|
||||
this.newHeight /
|
||||
this.objectStates[0]?.obj._originalState?.baseCanvasHeight ||
|
||||
this.newHeight / this.oldHeight;
|
||||
|
||||
// 根据策略缩放所有非背景对象
|
||||
this.objectStates.forEach((state) => {
|
||||
const obj = state.obj;
|
||||
|
||||
if (this.scaleStrategy === "stretch") {
|
||||
// 拉伸模式:使用不同的X和Y缩放比例
|
||||
obj.set({
|
||||
left: state.left * baseScaleX,
|
||||
top: state.top * baseScaleY,
|
||||
scaleX: state.scaleX * baseScaleX,
|
||||
scaleY: state.scaleY * baseScaleY,
|
||||
});
|
||||
} else {
|
||||
// 其他模式:计算基于原始状态的统一缩放比例
|
||||
const baseUniformScale = Math.sqrt(baseScaleX * baseScaleY);
|
||||
const baseOffsetX =
|
||||
(this.newWidth -
|
||||
(obj._originalState?.baseCanvasWidth || this.oldWidth) *
|
||||
baseUniformScale) /
|
||||
2;
|
||||
const baseOffsetY =
|
||||
(this.newHeight -
|
||||
(obj._originalState?.baseCanvasHeight || this.oldHeight) *
|
||||
baseUniformScale) /
|
||||
2;
|
||||
|
||||
obj.set({
|
||||
left: state.left * baseUniformScale + baseOffsetX,
|
||||
top: state.top * baseUniformScale + baseOffsetY,
|
||||
scaleX: state.scaleX * baseUniformScale,
|
||||
scaleY: state.scaleY * baseUniformScale,
|
||||
});
|
||||
}
|
||||
|
||||
obj.setCoords();
|
||||
});
|
||||
|
||||
// 渲染画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// 恢复画布大小
|
||||
this.canvas.setWidth(this.oldWidth);
|
||||
this.canvas.setHeight(this.oldHeight);
|
||||
|
||||
// 如果使用 CanvasManager,通知它画布大小恢复
|
||||
if (
|
||||
this.canvasManager &&
|
||||
typeof this.canvasManager.updateCanvasSize === "function"
|
||||
) {
|
||||
this.canvasManager.updateCanvasSize(this.oldWidth, this.oldHeight);
|
||||
}
|
||||
|
||||
// 恢复背景对象大小和位置
|
||||
if (this.bgLayer && this.bgLayer.fabricObject) {
|
||||
this.bgLayer.fabricObject.set({
|
||||
width: this.oldWidth,
|
||||
height: this.oldHeight,
|
||||
left: this.oldWidth / 2,
|
||||
top: this.oldHeight / 2,
|
||||
});
|
||||
|
||||
// 恢复图层记录的尺寸
|
||||
this.bgLayer.canvasWidth = this.oldWidth;
|
||||
this.bgLayer.canvasHeight = this.oldHeight;
|
||||
}
|
||||
|
||||
// 恢复所有非背景对象的当前状态(而不是原始状态,因为可能有其他操作)
|
||||
this.objectStates.forEach((state) => {
|
||||
// 计算恢复到之前画布尺寸时的状态
|
||||
const obj = state.obj;
|
||||
const originalState = obj._originalState;
|
||||
|
||||
if (originalState) {
|
||||
const baseScaleX = this.oldWidth / originalState.baseCanvasWidth;
|
||||
const baseScaleY = this.oldHeight / originalState.baseCanvasHeight;
|
||||
const baseUniformScale = Math.sqrt(baseScaleX * baseScaleY);
|
||||
const baseOffsetX =
|
||||
(this.oldWidth - originalState.baseCanvasWidth * baseUniformScale) /
|
||||
2;
|
||||
const baseOffsetY =
|
||||
(this.oldHeight - originalState.baseCanvasHeight * baseUniformScale) /
|
||||
2;
|
||||
|
||||
obj.set({
|
||||
left: originalState.left * baseUniformScale + baseOffsetX,
|
||||
top: originalState.top * baseUniformScale + baseOffsetY,
|
||||
scaleX: originalState.scaleX * baseUniformScale,
|
||||
scaleY: originalState.scaleY * baseUniformScale,
|
||||
});
|
||||
} else {
|
||||
// 降级到原来的逻辑
|
||||
obj.set({
|
||||
left: state.left,
|
||||
top: state.top,
|
||||
scaleX: state.scaleX,
|
||||
scaleY: state.scaleY,
|
||||
});
|
||||
}
|
||||
|
||||
obj.setCoords();
|
||||
});
|
||||
|
||||
// 渲染画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
const info = {
|
||||
name: this.name,
|
||||
oldWidth: this.oldWidth,
|
||||
oldHeight: this.oldHeight,
|
||||
newWidth: this.newWidth,
|
||||
newHeight: this.newHeight,
|
||||
scaleStrategy: this.scaleStrategy,
|
||||
objectCount: this.objectStates.length,
|
||||
};
|
||||
|
||||
if (this.scaleStrategy === "stretch") {
|
||||
info.scaleX = this.scaleX;
|
||||
info.scaleY = this.scaleY;
|
||||
} else {
|
||||
info.uniformScale = this.uniformScale;
|
||||
info.offsetX = this.offsetX;
|
||||
info.offsetY = this.offsetY;
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
}
|
||||
698
src/component/Canvas/CanvasEditor/commands/BrushCommands.js
Normal file
698
src/component/Canvas/CanvasEditor/commands/BrushCommands.js
Normal file
@@ -0,0 +1,698 @@
|
||||
import { Command } from "./Command";
|
||||
import { BrushStore } from "../store/BrushStore";
|
||||
|
||||
/**
|
||||
* 笔刷属性设置基类命令
|
||||
* 所有笔刷属性设置命令的基类
|
||||
*/
|
||||
class BaseBrushCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
this.brushStore = options.brushStore || BrushStore;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔刷大小设置命令
|
||||
*/
|
||||
export class BrushSizeCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {Number} options.size 要设置的笔刷大小
|
||||
* @param {Number} options.previousSize 之前的笔刷大小(可选)
|
||||
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `设置笔刷大小: ${options.size}`,
|
||||
description: `将笔刷大小从 ${options.previousSize || "?"} 设为 ${
|
||||
options.size
|
||||
}`,
|
||||
});
|
||||
|
||||
this.size = options.size;
|
||||
this.previousSize = options.previousSize || this.brushStore.state.size;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前大小用于撤销
|
||||
if (this.previousSize === null) {
|
||||
this.previousSize = this.brushStore.state.size;
|
||||
}
|
||||
|
||||
// 执行设置
|
||||
this.brushStore.setBrushSize(this.size);
|
||||
return this.size;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.brushStore.setBrushSize(this.previousSize);
|
||||
return this.previousSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔刷颜色设置命令
|
||||
*/
|
||||
export class BrushColorCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {String} options.color 要设置的颜色
|
||||
* @param {String} options.previousColor 之前的颜色(可选)
|
||||
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `设置笔刷颜色: ${options.color}`,
|
||||
description: `将笔刷颜色从 ${options.previousColor || "?"} 设为 ${
|
||||
options.color
|
||||
}`,
|
||||
});
|
||||
|
||||
this.color = options.color;
|
||||
this.previousColor = options.previousColor || this.brushStore.state.color;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前颜色用于撤销
|
||||
if (this.previousColor === null) {
|
||||
this.previousColor = this.brushStore.state.color;
|
||||
}
|
||||
|
||||
// 执行设置
|
||||
this.brushStore.setBrushColor(this.color);
|
||||
return this.color;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.brushStore.setBrushColor(this.previousColor);
|
||||
return this.previousColor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔刷透明度设置命令
|
||||
*/
|
||||
export class BrushOpacityCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {Number} options.opacity 要设置的透明度 (0-1)
|
||||
* @param {Number} options.previousOpacity 之前的透明度(可选)
|
||||
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `设置笔刷透明度: ${options.opacity}`,
|
||||
description: `将笔刷透明度从 ${options.previousOpacity || "?"} 设为 ${
|
||||
options.opacity
|
||||
}`,
|
||||
});
|
||||
|
||||
this.opacity = options.opacity;
|
||||
this.previousOpacity =
|
||||
options.previousOpacity || this.brushStore.state.opacity;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前透明度用于撤销
|
||||
if (this.previousOpacity === null) {
|
||||
this.previousOpacity = this.brushStore.state.opacity;
|
||||
}
|
||||
|
||||
// 执行设置
|
||||
this.brushStore.setBrushOpacity(this.opacity);
|
||||
return this.opacity;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.brushStore.setBrushOpacity(this.previousOpacity);
|
||||
return this.previousOpacity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔刷类型设置命令
|
||||
*/
|
||||
export class BrushTypeCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {String} options.brushType 要设置的笔刷类型
|
||||
* @param {String} options.previousType 之前的笔刷类型(可选)
|
||||
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `设置笔刷类型: ${options.brushType}`,
|
||||
description: `将笔刷类型从 ${options.previousType || "?"} 设为 ${
|
||||
options.brushType
|
||||
}`,
|
||||
});
|
||||
|
||||
this.brushType = options.brushType;
|
||||
this.previousType = options.previousType || this.brushStore.state.type;
|
||||
this.brushManager = options.brushManager;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前类型用于撤销
|
||||
if (this.previousType === null) {
|
||||
this.previousType = this.brushStore.state.type;
|
||||
}
|
||||
|
||||
// 执行设置
|
||||
// this.brushStore.setBrushType(this.brushType);
|
||||
this.brushManager.setBrushType(this.brushType);
|
||||
return this.brushType;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// this.brushStore.setBrushType(this.previousType);
|
||||
this.brushManager.setBrushType(this.previousType);
|
||||
return this.previousType;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 材质设置命令
|
||||
*/
|
||||
export class TextureCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {Boolean} options.enabled 是否启用材质
|
||||
* @param {String} options.path 材质路径
|
||||
* @param {Number} options.scale 材质缩放
|
||||
* @param {Object} options.previous 之前的材质设置(可选)
|
||||
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: options.enabled ? "启用笔刷材质" : "禁用笔刷材质",
|
||||
description: options.enabled
|
||||
? `启用材质: ${options.path || "[默认]"}`
|
||||
: "禁用笔刷材质",
|
||||
});
|
||||
|
||||
this.enabled = options.enabled;
|
||||
this.path = options.path;
|
||||
this.scale = options.scale;
|
||||
|
||||
// 保存之前状态用于撤销
|
||||
this.previous = options.previous || {
|
||||
enabled: this.brushStore.state.textureEnabled,
|
||||
path: this.brushStore.state.texturePath,
|
||||
scale: this.brushStore.state.textureScale,
|
||||
};
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前状态用于撤销
|
||||
if (!this.previous) {
|
||||
this.previous = {
|
||||
enabled: this.brushStore.state.textureEnabled,
|
||||
path: this.brushStore.state.texturePath,
|
||||
scale: this.brushStore.state.textureScale,
|
||||
};
|
||||
}
|
||||
|
||||
// 执行设置
|
||||
this.brushStore.setTextureEnabled(this.enabled);
|
||||
|
||||
if (this.path) {
|
||||
this.brushStore.setTexturePath(this.path);
|
||||
}
|
||||
|
||||
if (this.scale !== undefined) {
|
||||
this.brushStore.setTextureScale(this.scale);
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: this.enabled,
|
||||
path: this.path,
|
||||
scale: this.scale,
|
||||
};
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.previous) return null;
|
||||
|
||||
this.brushStore.setTextureEnabled(this.previous.enabled);
|
||||
this.brushStore.setTexturePath(this.previous.path);
|
||||
this.brushStore.setTextureScale(this.previous.scale);
|
||||
|
||||
return this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设应用命令
|
||||
*/
|
||||
export class BrushPresetCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {Number|Object} options.preset 预设索引或预设对象
|
||||
* @param {Object} options.previousState 之前的状态(可选)
|
||||
* @param {Object} options.brushStore BrushStore实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
const presetName =
|
||||
typeof options.preset === "object"
|
||||
? options.preset.name
|
||||
: `预设 ${options.preset}`;
|
||||
|
||||
super({
|
||||
...options,
|
||||
name: `应用笔刷预设: ${presetName}`,
|
||||
description: `应用预设: ${presetName}`,
|
||||
});
|
||||
|
||||
this.preset = options.preset;
|
||||
|
||||
// 保存之前状态用于撤销
|
||||
this.previousState = options.previousState || {
|
||||
size: this.brushStore.state.size,
|
||||
color: this.brushStore.state.color,
|
||||
opacity: this.brushStore.state.opacity,
|
||||
type: this.brushStore.state.type,
|
||||
textureEnabled: this.brushStore.state.textureEnabled,
|
||||
textureScale: this.brushStore.state.textureScale,
|
||||
texturePath: this.brushStore.state.texturePath,
|
||||
};
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前状态用于撤销
|
||||
if (!this.previousState) {
|
||||
this.previousState = {
|
||||
size: this.brushStore.state.size,
|
||||
color: this.brushStore.state.color,
|
||||
opacity: this.brushStore.state.opacity,
|
||||
type: this.brushStore.state.type,
|
||||
textureEnabled: this.brushStore.state.textureEnabled,
|
||||
textureScale: this.brushStore.state.textureScale,
|
||||
texturePath: this.brushStore.state.texturePath,
|
||||
};
|
||||
}
|
||||
|
||||
// 应用预设
|
||||
if (typeof this.preset === "number") {
|
||||
this.brushStore.applyPreset(this.preset);
|
||||
} else if (typeof this.preset === "object") {
|
||||
// 应用自定义预设对象
|
||||
if (this.preset.size !== undefined)
|
||||
this.brushStore.setBrushSize(this.preset.size);
|
||||
if (this.preset.color !== undefined)
|
||||
this.brushStore.setBrushColor(this.preset.color);
|
||||
if (this.preset.opacity !== undefined)
|
||||
this.brushStore.setBrushOpacity(this.preset.opacity);
|
||||
if (this.preset.type !== undefined)
|
||||
this.brushStore.setBrushType(this.preset.type);
|
||||
|
||||
if (this.preset.textureEnabled !== undefined) {
|
||||
this.brushStore.setTextureEnabled(this.preset.textureEnabled);
|
||||
}
|
||||
|
||||
if (this.preset.texturePath !== undefined) {
|
||||
this.brushStore.setTexturePath(this.preset.texturePath);
|
||||
}
|
||||
|
||||
if (this.preset.textureScale !== undefined) {
|
||||
this.brushStore.setTextureScale(this.preset.textureScale);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.previousState) return false;
|
||||
|
||||
// 恢复之前的状态
|
||||
this.brushStore.setBrushSize(this.previousState.size);
|
||||
this.brushStore.setBrushColor(this.previousState.color);
|
||||
this.brushStore.setBrushOpacity(this.previousState.opacity);
|
||||
this.brushStore.setBrushType(this.previousState.type);
|
||||
this.brushStore.setTextureEnabled(this.previousState.textureEnabled);
|
||||
this.brushStore.setTextureScale(this.previousState.textureScale);
|
||||
this.brushStore.setTexturePath(this.previousState.texturePath);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔刷属性命令
|
||||
* 用于修改笔刷的任意属性,包括特殊属性
|
||||
*/
|
||||
export class BrushPropertyCommand extends Command {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} options 命令选项
|
||||
* @param {String} options.propertyId 属性ID
|
||||
* @param {any} options.value 新的属性值
|
||||
* @param {Object} options.brushStore BrushStore实例
|
||||
*/
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "笔刷属性更改",
|
||||
description: `更改笔刷属性 ${options.propertyId}`,
|
||||
options,
|
||||
});
|
||||
|
||||
this.propertyId = options.propertyId;
|
||||
this.newValue = options.value;
|
||||
this.oldValue = null;
|
||||
this.brushStore = options.brushStore || BrushStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
* @returns {Boolean} 是否执行成功
|
||||
*/
|
||||
execute() {
|
||||
if (!this.brushStore) {
|
||||
console.error("BrushStore不可用");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存旧值用于撤销
|
||||
this.oldValue = this.brushStore.getPropertyValue(this.propertyId);
|
||||
|
||||
// 更新属性值
|
||||
this.brushStore.updatePropertyValue(this.propertyId, this.newValue);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销命令
|
||||
* @param {Object} context 命令上下文
|
||||
* @returns {Boolean} 是否撤销成功
|
||||
*/
|
||||
undo() {
|
||||
if (!this.brushStore || this.oldValue === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 恢复旧值
|
||||
this.brushStore.updatePropertyValue(this.propertyId, this.oldValue);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 材质选择命令
|
||||
*/
|
||||
export class TextureSelectionCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {String} options.textureId 要设置的材质ID
|
||||
* @param {String} options.previousTextureId 之前的材质ID(可选)
|
||||
* @param {Object} options.brushManager 笔刷管理器实例
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `切换纹理材质`,
|
||||
description: `切换到材质: ${options.textureId}`,
|
||||
});
|
||||
|
||||
this.textureId = options.textureId;
|
||||
this.previousTextureId = options.previousTextureId;
|
||||
this.brushManager = options.brushManager;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 记录当前材质用于撤销
|
||||
const currentBrush = this.brushManager.activeBrush;
|
||||
if (currentBrush && currentBrush.getCurrentTexture) {
|
||||
const currentTexture = currentBrush.getCurrentTexture();
|
||||
this.previousTextureId = currentTexture ? currentTexture.id : null;
|
||||
}
|
||||
|
||||
// 确保当前是材质笔刷
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
// 设置材质
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (activeBrush && activeBrush.setTextureById) {
|
||||
activeBrush.setTextureById(this.textureId);
|
||||
}
|
||||
|
||||
return this.textureId;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.previousTextureId) return false;
|
||||
|
||||
// 确保当前是材质笔刷
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
// 恢复之前的材质
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (activeBrush && activeBrush.setTextureById) {
|
||||
activeBrush.setTextureById(this.previousTextureId);
|
||||
}
|
||||
|
||||
return this.previousTextureId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 材质属性设置命令
|
||||
*/
|
||||
export class TexturePropertyCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {String} options.property 要设置的属性名称 (scale, rotation, offsetX, offsetY等)
|
||||
* @param {any} options.value 要设置的属性值
|
||||
* @param {any} options.previousValue 之前的属性值(可选)
|
||||
* @param {Object} options.brushManager 笔刷管理器实例
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `设置材质属性: ${options.property}`,
|
||||
description: `将材质${options.property}从 ${
|
||||
options.previousValue || "?"
|
||||
} 设为 ${options.value}`,
|
||||
});
|
||||
|
||||
this.property = options.property;
|
||||
this.value = options.value;
|
||||
this.previousValue = options.previousValue;
|
||||
this.brushManager = options.brushManager;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 确保当前是材质笔刷
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (!activeBrush || !activeBrush.setTextureProperty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录当前值用于撤销
|
||||
if (this.previousValue === undefined) {
|
||||
this.previousValue = activeBrush.getTextureProperty(this.property);
|
||||
}
|
||||
|
||||
// 设置新值
|
||||
activeBrush.setTextureProperty(this.property, this.value);
|
||||
return this.value;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.previousValue === undefined) return false;
|
||||
|
||||
// 确保当前是材质笔刷
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (activeBrush && activeBrush.setTextureProperty) {
|
||||
activeBrush.setTextureProperty(this.property, this.previousValue);
|
||||
}
|
||||
|
||||
return this.previousValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 材质预设应用命令
|
||||
*/
|
||||
export class TexturePresetCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {String|Object} options.preset 预设ID或预设对象
|
||||
* @param {Object} options.previousState 之前的材质状态(可选)
|
||||
* @param {Object} options.brushManager 笔刷管理器实例
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
const presetName =
|
||||
typeof options.preset === "object" ? options.preset.name : options.preset;
|
||||
|
||||
super({
|
||||
...options,
|
||||
name: `应用材质预设: ${presetName}`,
|
||||
description: `应用材质预设: ${presetName}`,
|
||||
});
|
||||
|
||||
this.preset = options.preset;
|
||||
this.previousState = options.previousState;
|
||||
this.brushManager = options.brushManager;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 确保当前是材质笔刷
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (!activeBrush || !activeBrush.applyTexturePreset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录当前状态用于撤销
|
||||
if (!this.previousState) {
|
||||
this.previousState = activeBrush.getCurrentTextureState();
|
||||
}
|
||||
|
||||
// 应用预设
|
||||
activeBrush.applyTexturePreset(this.preset);
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.previousState) return false;
|
||||
|
||||
// 确保当前是材质笔刷
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (activeBrush && activeBrush.restoreTextureState) {
|
||||
activeBrush.restoreTextureState(this.previousState);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 纹理上传命令
|
||||
*/
|
||||
export class TextureUploadCommand extends BaseBrushCommand {
|
||||
/**
|
||||
* @param {Object} options 命令选项
|
||||
* @param {File} options.file 要上传的纹理文件
|
||||
* @param {String} options.name 纹理名称(可选)
|
||||
* @param {String} options.category 纹理分类(可选)
|
||||
* @param {Object} options.texturePresetManager 纹理预设管理器实例
|
||||
* @param {Object} options.brushManager 笔刷管理器实例
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
name: `上传纹理: ${options.name || options.file?.name || '未知'}`,
|
||||
description: `上传自定义纹理文件`,
|
||||
});
|
||||
|
||||
this.file = options.file;
|
||||
this.name = options.name || options.file?.name?.replace(/\.[^/.]+$/, "") || "自定义纹理";
|
||||
this.category = options.category || "自定义材质";
|
||||
this.texturePresetManager = options.texturePresetManager;
|
||||
this.brushManager = options.brushManager;
|
||||
this.uploadedTextureId = null;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.file || !this.texturePresetManager) {
|
||||
throw new Error('缺少必要的文件或纹理预设管理器');
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建文件 data URL
|
||||
const dataUrl = await this._fileToDataUrl(this.file);
|
||||
|
||||
// 添加到纹理预设管理器
|
||||
this.uploadedTextureId = this.texturePresetManager.addCustomTexture({
|
||||
name: this.name,
|
||||
category: this.category,
|
||||
file: this.file,
|
||||
dataUrl: dataUrl,
|
||||
preview: dataUrl,
|
||||
});
|
||||
|
||||
// 如果是纹理笔刷,自动应用新上传的纹理
|
||||
if (this.brushManager) {
|
||||
if (this.brushManager.getCurrentBrushType() !== "texture") {
|
||||
this.brushManager.setBrushType("texture");
|
||||
}
|
||||
|
||||
const activeBrush = this.brushManager.activeBrush;
|
||||
if (activeBrush && activeBrush.updateProperty) {
|
||||
activeBrush.updateProperty("textureSelector", this.uploadedTextureId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textureId: this.uploadedTextureId,
|
||||
dataUrl: dataUrl,
|
||||
name: this.name
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('纹理上传失败:', error);
|
||||
throw new Error(`纹理上传失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.uploadedTextureId || !this.texturePresetManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从纹理预设管理器中移除上传的纹理
|
||||
try {
|
||||
this.texturePresetManager.removeCustomTexture(this.uploadedTextureId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('撤销纹理上传失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将文件转换为 data URL
|
||||
* @private
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
_fileToDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
reader.onerror = (error) => {
|
||||
reject(error);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
296
src/component/Canvas/CanvasEditor/commands/Command.js
Normal file
296
src/component/Canvas/CanvasEditor/commands/Command.js
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 基础命令类
|
||||
* 所有命令都应该继承这个类
|
||||
*/
|
||||
export class Command {
|
||||
constructor(options = {}) {
|
||||
this.name = options.name || "未命名命令";
|
||||
this.description = options.description || "";
|
||||
this.undoable = options.undoable !== false; // 默认可撤销
|
||||
this.timestamp = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
* @returns {*} 执行结果,可以是Promise
|
||||
*/
|
||||
execute() {
|
||||
throw new Error("子类必须实现execute方法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销命令
|
||||
* @returns {*} 撤销结果,可以是Promise
|
||||
*/
|
||||
undo() {
|
||||
if (!this.undoable) {
|
||||
throw new Error("此命令不支持撤销");
|
||||
}
|
||||
throw new Error("可撤销命令必须实现undo方法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令信息
|
||||
*/
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
undoable: this.undoable,
|
||||
timestamp: this.timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复合命令类
|
||||
* 用于批量执行多个命令,替代事务系统
|
||||
*/
|
||||
export class CompositeCommand extends Command {
|
||||
constructor(commands = [], options = {}) {
|
||||
super({
|
||||
name: options.name || "复合命令",
|
||||
description: options.description || "批量执行多个命令",
|
||||
...options,
|
||||
});
|
||||
|
||||
this.commands = Array.isArray(commands) ? commands : [];
|
||||
this.executedCommands = []; // 记录已执行的命令,用于撤销
|
||||
this.isExecuting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加子命令
|
||||
*/
|
||||
addCommand(command) {
|
||||
if (!command || typeof command.execute !== "function") {
|
||||
throw new Error("无效的命令对象");
|
||||
}
|
||||
this.commands.push(command);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加子命令
|
||||
*/
|
||||
addCommands(commands) {
|
||||
if (Array.isArray(commands)) {
|
||||
commands.forEach((cmd) => this.addCommand(cmd));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行所有子命令(串行执行)
|
||||
*/
|
||||
async execute() {
|
||||
if (this.isExecuting) {
|
||||
throw new Error("复合命令正在执行中");
|
||||
}
|
||||
|
||||
this.isExecuting = true;
|
||||
this.executedCommands = [];
|
||||
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
// 串行执行所有子命令
|
||||
for (const command of this.commands) {
|
||||
try {
|
||||
console.log(`📦 复合命令执行子命令: ${command.constructor.name}`);
|
||||
|
||||
const result = command.execute();
|
||||
|
||||
// 如果是异步命令,等待完成
|
||||
const finalResult = this._isPromise(result) ? await result : result;
|
||||
|
||||
results.push(finalResult);
|
||||
this.executedCommands.push(command);
|
||||
|
||||
console.log(`✅ 子命令执行成功: ${command.constructor.name}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ 子命令执行失败: ${command.constructor.name}`,
|
||||
error
|
||||
);
|
||||
|
||||
// 执行失败时,撤销已执行的命令
|
||||
await this._rollbackExecutedCommands();
|
||||
throw new Error(`复合命令执行失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.isExecuting = false;
|
||||
console.log(`✅ 复合命令执行完成,共执行 ${results.length} 个子命令`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
this.isExecuting = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销所有已执行的子命令(逆序撤销)
|
||||
*/
|
||||
async undo() {
|
||||
if (this.isExecuting) {
|
||||
throw new Error("复合命令正在执行中,无法撤销");
|
||||
}
|
||||
|
||||
if (this.executedCommands.length === 0) {
|
||||
console.warn("没有已执行的子命令需要撤销");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`↩️ 开始撤销复合命令,共 ${this.executedCommands.length} 个子命令`
|
||||
);
|
||||
|
||||
try {
|
||||
// 逆序撤销已执行的命令
|
||||
const commands = [...this.executedCommands].reverse();
|
||||
const results = [];
|
||||
|
||||
for (const command of commands) {
|
||||
if (typeof command.undo === "function") {
|
||||
try {
|
||||
console.log(`↩️ 撤销子命令: ${command.constructor.name}`);
|
||||
|
||||
const result = command.undo();
|
||||
|
||||
// 如果是异步撤销,等待完成
|
||||
const finalResult = this._isPromise(result) ? await result : result;
|
||||
|
||||
results.push(finalResult);
|
||||
console.log(`✅ 子命令撤销成功: ${command.constructor.name}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ 子命令撤销失败: ${command.constructor.name}`,
|
||||
error
|
||||
);
|
||||
// 撤销失败不中断整个撤销过程,但要记录错误
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ 子命令不支持撤销: ${command.constructor.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.executedCommands = [];
|
||||
console.log(`✅ 复合命令撤销完成`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("❌ 复合命令撤销过程中发生错误:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚已执行的命令(内部使用)
|
||||
* @private
|
||||
*/
|
||||
async _rollbackExecutedCommands() {
|
||||
console.log(`🔄 开始回滚已执行的 ${this.executedCommands.length} 个子命令`);
|
||||
|
||||
const commands = [...this.executedCommands].reverse();
|
||||
|
||||
for (const command of commands) {
|
||||
if (typeof command.undo === "function") {
|
||||
try {
|
||||
console.log(`🔄 回滚子命令: ${command.constructor.name}`);
|
||||
const result = command.undo();
|
||||
if (this._isPromise(result)) {
|
||||
await result;
|
||||
}
|
||||
console.log(`✅ 子命令回滚成功: ${command.constructor.name}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ 子命令回滚失败: ${command.constructor.name}`,
|
||||
error
|
||||
);
|
||||
// 回滚失败不中断整个回滚过程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.executedCommands = [];
|
||||
console.log(`✅ 回滚完成`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查返回值是否为Promise
|
||||
* @private
|
||||
*/
|
||||
_isPromise(value) {
|
||||
return (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof value.then === "function" &&
|
||||
typeof value.catch === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取复合命令信息
|
||||
*/
|
||||
getInfo() {
|
||||
return {
|
||||
...super.getInfo(),
|
||||
commandCount: this.commands.length,
|
||||
executedCount: this.executedCommands.length,
|
||||
isExecuting: this.isExecuting,
|
||||
subCommands: this.commands.map((cmd) =>
|
||||
cmd.getInfo ? cmd.getInfo() : cmd.constructor.name
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数命令包装器
|
||||
* 将普通函数包装为命令对象
|
||||
*/
|
||||
export class FunctionCommand extends Command {
|
||||
constructor(executeFn, undoFn = null, options = {}) {
|
||||
super({
|
||||
name: options.name || "函数命令",
|
||||
undoable: typeof undoFn === "function",
|
||||
...options,
|
||||
});
|
||||
|
||||
if (typeof executeFn !== "function") {
|
||||
throw new Error("执行函数不能为空");
|
||||
}
|
||||
|
||||
this.executeFn = executeFn;
|
||||
this.undoFn = undoFn;
|
||||
this.executeResult = null; // 保存执行结果用于撤销
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const result = this.executeFn();
|
||||
this.executeResult = this._isPromise(result) ? await result : result;
|
||||
return this.executeResult;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.undoFn) {
|
||||
throw new Error("此函数命令不支持撤销");
|
||||
}
|
||||
|
||||
const result = this.undoFn(this.executeResult);
|
||||
return this._isPromise(result) ? await result : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查返回值是否为Promise
|
||||
* @private
|
||||
*/
|
||||
_isPromise(value) {
|
||||
return (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof value.then === "function" &&
|
||||
typeof value.catch === "function"
|
||||
);
|
||||
}
|
||||
}
|
||||
491
src/component/Canvas/CanvasEditor/commands/LassoCutoutCommand.js
Normal file
491
src/component/Canvas/CanvasEditor/commands/LassoCutoutCommand.js
Normal file
@@ -0,0 +1,491 @@
|
||||
import { CompositeCommand } from "./Command.js";
|
||||
import { AddLayerCommand, CreateImageLayerCommand } from "./LayerCommands.js";
|
||||
import { ToolCommand } from "./ToolCommands.js";
|
||||
import { ClearSelectionCommand } from "./SelectionCommands.js";
|
||||
import { createLayer, LayerType, OperationType } from "../utils/layerHelper.js";
|
||||
//import { fabric } from "fabric-with-all";
|
||||
|
||||
/**
|
||||
* 套索抠图命令
|
||||
* 实现将选区内容抠图到新图层的功能
|
||||
*/
|
||||
export class LassoCutoutCommand extends CompositeCommand {
|
||||
constructor(options = {}) {
|
||||
super([], {
|
||||
name: "套索抠图",
|
||||
description: "将选区抠图到新图层",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.toolManager = options.toolManager;
|
||||
this.sourceLayerId = options.sourceLayerId;
|
||||
this.newLayerName = options.newLayerName || "抠图";
|
||||
this.newLayerId = null;
|
||||
this.cutoutImageUrl = null;
|
||||
this.fabricImage = null;
|
||||
this.executedCommands = [];
|
||||
// 高清截图选项
|
||||
this.highResolutionEnabled = options.highResolutionEnabled !== false; // 默认启用
|
||||
this.baseResolutionScale = options.baseResolutionScale || 4; // 基础分辨率倍数,提高到4倍获得更清晰的图像
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||
console.error("无法执行套索抠图:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.executedCommands = [];
|
||||
|
||||
// 获取选区
|
||||
const selectionObject = this.selectionManager.getSelectionObject();
|
||||
if (!selectionObject) {
|
||||
console.error("无法执行套索抠图:当前没有选区");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确定源图层
|
||||
const sourceLayer = this.layerManager.getActiveLayer();
|
||||
if (!sourceLayer || sourceLayer.fabricObjects.length === 0) {
|
||||
console.error("无法执行套索抠图:源图层无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取选区边界信息用于后续定位
|
||||
const selectionBounds = selectionObject.getBoundingRect(true, true);
|
||||
|
||||
// 执行在当前画布上的抠图操作
|
||||
this.cutoutImageUrl = await this._performCutout(
|
||||
sourceLayer,
|
||||
selectionObject
|
||||
);
|
||||
if (!this.cutoutImageUrl) {
|
||||
console.error("抠图失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建fabric图像对象,传递选区边界信息
|
||||
this.fabricImage = await this._createFabricImage(
|
||||
this.cutoutImageUrl,
|
||||
selectionBounds
|
||||
);
|
||||
if (!this.fabricImage) {
|
||||
console.error("创建图像对象失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 创建图像图层命令
|
||||
const createImageLayerCmd = new CreateImageLayerCommand({
|
||||
layerManager: this.layerManager,
|
||||
fabricImage: this.fabricImage,
|
||||
toolManager: this.toolManager,
|
||||
layerName: this.newLayerName,
|
||||
});
|
||||
|
||||
// 执行创建图像图层命令
|
||||
const result = await createImageLayerCmd.execute();
|
||||
this.newLayerId = createImageLayerCmd.newLayerId;
|
||||
this.executedCommands.push(createImageLayerCmd);
|
||||
|
||||
// 2. 清除选区命令
|
||||
const clearSelectionCmd = new ClearSelectionCommand({
|
||||
canvas: this.canvas,
|
||||
selectionManager: this.selectionManager,
|
||||
});
|
||||
|
||||
// 执行清除选区命令
|
||||
await clearSelectionCmd.execute();
|
||||
this.executedCommands.push(clearSelectionCmd);
|
||||
|
||||
console.log(`套索抠图完成,新图层ID: ${this.newLayerId}`);
|
||||
return {
|
||||
newLayerId: this.newLayerId,
|
||||
cutoutImageUrl: this.cutoutImageUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("套索抠图过程中出错:", error);
|
||||
|
||||
// 如果已经创建了新图层,需要进行清理
|
||||
if (this.newLayerId) {
|
||||
try {
|
||||
await this.layerManager.removeLayer(this.newLayerId);
|
||||
} catch (cleanupError) {
|
||||
console.warn("清理新图层失败:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async undo() {
|
||||
try {
|
||||
// 逆序撤销所有已执行的命令
|
||||
for (let i = this.executedCommands.length - 1; i >= 0; i--) {
|
||||
const command = this.executedCommands[i];
|
||||
if (command && typeof command.undo === "function") {
|
||||
await command.undo();
|
||||
}
|
||||
}
|
||||
|
||||
this.executedCommands = [];
|
||||
this.newLayerId = null;
|
||||
this.cutoutImageUrl = null;
|
||||
this.fabricImage = null;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("撤销套索抠图失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前画布上执行抠图操作
|
||||
* @param {Object} sourceLayer 源图层
|
||||
* @param {Object} selectionObject 选区对象
|
||||
* @returns {String} 抠图结果的DataURL
|
||||
* @private
|
||||
*/
|
||||
async _performCutout(sourceLayer, selectionObject) {
|
||||
try {
|
||||
console.log("=== 开始在当前画布执行抠图 ===");
|
||||
|
||||
// 获取选区边界
|
||||
const selectionBounds = selectionObject.getBoundingRect(true, true);
|
||||
console.log(
|
||||
`选区边界: left=${selectionBounds.left}, top=${selectionBounds.top}, width=${selectionBounds.width}, height=${selectionBounds.height}`
|
||||
);
|
||||
|
||||
// 保存画布当前状态
|
||||
const originalActiveObject = this.canvas.getActiveObject();
|
||||
const originalSelection = this.canvas.selection;
|
||||
|
||||
// 临时禁用画布选择
|
||||
this.canvas.selection = false;
|
||||
this.canvas.discardActiveObject();
|
||||
|
||||
let tempGroup = null;
|
||||
let originalObjects = [];
|
||||
|
||||
try {
|
||||
// 收集源图层中的可见对象
|
||||
const visibleObjects = sourceLayer.fabricObjects.filter(
|
||||
(obj) => obj.visible
|
||||
);
|
||||
console.log(`源图层可见对象数量: ${visibleObjects.length}`);
|
||||
|
||||
if (visibleObjects.length === 0) {
|
||||
throw new Error("源图层没有可见对象");
|
||||
}
|
||||
|
||||
// 如果只有一个对象且已经是组,直接使用
|
||||
if (visibleObjects.length === 1 && visibleObjects[0].type === "group") {
|
||||
tempGroup = visibleObjects[0];
|
||||
console.log("使用现有组对象");
|
||||
} else {
|
||||
// 创建临时组
|
||||
console.log("创建临时组...");
|
||||
|
||||
// 记录原始对象的位置和状态,用于后续恢复
|
||||
originalObjects = visibleObjects.map((obj) => ({
|
||||
object: obj,
|
||||
originalLeft: obj.left,
|
||||
originalTop: obj.top,
|
||||
originalAngle: obj.angle,
|
||||
originalScaleX: obj.scaleX,
|
||||
originalScaleY: obj.scaleY,
|
||||
}));
|
||||
|
||||
// 不需要从画布移除原对象,直接创建组
|
||||
// 克隆对象来创建组,避免影响原对象
|
||||
const clonedObjects = [];
|
||||
for (const obj of visibleObjects) {
|
||||
const cloned = await this._cloneObject(obj);
|
||||
clonedObjects.push(cloned);
|
||||
}
|
||||
|
||||
// 创建组
|
||||
tempGroup = new fabric.Group(clonedObjects, {
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
// 添加组到画布
|
||||
this.canvas.add(tempGroup);
|
||||
}
|
||||
|
||||
// 设置选区为裁剪路径
|
||||
const clipPath = await this._cloneObject(selectionObject);
|
||||
clipPath.set({
|
||||
fill: "",
|
||||
stroke: "",
|
||||
absolutePositioned: true,
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
});
|
||||
|
||||
// 应用裁剪路径到组
|
||||
tempGroup.set({
|
||||
clipPath: clipPath,
|
||||
});
|
||||
|
||||
this.canvas.renderAll();
|
||||
|
||||
// 计算渲染区域
|
||||
const renderBounds = {
|
||||
left: selectionBounds.left,
|
||||
top: selectionBounds.top,
|
||||
width: selectionBounds.width,
|
||||
height: selectionBounds.height,
|
||||
};
|
||||
|
||||
// 设置高分辨率倍数,用于提高图像清晰度
|
||||
let highResolutionScale = 1;
|
||||
|
||||
if (this.highResolutionEnabled) {
|
||||
// 结合设备像素比和配置的基础倍数,确保在所有设备上都有最佳效果
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
// 使用更激进的缩放策略,确保高清晰度
|
||||
highResolutionScale = Math.max(
|
||||
this.baseResolutionScale,
|
||||
devicePixelRatio * 2
|
||||
);
|
||||
|
||||
console.log(
|
||||
`设备像素比: ${devicePixelRatio}, 基础倍数: ${this.baseResolutionScale}, 最终放大倍数: ${highResolutionScale}`
|
||||
);
|
||||
} else {
|
||||
console.log("高分辨率渲染已禁用,使用1x倍数");
|
||||
}
|
||||
|
||||
// 创建用于导出的高分辨率canvas
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
const exportCtx = exportCanvas.getContext("2d", {
|
||||
alpha: true,
|
||||
willReadFrequently: false,
|
||||
colorSpace: "srgb",
|
||||
});
|
||||
|
||||
// 设置canvas的实际像素尺寸(放大倍数)
|
||||
const actualWidth = Math.round(
|
||||
renderBounds.width * highResolutionScale
|
||||
);
|
||||
const actualHeight = Math.round(
|
||||
renderBounds.height * highResolutionScale
|
||||
);
|
||||
|
||||
exportCanvas.width = actualWidth;
|
||||
exportCanvas.height = actualHeight;
|
||||
|
||||
// 设置canvas的显示尺寸(CSS尺寸,保持与选区一致)
|
||||
exportCanvas.style.width = renderBounds.width + "px";
|
||||
exportCanvas.style.height = renderBounds.height + "px";
|
||||
|
||||
// 启用最高质量渲染设置
|
||||
exportCtx.imageSmoothingEnabled = true;
|
||||
exportCtx.imageSmoothingQuality = "high";
|
||||
|
||||
// 设置文本渲染质量
|
||||
if (exportCtx.textRenderingOptimization) {
|
||||
exportCtx.textRenderingOptimization = "optimizeQuality";
|
||||
}
|
||||
|
||||
// 设置线条和图形的渲染质量
|
||||
exportCtx.lineCap = "round";
|
||||
exportCtx.lineJoin = "round";
|
||||
exportCtx.miterLimit = 10;
|
||||
|
||||
// 设置画布背景为透明
|
||||
exportCtx.clearRect(0, 0, actualWidth, actualHeight);
|
||||
|
||||
// 获取画布当前的变换矩阵
|
||||
const vpt = this.canvas.viewportTransform;
|
||||
const zoom = this.canvas.getZoom();
|
||||
|
||||
// 保存当前变换状态
|
||||
exportCtx.save();
|
||||
|
||||
// 应用高分辨率缩放
|
||||
exportCtx.scale(highResolutionScale, highResolutionScale);
|
||||
|
||||
// 应用偏移,只渲染选区部分
|
||||
exportCtx.translate(-renderBounds.left, -renderBounds.top);
|
||||
|
||||
// 如果画布有缩放和平移,需要应用相应变换
|
||||
if (zoom !== 1 || vpt[4] !== 0 || vpt[5] !== 0) {
|
||||
exportCtx.transform(vpt[0], vpt[1], vpt[2], vpt[3], vpt[4], vpt[5]);
|
||||
}
|
||||
|
||||
// 渲染被裁剪的组
|
||||
tempGroup.render(exportCtx);
|
||||
|
||||
exportCtx.restore();
|
||||
|
||||
// 获取结果 - 使用最高质量设置
|
||||
const dataUrl = exportCanvas.toDataURL("image/png", 1.0);
|
||||
|
||||
console.log(
|
||||
`抠图完成,选区尺寸: ${renderBounds.width}x${renderBounds.height}, 实际渲染尺寸: ${actualWidth}x${actualHeight}, 放大倍数: ${highResolutionScale}x`
|
||||
);
|
||||
|
||||
return dataUrl;
|
||||
} finally {
|
||||
// 清理和恢复
|
||||
if (tempGroup) {
|
||||
// 移除裁剪路径
|
||||
tempGroup.set({ clipPath: null });
|
||||
|
||||
// 如果是我们创建的临时组,需要移除它
|
||||
if (originalObjects.length > 0) {
|
||||
this.canvas.remove(tempGroup);
|
||||
|
||||
// 恢复原始对象的状态(位置等信息保持不变)
|
||||
originalObjects.forEach(
|
||||
({
|
||||
object,
|
||||
originalLeft,
|
||||
originalTop,
|
||||
originalAngle,
|
||||
originalScaleX,
|
||||
originalScaleY,
|
||||
}) => {
|
||||
// 确保对象仍然在画布上且状态正确
|
||||
if (!this.canvas.getObjects().includes(object)) {
|
||||
this.canvas.add(object);
|
||||
}
|
||||
|
||||
// 恢复原始变换状态(如果需要的话)
|
||||
object.set({
|
||||
left: originalLeft,
|
||||
top: originalTop,
|
||||
angle: originalAngle,
|
||||
scaleX: originalScaleX,
|
||||
scaleY: originalScaleY,
|
||||
});
|
||||
object.setCoords();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复画布状态
|
||||
this.canvas.selection = originalSelection;
|
||||
if (originalActiveObject) {
|
||||
this.canvas.setActiveObject(originalActiveObject);
|
||||
}
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("在当前画布执行抠图失败:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从DataURL创建fabric图像对象
|
||||
* @param {String} dataUrl 图像DataURL
|
||||
* @param {Object} selectionBounds 选区边界信息
|
||||
* @returns {fabric.Image} fabric图像对象
|
||||
* @private
|
||||
*/
|
||||
async _createFabricImage(dataUrl, selectionBounds) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fabric.Image.fromURL(
|
||||
dataUrl,
|
||||
(img) => {
|
||||
if (!img) {
|
||||
reject(new Error("无法从DataURL创建图像"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算画布中心位置
|
||||
const canvasCenter = this.canvas.getCenter();
|
||||
|
||||
// 如果有选区边界信息,使用选区的原始位置和尺寸
|
||||
let targetLeft = canvasCenter.left;
|
||||
let targetTop = canvasCenter.top;
|
||||
let targetWidth = img.width;
|
||||
let targetHeight = img.height;
|
||||
|
||||
if (selectionBounds) {
|
||||
// 使用选区的原始位置
|
||||
targetLeft = selectionBounds.left + selectionBounds.width / 2;
|
||||
targetTop = selectionBounds.top + selectionBounds.height / 2;
|
||||
|
||||
// 确保图像显示尺寸与选区尺寸一致
|
||||
targetWidth = selectionBounds.width;
|
||||
targetHeight = selectionBounds.height;
|
||||
|
||||
console.log(
|
||||
`设置图像位置: left=${targetLeft}, top=${targetTop}, 尺寸: ${targetWidth}x${targetHeight}`
|
||||
);
|
||||
}
|
||||
|
||||
// 计算缩放比例以匹配目标尺寸
|
||||
const scaleX = targetWidth / img.width;
|
||||
const scaleY = targetHeight / img.height;
|
||||
|
||||
// 设置图像属性
|
||||
img.set({
|
||||
left: targetLeft,
|
||||
top: targetTop,
|
||||
scaleX: scaleX,
|
||||
scaleY: scaleY,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
selectable: true,
|
||||
evented: true,
|
||||
hasControls: true,
|
||||
hasBorders: true,
|
||||
cornerStyle: "circle",
|
||||
cornerColor: "#007aff",
|
||||
cornerSize: 10,
|
||||
transparentCorners: false,
|
||||
borderColor: "#007aff",
|
||||
borderScaleFactor: 2,
|
||||
// 优化图像渲染质量
|
||||
objectCaching: false, // 禁用缓存以确保最佳质量
|
||||
statefullCache: true,
|
||||
noScaleCache: false,
|
||||
});
|
||||
|
||||
// 更新坐标
|
||||
img.setCoords();
|
||||
|
||||
resolve(img);
|
||||
},
|
||||
{
|
||||
crossOrigin: "anonymous",
|
||||
// 确保图像以最高质量加载
|
||||
quality: 1.0,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆fabric对象
|
||||
* @param {Object} obj fabric对象
|
||||
* @returns {Object} 克隆的对象
|
||||
* @private
|
||||
*/
|
||||
async _cloneObject(obj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!obj) {
|
||||
reject(new Error("对象无效,无法克隆"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
obj.clone((cloned) => {
|
||||
resolve(cloned);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
2801
src/component/Canvas/CanvasEditor/commands/LayerCommands.js
Normal file
2801
src/component/Canvas/CanvasEditor/commands/LayerCommands.js
Normal file
File diff suppressed because it is too large
Load Diff
578
src/component/Canvas/CanvasEditor/commands/LiquifyCommands.js
Normal file
578
src/component/Canvas/CanvasEditor/commands/LiquifyCommands.js
Normal file
@@ -0,0 +1,578 @@
|
||||
import { Command, FunctionCommand } from "./Command";
|
||||
|
||||
/**
|
||||
* 液化命令基类
|
||||
* 所有液化相关命令的基类
|
||||
*/
|
||||
export class LiquifyCommand extends Command {
|
||||
/**
|
||||
* 创建液化命令
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Object} options.canvas Fabric.js画布实例
|
||||
* @param {Object} options.layerManager 图层管理器实例
|
||||
* @param {Object} options.liquifyManager 液化管理器实例
|
||||
* @param {String} options.mode 液化模式
|
||||
* @param {Object} options.params 液化参数
|
||||
* @param {Object} options.targetObject 目标对象
|
||||
* @param {ImageData} options.originalData 原始图像数据
|
||||
* @param {ImageData} options.resultData 变形后图像数据
|
||||
*/
|
||||
constructor(options) {
|
||||
super({
|
||||
name: options.name || `液化操作: ${options.mode || "未知模式"}`,
|
||||
description:
|
||||
options.description ||
|
||||
`使用${options.mode || "未知模式"}模式进行液化操作`,
|
||||
});
|
||||
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.liquifyManager = options.liquifyManager;
|
||||
this.mode = options.mode;
|
||||
this.params = options.params || {};
|
||||
this.targetObject = options.targetObject;
|
||||
this.targetLayerId = options.targetLayerId;
|
||||
this.originalData = options.originalData; // 操作前的图像数据
|
||||
this.resultData = options.resultData; // 操作后的图像数据
|
||||
this.savedState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行液化操作
|
||||
* @returns {Promise} 执行结果
|
||||
*/
|
||||
async execute() {
|
||||
if (!this.canvas || !this.targetObject) {
|
||||
throw new Error("液化命令缺少必要的画布或目标对象");
|
||||
}
|
||||
|
||||
if (!this.resultData) {
|
||||
// 如果没有预先计算的结果数据,现场执行变形
|
||||
this.resultData = await this.liquifyManager.applyLiquify(
|
||||
this.targetObject,
|
||||
this.mode,
|
||||
this.params
|
||||
);
|
||||
}
|
||||
|
||||
// 保存执行前的状态
|
||||
this.savedState = await this._saveObjectState();
|
||||
|
||||
// 更新画布上的对象
|
||||
await this._updateObjectWithResult();
|
||||
|
||||
// 刷新Canvas
|
||||
this.canvas.renderAll();
|
||||
|
||||
return this.resultData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销液化操作
|
||||
* @returns {Promise} 撤销结果
|
||||
*/
|
||||
async undo() {
|
||||
if (!this.canvas || !this.targetObject || !this.savedState) {
|
||||
throw new Error("无法撤销:缺少必要的状态信息");
|
||||
}
|
||||
|
||||
// 恢复对象到原始状态
|
||||
await this._restoreObjectState();
|
||||
|
||||
// 刷新Canvas
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对象状态
|
||||
* @private
|
||||
*/
|
||||
async _saveObjectState() {
|
||||
if (!this.targetObject) return null;
|
||||
|
||||
// 对于图像对象,我们需要保存src和元数据
|
||||
const state = {
|
||||
src: this.targetObject.getSrc ? this.targetObject.getSrc() : null,
|
||||
element: this.targetObject._element
|
||||
? this.targetObject._element.cloneNode(true)
|
||||
: null,
|
||||
filters: this.targetObject.filters ? [...this.targetObject.filters] : [],
|
||||
originalData: this.originalData,
|
||||
targetLayerId: this.targetLayerId,
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复对象状态
|
||||
* @private
|
||||
*/
|
||||
async _restoreObjectState() {
|
||||
if (!this.targetObject || !this.savedState) return false;
|
||||
|
||||
// 获取当前图层对象
|
||||
const layer = this.layerManager.getLayerById(this.savedState.targetLayerId);
|
||||
if (!layer) return false;
|
||||
|
||||
// 恢复原始图像
|
||||
if (this.savedState.element && this.targetObject.setElement) {
|
||||
this.targetObject.setElement(this.savedState.element);
|
||||
|
||||
// 恢复滤镜
|
||||
if (this.savedState.filters) {
|
||||
this.targetObject.filters = [...this.savedState.filters];
|
||||
this.targetObject.applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用变形结果更新对象
|
||||
* @private
|
||||
*/
|
||||
async _updateObjectWithResult() {
|
||||
if (!this.targetObject || !this.resultData) return false;
|
||||
|
||||
// 创建临时canvas来渲染结果数据
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = this.resultData.width;
|
||||
tempCanvas.height = this.resultData.height;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
tempCtx.putImageData(this.resultData, 0, 0);
|
||||
console.log("临时Canvas创建成功 _updateObjectWithResult", this.resultData);
|
||||
// 更新Fabric图像
|
||||
await new Promise((resolve) => {
|
||||
fabric.Image.fromURL(tempCanvas.toDataURL(), (img) => {
|
||||
// 保留原对象的属性
|
||||
img.set({
|
||||
left: this.targetObject.left,
|
||||
top: this.targetObject.top,
|
||||
scaleX: this.targetObject.scaleX,
|
||||
scaleY: this.targetObject.scaleY,
|
||||
angle: this.targetObject.angle,
|
||||
flipX: this.targetObject.flipX,
|
||||
flipY: this.targetObject.flipY,
|
||||
opacity: this.targetObject.opacity,
|
||||
});
|
||||
|
||||
// 替换Canvas上的对象
|
||||
const index = this.canvas.getObjects().indexOf(this.targetObject);
|
||||
if (index !== -1) {
|
||||
this.canvas.remove(this.targetObject);
|
||||
this.canvas.insertAt(img, index);
|
||||
this.targetObject = img;
|
||||
}
|
||||
|
||||
// 确保图层引用更新
|
||||
const layer = this.layerManager.getLayerById(this.targetLayerId);
|
||||
if (layer) {
|
||||
if (
|
||||
layer.type === "background" &&
|
||||
layer.fabricObject === this.targetObject
|
||||
) {
|
||||
layer.fabricObject = img;
|
||||
} else if (layer.fabricObjects) {
|
||||
const objIndex = layer.fabricObjects.indexOf(this.targetObject);
|
||||
if (objIndex !== -1) {
|
||||
layer.fabricObjects[objIndex] = img;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 图层栅格化命令
|
||||
* 用于将复杂图层栅格化为单一图像,以便进行液化操作
|
||||
*/
|
||||
export class RasterizeForLiquifyCommand extends Command {
|
||||
/**
|
||||
* 创建栅格化命令
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Object} options.canvas Fabric.js画布实例
|
||||
* @param {Object} options.layerManager 图层管理器实例
|
||||
* @param {String} options.layerId 需要栅格化的图层ID
|
||||
*/
|
||||
constructor(options) {
|
||||
super({
|
||||
name: options.name || "栅格化图层",
|
||||
description: options.description || "将图层栅格化为单一图像以便液化操作",
|
||||
});
|
||||
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.layerId = options.layerId;
|
||||
this.originalLayer = null;
|
||||
this.rasterizedImageObj = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行栅格化操作
|
||||
* @returns {Promise<Object>} 栅格化后的图像对象
|
||||
*/
|
||||
async execute() {
|
||||
if (!this.canvas || !this.layerManager || !this.layerId) {
|
||||
throw new Error("栅格化命令缺少必要参数");
|
||||
}
|
||||
|
||||
// 保存原始图层信息
|
||||
this.originalLayer = this.layerManager.getLayerById(this.layerId);
|
||||
if (!this.originalLayer) {
|
||||
throw new Error(`图层ID不存在: ${this.layerId}`);
|
||||
}
|
||||
|
||||
// 栅格化图层
|
||||
const rasterizedImage = await this.layerManager.rasterizeLayer(
|
||||
this.layerId
|
||||
);
|
||||
if (!rasterizedImage) {
|
||||
throw new Error("栅格化图层失败");
|
||||
}
|
||||
|
||||
this.rasterizedImageObj = rasterizedImage;
|
||||
return rasterizedImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销栅格化操作
|
||||
* 注意:完整撤销栅格化是复杂的,这里提供近似还原
|
||||
* @returns {Promise<boolean>} 撤销结果
|
||||
*/
|
||||
async undo() {
|
||||
if (!this.canvas || !this.layerManager || !this.originalLayer) {
|
||||
throw new Error("无法撤销:缺少必要的状态信息");
|
||||
}
|
||||
|
||||
// 恢复图层为原始状态是复杂的,这里可能需要与LayerManager协作
|
||||
// 这个实现可能需要根据实际的LayerManager功能来调整
|
||||
const restored = await this.layerManager.restoreLayerFromBackup(
|
||||
this.layerId
|
||||
);
|
||||
if (!restored) {
|
||||
console.warn("无法完全还原栅格化前的图层状态");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 液化工具初始化命令
|
||||
* 用于初始化液化工具的状态,不执行实际操作
|
||||
*/
|
||||
export class InitLiquifyToolCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "初始化液化工具",
|
||||
description: "准备液化工具工作环境",
|
||||
undoable: false, // 这个命令不需要撤销
|
||||
});
|
||||
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.liquifyManager = options.liquifyManager;
|
||||
this.toolManager = options.toolManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行初始化
|
||||
*/
|
||||
execute() {
|
||||
if (this.liquifyManager) {
|
||||
this.liquifyManager.initialize({
|
||||
canvas: this.canvas,
|
||||
layerManager: this.layerManager,
|
||||
});
|
||||
}
|
||||
|
||||
// 通知各管理器进入液化模式
|
||||
this.toolManager?.notifyObservers("LIQUIFY");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// 不需要撤销初始化
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 液化操作命令 - 针对单次变形操作
|
||||
* 用于实现可撤销的单次液化变形
|
||||
*/
|
||||
export class LiquifyDeformCommand extends LiquifyCommand {
|
||||
/**
|
||||
* 创建液化变形命令
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Number} options.x 变形中心X坐标
|
||||
* @param {Number} options.y 变形中心Y坐标
|
||||
* @param {ImageData} options.beforeData 变形前的图像数据
|
||||
* @param {ImageData} options.afterData 变形后的图像数据
|
||||
*/
|
||||
constructor(options) {
|
||||
super({
|
||||
...options,
|
||||
name: `液化变形: ${options.mode || "未知模式"}`,
|
||||
description: `在(${options.x}, ${options.y})应用${
|
||||
options.mode || "未知模式"
|
||||
}变形`,
|
||||
});
|
||||
|
||||
this.x = options.x;
|
||||
this.y = options.y;
|
||||
this.beforeData = options.beforeData;
|
||||
this.afterData = options.afterData;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.afterData) {
|
||||
// 如果没有预计算的结果,实时计算
|
||||
await this.liquifyManager.prepareForLiquify(this.targetObject);
|
||||
this.afterData = await this.liquifyManager.applyLiquify(
|
||||
this.targetObject,
|
||||
this.mode,
|
||||
this.params,
|
||||
this.x,
|
||||
this.y
|
||||
);
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
this.savedState = await this._saveObjectState();
|
||||
|
||||
// 应用变形结果
|
||||
await this._updateObjectWithImageData(this.afterData);
|
||||
|
||||
this.canvas.renderAll();
|
||||
return this.afterData;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.beforeData) {
|
||||
throw new Error("无法撤销:缺少变形前的数据");
|
||||
}
|
||||
|
||||
// 恢复到变形前的状态
|
||||
await this._updateObjectWithImageData(this.beforeData);
|
||||
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用图像数据更新对象
|
||||
* @param {ImageData} imageData 图像数据
|
||||
* @private
|
||||
*/
|
||||
async _updateObjectWithImageData(imageData) {
|
||||
return new Promise((resolve) => {
|
||||
// 创建临时canvas
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = imageData.width;
|
||||
tempCanvas.height = imageData.height;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
// 从canvas创建新的fabric图像
|
||||
fabric.Image.fromURL(tempCanvas.toDataURL(), (img) => {
|
||||
// 保留原对象的变换属性
|
||||
img.set({
|
||||
left: this.targetObject.left,
|
||||
top: this.targetObject.top,
|
||||
scaleX: this.targetObject.scaleX,
|
||||
scaleY: this.targetObject.scaleY,
|
||||
angle: this.targetObject.angle,
|
||||
flipX: this.targetObject.flipX,
|
||||
flipY: this.targetObject.flipY,
|
||||
opacity: this.targetObject.opacity,
|
||||
});
|
||||
|
||||
// 替换canvas上的对象
|
||||
const index = this.canvas.getObjects().indexOf(this.targetObject);
|
||||
if (index !== -1) {
|
||||
this.canvas.remove(this.targetObject);
|
||||
this.canvas.insertAt(img, index);
|
||||
this.targetObject = img;
|
||||
|
||||
// 更新图层引用
|
||||
const layer = this.layerManager.getLayerById(this.targetLayerId);
|
||||
if (layer) {
|
||||
if (
|
||||
layer.type === "background" &&
|
||||
(layer.fabricObject === this.savedState?.originalObject ||
|
||||
layer.fabricObject === this.targetObject)
|
||||
) {
|
||||
layer.fabricObject = img;
|
||||
} else if (layer.fabricObjects) {
|
||||
const objIndex = layer.fabricObjects.findIndex(
|
||||
(obj) =>
|
||||
obj === this.savedState?.originalObject ||
|
||||
obj === this.targetObject
|
||||
);
|
||||
if (objIndex !== -1) {
|
||||
layer.fabricObjects[objIndex] = img;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复合液化命令 - 用于组合多个液化操作
|
||||
* 支持一次撤销多个相关的液化变形
|
||||
*/
|
||||
export class CompositeLiquifyCommand extends Command {
|
||||
/**
|
||||
* 创建复合液化命令
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Array} options.commands 子命令列表
|
||||
* @param {String} options.name 命令名称
|
||||
*/
|
||||
constructor(options) {
|
||||
super({
|
||||
name: options.name || "液化操作组合",
|
||||
description:
|
||||
options.description || `包含${options.commands?.length || 0}个液化操作`,
|
||||
});
|
||||
|
||||
this.commands = options.commands || [];
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.liquifyManager = options.liquifyManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加子命令
|
||||
* @param {Command} command 要添加的命令
|
||||
*/
|
||||
addCommand(command) {
|
||||
this.commands.push(command);
|
||||
}
|
||||
|
||||
async execute() {
|
||||
const results = [];
|
||||
|
||||
for (const command of this.commands) {
|
||||
try {
|
||||
const result = await command.execute();
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
// 如果有命令失败,尝试回滚已执行的命令
|
||||
console.error("复合液化命令执行失败:", error);
|
||||
await this.undo();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
// 逆序撤销所有子命令
|
||||
const errors = [];
|
||||
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
await this.commands[i].undo();
|
||||
} catch (error) {
|
||||
console.error(`撤销子命令${i}失败:`, error);
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`复合命令撤销部分失败: ${errors.length}个错误`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查命令是否可以执行
|
||||
* @returns {Boolean} 是否可执行
|
||||
*/
|
||||
canExecute() {
|
||||
return (
|
||||
this.commands.length > 0 &&
|
||||
this.commands.every((cmd) => (cmd.canExecute ? cmd.canExecute() : true))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 液化重置命令 - 将图像恢复到原始状态
|
||||
*/
|
||||
export class LiquifyResetCommand extends LiquifyCommand {
|
||||
constructor(options) {
|
||||
super({
|
||||
...options,
|
||||
name: "重置液化",
|
||||
description: "将图像恢复到液化前的原始状态",
|
||||
});
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.liquifyManager || !this.targetObject) {
|
||||
throw new Error("无法重置:缺少必要的管理器或目标对象");
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
this.savedState = await this._saveObjectState();
|
||||
|
||||
// 重置液化管理器
|
||||
const resetData = this.liquifyManager.reset();
|
||||
if (!resetData) {
|
||||
throw new Error("重置失败:没有原始数据");
|
||||
}
|
||||
|
||||
// 应用重置结果
|
||||
await this._updateObjectWithResult();
|
||||
|
||||
this.canvas.renderAll();
|
||||
return resetData;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:创建液化重置命令
|
||||
* @param {Object} options 配置选项
|
||||
* @returns {LiquifyResetCommand} 重置命令实例
|
||||
*/
|
||||
export function createLiquifyResetCommand(options) {
|
||||
return new LiquifyResetCommand(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:创建液化变形命令
|
||||
* @param {Object} options 配置选项
|
||||
* @returns {LiquifyDeformCommand} 变形命令实例
|
||||
*/
|
||||
export function createLiquifyDeformCommand(options) {
|
||||
return new LiquifyDeformCommand(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:创建复合液化命令
|
||||
* @param {Object} options 配置选项
|
||||
* @returns {CompositeLiquifyCommand} 复合命令实例
|
||||
*/
|
||||
export function createCompositeLiquifyCommand(options) {
|
||||
return new CompositeLiquifyCommand(options);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 查询类命令示例 - 不需要撤销
|
||||
*/
|
||||
export class GetCanvasInfoCommand {
|
||||
constructor(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.undoable = false; // 明确标记为不可撤销
|
||||
}
|
||||
|
||||
execute() {
|
||||
return {
|
||||
width: this.canvas.getWidth(),
|
||||
height: this.canvas.getHeight(),
|
||||
zoom: this.canvas.getZoom(),
|
||||
layers: this.layerManager?.getLayers()?.length || 0,
|
||||
objects: this.canvas.getObjects().length,
|
||||
};
|
||||
}
|
||||
|
||||
// 查询类命令不需要实现 undo 方法
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出类命令示例 - 不需要撤销
|
||||
*/
|
||||
export class ExportCanvasCommand {
|
||||
constructor(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.format = options.format || "png";
|
||||
this.quality = options.quality || 1;
|
||||
this.undoable = false;
|
||||
}
|
||||
|
||||
execute() {
|
||||
return this.canvas.toDataURL(`image/${this.format}`, this.quality);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证类命令示例 - 不需要撤销
|
||||
*/
|
||||
export class ValidateCanvasCommand {
|
||||
constructor(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.undoable = false;
|
||||
}
|
||||
|
||||
execute() {
|
||||
const objects = this.canvas.getObjects();
|
||||
const errors = [];
|
||||
|
||||
objects.forEach((obj, index) => {
|
||||
if (!obj.left || !obj.top) {
|
||||
errors.push(`对象 ${index} 位置无效`);
|
||||
}
|
||||
if (obj.width <= 0 || obj.height <= 0) {
|
||||
errors.push(`对象 ${index} 尺寸无效`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
objectCount: objects.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统清理命令示例 - 不可逆操作,不需要撤销
|
||||
*/
|
||||
export class CleanupTempDataCommand {
|
||||
constructor(options) {
|
||||
this.canvas = options.canvas;
|
||||
this.undoable = false;
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 清理临时数据
|
||||
const cleaned = [];
|
||||
|
||||
// 移除无效对象
|
||||
const objects = this.canvas.getObjects();
|
||||
objects.forEach((obj, index) => {
|
||||
if (obj._isTemp || obj._invalid) {
|
||||
this.canvas.remove(obj);
|
||||
cleaned.push(`临时对象 ${index}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理缓存
|
||||
if (this.canvas._clearCache) {
|
||||
this.canvas._clearCache();
|
||||
cleaned.push("画布缓存");
|
||||
}
|
||||
|
||||
return {
|
||||
cleaned,
|
||||
count: cleaned.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import { Command } from "./Command";
|
||||
import { generateId } from "../utils/helper";
|
||||
|
||||
/**
|
||||
* 设置活动图层命令
|
||||
*/
|
||||
export class SetActiveLayerCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "设置活动图层",
|
||||
saveState: false,
|
||||
});
|
||||
this.layers = options.layers;
|
||||
this.canvas = options.canvas;
|
||||
this.activeLayerId = options.activeLayerId;
|
||||
this.layerId = options.layerId;
|
||||
this.oldActiveLayerId = this.activeLayerId.value;
|
||||
this.layerManager = options.layerManager;
|
||||
this.oldActiveObjects = [];
|
||||
this.newLayer = null;
|
||||
this.editorMode = options.editorMode;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.newLayer = this.layers.value.find(
|
||||
(layer) => layer.id === this.layerId
|
||||
);
|
||||
|
||||
if (!this.newLayer) {
|
||||
console.error(`图层 ${this.layerId} 不存在`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果是背景层,不设置为活动图层
|
||||
if (this.newLayer.isBackground) {
|
||||
console.warn("背景层不能设为活动图层");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果图层已锁定,不设置为活动图层
|
||||
if (this.newLayer.locked) {
|
||||
console.warn("锁定图层不能设为活动图层");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.oldActiveObjects = this.canvas.getActiveObjects();
|
||||
|
||||
// 设置为活动图层
|
||||
this.activeLayerId.value = this.layerId;
|
||||
|
||||
// 如果在选择模式下,取消所有选择
|
||||
if (this.editorMode === OperationType.SELECT && this.canvas) {
|
||||
this.canvas.discardActiveObject();
|
||||
|
||||
// 设置为新的图层下的对象为激活,但需要确保对象存在于画布上
|
||||
if (
|
||||
this.newLayer.fabricObjects &&
|
||||
this.newLayer.fabricObjects.length > 0
|
||||
) {
|
||||
const canvasObjects = this.canvas.getObjects();
|
||||
const validObjects = this.newLayer.fabricObjects.filter(
|
||||
(obj) => obj && canvasObjects.includes(obj)
|
||||
);
|
||||
|
||||
if (validObjects.length > 0) {
|
||||
if (validObjects.length === 1) {
|
||||
// 只有一个对象时直接设置
|
||||
this.canvas.setActiveObject(validObjects[0]);
|
||||
} else {
|
||||
// 多个对象时创建活动选择组
|
||||
const activeSelection = new fabric.ActiveSelection(validObjects, {
|
||||
canvas: this.canvas,
|
||||
});
|
||||
this.canvas.setActiveObject(activeSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// 恢复原活动图层ID
|
||||
this.activeLayerId.value = this.oldActiveLayerId;
|
||||
|
||||
// 如果在选择模式下,恢复取消的所有选择
|
||||
if (this.editorMode === OperationType.SELECT && this.canvas) {
|
||||
this.canvas.discardActiveObject();
|
||||
|
||||
// 修复:确保对象存在于画布上才激活
|
||||
if (this.oldActiveObjects && this.oldActiveObjects.length > 0) {
|
||||
const canvasObjects = this.canvas.getObjects();
|
||||
const validObjects = this.oldActiveObjects.filter(
|
||||
(obj) => obj && canvasObjects.includes(obj)
|
||||
);
|
||||
|
||||
if (validObjects.length > 0) {
|
||||
if (validObjects.length > 1) {
|
||||
// 如果有多个对象,需要创建一个活动选择组
|
||||
const activeSelection = new fabric.ActiveSelection(validObjects, {
|
||||
canvas: this.canvas,
|
||||
});
|
||||
this.canvas.setActiveObject(activeSelection);
|
||||
} else {
|
||||
// 只有一个对象时直接设置
|
||||
this.canvas.setActiveObject(validObjects[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
layerId: this.layerId,
|
||||
oldActiveLayerId: this.oldActiveLayerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加对象到图层命令
|
||||
*/
|
||||
export class AddObjectToLayerCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "添加对象到图层",
|
||||
saveState: true,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layers = options.layers;
|
||||
this.layerId = options.layerId;
|
||||
this.fabricObject = options.fabricObject;
|
||||
|
||||
// 保存对象原始状态和ID
|
||||
this.objectId =
|
||||
this.fabricObject.id ||
|
||||
`obj_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
this.originalObjectState = this.fabricObject.toObject([
|
||||
"id",
|
||||
"layerId",
|
||||
"layerName",
|
||||
]);
|
||||
}
|
||||
|
||||
execute() {
|
||||
// 查找目标图层
|
||||
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||
|
||||
if (!layer) {
|
||||
console.error(`图层 ${this.layerId} 不存在`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果是背景层,不允许添加对象
|
||||
if (layer.isBackground) {
|
||||
console.warn("不能向背景层添加对象");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 为对象生成唯一ID
|
||||
this.fabricObject.id = this.objectId;
|
||||
|
||||
// 设置对象与图层的关联
|
||||
this.fabricObject.layerId = this.layerId;
|
||||
this.fabricObject.layerName = layer.name;
|
||||
|
||||
// 设置对象可操作可选择
|
||||
this.fabricObject.selectable = true;
|
||||
this.fabricObject.evented = true;
|
||||
|
||||
// 将对象添加到画布
|
||||
this.canvas.add(this.fabricObject);
|
||||
|
||||
// 将对象添加到图层的fabricObjects数组
|
||||
layer.fabricObjects = layer.fabricObjects || [];
|
||||
layer.fabricObjects.push(this.fabricObject);
|
||||
|
||||
this.canvas.discardActiveObject();
|
||||
|
||||
// 确保对象确实存在于画布上才激活
|
||||
const canvasObjects = this.canvas.getObjects();
|
||||
const validObjects = layer.fabricObjects.filter(
|
||||
(obj) => obj && canvasObjects.includes(obj)
|
||||
);
|
||||
|
||||
if (validObjects.length > 0) {
|
||||
if (validObjects.length === 1) {
|
||||
// 只有一个对象时直接设置
|
||||
this.canvas.setActiveObject(validObjects[0]);
|
||||
} else {
|
||||
// 多个对象时创建活动选择组
|
||||
const activeSelection = new fabric.ActiveSelection(validObjects, {
|
||||
canvas: this.canvas,
|
||||
});
|
||||
this.canvas.setActiveObject(activeSelection);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return this.fabricObject;
|
||||
}
|
||||
|
||||
undo() {
|
||||
// 查找图层
|
||||
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||
|
||||
if (!layer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从图层的fabricObjects数组中移除对象
|
||||
if (layer.fabricObjects) {
|
||||
layer.fabricObjects = layer.fabricObjects.filter(
|
||||
(obj) => obj.id !== this.objectId
|
||||
);
|
||||
}
|
||||
// 从画布移除对象
|
||||
const object = this.canvas
|
||||
.getObjects()
|
||||
.find((obj) => obj.id === this.objectId);
|
||||
if (object) {
|
||||
// 先丢弃活动对象,避免控制点渲染错误
|
||||
this.canvas.discardActiveObject();
|
||||
this.canvas.remove(object);
|
||||
// 更新画布
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
layerId: this.layerId,
|
||||
objectId: this.objectId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从图层中移除对象命令
|
||||
*/
|
||||
export class RemoveObjectFromLayerCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "从图层中移除对象",
|
||||
saveState: true,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layers = options.layers;
|
||||
this.objectId = options.objectId;
|
||||
|
||||
// 查找对象和图层
|
||||
this.object =
|
||||
typeof options.objectOrId === "object"
|
||||
? options.objectOrId
|
||||
: this.canvas.getObjects().find((obj) => obj.id === this.objectId);
|
||||
|
||||
if (this.object) {
|
||||
this.layerId = this.object.layerId;
|
||||
this.objectData = this.object.toObject(["id", "layerId", "layerName"]);
|
||||
}
|
||||
}
|
||||
|
||||
execute() {
|
||||
if (!this.object) {
|
||||
console.error(`对象 ${this.objectId} 不存在`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.layerId) {
|
||||
console.error(`对象 ${this.objectId} 未关联到任何图层`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找图层
|
||||
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||
if (!layer) {
|
||||
console.error(`图层 ${this.layerId} 不存在`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 从画布移除对象
|
||||
this.canvas.remove(this.object);
|
||||
|
||||
// 从图层的fabricObjects数组移除对象
|
||||
if (layer.fabricObjects) {
|
||||
layer.fabricObjects = layer.fabricObjects.filter(
|
||||
(obj) => obj.id !== this.objectId
|
||||
);
|
||||
}
|
||||
|
||||
// 更新画布
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (!this.objectData || !this.layerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找图层
|
||||
const layer = this.layers.value.find((l) => l.id === this.layerId);
|
||||
if (!layer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 恢复对象到画布
|
||||
fabric.util.enlivenObjects([this.objectData], (objects) => {
|
||||
const restoredObject = objects[0];
|
||||
|
||||
// 将对象添加到画布
|
||||
this.canvas.add(restoredObject);
|
||||
|
||||
// 将对象添加回图层
|
||||
layer.fabricObjects = layer.fabricObjects || [];
|
||||
layer.fabricObjects.push(restoredObject);
|
||||
|
||||
// 更新画布
|
||||
this.canvas.renderAll();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
objectId: this.objectId,
|
||||
layerId: this.layerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换固定图层图像命令
|
||||
* 专门用于更换固定图层(如背景图层)的图像
|
||||
*/
|
||||
export class ChangeFixedImageCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.imageUrl = options.imageUrl;
|
||||
this.targetLayerType = options.targetLayerType || "background"; // 'background', 'fixed', etc.
|
||||
this.position = options.position || { x: 0, y: 0 };
|
||||
this.scale = options.scale || { x: 1, y: 1 };
|
||||
this.preserveTransform = options.preserveTransform !== false; // 默认保留变换
|
||||
|
||||
// 用于回滚的状态
|
||||
this.previousImage = null;
|
||||
this.previousTransform = null;
|
||||
this.targetLayer = null;
|
||||
this.isExecuted = false;
|
||||
|
||||
// 错误处理
|
||||
this.maxRetries = options.maxRetries || 3;
|
||||
this.retryCount = 0;
|
||||
this.timeoutMs = options.timeoutMs || 10000;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
try {
|
||||
this.validateInputs();
|
||||
|
||||
// 查找目标图层
|
||||
this.targetLayer = this.findTargetLayer();
|
||||
if (!this.targetLayer) {
|
||||
throw new Error(`找不到目标图层类型: ${this.targetLayerType}`);
|
||||
}
|
||||
|
||||
// 保存当前状态用于回滚
|
||||
await this.saveCurrentState();
|
||||
|
||||
// 加载新图像
|
||||
const newImage = await this.loadImageWithRetry();
|
||||
|
||||
// 应用图像到图层
|
||||
await this.applyImageToLayer(newImage);
|
||||
|
||||
this.isExecuted = true;
|
||||
|
||||
// 触发成功事件
|
||||
this.emitEvent("image:changed", {
|
||||
layerId: this.targetLayer.id,
|
||||
newImageUrl: this.imageUrl,
|
||||
command: this,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
layerId: this.targetLayer.id,
|
||||
imageUrl: this.imageUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("ChangeFixedImageCommand执行失败:", error);
|
||||
|
||||
// 如果已经执行了部分操作,尝试回滚
|
||||
if (this.isExecuted) {
|
||||
try {
|
||||
await this.undo();
|
||||
} catch (rollbackError) {
|
||||
console.error("回滚失败:", rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.isExecuted || !this.targetLayer) {
|
||||
throw new Error("命令未执行或目标图层不存在");
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.previousImage) {
|
||||
// 恢复之前的图像
|
||||
await this.restorePreviousImage();
|
||||
} else {
|
||||
// 如果没有之前的图像,移除当前图像
|
||||
await this.removeCurrentImage();
|
||||
}
|
||||
|
||||
this.isExecuted = false;
|
||||
|
||||
// 触发撤销事件
|
||||
this.emitEvent("image:reverted", {
|
||||
layerId: this.targetLayer.id,
|
||||
command: this,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "reverted",
|
||||
layerId: this.targetLayer.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("ChangeFixedImageCommand撤销失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
validateInputs() {
|
||||
if (!this.canvas) throw new Error("Canvas实例是必需的");
|
||||
if (!this.layerManager) throw new Error("LayerManager实例是必需的");
|
||||
if (!this.imageUrl) throw new Error("图像URL是必需的");
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(this.imageUrl);
|
||||
} catch {
|
||||
throw new Error("无效的图像URL格式");
|
||||
}
|
||||
}
|
||||
|
||||
findTargetLayer() {
|
||||
const layers = this.layerManager.layers?.value || [];
|
||||
|
||||
switch (this.targetLayerType) {
|
||||
case "background":
|
||||
return layers.find((layer) => layer.isBackground);
|
||||
case "fixed":
|
||||
return layers.find((layer) => layer.isFixed);
|
||||
default:
|
||||
return layers.find((layer) => layer.type === this.targetLayerType);
|
||||
}
|
||||
}
|
||||
|
||||
async saveCurrentState() {
|
||||
if (!this.targetLayer.fabricObject) return;
|
||||
|
||||
const currentObj = this.targetLayer.fabricObject;
|
||||
|
||||
// 保存当前图像URL(如果存在)
|
||||
this.previousImage = {
|
||||
url: currentObj.getSrc ? currentObj.getSrc() : null,
|
||||
element: currentObj._element ? currentObj._element.cloneNode() : null,
|
||||
};
|
||||
|
||||
// 保存变换状态
|
||||
this.previousTransform = {
|
||||
left: currentObj.left,
|
||||
top: currentObj.top,
|
||||
scaleX: currentObj.scaleX,
|
||||
scaleY: currentObj.scaleY,
|
||||
angle: currentObj.angle,
|
||||
flipX: currentObj.flipX,
|
||||
flipY: currentObj.flipY,
|
||||
opacity: currentObj.opacity,
|
||||
};
|
||||
}
|
||||
|
||||
async loadImageWithRetry() {
|
||||
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
return await this.loadImage();
|
||||
} catch (error) {
|
||||
this.retryCount = attempt;
|
||||
|
||||
if (attempt === this.maxRetries) {
|
||||
throw new Error(
|
||||
`图像加载失败,已重试${this.maxRetries}次: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// 指数退避重试
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
console.warn(
|
||||
`图像加载重试 ${attempt + 1}/${this.maxRetries}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`图像加载超时 (${this.timeoutMs}ms): ${this.imageUrl}`)
|
||||
);
|
||||
}, this.timeoutMs);
|
||||
|
||||
fabric.Image.fromURL(
|
||||
this.imageUrl,
|
||||
(img) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!img || !img.getElement()) {
|
||||
reject(new Error("图像加载失败或无效"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(img);
|
||||
},
|
||||
{
|
||||
crossOrigin: "anonymous",
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async applyImageToLayer(newImage) {
|
||||
const currentObj = this.targetLayer.fabricObject;
|
||||
|
||||
// 设置基本属性
|
||||
newImage.set({
|
||||
id: currentObj?.id || generateId(),
|
||||
layerId: this.targetLayer.id,
|
||||
layerName: this.targetLayer.name,
|
||||
isBackground: this.targetLayer.isBackground,
|
||||
isFixed: this.targetLayer.isFixed,
|
||||
});
|
||||
|
||||
// 应用位置和变换
|
||||
if (this.preserveTransform && this.previousTransform) {
|
||||
newImage.set(this.previousTransform);
|
||||
} else {
|
||||
newImage.set({
|
||||
left: this.position.x,
|
||||
top: this.position.y,
|
||||
scaleX: this.scale.x,
|
||||
scaleY: this.scale.y,
|
||||
});
|
||||
}
|
||||
|
||||
// 移除旧对象(如果存在)
|
||||
if (currentObj) {
|
||||
this.canvas.remove(currentObj);
|
||||
}
|
||||
|
||||
// 添加新图像
|
||||
this.canvas.add(newImage);
|
||||
newImage.setCoords();
|
||||
|
||||
// 更新图层引用
|
||||
this.targetLayer.fabricObject = newImage;
|
||||
|
||||
// 更新图层管理器
|
||||
this.layerManager.updateLayerObject(this.targetLayer.id, newImage);
|
||||
|
||||
// 重新渲染画布
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
async restorePreviousImage() {
|
||||
if (!this.previousImage.url) return;
|
||||
|
||||
const restoredImage = await this.loadImageFromUrl(this.previousImage.url);
|
||||
|
||||
// 恢复之前的变换
|
||||
if (this.previousTransform) {
|
||||
restoredImage.set(this.previousTransform);
|
||||
}
|
||||
|
||||
// 设置图层属性
|
||||
restoredImage.set({
|
||||
id: this.targetLayer.fabricObject?.id || generateId(),
|
||||
layerId: this.targetLayer.id,
|
||||
layerName: this.targetLayer.name,
|
||||
isBackground: this.targetLayer.isBackground,
|
||||
isFixed: this.targetLayer.isFixed,
|
||||
});
|
||||
|
||||
// 替换当前对象
|
||||
if (this.targetLayer.fabricObject) {
|
||||
this.canvas.remove(this.targetLayer.fabricObject);
|
||||
}
|
||||
|
||||
this.canvas.add(restoredImage);
|
||||
restoredImage.setCoords();
|
||||
|
||||
// 更新引用
|
||||
this.targetLayer.fabricObject = restoredImage;
|
||||
this.layerManager.updateLayerObject(this.targetLayer.id, restoredImage);
|
||||
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
async removeCurrentImage() {
|
||||
if (this.targetLayer.fabricObject) {
|
||||
this.canvas.remove(this.targetLayer.fabricObject);
|
||||
this.targetLayer.fabricObject = null;
|
||||
this.layerManager.updateLayerObject(this.targetLayer.id, null);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
loadImageFromUrl(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fabric.Image.fromURL(
|
||||
url,
|
||||
(img) => {
|
||||
if (!img || !img.getElement()) {
|
||||
reject(new Error("恢复图像加载失败"));
|
||||
return;
|
||||
}
|
||||
resolve(img);
|
||||
},
|
||||
{ crossOrigin: "anonymous" }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
emitEvent(eventName, data) {
|
||||
if (this.canvas && this.canvas.fire) {
|
||||
this.canvas.fire(eventName, data);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取命令信息用于调试
|
||||
getCommandInfo() {
|
||||
return {
|
||||
type: "ChangeFixedImageCommand",
|
||||
targetLayerType: this.targetLayerType,
|
||||
imageUrl: this.imageUrl,
|
||||
isExecuted: this.isExecuted,
|
||||
retryCount: this.retryCount,
|
||||
targetLayerId: this.targetLayer?.id,
|
||||
preserveTransform: this.preserveTransform,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向图层添加图像命令
|
||||
* 用于向指定图层添加新的图像对象
|
||||
*/
|
||||
export class AddImageToLayerCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.imageUrl = options.imageUrl;
|
||||
this.layerId = options.layerId;
|
||||
this.position = options.position || { x: 100, y: 100 };
|
||||
this.scale = options.scale || { x: 1, y: 1 };
|
||||
this.zIndex = options.zIndex || null; // 可选的层级控制
|
||||
|
||||
// 用于回滚的状态
|
||||
this.addedObject = null;
|
||||
this.targetLayer = null;
|
||||
this.isExecuted = false;
|
||||
|
||||
// 错误处理
|
||||
this.maxRetries = options.maxRetries || 3;
|
||||
this.retryCount = 0;
|
||||
this.timeoutMs = options.timeoutMs || 10000;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
try {
|
||||
this.validateInputs();
|
||||
|
||||
// 查找目标图层
|
||||
this.targetLayer = this.findTargetLayer();
|
||||
if (!this.targetLayer) {
|
||||
throw new Error(`找不到目标图层: ${this.layerId}`);
|
||||
}
|
||||
|
||||
// 检查图层是否可编辑
|
||||
this.validateLayerEditability();
|
||||
|
||||
// 加载新图像
|
||||
const newImage = await this.loadImageWithRetry();
|
||||
|
||||
// 添加图像到图层
|
||||
await this.addImageToLayer(newImage);
|
||||
|
||||
this.isExecuted = true;
|
||||
|
||||
// 触发成功事件
|
||||
this.emitEvent("image:added", {
|
||||
layerId: this.layerId,
|
||||
objectId: this.addedObject.id,
|
||||
imageUrl: this.imageUrl,
|
||||
command: this,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
layerId: this.layerId,
|
||||
objectId: this.addedObject.id,
|
||||
imageUrl: this.imageUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("AddImageToLayerCommand执行失败:", error);
|
||||
|
||||
// 如果已经添加了对象,尝试移除
|
||||
if (this.addedObject) {
|
||||
try {
|
||||
await this.undo();
|
||||
} catch (rollbackError) {
|
||||
console.error("回滚失败:", rollbackError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.isExecuted || !this.addedObject) {
|
||||
throw new Error("命令未执行或没有添加的对象");
|
||||
}
|
||||
|
||||
try {
|
||||
// 移除添加的对象
|
||||
this.canvas.remove(this.addedObject);
|
||||
|
||||
// 从图层管理器中移除
|
||||
this.layerManager.removeObjectFromLayer(
|
||||
this.addedObject.id,
|
||||
this.layerId
|
||||
);
|
||||
|
||||
this.isExecuted = false;
|
||||
|
||||
// 触发撤销事件
|
||||
this.emitEvent("image:removed", {
|
||||
layerId: this.layerId,
|
||||
objectId: this.addedObject.id,
|
||||
command: this,
|
||||
});
|
||||
|
||||
// 重新渲染
|
||||
this.canvas.renderAll();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
action: "removed",
|
||||
layerId: this.layerId,
|
||||
objectId: this.addedObject.id,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("AddImageToLayerCommand撤销失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
validateInputs() {
|
||||
if (!this.canvas) throw new Error("Canvas实例是必需的");
|
||||
if (!this.layerManager) throw new Error("LayerManager实例是必需的");
|
||||
if (!this.imageUrl) throw new Error("图像URL是必需的");
|
||||
if (!this.layerId) throw new Error("图层ID是必需的");
|
||||
|
||||
// 验证URL格式
|
||||
try {
|
||||
new URL(this.imageUrl);
|
||||
} catch {
|
||||
throw new Error("无效的图像URL格式");
|
||||
}
|
||||
}
|
||||
|
||||
findTargetLayer() {
|
||||
const layers = this.layerManager.layers?.value || [];
|
||||
return layers.find((layer) => layer.id === this.layerId);
|
||||
}
|
||||
|
||||
validateLayerEditability() {
|
||||
if (this.targetLayer.locked) {
|
||||
throw new Error("目标图层已锁定,无法添加对象");
|
||||
}
|
||||
|
||||
if (!this.targetLayer.visible) {
|
||||
console.warn("目标图层不可见,添加的对象可能不会显示");
|
||||
}
|
||||
}
|
||||
|
||||
async loadImageWithRetry() {
|
||||
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
return await this.loadImage();
|
||||
} catch (error) {
|
||||
this.retryCount = attempt;
|
||||
|
||||
if (attempt === this.maxRetries) {
|
||||
throw new Error(
|
||||
`图像加载失败,已重试${this.maxRetries}次: ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// 指数退避重试
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
console.warn(
|
||||
`图像加载重试 ${attempt + 1}/${this.maxRetries}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(
|
||||
new Error(`图像加载超时 (${this.timeoutMs}ms): ${this.imageUrl}`)
|
||||
);
|
||||
}, this.timeoutMs);
|
||||
|
||||
fabric.Image.fromURL(
|
||||
this.imageUrl,
|
||||
(img) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!img || !img.getElement()) {
|
||||
reject(new Error("图像加载失败或无效"));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(img);
|
||||
},
|
||||
{
|
||||
crossOrigin: "anonymous",
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async addImageToLayer(newImage) {
|
||||
// 生成唯一ID
|
||||
const objectId = generateId();
|
||||
|
||||
// 设置图像属性
|
||||
newImage.set({
|
||||
id: objectId,
|
||||
layerId: this.layerId,
|
||||
layerName: this.targetLayer.name,
|
||||
left: this.position.x,
|
||||
top: this.position.y,
|
||||
scaleX: this.scale.x,
|
||||
scaleY: this.scale.y,
|
||||
selectable: true,
|
||||
evented: true,
|
||||
});
|
||||
|
||||
// 添加到画布
|
||||
this.canvas.add(newImage);
|
||||
|
||||
// 设置层级
|
||||
if (this.zIndex !== null) {
|
||||
this.setObjectZIndex(newImage, this.zIndex);
|
||||
}
|
||||
|
||||
newImage.setCoords();
|
||||
|
||||
// 保存引用用于回滚
|
||||
this.addedObject = newImage;
|
||||
|
||||
// 添加到图层管理器
|
||||
this.layerManager.addObjectToLayer(newImage, this.layerId);
|
||||
|
||||
// 重新渲染画布
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
setObjectZIndex(object, zIndex) {
|
||||
if (zIndex === "top") {
|
||||
object.bringToFront();
|
||||
} else if (zIndex === "bottom") {
|
||||
object.sendToBack();
|
||||
} else if (typeof zIndex === "number") {
|
||||
object.moveTo(zIndex);
|
||||
}
|
||||
}
|
||||
|
||||
emitEvent(eventName, data) {
|
||||
if (this.canvas && this.canvas.fire) {
|
||||
this.canvas.fire(eventName, data);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取命令信息用于调试
|
||||
getCommandInfo() {
|
||||
return {
|
||||
type: "AddImageToLayerCommand",
|
||||
layerId: this.layerId,
|
||||
imageUrl: this.imageUrl,
|
||||
position: this.position,
|
||||
scale: this.scale,
|
||||
isExecuted: this.isExecuted,
|
||||
retryCount: this.retryCount,
|
||||
addedObjectId: this.addedObject?.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
379
src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js
Normal file
379
src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js
Normal file
@@ -0,0 +1,379 @@
|
||||
import { OperationType } from "../utils/layerHelper.js";
|
||||
import { Command, CompositeCommand } from "./Command.js";
|
||||
//import { fabric } from "fabric-with-all";
|
||||
|
||||
/**
|
||||
* 批量初始化红绿图模式命令
|
||||
* 将衣服底图添加到背景层、红绿图添加到固定图层、调整位置和大小,以及设置画布背景为白色等操作合并到一个命令中
|
||||
* 减少页面闪烁,一次性渲染完成
|
||||
*/
|
||||
export class BatchInitializeRedGreenModeCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "批量初始化红绿图模式",
|
||||
description: "一次性完成红绿图模式的所有初始化操作",
|
||||
});
|
||||
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.toolManager = options.toolManager;
|
||||
this.clothingImageUrl = options.clothingImageUrl;
|
||||
this.redGreenImageUrl = options.redGreenImageUrl;
|
||||
this.onImageGenerated = options.onImageGenerated;
|
||||
this.normalLayerOpacity = options.normalLayerOpacity || 0.4;
|
||||
|
||||
// 存储原始状态以便撤销
|
||||
this.originalCanvasBackground = null;
|
||||
this.originalBackgroundObject = null;
|
||||
this.originalFixedObjects = null;
|
||||
this.originalNormalObjects = null;
|
||||
this.originalNormalOpacities = new Map();
|
||||
this.originalToolState = null;
|
||||
|
||||
// 存储加载的图片对象
|
||||
this.clothingImage = null;
|
||||
this.redGreenImage = null;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
try {
|
||||
// 禁用画布渲染以避免闪烁
|
||||
this.canvas.renderOnAddRemove = false;
|
||||
|
||||
// 1. 设置画布背景为白色
|
||||
this.originalCanvasBackground = this.canvas.backgroundColor;
|
||||
this.canvas.setBackgroundColor('#ffffff', () => {});
|
||||
|
||||
// 2. 查找图层结构
|
||||
const layers = this.layerManager.layers?.value || [];
|
||||
const backgroundLayer = layers.find((layer) => layer.isBackground);
|
||||
const fixedLayer = layers.find((layer) => layer.isFixed);
|
||||
const normalLayers = layers.filter(
|
||||
(layer) => !layer.isBackground && !layer.isFixed
|
||||
);
|
||||
|
||||
if (!backgroundLayer || !fixedLayer || normalLayers.length === 0) {
|
||||
throw new Error("缺少必要的图层结构");
|
||||
}
|
||||
|
||||
const normalLayer = normalLayers[0]; // 使用第一个普通图层
|
||||
|
||||
// 3. 保存原始状态
|
||||
this.originalBackgroundObject = backgroundLayer.fabricObject ? {
|
||||
...backgroundLayer.fabricObject.toObject(),
|
||||
ref: backgroundLayer.fabricObject
|
||||
} : null;
|
||||
|
||||
this.originalFixedObjects = fixedLayer.fabricObject
|
||||
? [fixedLayer.fabricObject]
|
||||
: [];
|
||||
|
||||
this.originalNormalObjects = normalLayer.fabricObjects
|
||||
? [...normalLayer.fabricObjects]
|
||||
: [];
|
||||
|
||||
// 保存普通图层透明度
|
||||
normalLayers.forEach((layer) => {
|
||||
this.originalNormalOpacities.set(layer.id, layer.opacity || 1);
|
||||
if (layer.fabricObjects) {
|
||||
layer.fabricObjects.forEach((obj) => {
|
||||
this.originalNormalOpacities.set(
|
||||
`${layer.id}_${obj.id || "unknown"}`,
|
||||
obj.opacity || 1
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 保存工具状态
|
||||
if (this.toolManager) {
|
||||
this.originalToolState = {
|
||||
currentTool: this.toolManager.getCurrentTool(),
|
||||
isRedGreenMode: this.toolManager.isRedGreenMode,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 确保背景图层大小正确
|
||||
await this._setupBackgroundLayer(backgroundLayer);
|
||||
|
||||
// 5. 并行加载两个图片
|
||||
const [clothingImg, redGreenImg] = await Promise.all([
|
||||
this._loadImage(this.clothingImageUrl),
|
||||
this._loadImage(this.redGreenImageUrl)
|
||||
]);
|
||||
|
||||
// 6. 设置衣服底图到固定图层
|
||||
await this._setupClothingImage(clothingImg, fixedLayer);
|
||||
|
||||
// 7. 设置红绿图到普通图层,位置和大小与衣服底图一致
|
||||
await this._setupRedGreenImage(redGreenImg, normalLayer, this.clothingImage);
|
||||
|
||||
// 8. 设置普通图层透明度
|
||||
this._setupNormalLayerOpacity(normalLayers);
|
||||
|
||||
// 9. 配置工具管理器
|
||||
this._setupToolManager();
|
||||
|
||||
// 10. 重新启用渲染并执行一次性渲染
|
||||
this.canvas.renderOnAddRemove = true;
|
||||
this.canvas.renderAll();
|
||||
|
||||
console.log("批量红绿图模式初始化完成", {
|
||||
衣服底图: this.clothingImageUrl,
|
||||
红绿图: this.redGreenImageUrl,
|
||||
普通图层透明度: `${Math.round(this.normalLayerOpacity * 100)}%`,
|
||||
画布背景: "白色",
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 恢复渲染
|
||||
this.canvas.renderOnAddRemove = true;
|
||||
console.error("批量红绿图模式初始化失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async undo() {
|
||||
try {
|
||||
// 禁用渲染
|
||||
this.canvas.renderOnAddRemove = false;
|
||||
|
||||
// 1. 恢复画布背景
|
||||
if (this.originalCanvasBackground !== null) {
|
||||
this.canvas.setBackgroundColor(this.originalCanvasBackground, () => {});
|
||||
}
|
||||
|
||||
// 2. 恢复图层对象
|
||||
const layers = this.layerManager.layers?.value || [];
|
||||
const backgroundLayer = layers.find((layer) => layer.isBackground);
|
||||
const fixedLayer = layers.find((layer) => layer.isFixed);
|
||||
const normalLayers = layers.filter(
|
||||
(layer) => !layer.isBackground && !layer.isFixed
|
||||
);
|
||||
|
||||
// 移除当前添加的对象
|
||||
if (this.clothingImage) {
|
||||
this.canvas.remove(this.clothingImage);
|
||||
}
|
||||
if (this.redGreenImage) {
|
||||
this.canvas.remove(this.redGreenImage);
|
||||
}
|
||||
|
||||
// 恢复背景图层
|
||||
if (backgroundLayer && this.originalBackgroundObject) {
|
||||
if (this.originalBackgroundObject.ref) {
|
||||
backgroundLayer.fabricObject = this.originalBackgroundObject.ref;
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复固定图层
|
||||
if (fixedLayer) {
|
||||
fixedLayer.fabricObject = this.originalFixedObjects.length > 0
|
||||
? this.originalFixedObjects[0]
|
||||
: null;
|
||||
|
||||
if (fixedLayer.fabricObject) {
|
||||
this.canvas.add(fixedLayer.fabricObject);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复普通图层
|
||||
if (normalLayers.length > 0) {
|
||||
const normalLayer = normalLayers[0];
|
||||
normalLayer.fabricObjects = [...this.originalNormalObjects];
|
||||
this.originalNormalObjects.forEach((obj) => {
|
||||
this.canvas.add(obj);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 恢复透明度
|
||||
normalLayers.forEach((layer) => {
|
||||
if (this.originalNormalOpacities.has(layer.id)) {
|
||||
layer.opacity = this.originalNormalOpacities.get(layer.id);
|
||||
}
|
||||
|
||||
if (layer.fabricObjects) {
|
||||
layer.fabricObjects.forEach((obj) => {
|
||||
const key = `${layer.id}_${obj.id || "unknown"}`;
|
||||
if (this.originalNormalOpacities.has(key)) {
|
||||
obj.opacity = this.originalNormalOpacities.get(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 恢复工具状态
|
||||
if (this.toolManager && this.originalToolState) {
|
||||
this.toolManager.isRedGreenMode = this.originalToolState.isRedGreenMode;
|
||||
if (this.originalToolState.currentTool) {
|
||||
this.toolManager.setTool(this.originalToolState.currentTool);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 重新启用渲染
|
||||
this.canvas.renderOnAddRemove = true;
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.canvas.renderOnAddRemove = true;
|
||||
console.error("撤销批量红绿图模式初始化失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置背景图层
|
||||
*/
|
||||
async _setupBackgroundLayer(backgroundLayer) {
|
||||
let backgroundObject = backgroundLayer.fabricObject;
|
||||
|
||||
if (!backgroundObject) {
|
||||
// 创建白色背景矩形
|
||||
backgroundObject = new fabric.Rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
fill: "#ffffff",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
isBackground: true,
|
||||
layerId: backgroundLayer.id,
|
||||
layerName: backgroundLayer.name,
|
||||
});
|
||||
|
||||
this.canvas.add(backgroundObject);
|
||||
this.canvas.sendToBack(backgroundObject);
|
||||
backgroundLayer.fabricObject = backgroundObject;
|
||||
} else {
|
||||
// 更新现有背景对象大小
|
||||
backgroundObject.set({
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
left: 0,
|
||||
top: 0,
|
||||
fill: "#ffffff", // 确保背景是白色
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片
|
||||
*/
|
||||
async _loadImage(imageUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fabric.Image.fromURL(
|
||||
imageUrl,
|
||||
(img) => {
|
||||
if (!img) {
|
||||
reject(new Error(`无法加载图片: ${imageUrl}`));
|
||||
return;
|
||||
}
|
||||
resolve(img);
|
||||
},
|
||||
{ crossOrigin: "anonymous" }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置衣服底图
|
||||
*/
|
||||
async _setupClothingImage(img, fixedLayer) {
|
||||
// 计算图片缩放,保持上下留边距
|
||||
const margin = 50;
|
||||
const maxWidth = this.canvas.width - margin * 2;
|
||||
const maxHeight = this.canvas.height - margin * 2;
|
||||
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
|
||||
|
||||
img.set({
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
left: this.canvas.width / 2,
|
||||
top: this.canvas.height / 2,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
layerId: fixedLayer.id,
|
||||
layerName: fixedLayer.name,
|
||||
});
|
||||
|
||||
// 清除固定图层原有内容
|
||||
if (fixedLayer.fabricObject) {
|
||||
this.canvas.remove(fixedLayer.fabricObject);
|
||||
}
|
||||
|
||||
// 添加到画布和固定图层
|
||||
this.canvas.add(img);
|
||||
fixedLayer.fabricObject = img;
|
||||
this.clothingImage = img;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置红绿图
|
||||
*/
|
||||
async _setupRedGreenImage(img, normalLayer, clothingImage) {
|
||||
if (!clothingImage) {
|
||||
throw new Error("衣服底图未加载,无法设置红绿图位置");
|
||||
}
|
||||
|
||||
// 使用与衣服底图完全相同的属性
|
||||
img.set({
|
||||
scaleX: clothingImage.scaleX,
|
||||
scaleY: clothingImage.scaleY,
|
||||
left: clothingImage.left,
|
||||
top: clothingImage.top,
|
||||
originX: clothingImage.originX,
|
||||
originY: clothingImage.originY,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
layerId: normalLayer.id,
|
||||
layerName: normalLayer.name,
|
||||
});
|
||||
|
||||
// 清除普通图层原有内容
|
||||
if (normalLayer.fabricObjects) {
|
||||
normalLayer.fabricObjects.forEach((obj) => {
|
||||
this.canvas.remove(obj);
|
||||
});
|
||||
}
|
||||
|
||||
// 添加到画布和普通图层
|
||||
this.canvas.add(img);
|
||||
normalLayer.fabricObjects = [img];
|
||||
this.redGreenImage = img;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置普通图层透明度
|
||||
*/
|
||||
_setupNormalLayerOpacity(normalLayers) {
|
||||
normalLayers.forEach((layer) => {
|
||||
// 设置图层透明度
|
||||
layer.opacity = this.normalLayerOpacity;
|
||||
|
||||
// 更新图层中所有对象的透明度
|
||||
if (layer.fabricObjects) {
|
||||
layer.fabricObjects.forEach((obj) => {
|
||||
obj.opacity = this.normalLayerOpacity;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置工具管理器
|
||||
*/
|
||||
_setupToolManager() {
|
||||
if (this.toolManager) {
|
||||
// 设置红绿图模式
|
||||
this.toolManager.isRedGreenMode = true;
|
||||
|
||||
// 切换到红色笔刷工具
|
||||
this.toolManager.setTool(OperationType.RED_BRUSH);
|
||||
}
|
||||
}
|
||||
}
|
||||
578
src/component/Canvas/CanvasEditor/commands/SelectionCommands.js
Normal file
578
src/component/Canvas/CanvasEditor/commands/SelectionCommands.js
Normal file
@@ -0,0 +1,578 @@
|
||||
import { Command, CompositeCommand } from "./Command.js";
|
||||
//import { fabric } from "fabric-with-all";
|
||||
import { createLayer, LayerType } from "../utils/layerHelper.js";
|
||||
|
||||
/**
|
||||
* 创建选区命令
|
||||
*/
|
||||
export class CreateSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: options.name || "创建选区",
|
||||
description: "在画布上创建选区",
|
||||
saveState: false,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.selectionObject = options.selectionObject;
|
||||
this.selectionType = options.selectionType || "rectangle";
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.selectionManager || !this.selectionObject) {
|
||||
console.error("无法创建选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 将选择对象添加到选区管理器
|
||||
this.selectionManager.setSelectionObject(this.selectionObject);
|
||||
return true;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.selectionManager) return false;
|
||||
this.selectionManager.clearSelection();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转选区命令
|
||||
*/
|
||||
export class InvertSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "反转选区",
|
||||
description: "反转当前选区",
|
||||
saveState: false,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.originalSelection = options.selectionManager
|
||||
? options.selectionManager.getSelectionPath()
|
||||
: null;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.selectionManager) {
|
||||
console.error("无法反转选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存原始选区
|
||||
if (!this.originalSelection) {
|
||||
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||
}
|
||||
|
||||
// 反转选区
|
||||
const result = await this.selectionManager.invertSelection();
|
||||
return result;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.selectionManager || !this.originalSelection) return false;
|
||||
|
||||
// 恢复原始选区
|
||||
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到选区命令
|
||||
*/
|
||||
export class AddToSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "添加到选区",
|
||||
description: "将新的选区添加到现有选区",
|
||||
saveState: false,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.newSelection = options.newSelection;
|
||||
this.originalSelection = options.selectionManager
|
||||
? options.selectionManager.getSelectionPath()
|
||||
: null;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.selectionManager || !this.newSelection) {
|
||||
console.error("无法添加到选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存原始选区
|
||||
if (!this.originalSelection) {
|
||||
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||
}
|
||||
|
||||
// 添加到选区
|
||||
const result = await this.selectionManager.addToSelection(
|
||||
this.newSelection
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.selectionManager || !this.originalSelection) return false;
|
||||
|
||||
// 恢复原始选区
|
||||
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从选区中移除命令
|
||||
*/
|
||||
export class RemoveFromSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "从选区中移除",
|
||||
description: "从现有选区中移除指定区域",
|
||||
saveState: false,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.removeSelection = options.removeSelection;
|
||||
this.originalSelection = options.selectionManager
|
||||
? options.selectionManager.getSelectionPath()
|
||||
: null;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.selectionManager || !this.removeSelection) {
|
||||
console.error("无法从选区中移除:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存原始选区
|
||||
if (!this.originalSelection) {
|
||||
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||
}
|
||||
|
||||
// 从选区中移除
|
||||
const result = await this.selectionManager.removeFromSelection(
|
||||
this.removeSelection
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.selectionManager || !this.originalSelection) return false;
|
||||
|
||||
// 恢复原始选区
|
||||
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除选区命令
|
||||
*/
|
||||
export class ClearSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "清除选区",
|
||||
description: "清除当前选区",
|
||||
saveState: false,
|
||||
});
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.originalSelection = options.selectionManager
|
||||
? options.selectionManager.getSelectionPath()
|
||||
: null;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.selectionManager) {
|
||||
console.error("无法清除选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存原始选区
|
||||
if (!this.originalSelection) {
|
||||
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||
}
|
||||
|
||||
// 清除选区
|
||||
this.selectionManager.clearSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.selectionManager || !this.originalSelection) return false;
|
||||
|
||||
// 恢复原始选区
|
||||
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 羽化选区命令
|
||||
*/
|
||||
export class FeatherSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "羽化选区",
|
||||
description: "对当前选区应用羽化效果",
|
||||
saveState: false,
|
||||
});
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.featherAmount = options.featherAmount || 5;
|
||||
this.originalSelection = options.selectionManager
|
||||
? options.selectionManager.getSelectionPath()
|
||||
: null;
|
||||
this.originalFeatherAmount = options.selectionManager
|
||||
? options.selectionManager.getFeatherAmount()
|
||||
: 0;
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.selectionManager) {
|
||||
console.error("无法羽化选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保存原始选区和羽化值
|
||||
if (!this.originalSelection) {
|
||||
this.originalSelection = this.selectionManager.getSelectionPath();
|
||||
this.originalFeatherAmount = this.selectionManager.getFeatherAmount();
|
||||
}
|
||||
|
||||
// 应用羽化
|
||||
const result = await this.selectionManager.featherSelection(
|
||||
this.featherAmount
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.selectionManager || !this.originalSelection) return false;
|
||||
|
||||
// 恢复原始选区和羽化值
|
||||
this.selectionManager.setSelectionFromPath(this.originalSelection);
|
||||
this.selectionManager.setFeatherAmount(this.originalFeatherAmount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充选区命令
|
||||
*/
|
||||
export class FillSelectionCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "填充选区",
|
||||
description: "使用指定颜色填充当前选区",
|
||||
saveState: false,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.color = options.color || "#000000";
|
||||
this.targetLayerId = options.targetLayerId;
|
||||
this.createdObjectIds = [];
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||
console.error("无法填充选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取选区路径
|
||||
const selectionPath = this.selectionManager.getSelectionObject();
|
||||
if (!selectionPath) {
|
||||
console.error("无法填充选区:当前没有选区");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确定目标图层
|
||||
const layerId = this.targetLayerId || this.layerManager.getActiveLayerId();
|
||||
if (!layerId) {
|
||||
console.error("无法填充选区:没有活动图层");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建填充对象
|
||||
const fillObject = new fabric.Path(selectionPath.path, {
|
||||
fill: this.color,
|
||||
stroke: null,
|
||||
opacity: 1,
|
||||
id: `fill_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
layerId: layerId,
|
||||
selectable: false,
|
||||
});
|
||||
|
||||
// 应用羽化效果(如果有)
|
||||
const featherAmount = this.selectionManager.getFeatherAmount();
|
||||
if (featherAmount > 0) {
|
||||
fillObject.shadow = new fabric.Shadow({
|
||||
color: this.color,
|
||||
blur: featherAmount,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加到图层
|
||||
this.layerManager.addObjectToLayer(layerId, fillObject);
|
||||
this.createdObjectIds.push(fillObject.id);
|
||||
|
||||
// 清空选区
|
||||
this.selectionManager.clearSelection();
|
||||
return true;
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.layerManager || this.createdObjectIds.length === 0) return false;
|
||||
|
||||
// 移除创建的填充对象
|
||||
for (const id of this.createdObjectIds) {
|
||||
const layerObj = this._findObjectInLayers(id);
|
||||
if (layerObj) {
|
||||
this.layerManager.removeObjectFromLayer(layerObj.layerId, id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_findObjectInLayers(objectId) {
|
||||
if (!this.layerManager) return null;
|
||||
|
||||
const layers = this.layerManager.layers.value;
|
||||
for (const layer of layers) {
|
||||
if (layer.fabricObjects) {
|
||||
const obj = layer.fabricObjects.find((obj) => obj.id === objectId);
|
||||
if (obj) return { object: obj, layerId: layer.id };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制选区内容到新图层命令
|
||||
*/
|
||||
export class CopySelectionToNewLayerCommand extends CompositeCommand {
|
||||
constructor(options = {}) {
|
||||
super([], {
|
||||
name: "复制选区到新图层",
|
||||
description: "将选区中的内容复制到新图层",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.sourceLayerId = options.sourceLayerId;
|
||||
this.newLayerName = options.newLayerName || "选区复制";
|
||||
this.newLayerId = null;
|
||||
this.copiedObjectIds = [];
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||
console.error("无法复制选区:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取选区
|
||||
const selectionObject = this.selectionManager.getSelectionObject();
|
||||
if (!selectionObject) {
|
||||
console.error("无法复制选区:当前没有选区");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确定源图层
|
||||
const sourceId =
|
||||
this.sourceLayerId || this.layerManager.getActiveLayerId();
|
||||
const sourceLayer = this.layerManager.getLayerById(sourceId);
|
||||
if (!sourceLayer || !sourceLayer.fabricObjects) {
|
||||
console.error("无法复制选区:源图层无效或为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建新图层
|
||||
this.newLayerId = await this.layerManager.createLayer(
|
||||
this.newLayerName,
|
||||
LayerType.EMPTY
|
||||
);
|
||||
|
||||
// 获取选区内的对象
|
||||
const objectsToCopy = sourceLayer.fabricObjects.filter((obj) => {
|
||||
return this.selectionManager.isObjectInSelection(obj);
|
||||
});
|
||||
|
||||
if (objectsToCopy.length === 0) {
|
||||
console.warn("选区内没有对象可复制");
|
||||
return true; // 仍然返回成功,因为已创建了新图层
|
||||
}
|
||||
|
||||
// 复制对象到新图层
|
||||
for (const obj of objectsToCopy) {
|
||||
// 克隆对象
|
||||
const clonedObj = await this._cloneObject(obj);
|
||||
// 设置新的ID和图层ID
|
||||
const newId = `copy_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
clonedObj.id = newId;
|
||||
clonedObj.layerId = this.newLayerId;
|
||||
|
||||
// 添加到新图层
|
||||
await this.layerManager.addObjectToLayer(this.newLayerId, clonedObj);
|
||||
this.copiedObjectIds.push(newId);
|
||||
}
|
||||
|
||||
// 设置新图层为活动图层
|
||||
this.layerManager.setActiveLayer(this.newLayerId);
|
||||
|
||||
// 清空选区
|
||||
this.selectionManager.clearSelection();
|
||||
|
||||
return {
|
||||
newLayerId: this.newLayerId,
|
||||
copiedCount: this.copiedObjectIds.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("复制选区过程中出错:", error);
|
||||
|
||||
// 如果已经创建了新图层,需要进行清理
|
||||
if (this.newLayerId) {
|
||||
try {
|
||||
await this.layerManager.removeLayer(this.newLayerId);
|
||||
} catch (cleanupError) {
|
||||
console.warn("清理新图层失败:", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _cloneObject(obj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!obj) {
|
||||
reject(new Error("对象无效,无法克隆"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
obj.clone((cloned) => {
|
||||
resolve(cloned);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从选区中删除内容命令
|
||||
*/
|
||||
export class ClearSelectionContentCommand extends Command {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
name: "清除选区内容",
|
||||
description: "删除选区中的内容",
|
||||
saveState: false,
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.selectionManager = options.selectionManager;
|
||||
this.targetLayerId = options.targetLayerId;
|
||||
this.removedObjects = [];
|
||||
}
|
||||
|
||||
async execute() {
|
||||
if (!this.canvas || !this.layerManager || !this.selectionManager) {
|
||||
console.error("无法清除选区内容:参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取选区
|
||||
const selectionObject = this.selectionManager.getSelectionObject();
|
||||
if (!selectionObject) {
|
||||
console.error("无法清除选区内容:当前没有选区");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 确定目标图层
|
||||
const layerId = this.targetLayerId || this.layerManager.getActiveLayerId();
|
||||
const layer = this.layerManager.getLayerById(layerId);
|
||||
if (!layer || !layer.fabricObjects) {
|
||||
console.error("无法清除选区内容:目标图层无效或为空");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 找到选区内的对象
|
||||
const objectsToRemove = layer.fabricObjects.filter((obj) => {
|
||||
return this.selectionManager.isObjectInSelection(obj);
|
||||
});
|
||||
|
||||
if (objectsToRemove.length === 0) {
|
||||
console.warn("选区内没有对象需要清除");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 备份被删除的对象
|
||||
this.removedObjects = objectsToRemove.map((obj) => ({
|
||||
object: this._cloneObjectSync(obj),
|
||||
layerId: layerId,
|
||||
}));
|
||||
|
||||
// 从图层中移除对象
|
||||
for (const obj of objectsToRemove) {
|
||||
this.layerManager.removeObjectFromLayer(layerId, obj.id);
|
||||
}
|
||||
|
||||
return { removedCount: objectsToRemove.length };
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.layerManager || this.removedObjects.length === 0) return false;
|
||||
|
||||
// 恢复被删除的对象
|
||||
for (const item of this.removedObjects) {
|
||||
if (item.object && item.layerId) {
|
||||
const clonedObj = await this._cloneObject(item.object);
|
||||
this.layerManager.addObjectToLayer(item.layerId, clonedObj);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_cloneObjectSync(obj) {
|
||||
// 这是一个简单的深拷贝,不适用于所有场景
|
||||
// 在实际应用中,应该使用fabric.js的clone方法
|
||||
if (!obj) return null;
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
async _cloneObject(obj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!obj) {
|
||||
reject(new Error("对象无效,无法克隆"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof obj.clone === "function") {
|
||||
obj.clone((cloned) => {
|
||||
resolve(cloned);
|
||||
});
|
||||
} else {
|
||||
// 如果对象没有clone方法(可能是因为它已经是序列化后的对象)
|
||||
// 在实际代码中需要适当处理这种情况
|
||||
resolve(Object.assign({}, obj));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导入套索抠图命令
|
||||
export { LassoCutoutCommand } from "./LassoCutoutCommand.js";
|
||||
134
src/component/Canvas/CanvasEditor/commands/StateCommands.js
Normal file
134
src/component/Canvas/CanvasEditor/commands/StateCommands.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Command } from "./Command";
|
||||
|
||||
/**
|
||||
* 对象变换命令
|
||||
* 轻量级命令,只记录对象的变换属性变化(位置、缩放、旋转)
|
||||
* 不保存整个对象或画布状态,只关注变换属性
|
||||
*/
|
||||
export class TransformCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: options.name || "对象变换",
|
||||
description: options.description || "移动、缩放或旋转对象",
|
||||
saveState: false, // 自己管理状态,避免递归
|
||||
});
|
||||
|
||||
this.canvas = options.canvas;
|
||||
this.objectId = options.objectId;
|
||||
this.initialState = options.initialState || null;
|
||||
this.finalState = options.finalState || null;
|
||||
this.objectType = options.objectType || "object";
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令
|
||||
* 如果是首次执行,记录初始和最终状态
|
||||
* 如果是重做,应用最终状态
|
||||
*/
|
||||
execute() {
|
||||
if (!this.finalState) {
|
||||
console.warn("没有最终状态可应用");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找目标对象
|
||||
const targetObject = this._findObject(this.objectId);
|
||||
if (!targetObject) {
|
||||
console.warn(`未找到ID为 ${this.objectId} 的对象`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 应用最终变换状态
|
||||
this._applyTransform(targetObject, this.finalState);
|
||||
|
||||
// 触发画布更新
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销命令
|
||||
* 应用初始状态
|
||||
*/
|
||||
undo() {
|
||||
if (!this.initialState) {
|
||||
console.warn("没有初始状态可恢复");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查找目标对象
|
||||
const targetObject = this._findObject(this.objectId);
|
||||
if (!targetObject) {
|
||||
console.warn(`未找到ID为 ${this.objectId} 的对象`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 应用初始变换状态
|
||||
this._applyTransform(targetObject, this.initialState);
|
||||
|
||||
// 触发画布更新
|
||||
this.canvas.renderAll();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找对象
|
||||
* @private
|
||||
*/
|
||||
_findObject(objectId) {
|
||||
if (!this.canvas) return null;
|
||||
return this.canvas.getObjects().find((obj) => obj.id === objectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用变换状态到对象
|
||||
* @private
|
||||
*/
|
||||
_applyTransform(object, transformState) {
|
||||
if (!object || !transformState) return;
|
||||
|
||||
// 应用变换属性,只设置真正变化的值
|
||||
Object.entries(transformState).forEach(([key, value]) => {
|
||||
object.set(key, value);
|
||||
});
|
||||
|
||||
// 确保对象更新
|
||||
object.setCoords();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令信息
|
||||
*/
|
||||
getInfo() {
|
||||
return {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
objectId: this.objectId,
|
||||
objectType: this.objectType,
|
||||
changedProps: this.finalState ? Object.keys(this.finalState) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获对象的变换状态
|
||||
* @static
|
||||
*/
|
||||
static captureTransformState(object) {
|
||||
if (!object) return null;
|
||||
|
||||
// 只捕获变换相关的属性
|
||||
return {
|
||||
left: object.left,
|
||||
top: object.top,
|
||||
scaleX: object.scaleX,
|
||||
scaleY: object.scaleY,
|
||||
angle: object.angle,
|
||||
flipX: object.flipX,
|
||||
flipY: object.flipY,
|
||||
skewX: object.skewX,
|
||||
skewY: object.skewY,
|
||||
};
|
||||
}
|
||||
}
|
||||
304
src/component/Canvas/CanvasEditor/commands/TextCommands.js
Normal file
304
src/component/Canvas/CanvasEditor/commands/TextCommands.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Command } from "./Command";
|
||||
|
||||
/**
|
||||
* 文本内容命令
|
||||
* 用于更改文本图层的文本内容
|
||||
*/
|
||||
export class TextContentCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本内容",
|
||||
description: "修改文本图层的文本内容",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newText = options.newText;
|
||||
this.oldText = this.textObject.text;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("text", this.newText);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("text", this.oldText);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本字体命令
|
||||
* 用于更改文本图层的字体
|
||||
*/
|
||||
export class TextFontCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本字体",
|
||||
description: "修改文本图层的字体",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newFont = options.newFont;
|
||||
this.oldFont = this.textObject.fontFamily;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("fontFamily", this.newFont);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("fontFamily", this.oldFont);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本尺寸命令
|
||||
* 用于更改文本图层的字体大小
|
||||
*/
|
||||
export class TextSizeCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本尺寸",
|
||||
description: "修改文本图层的字体大小",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newSize = options.newSize;
|
||||
this.oldSize = this.textObject.fontSize;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("fontSize", this.newSize);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("fontSize", this.oldSize);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本颜色命令
|
||||
* 用于更改文本图层的颜色
|
||||
*/
|
||||
export class TextColorCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本颜色",
|
||||
description: "修改文本图层的颜色",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newColor = options.newColor;
|
||||
this.oldColor = this.textObject.fill;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("fill", this.newColor);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("fill", this.oldColor);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本对齐方式命令
|
||||
* 用于更改文本图层的对齐方式
|
||||
*/
|
||||
export class TextAlignCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本对齐",
|
||||
description: "修改文本图层的对齐方式",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newAlign = options.newAlign;
|
||||
this.oldAlign = this.textObject.textAlign;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("textAlign", this.newAlign);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("textAlign", this.oldAlign);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本样式命令
|
||||
* 用于更改文本图层的样式(粗体、斜体、下划线等)
|
||||
*/
|
||||
export class TextStyleCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本样式",
|
||||
description: "修改文本图层的样式",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.property = options.property; // 'fontWeight', 'fontStyle', 'underline', 'linethrough', 'overline'
|
||||
this.newValue = options.newValue;
|
||||
this.oldValue = this.textObject[this.property];
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set(this.property, this.newValue);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set(this.property, this.oldValue);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本间距命令
|
||||
* 用于更改文本图层的字符间距或行高
|
||||
*/
|
||||
export class TextSpacingCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本间距",
|
||||
description: "修改文本图层的字符间距或行高",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.property = options.property; // 'charSpacing' 或 'lineHeight'
|
||||
this.newValue = options.newValue;
|
||||
this.oldValue = this.textObject[this.property];
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set(this.property, this.newValue);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set(this.property, this.oldValue);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本背景颜色命令
|
||||
* 用于更改文本图层的背景颜色
|
||||
*/
|
||||
export class TextBackgroundCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本背景",
|
||||
description: "修改文本图层的背景颜色",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newColor = options.newColor;
|
||||
this.oldColor = this.textObject.textBackgroundColor;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("textBackgroundColor", this.newColor);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("textBackgroundColor", this.oldColor);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本透明度命令
|
||||
* 用于更改文本图层的透明度
|
||||
*/
|
||||
export class TextOpacityCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "修改文本透明度",
|
||||
description: "修改文本图层的透明度",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.newOpacity = options.newOpacity;
|
||||
this.oldOpacity = this.textObject.opacity;
|
||||
}
|
||||
|
||||
execute() {
|
||||
this.textObject.set("opacity", this.newOpacity);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.textObject.set("opacity", this.oldOpacity);
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合文本编辑命令
|
||||
* 用于一次性应用多个文本属性更改
|
||||
*/
|
||||
export class CompositeTextCommand extends Command {
|
||||
constructor(options) {
|
||||
super({
|
||||
name: "组合文本编辑",
|
||||
description: "组合多个文本编辑操作",
|
||||
});
|
||||
this.canvas = options.canvas;
|
||||
this.textObject = options.textObject;
|
||||
this.changes = options.changes; // {property: newValue} 形式的对象
|
||||
this.oldValues = {};
|
||||
|
||||
// 保存所有属性的旧值
|
||||
for (const property in this.changes) {
|
||||
if (this.textObject[property] !== undefined) {
|
||||
this.oldValues[property] = this.textObject[property];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
execute() {
|
||||
for (const property in this.changes) {
|
||||
this.textObject.set(property, this.changes[property]);
|
||||
}
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
undo() {
|
||||
for (const property in this.oldValues) {
|
||||
this.textObject.set(property, this.oldValues[property]);
|
||||
}
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
54
src/component/Canvas/CanvasEditor/commands/ToolCommands.js
Normal file
54
src/component/Canvas/CanvasEditor/commands/ToolCommands.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Command } from "./Command";
|
||||
|
||||
/**
|
||||
* 工具切换命令
|
||||
* 用于切换编辑器的工具模式(如绘画、选择、橡皮擦等)
|
||||
*/
|
||||
export class ToolCommand extends Command {
|
||||
/**
|
||||
* 创建一个工具切换命令
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Object} options.toolManager 工具管理器实例
|
||||
* @param {String} options.tool 要设置的工具名称
|
||||
* @param {String} options.previousTool 先前的工具名称(可选,如果不提供会在执行时记录)
|
||||
* @param {Boolean} options.saveState 是否保存画布状态(默认为false)
|
||||
*/
|
||||
constructor(options) {
|
||||
super({
|
||||
...options,
|
||||
name: `切换工具: ${options.tool}`,
|
||||
description: `将工具切换为 ${options.tool}`,
|
||||
});
|
||||
|
||||
this.toolManager = options.toolManager;
|
||||
this.tool = options.tool;
|
||||
this.previousTool = options.previousTool || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行工具切换
|
||||
* @returns {String} 设置的工具名称
|
||||
*/
|
||||
execute() {
|
||||
if (!this.toolManager) return null;
|
||||
|
||||
// 记录当前工具(用于撤销)
|
||||
if (!this.previousTool) {
|
||||
this.previousTool = this.toolManager.getCurrentTool();
|
||||
}
|
||||
|
||||
// 切换工具
|
||||
return this.toolManager.setTool(this.tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销工具切换
|
||||
* @returns {String} 恢复的工具名称
|
||||
*/
|
||||
undo() {
|
||||
if (!this.toolManager || !this.previousTool) return null;
|
||||
|
||||
// 恢复到先前工具
|
||||
return this.toolManager.setTool(this.previousTool);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="isVisible" class="brush-control-panel">
|
||||
<!-- 笔刷大小控制 -->
|
||||
<VerticalSlider
|
||||
v-model="brushSize"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:presets="sizePresets"
|
||||
:memorized-values="memorizedSizes"
|
||||
:active-threshold="1"
|
||||
custom-class="size-slider"
|
||||
:step="1"
|
||||
v-model:showTooltip="showSizeTooltip"
|
||||
@slide-start="handleSizeSlideStart"
|
||||
@slide-end="handleSizeSlideEnd"
|
||||
@click="showSizeTooltip = true"
|
||||
>
|
||||
<template #tooltip-content>
|
||||
<div class="tooltip-header">
|
||||
<div class="tooltip-title">Size</div>
|
||||
<div class="tooltip-close-btn" @click.stop="closeSizeTooltip">
|
||||
<SvgIcon name="CClose" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="brush-preview-container">
|
||||
<div
|
||||
class="brush-size-preview"
|
||||
:style="{
|
||||
width: `${Math.min(100, brushSize)}px`,
|
||||
height: `${Math.min(100, brushSize)}px`,
|
||||
backgroundColor: showColorPicker ? brushColor : '#888888',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-text">{{ Math.round(brushSize) }}px</div>
|
||||
<div class="tooltip-controls">
|
||||
<button
|
||||
v-if="!memorizedSizes.includes(brushSize)"
|
||||
class="control-btn add"
|
||||
@click="memorizeSize"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="control-btn remove"
|
||||
@click="removeMemorizedSize"
|
||||
v-if="canRemoveSize"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VerticalSlider>
|
||||
|
||||
<!-- 颜色选择器 - 仅在特定工具下显示 -->
|
||||
<div v-if="showColorPicker" class="color-picker-container">
|
||||
<label for="color-picker" class="current-color-label">
|
||||
<div
|
||||
class="current-color"
|
||||
:style="{ backgroundColor: brushColor }"
|
||||
></div>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="color-picker"
|
||||
class="system-color-picker"
|
||||
v-model="customColor"
|
||||
@input="setBrushColor(customColor)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 透明度控制 - 仅在特定工具下显示 -->
|
||||
<VerticalSlider
|
||||
v-if="showOpacitySlider"
|
||||
v-model="brushOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:presets="opacityPresets"
|
||||
:memorized-values="memorizedOpacities"
|
||||
:is-percentage="true"
|
||||
custom-class="opacity-slider"
|
||||
:active-threshold="0.01"
|
||||
:step="0.01"
|
||||
v-model:showTooltip="showOpacityTooltip"
|
||||
@slide-start="handleOpacitySlideStart"
|
||||
@slide-end="handleOpacitySlideEnd"
|
||||
@click="showOpacityTooltip = true"
|
||||
>
|
||||
<template #tooltip-content>
|
||||
<div class="tooltip-header">
|
||||
<div class="tooltip-title">Opacity</div>
|
||||
<div class="tooltip-close-btn" @click.stop="closeOpacityTooltip">
|
||||
<SvgIcon name="CClose" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="opacity-preview">
|
||||
<div class="opacity-checker"></div>
|
||||
<div
|
||||
class="opacity-color"
|
||||
:style="{
|
||||
backgroundColor: brushColor,
|
||||
opacity: brushOpacity,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-text">
|
||||
{{ Math.round(brushOpacity * 100) }}%
|
||||
</div>
|
||||
<div class="tooltip-controls">
|
||||
<button
|
||||
class="control-btn add"
|
||||
v-if="!memorizedOpacities.includes(brushOpacity)"
|
||||
@click="memorizeOpacity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="control-btn remove"
|
||||
@click="removeMemorizedOpacity"
|
||||
v-if="canRemoveOpacity"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VerticalSlider>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
import { BrushStore } from "../store/BrushStore";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import { inject } from "vue";
|
||||
import VerticalSlider from "./VerticalSlider.vue";
|
||||
|
||||
const props = defineProps({
|
||||
activeTool: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 工具管理器和画布管理器
|
||||
const toolManager = inject("toolManager");
|
||||
const canvasManager = inject("canvasManager");
|
||||
|
||||
// 可见性控制
|
||||
const isVisible = computed(() => {
|
||||
return [
|
||||
OperationType.DRAW,
|
||||
OperationType.ERASER,
|
||||
OperationType.RED_BRUSH,
|
||||
OperationType.GREEN_BRUSH,
|
||||
].includes(props.activeTool);
|
||||
});
|
||||
|
||||
// 控制颜色选择器的显示
|
||||
const showColorPicker = computed(() => {
|
||||
return props.activeTool === OperationType.DRAW;
|
||||
});
|
||||
|
||||
// 控制透明度滑块的显示
|
||||
const showOpacitySlider = computed(() => {
|
||||
return props.activeTool === OperationType.DRAW;
|
||||
});
|
||||
|
||||
// 笔刷大小相关
|
||||
const brushSize = ref(BrushStore.state.size);
|
||||
const showSizeTooltip = ref(false);
|
||||
const sizePresets = ref([5, 10, 20, 50]); // 预设大小,便于吸附
|
||||
const memorizedSizes = ref([]);
|
||||
const canRemoveSize = computed(() => {
|
||||
return memorizedSizes.value.includes(brushSize.value);
|
||||
});
|
||||
const isSizeSliding = ref(false); // 是否正在滑动大小滑块
|
||||
|
||||
// 笔刷颜色相关
|
||||
const brushColor = ref(BrushStore.state.color);
|
||||
const customColor = ref(BrushStore.state.color);
|
||||
|
||||
// 笔刷透明度相关
|
||||
const brushOpacity = ref(BrushStore.state.opacity);
|
||||
const showOpacityTooltip = ref(false);
|
||||
const opacityPresets = ref([0.1, 0.2, 0.5, 0.8]); // 预设透明度,便于吸附
|
||||
const memorizedOpacities = ref([]);
|
||||
const canRemoveOpacity = computed(() => {
|
||||
return memorizedOpacities.value.includes(brushOpacity.value);
|
||||
});
|
||||
const isOpacitySliding = ref(false); // 是否正在滑动透明度滑块
|
||||
|
||||
// 添加计时器变量用于控制外部变化引起的提示框自动隐藏
|
||||
const sizeTooltipTimer = ref(null);
|
||||
const opacityTooltipTimer = ref(null);
|
||||
const TOOLTIP_HIDE_DELAY = 1500; // 与VerticalSlider组件保持一致的延迟时间
|
||||
|
||||
// 处理滑块开始和结束事件
|
||||
function handleSizeSlideStart() {
|
||||
isSizeSliding.value = true;
|
||||
}
|
||||
|
||||
function handleSizeSlideEnd(event) {
|
||||
isSizeSliding.value = false;
|
||||
}
|
||||
|
||||
function handleOpacitySlideStart() {
|
||||
isOpacitySliding.value = true;
|
||||
}
|
||||
|
||||
function handleOpacitySlideEnd(event) {
|
||||
isOpacitySliding.value = false;
|
||||
}
|
||||
|
||||
// 设置笔刷大小
|
||||
function setBrushSize(size) {
|
||||
brushSize.value = size;
|
||||
BrushStore.setBrushSize(size);
|
||||
|
||||
// 如果工具管理器存在,立即应用此更改
|
||||
if (toolManager) {
|
||||
toolManager.updateBrushSize(size);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置笔刷颜色
|
||||
function setBrushColor(color) {
|
||||
brushColor.value = color;
|
||||
customColor.value = color;
|
||||
BrushStore.setBrushColor(color);
|
||||
|
||||
// 如果工具管理器存在,立即应用此更改
|
||||
if (toolManager && props.activeTool === OperationType.DRAW) {
|
||||
toolManager.updateBrushColor(color);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置笔刷透明度
|
||||
function setBrushOpacity(opacity) {
|
||||
brushOpacity.value = opacity;
|
||||
BrushStore.setBrushOpacity(opacity);
|
||||
|
||||
// 如果工具管理器存在,立即应用此更改
|
||||
if (toolManager) {
|
||||
toolManager.updateBrushOpacity(opacity);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加用于自动隐藏大小提示框的方法
|
||||
function startSizeTooltipHideTimer() {
|
||||
// 清除已有的计时器
|
||||
clearSizeTooltipTimer();
|
||||
|
||||
// 创建新计时器
|
||||
sizeTooltipTimer.value = setTimeout(() => {
|
||||
showSizeTooltip.value = false;
|
||||
sizeTooltipTimer.value = null;
|
||||
}, TOOLTIP_HIDE_DELAY);
|
||||
}
|
||||
|
||||
// 清除大小提示框隐藏计时器
|
||||
function clearSizeTooltipTimer() {
|
||||
if (sizeTooltipTimer.value) {
|
||||
clearTimeout(sizeTooltipTimer.value);
|
||||
sizeTooltipTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加用于自动隐藏透明度提示框的方法
|
||||
function startOpacityTooltipHideTimer() {
|
||||
// 清除已有的计时器
|
||||
clearOpacityTooltipTimer();
|
||||
|
||||
// 创建新计时器
|
||||
opacityTooltipTimer.value = setTimeout(() => {
|
||||
showOpacityTooltip.value = false;
|
||||
opacityTooltipTimer.value = null;
|
||||
}, TOOLTIP_HIDE_DELAY);
|
||||
}
|
||||
|
||||
// 清除透明度提示框隐藏计时器
|
||||
function clearOpacityTooltipTimer() {
|
||||
if (opacityTooltipTimer.value) {
|
||||
clearTimeout(opacityTooltipTimer.value);
|
||||
opacityTooltipTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 主动关闭提示框
|
||||
function closeSizeTooltip() {
|
||||
showSizeTooltip.value = false;
|
||||
clearSizeTooltipTimer(); // 清除任何存在的计时器
|
||||
}
|
||||
|
||||
function closeOpacityTooltip() {
|
||||
showOpacityTooltip.value = false;
|
||||
clearOpacityTooltipTimer(); // 清除任何存在的计时器
|
||||
}
|
||||
|
||||
// 记忆当前笔刷大小
|
||||
function memorizeSize() {
|
||||
if (!memorizedSizes.value.includes(brushSize.value)) {
|
||||
memorizedSizes.value.push(brushSize.value);
|
||||
// 记忆值限制为最多5个
|
||||
if (memorizedSizes.value.length > 5) {
|
||||
memorizedSizes.value.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除当前记忆的笔刷大小
|
||||
function removeMemorizedSize() {
|
||||
if (memorizedSizes.value.includes(brushSize.value)) {
|
||||
const index = memorizedSizes.value.indexOf(brushSize.value);
|
||||
memorizedSizes.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 记忆当前笔刷透明度
|
||||
function memorizeOpacity() {
|
||||
if (!memorizedOpacities.value.includes(brushOpacity.value)) {
|
||||
memorizedOpacities.value.push(brushOpacity.value);
|
||||
// 记忆值限制为最多5个
|
||||
if (memorizedOpacities.value.length > 5) {
|
||||
memorizedOpacities.value.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除当前记忆的笔刷透明度
|
||||
function removeMemorizedOpacity() {
|
||||
if (memorizedOpacities.value.includes(brushOpacity.value)) {
|
||||
const index = memorizedOpacities.value.indexOf(brushOpacity.value);
|
||||
memorizedOpacities.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听工具的变化
|
||||
watch(
|
||||
() => props.activeTool,
|
||||
(newTool) => {
|
||||
// 当切换到橡皮擦工具时,可以设置特殊的默认值
|
||||
if (newTool === OperationType.ERASER) {
|
||||
// 橡皮擦模式下不需要调整颜色,但可能会调整大小和不透明度
|
||||
} else if (newTool === OperationType.DRAW) {
|
||||
// 恢复到绘制模式时可能有特殊设置
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 监听brushSize的变化,更新到BrushStore
|
||||
watch(
|
||||
() => brushSize.value,
|
||||
(newSize) => {
|
||||
setBrushSize(newSize);
|
||||
}
|
||||
);
|
||||
|
||||
// 监听brushOpacity的变化,更新到BrushStore
|
||||
watch(
|
||||
() => brushOpacity.value,
|
||||
(newOpacity) => {
|
||||
setBrushOpacity(newOpacity);
|
||||
}
|
||||
);
|
||||
|
||||
// 监听BrushStore中的变化
|
||||
watch(
|
||||
() => BrushStore.state.size,
|
||||
(newSize) => {
|
||||
if (Math.abs(brushSize.value - newSize) > 0.1) {
|
||||
brushSize.value = newSize;
|
||||
// 当外部修改了笔刷大小时,显示提示框
|
||||
showSizeTooltip.value = true;
|
||||
// 启动自动隐藏计时器
|
||||
startSizeTooltipHideTimer();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => BrushStore.state.opacity,
|
||||
(newOpacity) => {
|
||||
if (Math.abs(brushOpacity.value - newOpacity) > 0.01) {
|
||||
brushOpacity.value = newOpacity;
|
||||
// 当外部修改了笔刷透明度时,显示提示框
|
||||
showOpacityTooltip.value = true;
|
||||
// 启动自动隐藏计时器
|
||||
startOpacityTooltipHideTimer();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => BrushStore.state.color,
|
||||
(newColor) => {
|
||||
if (brushColor.value !== newColor) {
|
||||
brushColor.value = newColor;
|
||||
customColor.value = newColor;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化时从BrushStore获取当前值
|
||||
brushSize.value = BrushStore.state.size;
|
||||
brushOpacity.value = BrushStore.state.opacity;
|
||||
brushColor.value = BrushStore.state.color;
|
||||
customColor.value = BrushStore.state.color;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 组件卸载前清除所有计时器
|
||||
clearSizeTooltipTimer();
|
||||
clearOpacityTooltipTimer();
|
||||
});
|
||||
|
||||
// 监听showSizeTooltip和showOpacityTooltip的变化,防止两个滑块的提示框同时显示
|
||||
watch(
|
||||
() => showSizeTooltip.value,
|
||||
(newValue) => {
|
||||
if (newValue && showOpacityTooltip.value) {
|
||||
// 如果大小提示框显示,则隐藏透明度提示框
|
||||
showOpacityTooltip.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => showOpacityTooltip.value,
|
||||
(newValue) => {
|
||||
if (newValue && showSizeTooltip.value) {
|
||||
// 如果透明度提示框显示,则隐藏大小提示框
|
||||
showSizeTooltip.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.brush-control-panel {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 15px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 5px;
|
||||
padding: 15px 3px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
z-index: 8;
|
||||
backdrop-filter: blur(2px);
|
||||
color: #333;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
// 笔刷大小预览相关样式
|
||||
.brush-preview-container {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
|
||||
10px 10px;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.brush-size-preview {
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// 透明度预览相关样式
|
||||
.opacity-preview {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.opacity-checker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
|
||||
10px 10px;
|
||||
}
|
||||
|
||||
.opacity-color {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// 工具提示内容样式
|
||||
.tooltip-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.tooltip-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tooltip-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.add {
|
||||
color: #4caf50;
|
||||
|
||||
&:hover {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&.remove {
|
||||
color: #f44336;
|
||||
|
||||
&:hover {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色选择器样式
|
||||
.color-picker-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.current-color-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.current-color {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2), 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.system-color-picker {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 工具提示标题和关闭按钮
|
||||
.tooltip-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tooltip-close-btn {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
top: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 99;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// 淡入淡出动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) translateY(-50%);
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-height: 600px) {
|
||||
.brush-control-panel {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brush-control-panel {
|
||||
left: 10px;
|
||||
// padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1596
src/component/Canvas/CanvasEditor/components/BrushPanel.vue
Normal file
1596
src/component/Canvas/CanvasEditor/components/BrushPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
525
src/component/Canvas/CanvasEditor/components/HeaderMenu.vue
Normal file
525
src/component/Canvas/CanvasEditor/components/HeaderMenu.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<script setup>
|
||||
import {
|
||||
inject,
|
||||
ref,
|
||||
provide,
|
||||
onMounted,
|
||||
computed,
|
||||
watch,
|
||||
onUnmounted,
|
||||
} from "vue";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import BrushPanel from "./BrushPanel.vue";
|
||||
import { BrushStore } from "../store/BrushStore";
|
||||
|
||||
// 提供brushStore给子组件
|
||||
provide("brushStore", BrushStore);
|
||||
|
||||
const toolManager = inject("toolManager");
|
||||
const layerManager = inject("layerManager");
|
||||
|
||||
const props = defineProps({
|
||||
activeTool: String,
|
||||
canvasWidth: Number,
|
||||
canvasHeight: Number,
|
||||
canvasColor: String,
|
||||
brushSize: Number,
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:canvasWidth",
|
||||
"update:canvasHeight",
|
||||
"update:canvasColor",
|
||||
"update:brushSize",
|
||||
"canvas-size-change",
|
||||
"canvas-color-change",
|
||||
]);
|
||||
|
||||
// 笔刷面板相关状态
|
||||
const showBrushPanel = ref(false);
|
||||
const brushPanelRef = ref(null);
|
||||
|
||||
// 计算属性
|
||||
const shouldShowBrushSettings = computed(() => {
|
||||
return props.activeTool === OperationType.DRAW;
|
||||
});
|
||||
|
||||
function updateCanvasSize() {
|
||||
if (!layerManager) {
|
||||
console.warn("LayerManager 未初始化,无法调整背景层尺寸");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查画布上是否有除了背景层的其他元素
|
||||
const hasOtherElements = layerManager.layers.value.some((layer) => {
|
||||
if (layer.isBackground) return false;
|
||||
// 检查普通图层是否有对象
|
||||
if (layer.fabricObjects && layer.fabricObjects.length > 0) return true;
|
||||
// 检查固定图层是否有对象
|
||||
if (layer.isFixed && layer.fabricObjects && layer.fabricObjects.length > 0)
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasOtherElements) {
|
||||
// 有其他元素时使用等比缩放命令
|
||||
layerManager.resizeCanvasWithScale(props.canvasWidth, props.canvasHeight);
|
||||
} else {
|
||||
// 只有背景层时使用普通调整命令
|
||||
layerManager.resizeCanvas(props.canvasWidth, props.canvasHeight);
|
||||
}
|
||||
|
||||
emit("canvas-size-change");
|
||||
}
|
||||
|
||||
function updateCanvasColor() {
|
||||
if (!layerManager) {
|
||||
console.warn("LayerManager 未初始化,无法更改背景色");
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新背景层颜色而不是画布颜色
|
||||
layerManager.updateBackgroundColor(props.canvasColor);
|
||||
|
||||
emit("canvas-color-change");
|
||||
}
|
||||
|
||||
// 切换笔刷面板显示状态
|
||||
function toggleBrushPanel() {
|
||||
showBrushPanel.value = !showBrushPanel.value;
|
||||
}
|
||||
|
||||
// 处理笔刷大小变化
|
||||
function handleBrushSizeChange(event) {
|
||||
const newSize = parseFloat(event.target.value);
|
||||
emit("update:brushSize", newSize);
|
||||
}
|
||||
|
||||
// 处理笔刷设置变化,将BrushStore的数据同步到brushManager
|
||||
function syncBrushStoreToManager() {
|
||||
if (!toolManager?.brushManager) return;
|
||||
|
||||
const brushManager = toolManager.brushManager;
|
||||
|
||||
// 检查画笔是否正在更新中
|
||||
if (brushManager.isUpdatingBrush) {
|
||||
console.warn("画笔正在更新中,请稍候...");
|
||||
// 延迟重试,确保在画笔更新完成后应用最新设置
|
||||
setTimeout(syncBrushStoreToManager, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 监听BrushStore的变化,更新brushManager
|
||||
const size = BrushStore.state.size;
|
||||
const color = BrushStore.state.color;
|
||||
const type = BrushStore.state.type;
|
||||
const opacity = BrushStore.state.opacity;
|
||||
const textureEnabled = BrushStore.state.textureEnabled;
|
||||
const texturePath = BrushStore.state.texturePath;
|
||||
const textureScale = BrushStore.state.textureScale;
|
||||
|
||||
// 将所有更改一次性应用,减少updateBrush调用次数
|
||||
let needsUpdate = false;
|
||||
|
||||
if (
|
||||
brushManager.brushSize &&
|
||||
typeof brushManager.setBrushSize === "function" &&
|
||||
brushManager.getBrushSize() !== size
|
||||
) {
|
||||
brushManager.brushSize.value = size; // 直接设置值,避免触发updateBrush
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (
|
||||
brushManager.brushColor &&
|
||||
typeof brushManager.setBrushColor === "function" &&
|
||||
brushManager.getBrushColor() !== color
|
||||
) {
|
||||
brushManager.brushColor.value = color; // 直接设置值,避免触发updateBrush
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof brushManager.setBrushType === "function" &&
|
||||
brushManager.getCurrentBrushType() !== type
|
||||
) {
|
||||
brushManager.setBrushType(type);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (typeof brushManager.setBrushOpacity === "function") {
|
||||
brushManager.setBrushOpacity(opacity);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// 同步材质相关设置
|
||||
if (textureEnabled && texturePath) {
|
||||
if (typeof brushManager.setTexturePath === "function") {
|
||||
brushManager.setTexturePath(texturePath);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof brushManager.setTextureScale === "function" &&
|
||||
brushManager.getTextureScale() !== textureScale
|
||||
) {
|
||||
brushManager.textureScale.value = textureScale; // 直接设置值,避免触发updateBrush
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 只在有变化时调用一次updateBrush,减少重绘次数
|
||||
if (needsUpdate && typeof brushManager.updateBrush === "function") {
|
||||
brushManager.updateBrush();
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部时关闭笔刷面板
|
||||
function handleClickOutside(event) {
|
||||
if (
|
||||
showBrushPanel.value &&
|
||||
brushPanelRef.value &&
|
||||
!brushPanelRef.value.contains(event.target) &&
|
||||
!event.target.closest(".brush-selector")
|
||||
) {
|
||||
showBrushPanel.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取工具管理器和笔刷管理器
|
||||
const brushManager = toolManager?.brushManager;
|
||||
|
||||
// 设置初始的可用笔刷类型
|
||||
if (brushManager) {
|
||||
const availableBrushes = brushManager.getBrushTypes();
|
||||
BrushStore.setAvailableBrushes(availableBrushes);
|
||||
|
||||
// 初始化BrushStore与brushManager的数据同步
|
||||
BrushStore.setBrushSize(brushManager.brushSize?.value || 5);
|
||||
BrushStore.setBrushColor(brushManager.brushColor?.value || "#000000");
|
||||
BrushStore.setBrushType(brushManager.getCurrentBrushType() || "pencil");
|
||||
}
|
||||
|
||||
// 添加点击外部关闭面板的事件监听
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
// 监听BrushStore的变化,同步到brushManager
|
||||
const unwatch = watch(
|
||||
() => [
|
||||
BrushStore.state.size,
|
||||
BrushStore.state.color,
|
||||
BrushStore.state.type,
|
||||
BrushStore.state.opacity,
|
||||
BrushStore.state.textureEnabled,
|
||||
BrushStore.state.texturePath,
|
||||
BrushStore.state.textureScale,
|
||||
],
|
||||
syncBrushStoreToManager,
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// 组件卸载时移除事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
unwatch();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas-header">
|
||||
<span class="canvas-title">Canvas</span>
|
||||
|
||||
<!-- 默认设置 -->
|
||||
<div
|
||||
v-if="
|
||||
!activeTool ||
|
||||
activeTool === OperationType.SELECT ||
|
||||
activeTool === OperationType.PAN
|
||||
"
|
||||
class="canvas-settings"
|
||||
>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Width</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="canvasWidth"
|
||||
class="setting-input"
|
||||
@input="$emit('update:canvasWidth', Number($event.target.value))"
|
||||
@change="updateCanvasSize"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Height</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="canvasHeight"
|
||||
class="setting-input"
|
||||
@input="$emit('update:canvasHeight', Number($event.target.value))"
|
||||
@change="updateCanvasSize"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Color</span>
|
||||
<div class="color-picker-wrapper">
|
||||
<input
|
||||
type="color"
|
||||
:value="canvasColor"
|
||||
class="color-picker"
|
||||
@input="$emit('update:canvasColor', $event.target.value)"
|
||||
@change="updateCanvasColor"
|
||||
/>
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 绘图工具设置 -->
|
||||
<div v-if="shouldShowBrushSettings" class="canvas-settings">
|
||||
<!-- 简化的笔刷控制UI -->
|
||||
<!-- <div class="setting-group">
|
||||
<span class="setting-label">大小:</span>
|
||||
<input
|
||||
type="range"
|
||||
:value="BrushStore.state.size"
|
||||
min="0.5"
|
||||
max="100"
|
||||
step="0.5"
|
||||
class="size-slider"
|
||||
@input="handleBrushSizeChange"
|
||||
/>
|
||||
<span class="size-value">{{ BrushStore.state.size }}px</span>
|
||||
</div> -->
|
||||
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">笔刷:</span>
|
||||
<div class="brush-selector" @click="toggleBrushPanel">
|
||||
<div
|
||||
class="brush-preview"
|
||||
:style="{
|
||||
backgroundColor: BrushStore.state.color,
|
||||
height: BrushStore.state.type === 'marker' ? '4px' : '2px',
|
||||
opacity: BrushStore.state.opacity,
|
||||
}"
|
||||
></div>
|
||||
<span class="brush-dropdown">▼</span>
|
||||
</div>
|
||||
|
||||
<!-- 笔刷面板 -->
|
||||
<div
|
||||
v-if="showBrushPanel"
|
||||
class="brush-panel-container"
|
||||
ref="brushPanelRef"
|
||||
>
|
||||
<BrushPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">颜色:</span>
|
||||
<div class="color-picker-wrapper">
|
||||
<input
|
||||
type="color"
|
||||
:value="BrushStore.state.color"
|
||||
class="color-picker"
|
||||
@input="BrushStore.setBrushColor($event.target.value)"
|
||||
/>
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本工具设置 -->
|
||||
<div v-if="activeTool === OperationType.TEXT" class="canvas-settings">
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Font:</span>
|
||||
<select class="font-select">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Size:</span>
|
||||
<input
|
||||
type="number"
|
||||
class="setting-input"
|
||||
value="16"
|
||||
min="8"
|
||||
max="72"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Color:</span>
|
||||
<div class="color-picker-wrapper">
|
||||
<input type="color" class="color-picker" value="#000000" />
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传工具设置 -->
|
||||
<div v-if="activeTool === OperationType.UPLOAD" class="canvas-settings">
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Upload Type:</span>
|
||||
<select class="setting-select">
|
||||
<option value="image">Image</option>
|
||||
<option value="vector">Vector Graphics</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出设置 -->
|
||||
<div class="setting-group export-group">
|
||||
<span class="export-model-select">exportModel.select:</span>
|
||||
<span class="export-model-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-right: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas-title::before {
|
||||
content: "⟳";
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.canvas-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.setting-input {
|
||||
width: 60px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.setting-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.font-select {
|
||||
width: 150px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-dropdown {
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.size-slider {
|
||||
width: 100px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.size-value {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.brush-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
width: 80px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brush-preview {
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background-color: #000;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.brush-dropdown,
|
||||
.export-model-dropdown {
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.export-model-select {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 笔刷面板 */
|
||||
.brush-panel-container {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
width: 600px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,506 @@
|
||||
<script setup>
|
||||
import { ref, inject, onMounted } from "vue";
|
||||
import { Skeleton } from "ant-design-vue";
|
||||
|
||||
const loading = ref(true);
|
||||
const shortcuts = ref([]);
|
||||
const keyboardManager = inject("keyboardManager", null);
|
||||
const platform = ref({});
|
||||
|
||||
// 初始化键盘快捷键信息
|
||||
onMounted(() => {
|
||||
// 添加延迟以显示骨架屏效果
|
||||
setTimeout(() => {
|
||||
if (keyboardManager) {
|
||||
// 使用KeyboardManager的平台检测
|
||||
platform.value = {
|
||||
isMac: keyboardManager.platform === "mac",
|
||||
isIOS: keyboardManager.platform === "ios",
|
||||
isIPad:
|
||||
keyboardManager.platform === "ios" &&
|
||||
/iPad/.test(window.navigator.userAgent),
|
||||
isTouchDevice: keyboardManager.isTouchDevice,
|
||||
isWindows: keyboardManager.platform === "windows",
|
||||
isAndroid: keyboardManager.platform === "android",
|
||||
};
|
||||
|
||||
// 使用KeyboardManager的API获取所有快捷键
|
||||
const managerShortcuts = keyboardManager.getShortcuts();
|
||||
|
||||
// 转换为组件所需的格式
|
||||
shortcuts.value = convertShortcuts(managerShortcuts);
|
||||
} else {
|
||||
// 如果没有注入keyboardManager,使用默认检测和默认快捷键
|
||||
platform.value = detectPlatform();
|
||||
shortcuts.value = generateDefaultShortcuts();
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// 转换KeyboardManager返回的快捷键格式为组件需要的格式
|
||||
function convertShortcuts(managerShortcuts) {
|
||||
// 转换快捷键列表
|
||||
const result = [];
|
||||
|
||||
// 基本的Action到显示名称的映射
|
||||
const actionDisplayMap = {
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
delete: "删除选中元素",
|
||||
selectAll: "全选",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
cut: "剪切",
|
||||
save: "保存",
|
||||
selectTool: "选择工具",
|
||||
increaseBrushSize: "增加笔触大小",
|
||||
decreaseBrushSize: "减小笔触大小",
|
||||
toggleTempTool: "临时切换工具",
|
||||
newLayer: "新建图层",
|
||||
groupLayers: "组合图层",
|
||||
ungroupLayers: "取消组合",
|
||||
mergeLayers: "合并图层",
|
||||
};
|
||||
|
||||
// 工具ID到显示名称的映射
|
||||
const toolDisplayMap = {
|
||||
select: "选择模式",
|
||||
draw: "绘画模式",
|
||||
eraser: "橡皮擦模式",
|
||||
eyedropper: "吸色工具",
|
||||
pan: "移动画布",
|
||||
lasso: "套索工具",
|
||||
area_custom: "自由选区工具",
|
||||
wave: "波浪工具",
|
||||
liquify: "液化工具",
|
||||
};
|
||||
|
||||
// 处理每个快捷键
|
||||
for (const shortcut of managerShortcuts) {
|
||||
let actionDisplay = actionDisplayMap[shortcut.action] || shortcut.action;
|
||||
|
||||
// 特殊处理工具选择
|
||||
if (
|
||||
shortcut.action === "selectTool" &&
|
||||
shortcut.param &&
|
||||
toolDisplayMap[shortcut.param]
|
||||
) {
|
||||
actionDisplay = toolDisplayMap[shortcut.param];
|
||||
}
|
||||
|
||||
result.push({
|
||||
action: actionDisplay,
|
||||
windows: shortcut.key.replace(/cmdOrCtrl\+/g, "Ctrl+"),
|
||||
mac: shortcut.key.replace(/cmdOrCtrl\+/g, "⌘+"),
|
||||
touch: shortcut.touch || "触控界面点击对应工具",
|
||||
displayKey: shortcut.displayKey,
|
||||
});
|
||||
}
|
||||
|
||||
// 添加一些组件特定的快捷键
|
||||
result.push({
|
||||
action: "缩放画布",
|
||||
windows: "鼠标滚轮",
|
||||
mac: "鼠标滚轮 或 触控板缩放手势",
|
||||
touch: "双指捏合",
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检测平台 - 作为备用
|
||||
function detectPlatform() {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
return {
|
||||
isMac: /Mac/.test(userAgent),
|
||||
isIOS: /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream,
|
||||
isIPad: /iPad/.test(userAgent),
|
||||
isTouchDevice: "ontouchstart" in window || navigator.maxTouchPoints > 0,
|
||||
isWindows: /Win/.test(userAgent),
|
||||
isAndroid: /Android/.test(userAgent),
|
||||
};
|
||||
}
|
||||
|
||||
// 生成默认快捷键描述
|
||||
function generateDefaultShortcuts() {
|
||||
return [
|
||||
{
|
||||
action: "撤销",
|
||||
windows: "Ctrl+Z",
|
||||
mac: "⌘+Z",
|
||||
touch: "双指向右轻扫",
|
||||
},
|
||||
{
|
||||
action: "重做",
|
||||
windows: "Ctrl+Y 或 Ctrl+Shift+Z",
|
||||
mac: "⌘+Shift+Z",
|
||||
touch: "双指向左轻扫",
|
||||
},
|
||||
{
|
||||
action: "删除选中元素",
|
||||
windows: "Delete 或 Backspace",
|
||||
mac: "Delete 或 ⌫",
|
||||
touch: "长按选中元素后点击删除",
|
||||
},
|
||||
{
|
||||
action: "全选",
|
||||
windows: "Ctrl+A",
|
||||
mac: "⌘+A",
|
||||
touch: "无",
|
||||
},
|
||||
{
|
||||
action: "复制",
|
||||
windows: "Ctrl+C",
|
||||
mac: "⌘+C",
|
||||
touch: "无",
|
||||
},
|
||||
{
|
||||
action: "粘贴",
|
||||
windows: "Ctrl+V",
|
||||
mac: "⌘+V",
|
||||
touch: "无",
|
||||
},
|
||||
{
|
||||
action: "剪切",
|
||||
windows: "Ctrl+X",
|
||||
mac: "⌘+X",
|
||||
touch: "无",
|
||||
},
|
||||
{
|
||||
action: "缩放画布",
|
||||
windows: "鼠标滚轮",
|
||||
mac: "鼠标滚轮 或 触控板缩放手势",
|
||||
touch: "双指捏合",
|
||||
},
|
||||
{
|
||||
action: "移动画布",
|
||||
windows: "Alt+拖动 或 鼠标中键拖动",
|
||||
mac: "Option+拖动 或 触控板双指拖动",
|
||||
touch: "双指拖动",
|
||||
},
|
||||
{
|
||||
action: "绘画模式",
|
||||
windows: "B",
|
||||
mac: "B",
|
||||
touch: "点击画笔工具",
|
||||
},
|
||||
{
|
||||
action: "选择模式",
|
||||
windows: "M",
|
||||
mac: "M",
|
||||
touch: "点击选择工具",
|
||||
},
|
||||
{
|
||||
action: "橡皮擦模式",
|
||||
windows: "E",
|
||||
mac: "E",
|
||||
touch: "点击橡皮擦工具",
|
||||
},
|
||||
{
|
||||
action: "吸色工具",
|
||||
windows: "I",
|
||||
mac: "I",
|
||||
touch: "点击吸色工具",
|
||||
},
|
||||
{
|
||||
action: "增加笔触大小",
|
||||
windows: "]",
|
||||
mac: "]",
|
||||
touch: "拖动笔刷大小滑块",
|
||||
},
|
||||
{
|
||||
action: "减小笔触大小",
|
||||
windows: "[",
|
||||
mac: "[",
|
||||
touch: "拖动笔刷大小滑块",
|
||||
},
|
||||
{
|
||||
action: "增加材质图片大小",
|
||||
windows: "Shift+]",
|
||||
mac: "⇧+]",
|
||||
touch: "拖动材质大小滑块",
|
||||
},
|
||||
{
|
||||
action: "减小材质图片大小",
|
||||
windows: "Shift+[",
|
||||
mac: "⇧+[",
|
||||
touch: "拖动材质大小滑块",
|
||||
},
|
||||
{
|
||||
action: "上传图片",
|
||||
windows: "Ctrl+O",
|
||||
mac: "⌘+O",
|
||||
touch: "点击上传按钮",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 获取当前平台的快捷键文本
|
||||
function getShortcutForCurrentPlatform(shortcut) {
|
||||
if (platform.value.isTouchDevice) {
|
||||
return shortcut.touch;
|
||||
} else if (platform.value.isMac) {
|
||||
return shortcut.displayKey || shortcut.mac;
|
||||
} else {
|
||||
return shortcut.displayKey || shortcut.windows;
|
||||
}
|
||||
}
|
||||
|
||||
// 按分类获取快捷键
|
||||
function getShortcutsByCategory(category) {
|
||||
const categoryMap = {
|
||||
basic: [
|
||||
"撤销",
|
||||
"重做",
|
||||
"全选",
|
||||
"复制",
|
||||
"粘贴",
|
||||
"剪切",
|
||||
"删除选中元素",
|
||||
"上传图片",
|
||||
],
|
||||
view: ["缩放画布", "移动画布"],
|
||||
tools: [
|
||||
"绘画模式",
|
||||
"选择模式",
|
||||
"橡皮擦模式",
|
||||
"吸色工具",
|
||||
"套索工具",
|
||||
"自由选区工具",
|
||||
"波浪工具",
|
||||
"液化工具",
|
||||
],
|
||||
brush: [
|
||||
"增加笔触大小",
|
||||
"减小笔触大小",
|
||||
"增加材质图片大小",
|
||||
"减小材质图片大小",
|
||||
],
|
||||
layer: ["新建图层", "组合图层", "取消组合", "合并图层"],
|
||||
};
|
||||
|
||||
return shortcuts.value.filter((s) =>
|
||||
categoryMap[category]?.includes(s.action)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="keyboard-shortcut-help">
|
||||
<h2>键盘快捷键 & 操作指南</h2>
|
||||
|
||||
<Skeleton active :loading="loading">
|
||||
<div class="platform-info">
|
||||
检测到的平台:
|
||||
<span v-if="platform.isMac">MacOS</span>
|
||||
<span v-else-if="platform.isWindows">Windows</span>
|
||||
<span v-else-if="platform.isIPad">iPad</span>
|
||||
<span v-else-if="platform.isIOS">iOS</span>
|
||||
<span v-else-if="platform.isAndroid">Android</span>
|
||||
<span v-else>其他</span>
|
||||
<span v-if="platform.isTouchDevice"> (触控设备)</span>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts-category">
|
||||
<h3>基本操作</h3>
|
||||
<table class="shortcuts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th>快捷键/手势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in getShortcutsByCategory('basic')"
|
||||
:key="item.action"
|
||||
>
|
||||
<td>{{ item.action }}</td>
|
||||
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts-category">
|
||||
<h3>视图操作</h3>
|
||||
<table class="shortcuts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th>快捷键/手势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in getShortcutsByCategory('view')"
|
||||
:key="item.action"
|
||||
>
|
||||
<td>{{ item.action }}</td>
|
||||
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts-category">
|
||||
<h3>工具切换</h3>
|
||||
<table class="shortcuts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th>快捷键/手势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in getShortcutsByCategory('tools')"
|
||||
:key="item.action"
|
||||
>
|
||||
<td>{{ item.action }}</td>
|
||||
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts-category">
|
||||
<h3>笔刷调整</h3>
|
||||
<table class="shortcuts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th>快捷键/手势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in getShortcutsByCategory('brush')"
|
||||
:key="item.action"
|
||||
>
|
||||
<td>{{ item.action }}</td>
|
||||
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts-category">
|
||||
<h3>图层操作</h3>
|
||||
<table class="shortcuts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作</th>
|
||||
<th>快捷键/手势</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in getShortcutsByCategory('layer')"
|
||||
:key="item.action"
|
||||
>
|
||||
<td>{{ item.action }}</td>
|
||||
<td>{{ getShortcutForCurrentPlatform(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="touch-tips" v-if="platform.isTouchDevice">
|
||||
<h3>触控设备提示</h3>
|
||||
<ul>
|
||||
<li>长按图层面板可访问更多选项</li>
|
||||
<li>双击元素可快速进入编辑模式</li>
|
||||
<li>双指拖动可平移画布</li>
|
||||
<li>双指捏合可缩放画布</li>
|
||||
<li>双指连按可显示元素变换控制点</li>
|
||||
<li>三指左右滑动可进行撤销/重做操作</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.keyboard-shortcut-help {
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #eaeaea;
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px;
|
||||
background-color: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shortcuts-category {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.shortcuts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.shortcuts-table th,
|
||||
.shortcuts-table td {
|
||||
border: 1px solid #eaeaea;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.shortcuts-table th {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.touch-tips {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
background-color: #fffbeb;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #fbbf24;
|
||||
}
|
||||
|
||||
.touch-tips ul {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.touch-tips li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.keyboard-shortcut-help {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.shortcuts-table th,
|
||||
.shortcuts-table td {
|
||||
padding: 12px 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1062
src/component/Canvas/CanvasEditor/components/LayersPanel.vue
Normal file
1062
src/component/Canvas/CanvasEditor/components/LayersPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
1388
src/component/Canvas/CanvasEditor/components/LiquifyPanel.vue
Normal file
1388
src/component/Canvas/CanvasEditor/components/LiquifyPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
146
src/component/Canvas/CanvasEditor/components/MinimapPanel.vue
Normal file
146
src/component/Canvas/CanvasEditor/components/MinimapPanel.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
minimapManager: Object,
|
||||
});
|
||||
|
||||
const minimapContainerRef = ref(null);
|
||||
let refreshTimeout = null;
|
||||
|
||||
// 强制重绘小地图,添加防抖处理
|
||||
const forceRefresh = () => {
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout);
|
||||
}
|
||||
|
||||
refreshTimeout = setTimeout(() => {
|
||||
if (props.minimapManager) {
|
||||
props.minimapManager.refresh();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.minimapManager && minimapContainerRef.value) {
|
||||
// 使用新的mount方法挂载小地图
|
||||
props.minimapManager.mount(minimapContainerRef.value);
|
||||
// 初始加载后延迟刷新一次,确保内容正确加载
|
||||
setTimeout(forceRefresh, 200);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.minimapManager,
|
||||
(newVal) => {
|
||||
if (newVal && minimapContainerRef.value) {
|
||||
newVal.mount(minimapContainerRef.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 添加resize observer以适应容器大小变化
|
||||
let resizeObserver = null;
|
||||
onMounted(() => {
|
||||
if (window.ResizeObserver) {
|
||||
// 使用防抖处理resize事件,避免过于频繁的刷新
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
forceRefresh();
|
||||
});
|
||||
if (minimapContainerRef.value) {
|
||||
resizeObserver.observe(minimapContainerRef.value.parentElement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver && minimapContainerRef.value) {
|
||||
resizeObserver.unobserve(minimapContainerRef.value.parentElement);
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
if (refreshTimeout) {
|
||||
clearTimeout(refreshTimeout);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="minimap-container">
|
||||
<div class="minimap-header">
|
||||
<span>画布小地图</span>
|
||||
<button class="minimap-refresh" @click="forceRefresh" title="刷新小地图">
|
||||
⟳
|
||||
</button>
|
||||
</div>
|
||||
<div class="minimap-content" ref="minimapContainerRef">
|
||||
<!-- 不再需要直接提供canvas引用,由MinimapManager内部创建 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.minimap-container {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
width: 200px;
|
||||
height: 140px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.minimap-header {
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
background-color: #f0f0f0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.minimap-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.minimap-refresh {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.minimap-refresh:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* 触控设备优化 */
|
||||
@media (pointer: coarse) {
|
||||
.minimap-container {
|
||||
width: 220px;
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.minimap-header {
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.minimap-refresh {
|
||||
font-size: 18px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
814
src/component/Canvas/CanvasEditor/components/SelectionPanel.vue
Normal file
814
src/component/Canvas/CanvasEditor/components/SelectionPanel.vue
Normal file
@@ -0,0 +1,814 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div class="selection-toolbar" v-if="visible">
|
||||
<!-- 顶部选区类型工具栏 -->
|
||||
<div class="toolbar-section">
|
||||
<div class="toolbar-header">
|
||||
<div class="header-title">选区工具</div>
|
||||
<!-- 移除关闭按钮,完全通过工具切换控制显示隐藏 -->
|
||||
</div>
|
||||
|
||||
<div class="tool-types">
|
||||
<div
|
||||
:class="[
|
||||
'tool-btn',
|
||||
{ active: selectionType === OperationType.LASSO },
|
||||
]"
|
||||
@click="setSelectionType(OperationType.LASSO)"
|
||||
>
|
||||
<svg-icon name="CFree" size="26" />
|
||||
<span>{{ $t("手绘") }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'tool-btn',
|
||||
{ active: selectionType === OperationType.LASSO_RECTANGLE },
|
||||
]"
|
||||
@click="setSelectionType(OperationType.LASSO_RECTANGLE)"
|
||||
>
|
||||
<svg-icon name="CRectangle" size="32" />
|
||||
<span>{{ $t("矩形") }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'tool-btn',
|
||||
{ active: selectionType === OperationType.LASSO_ELLIPSE },
|
||||
]"
|
||||
@click="setSelectionType(OperationType.LASSO_ELLIPSE)"
|
||||
>
|
||||
<svg-icon name="CEllipse" size="30" />
|
||||
<span>{{ $t("椭圆") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- 底部选区操作工具栏 -->
|
||||
<div class="tool-actions">
|
||||
<div class="action-btn" @click="copySelectionToNewLayer">
|
||||
<svg-icon name="CPaste" />
|
||||
<span class="btn-text">{{ $t("拷贝并粘贴") }}</span>
|
||||
</div>
|
||||
<!-- <button
|
||||
class="action-btn"
|
||||
@click="addSelection"
|
||||
:disabled="!hasSelection"
|
||||
title="添加"
|
||||
>
|
||||
<svg-icon name="plus" />
|
||||
<span class="btn-text">{{ $t("添加") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="removeSelection"
|
||||
:disabled="!hasSelection"
|
||||
title="移除"
|
||||
>
|
||||
<svg-icon name="minus" />
|
||||
<span class="btn-text">{{ $t("移除") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="invertSelection"
|
||||
:disabled="!hasSelection"
|
||||
title="反转"
|
||||
>
|
||||
<svg-icon name="flip-horizontal" />
|
||||
<span class="btn-text">{{ $t("反转") }}</span>
|
||||
</button> -->
|
||||
<!-- <button
|
||||
class="action-btn"
|
||||
@click="copySelectionToNewLayer"
|
||||
:disabled="!hasSelection"
|
||||
title="拷贝并粘贴"
|
||||
>
|
||||
<svg-icon name="copy" />
|
||||
<span class="btn-text">{{ $t("拷贝并粘贴") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="openFeatherDialog"
|
||||
:disabled="!hasSelection"
|
||||
title="羽化"
|
||||
>
|
||||
<svg-icon name="feather" />
|
||||
<span class="btn-text">{{ $t("羽化") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="fillSelection"
|
||||
:disabled="!hasSelection"
|
||||
title="颜色填充"
|
||||
>
|
||||
<svg-icon name="fill-color" />
|
||||
<span class="btn-text">{{ $t("颜色填充") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="action-btn"
|
||||
@click="clearSelection"
|
||||
:disabled="!hasSelection"
|
||||
title="清除"
|
||||
>
|
||||
<svg-icon name="trash" />
|
||||
<span class="btn-text">{{ $t("清除") }}</span>
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 羽化设置弹窗 -->
|
||||
<div v-if="showFeatherDialog" class="dialog-overlay">
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header">
|
||||
<h3>{{ $t("羽化") }}</h3>
|
||||
<button class="close-dialog-btn" @click="cancelFeather">×</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<div class="feather-control">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="50"
|
||||
v-model.number="featherAmount"
|
||||
class="slider-control"
|
||||
/>
|
||||
<div class="feather-value">{{ featherAmount }}px</div>
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<button class="cancel-btn" @click="cancelFeather">
|
||||
{{ $t("取消") }}
|
||||
</button>
|
||||
<button class="confirm-btn" @click="applyFeather">
|
||||
{{ $t("确认") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 颜色选择器 -->
|
||||
<div v-if="showColorPicker" class="dialog-overlay">
|
||||
<div class="dialog-container">
|
||||
<div class="dialog-header">
|
||||
<h3>{{ $t("选择填充颜色") }}</h3>
|
||||
<button class="close-dialog-btn" @click="cancelColorPicker">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="dialog-content">
|
||||
<input type="color" v-model="fillColor" class="color-picker" />
|
||||
<div class="dialog-buttons">
|
||||
<button class="cancel-btn" @click="cancelColorPicker">
|
||||
{{ $t("取消") }}
|
||||
</button>
|
||||
<button class="confirm-btn" @click="confirmColorPicker">
|
||||
{{ $t("确认") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import {
|
||||
CreateSelectionCommand,
|
||||
InvertSelectionCommand,
|
||||
ClearSelectionCommand,
|
||||
FeatherSelectionCommand,
|
||||
FillSelectionCommand,
|
||||
CopySelectionToNewLayerCommand,
|
||||
ClearSelectionContentCommand,
|
||||
LassoCutoutCommand,
|
||||
} from "../commands/SelectionCommands";
|
||||
import { ToolCommand } from "../commands/ToolCommands";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
|
||||
const props = defineProps({
|
||||
canvas: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
commandManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
selectionManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
layerManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
toolManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeTool: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false);
|
||||
const selectionType = ref("rectangle");
|
||||
const featherAmount = ref(0);
|
||||
const fillColor = ref("#000000");
|
||||
const hasSelection = ref(false);
|
||||
const showFeatherDialog = ref(false);
|
||||
const showColorPicker = ref(false);
|
||||
|
||||
// 国际化函数 (简单实现,可根据需要替换为实际的国际化方案)
|
||||
const $t = (key) => key;
|
||||
|
||||
onMounted(() => {
|
||||
// 为选区管理器添加监听,以便在选区变化时更新状态
|
||||
if (props.selectionManager) {
|
||||
// 在选区管理器中添加选区变化的监听
|
||||
checkSelectionStatus();
|
||||
|
||||
// 设置选区状态变化的回调
|
||||
props.selectionManager.onSelectionChanged = () => {
|
||||
checkSelectionStatus();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 监听 activeTool 变化
|
||||
watch(
|
||||
() => props.activeTool,
|
||||
(newTool) => {
|
||||
// 当工具为LASSO或AREA类型时显示选区面板
|
||||
const selectionTools = [
|
||||
OperationType.LASSO,
|
||||
OperationType.LASSO_RECTANGLE,
|
||||
OperationType.LASSO_ELLIPSE,
|
||||
];
|
||||
|
||||
if (selectionTools.includes(newTool)) {
|
||||
show();
|
||||
// 根据工具类型设置选区类型
|
||||
selectionType.value = newTool;
|
||||
|
||||
// 更新选区管理器的选区类型
|
||||
if (props.selectionManager) {
|
||||
props.selectionManager.setSelectionType(selectionType.value);
|
||||
props.selectionManager.setupSelectionEvents();
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 显示面板
|
||||
*/
|
||||
function show() {
|
||||
visible.value = true;
|
||||
checkSelectionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭面板
|
||||
*/
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置选区类型
|
||||
*/
|
||||
function setSelectionType(type) {
|
||||
selectionType.value = type;
|
||||
|
||||
// 通过 ToolManager 切换工具,这会自动通知 SelectionManager
|
||||
if (props.toolManager) {
|
||||
props.toolManager.setToolWithCommand(type);
|
||||
}
|
||||
|
||||
// 备用方案:如果没有 toolManager,直接更新 selectionManager
|
||||
else if (props.selectionManager) {
|
||||
props.selectionManager.setSelectionType(type);
|
||||
props.selectionManager.setupSelectionEvents();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查选区状态
|
||||
*/
|
||||
function checkSelectionStatus() {
|
||||
hasSelection.value =
|
||||
props.selectionManager &&
|
||||
props.selectionManager.getSelectionObject() !== null;
|
||||
|
||||
// 同步羽化值
|
||||
if (hasSelection.value) {
|
||||
featherAmount.value = props.selectionManager.getFeatherAmount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加选区
|
||||
*/
|
||||
function addSelection() {
|
||||
// TODO: 实现添加选区功能
|
||||
console.log("添加选区功能尚未实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除选区
|
||||
*/
|
||||
function removeSelection() {
|
||||
// TODO: 实现移除选区功能
|
||||
console.log("移除选区功能尚未实现");
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转选区
|
||||
*/
|
||||
function invertSelection() {
|
||||
if (!hasSelection.value) return;
|
||||
|
||||
props.commandManager.execute(
|
||||
new InvertSelectionCommand({
|
||||
canvas: props.canvas,
|
||||
selectionManager: props.selectionManager,
|
||||
})
|
||||
);
|
||||
checkSelectionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除选区
|
||||
*/
|
||||
function clearSelection() {
|
||||
if (!hasSelection.value) return;
|
||||
|
||||
props.commandManager.execute(
|
||||
new ClearSelectionCommand({
|
||||
selectionManager: props.selectionManager,
|
||||
})
|
||||
);
|
||||
checkSelectionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用羽化效果
|
||||
*/
|
||||
function applyFeather() {
|
||||
if (!hasSelection.value) return;
|
||||
|
||||
props.commandManager.execute(
|
||||
new FeatherSelectionCommand({
|
||||
selectionManager: props.selectionManager,
|
||||
featherAmount: featherAmount.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 填充选区
|
||||
*/
|
||||
function fillSelection() {
|
||||
if (!hasSelection.value) return;
|
||||
showColorPicker.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 套索抠图到新图层
|
||||
*/
|
||||
function copySelectionToNewLayer() {
|
||||
if (!hasSelection.value) return;
|
||||
|
||||
props.commandManager.execute(
|
||||
new LassoCutoutCommand({
|
||||
canvas: props.canvas,
|
||||
layerManager: props.layerManager,
|
||||
selectionManager: props.selectionManager,
|
||||
toolManager: props.toolManager,
|
||||
})
|
||||
);
|
||||
checkSelectionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除选区内容
|
||||
*/
|
||||
function clearSelectionContent() {
|
||||
if (!hasSelection.value) return;
|
||||
|
||||
props.commandManager.execute(
|
||||
new ClearSelectionContentCommand({
|
||||
canvas: props.canvas,
|
||||
layerManager: props.layerManager,
|
||||
selectionManager: props.selectionManager,
|
||||
})
|
||||
);
|
||||
checkSelectionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开羽化设置弹窗
|
||||
*/
|
||||
function openFeatherDialog() {
|
||||
showFeatherDialog.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消羽化设置
|
||||
*/
|
||||
function cancelFeather() {
|
||||
showFeatherDialog.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认羽化设置
|
||||
*/
|
||||
function confirmFeather() {
|
||||
applyFeather();
|
||||
showFeatherDialog.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消颜色选择
|
||||
*/
|
||||
function cancelColorPicker() {
|
||||
showColorPicker.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认颜色选择
|
||||
*/
|
||||
function confirmColorPicker() {
|
||||
if (!hasSelection.value) return;
|
||||
|
||||
props.commandManager.execute(
|
||||
new FillSelectionCommand({
|
||||
canvas: props.canvas,
|
||||
layerManager: props.layerManager,
|
||||
selectionManager: props.selectionManager,
|
||||
color: fillColor.value,
|
||||
})
|
||||
);
|
||||
checkSelectionStatus();
|
||||
showColorPicker.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.selection-toolbar {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
max-width: min(90vw, 640px);
|
||||
margin: 0 auto;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
color: #333;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 平板和手机适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.selection-toolbar {
|
||||
bottom: 15px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
max-width: calc(100vw - 30px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.selection-toolbar {
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
.selection-toolbar.is-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// align-items: center;
|
||||
padding: 8px 15px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
}
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.tool-types {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* 平板适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.tool-types {
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机适配 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.tool-types {
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 10px 5px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn span {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tool-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 5px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* 平板适配 - 每行4个按钮 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.tool-actions {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机适配 - 每行3个按钮 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.tool-actions {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
min-width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
padding: 8px 2px;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 对话框样式 */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dialog-container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
width: 280px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-dialog-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.feather-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.slider-control::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #007aff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feather-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
</style>
|
||||
1104
src/component/Canvas/CanvasEditor/components/TextEditorPanel.vue
Normal file
1104
src/component/Canvas/CanvasEditor/components/TextEditorPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
411
src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue
Normal file
411
src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<script setup>
|
||||
import { ref, inject, computed, onMounted, onUnmounted } from "vue";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import SvgIcon from "@/component/Canvas/SvgIcon/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
activeTool: String,
|
||||
minimapEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isRedGreenMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const commandManager = inject("commandManager");
|
||||
|
||||
// 撤销/重做按钮状态
|
||||
const canUndo = ref(false);
|
||||
const canRedo = ref(false);
|
||||
|
||||
// 监听命令管理器状态变化
|
||||
commandManager.setChangeCallback((info) => {
|
||||
canUndo.value = info.canUndo;
|
||||
canRedo.value = info.canRedo;
|
||||
});
|
||||
|
||||
// 撤销/重做操作
|
||||
const undoFun = () => commandManager.undo();
|
||||
const redoFun = () => commandManager.redo();
|
||||
|
||||
const emit = defineEmits([
|
||||
"tool-selected",
|
||||
"trigger-image-upload",
|
||||
"add-text",
|
||||
"undo",
|
||||
"redo",
|
||||
"toggle-minimap",
|
||||
"zoom-in",
|
||||
"zoom-out",
|
||||
"toggle-red-green-mode",
|
||||
]);
|
||||
|
||||
// 普通模式工具列表
|
||||
const normalToolsList = ref([
|
||||
{
|
||||
id: "undo",
|
||||
title: "Undo",
|
||||
action: undo,
|
||||
icon: { name: "CUndo", size: "20" },
|
||||
class: "undo-btn",
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
title: "Redo",
|
||||
action: redo,
|
||||
icon: { name: "CRedo", size: "20" },
|
||||
class: "redo-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.DRAW,
|
||||
title: "Drawing",
|
||||
action: () => selectTool(OperationType.DRAW),
|
||||
icon: { name: "CBrush", size: "24" },
|
||||
class: "draw-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.ERASER,
|
||||
title: "Eraser",
|
||||
action: () => selectTool(OperationType.ERASER),
|
||||
icon: { name: "CEraser", size: "22" },
|
||||
class: "eraser-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.PAN,
|
||||
title: "Pan",
|
||||
action: () => selectTool(OperationType.PAN),
|
||||
icon: { name: "CHand", size: "28" },
|
||||
class: "hand-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.SELECT,
|
||||
title: "Select",
|
||||
action: () => selectTool(OperationType.SELECT),
|
||||
icon: { name: "CSelect", size: "28" },
|
||||
class: "select-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.LIQUIFY,
|
||||
title: "Liquefying",
|
||||
action: () => selectTool(OperationType.LIQUIFY),
|
||||
icon: { name: "CLiquefying", size: "32" },
|
||||
class: "liquify-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.LASSO,
|
||||
title: "Lasso",
|
||||
action: () => selectTool(OperationType.LASSO),
|
||||
icon: { name: "CLasso", size: "28" },
|
||||
class: "lasso-btn",
|
||||
activeList: [
|
||||
OperationType.LASSO,
|
||||
OperationType.LASSO_RECTANGLE,
|
||||
OperationType.AREA_CUSTOM,
|
||||
OperationType.AREA_RECTANGLE,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "zoomIn",
|
||||
title: "Zoom In",
|
||||
action: zoomIn,
|
||||
icon: { name: "CZoomIn", size: "30" },
|
||||
class: "zoom-in-btn",
|
||||
},
|
||||
{
|
||||
id: "zoomOut",
|
||||
title: "Zoom Out",
|
||||
action: zoomOut,
|
||||
icon: { name: "CZoomOut", size: "26" },
|
||||
class: "zoom-out-btn",
|
||||
},
|
||||
{
|
||||
id: "upload",
|
||||
title: "Upload Image",
|
||||
action: triggerImageUpload,
|
||||
icon: { name: "CUpload", size: "26" },
|
||||
class: "upload-btn",
|
||||
},
|
||||
{
|
||||
id: "addText",
|
||||
title: "Add Text",
|
||||
action: () => addText(),
|
||||
icon: { name: "CFont", size: "20" },
|
||||
class: "text-btn",
|
||||
},
|
||||
]);
|
||||
|
||||
// 红绿图模式工具列表
|
||||
const redGreenToolsList = ref([
|
||||
{
|
||||
id: "undo",
|
||||
title: "Undo",
|
||||
action: undo,
|
||||
icon: { name: "CUndo", size: "20" },
|
||||
class: "undo-btn",
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
title: "Redo",
|
||||
action: redo,
|
||||
icon: { name: "CRedo", size: "20" },
|
||||
class: "redo-btn",
|
||||
},
|
||||
{
|
||||
id: OperationType.RED_BRUSH,
|
||||
title: "Red Brush (R)",
|
||||
action: () => selectTool(OperationType.RED_BRUSH),
|
||||
icon: { name: "CBrush", size: "24" },
|
||||
class: "red-brush-btn",
|
||||
style: { color: "#FF0000" },
|
||||
},
|
||||
{
|
||||
id: OperationType.GREEN_BRUSH,
|
||||
title: "Green Brush (G)",
|
||||
action: () => selectTool(OperationType.GREEN_BRUSH),
|
||||
icon: { name: "CBrush", size: "24" },
|
||||
class: "green-brush-btn",
|
||||
style: { color: "#00AA00" },
|
||||
},
|
||||
{
|
||||
id: OperationType.ERASER,
|
||||
title: "Eraser (E)",
|
||||
action: () => selectTool(OperationType.ERASER),
|
||||
icon: { name: "CEraser", size: "22" },
|
||||
class: "eraser-btn",
|
||||
},
|
||||
{
|
||||
id: "zoomIn",
|
||||
title: "Zoom In",
|
||||
action: zoomIn,
|
||||
icon: { name: "CZoomIn", size: "30" },
|
||||
class: "zoom-in-btn",
|
||||
},
|
||||
{
|
||||
id: "zoomOut",
|
||||
title: "Zoom Out",
|
||||
action: zoomOut,
|
||||
icon: { name: "CZoomOut", size: "26" },
|
||||
class: "zoom-out-btn",
|
||||
},
|
||||
]);
|
||||
|
||||
// 根据模式选择工具列表
|
||||
const toolsList = computed(() => {
|
||||
return props.isRedGreenMode ? redGreenToolsList.value : normalToolsList.value;
|
||||
});
|
||||
|
||||
function selectTool(tool) {
|
||||
emit("tool-selected", tool);
|
||||
}
|
||||
|
||||
function triggerImageUpload() {
|
||||
emit("trigger-image-upload");
|
||||
}
|
||||
|
||||
function addText() {
|
||||
emit("add-text");
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (!canUndo.value) return;
|
||||
undoFun();
|
||||
emit("undo", {
|
||||
canUndo: canUndo.value,
|
||||
canRedo: canRedo.value,
|
||||
commandManager,
|
||||
});
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (!canRedo.value) return;
|
||||
emit("redo", {
|
||||
canUndo: canUndo.value,
|
||||
canRedo: canRedo.value,
|
||||
commandManager,
|
||||
});
|
||||
redoFun();
|
||||
}
|
||||
|
||||
function toggleMinimap() {
|
||||
emit("toggle-minimap", !props.minimapEnabled);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
emit("zoom-in");
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
emit("zoom-out");
|
||||
}
|
||||
|
||||
function toggleRedGreenMode() {
|
||||
emit("toggle-red-green-mode");
|
||||
}
|
||||
|
||||
// 键盘快捷键处理
|
||||
function handleKeyDown(event) {
|
||||
// 在红绿图模式下处理特定快捷键
|
||||
if (props.isRedGreenMode) {
|
||||
const key = event.key.toUpperCase();
|
||||
|
||||
// 当处于输入状态时不触发快捷键
|
||||
if (
|
||||
event.target.tagName === "INPUT" ||
|
||||
event.target.tagName === "TEXTAREA"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "R":
|
||||
selectTool(OperationType.RED_BRUSH);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "G":
|
||||
selectTool(OperationType.GREEN_BRUSH);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "E":
|
||||
selectTool(OperationType.ERASER);
|
||||
event.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 添加键盘事件监听
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除键盘事件监听
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tools-sidebar">
|
||||
<div
|
||||
v-for="tool in toolsList"
|
||||
:key="tool.id"
|
||||
:class="[
|
||||
'tool-btn',
|
||||
tool.class,
|
||||
{
|
||||
active:
|
||||
tool.id === activeTool ||
|
||||
tool.id === activeTool.toLowerCase() ||
|
||||
tool?.activeList?.includes(activeTool),
|
||||
disabled:
|
||||
(tool.id === 'undo' && !canUndo) ||
|
||||
(tool.id === 'redo' && !canRedo),
|
||||
},
|
||||
]"
|
||||
:style="tool.style"
|
||||
@click="tool.action"
|
||||
>
|
||||
<SvgIcon :name="tool.icon.name" :size="tool.icon.size"></SvgIcon>
|
||||
<div class="tool-tooltip">{{ tool.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tools-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 15px 10px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
background-color: #ffffff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.tool-btn:hover .tool-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.tool-btn.disabled {
|
||||
cursor: not-allowed;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.tool-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tool-tooltip:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 100%;
|
||||
margin-top: -5px;
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: transparent rgba(0, 0, 0, 0.7) transparent transparent;
|
||||
}
|
||||
|
||||
.red-green-mode {
|
||||
background-color: #fff4f4;
|
||||
}
|
||||
|
||||
.mode-indicator {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #ffcccc;
|
||||
color: #a33;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mode-hint {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
688
src/component/Canvas/CanvasEditor/components/VerticalSlider.vue
Normal file
688
src/component/Canvas/CanvasEditor/components/VerticalSlider.vue
Normal file
@@ -0,0 +1,688 @@
|
||||
<template>
|
||||
<div class="vertical-slider-container" :class="customClass">
|
||||
<div
|
||||
class="slider-track"
|
||||
ref="sliderTrack"
|
||||
@mousedown="startSliding"
|
||||
@touchstart.prevent="startTouchSliding"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div
|
||||
class="slider-fill"
|
||||
:style="{ height: `${displayPercentage}%` }"
|
||||
></div>
|
||||
<div
|
||||
class="slider-thumb"
|
||||
:style="{
|
||||
bottom: `${displayPercentage}%`,
|
||||
transform: `translateX(0) translateY(8px) scale(${thumbScale})`,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
v-for="(preset, index) in presets"
|
||||
:key="`preset-${index}`"
|
||||
class="slider-notch"
|
||||
:class="{ active: isActivePreset(preset) }"
|
||||
:style="{ bottom: `${calculatePresetPosition(preset)}%` }"
|
||||
@click.stop="setValue(preset)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-for="(preset, index) in memorizedValues"
|
||||
:key="`preset-${index}`"
|
||||
class="slider-notch"
|
||||
:class="{ active: isActivePreset(preset) }"
|
||||
:style="{ bottom: `${calculatePresetPosition(preset)}%` }"
|
||||
@click.stop="setValue(preset)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 提示框 -->
|
||||
<transition name="fade">
|
||||
<div class="slider-tooltip" v-if="tooltipVisible" ref="tooltip">
|
||||
<slot name="tooltip-content" :value="modelValue"></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
presets: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
memorizedValues: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
snapThreshold: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
// 用于判断当前值与预设值是否匹配的阈值
|
||||
activeThreshold: {
|
||||
type: Number,
|
||||
default: 0.05,
|
||||
},
|
||||
// 是否将预设值位置按百分比计算
|
||||
isPercentage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// 添加步进属性,控制数值的步长
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
// 增加外部控制tooltip显示的属性
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否启用点击外部关闭tooltip
|
||||
closeOnOutsideClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"update:modelValue",
|
||||
"slide-start",
|
||||
"slide-end",
|
||||
"click",
|
||||
"update:showTooltip", // 新增emit事件用于双向绑定tooltip状态
|
||||
]);
|
||||
|
||||
const sliderTrack = ref(null);
|
||||
const tooltip = ref(null);
|
||||
const isSliding = ref(false);
|
||||
const internalShowTooltip = ref(false);
|
||||
const slideMoved = ref(false); // 用于跟踪是否发生了滑动移动
|
||||
const hideTooltipTimer = ref(null); // 用于存储隐藏tooltip的计时器
|
||||
|
||||
// 在script setup顶部添加新的ref
|
||||
const isSnapping = ref(false);
|
||||
const snapLockValue = ref(null);
|
||||
const snapLockTimeout = ref(null);
|
||||
const thumbScale = ref(1); // 新增:用于控制滑块的缩放效果
|
||||
|
||||
// 定义一个统一的延迟时间常量
|
||||
const TOOLTIP_HIDE_DELAY = 1500; // 1.5秒,可根据需要调整
|
||||
|
||||
// 开始新的隐藏tooltip计时器
|
||||
function startHideTooltipTimer() {
|
||||
// 先清除已有的计时器
|
||||
clearHideTooltipTimer();
|
||||
|
||||
// 创建新计时器
|
||||
hideTooltipTimer.value = setTimeout(() => {
|
||||
// 只有在用户不在滑动时才隐藏tooltip
|
||||
if (!isSliding.value) {
|
||||
updateTooltipVisibility(false);
|
||||
}
|
||||
hideTooltipTimer.value = null;
|
||||
}, TOOLTIP_HIDE_DELAY);
|
||||
}
|
||||
|
||||
// 清除隐藏tooltip的计时器
|
||||
function clearHideTooltipTimer() {
|
||||
if (hideTooltipTimer.value) {
|
||||
clearTimeout(hideTooltipTimer.value);
|
||||
hideTooltipTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算tooltip的可见性,优先使用props中的showTooltip,否则使用内部状态
|
||||
const tooltipVisible = computed(() => {
|
||||
return props.showTooltip !== undefined
|
||||
? props.showTooltip
|
||||
: internalShowTooltip.value;
|
||||
});
|
||||
|
||||
// 更新tooltip状态的方法
|
||||
function updateTooltipVisibility(visible) {
|
||||
if (props.showTooltip !== undefined) {
|
||||
// 如果父组件提供了showTooltip属性,通过emit更新
|
||||
emit("update:showTooltip", visible);
|
||||
} else {
|
||||
// 否则更新内部状态
|
||||
internalShowTooltip.value = visible;
|
||||
}
|
||||
}
|
||||
|
||||
// 计算显示百分比
|
||||
const displayPercentage = computed(() => {
|
||||
const range = props.max - props.min;
|
||||
return ((props.modelValue - props.min) / range) * 100;
|
||||
});
|
||||
|
||||
// 判断是否是活动预设值
|
||||
function isActivePreset(preset) {
|
||||
return Math.abs(props.modelValue - preset) < props.activeThreshold;
|
||||
}
|
||||
|
||||
// 计算预设值的位置
|
||||
function calculatePresetPosition(preset) {
|
||||
if (props.isPercentage) {
|
||||
return preset * 100;
|
||||
} else {
|
||||
const range = props.max - props.min;
|
||||
return ((preset - props.min) / range) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
// 将值舍入到最接近的步进值
|
||||
function roundToStep(value) {
|
||||
if (!props.step || props.step <= 0) return value;
|
||||
|
||||
const numSteps = Math.round((value - props.min) / props.step);
|
||||
return props.min + numSteps * props.step;
|
||||
}
|
||||
|
||||
// 鼠标滑动处理
|
||||
function startSliding(event) {
|
||||
isSliding.value = true;
|
||||
slideMoved.value = false; // 重置滑动状态
|
||||
updateTooltipVisibility(true);
|
||||
clearHideTooltipTimer(); // 清除任何现有的隐藏计时器
|
||||
updateValueFromMousePosition(event);
|
||||
|
||||
emit("slide-start");
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", stopSliding);
|
||||
}
|
||||
|
||||
function handleMouseMove(event) {
|
||||
if (isSliding.value) {
|
||||
slideMoved.value = true; // 标记已经发生了滑动
|
||||
updateValueFromMousePosition(event);
|
||||
}
|
||||
}
|
||||
|
||||
function updateValueFromMousePosition(event) {
|
||||
const rect = sliderTrack.value.getBoundingClientRect();
|
||||
const trackHeight = rect.height;
|
||||
const posY = event.clientY - rect.top;
|
||||
|
||||
// 计算相对位置并转换为min-max范围的值
|
||||
let newValue = props.max - (posY / trackHeight) * (props.max - props.min);
|
||||
|
||||
// 边界检查
|
||||
newValue = Math.max(props.min, Math.min(props.max, newValue));
|
||||
|
||||
// 应用步进
|
||||
if (props.step > 0) {
|
||||
newValue = roundToStep(newValue);
|
||||
}
|
||||
|
||||
// 如果当前处于吸附锁定状态,并且移动不超过锁定阈值,则保持当前值不变
|
||||
if (isSnapping.value && snapLockValue.value !== null) {
|
||||
// 增加吸附力度:扩大保持吸附的阈值范围
|
||||
if (
|
||||
Math.abs(newValue - snapLockValue.value) <
|
||||
(props.snapThreshold / trackHeight) * (props.max - props.min) * 1.2
|
||||
) {
|
||||
newValue = snapLockValue.value;
|
||||
} else {
|
||||
// 移动距离超过阈值,解除吸附锁定
|
||||
clearSnapLock();
|
||||
}
|
||||
} else {
|
||||
// 检查是否可以进入吸附状态
|
||||
const snapPercentage =
|
||||
(props.snapThreshold / trackHeight) * (props.max - props.min);
|
||||
|
||||
// 检查是否接近预设值,增加吸附判定范围
|
||||
for (const preset of props.presets) {
|
||||
if (Math.abs(newValue - preset) < snapPercentage * 0.4) {
|
||||
// 增加判定范围
|
||||
// 进入吸附状态
|
||||
setSnapLock(preset);
|
||||
newValue = preset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否接近记忆值
|
||||
if (!isSnapping.value) {
|
||||
for (const memValue of props.memorizedValues) {
|
||||
if (Math.abs(newValue - memValue) < snapPercentage * 0.4) {
|
||||
// 增加判定范围
|
||||
// 进入吸附状态
|
||||
setSnapLock(memValue);
|
||||
newValue = memValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
}
|
||||
|
||||
// 设置吸附锁定
|
||||
function setSnapLock(value) {
|
||||
if (!isSnapping.value) {
|
||||
isSnapping.value = true;
|
||||
snapLockValue.value = value;
|
||||
|
||||
// 添加视觉反馈:放大滑块
|
||||
thumbScale.value = 1.3;
|
||||
|
||||
// 触感反馈:在支持的设备上触发振动
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(15); // 短暂振动15ms
|
||||
}
|
||||
|
||||
// 延长锁定时间,增强吸附感
|
||||
clearTimeout(snapLockTimeout.value);
|
||||
snapLockTimeout.value = setTimeout(() => {
|
||||
// 恢复滑块大小
|
||||
thumbScale.value = 1;
|
||||
clearSnapLock();
|
||||
}, 500); // 500ms的吸附感觉,比原来的300ms更强
|
||||
}
|
||||
}
|
||||
|
||||
// 清除吸附锁定
|
||||
function clearSnapLock() {
|
||||
isSnapping.value = false;
|
||||
snapLockValue.value = null;
|
||||
clearTimeout(snapLockTimeout.value);
|
||||
snapLockTimeout.value = null;
|
||||
thumbScale.value = 1; // 确保滑块恢复正常大小
|
||||
}
|
||||
|
||||
function setValue(value) {
|
||||
// 应用步进,确保即使通过点击预设值也会遵循步进
|
||||
if (props.step > 0) {
|
||||
value = roundToStep(value);
|
||||
}
|
||||
emit("update:modelValue", value);
|
||||
}
|
||||
|
||||
function stopSliding() {
|
||||
// 清除吸附锁定
|
||||
clearSnapLock();
|
||||
|
||||
if (isSliding.value) {
|
||||
// 如果确实进行了滑动,则发送滑动结束事件
|
||||
if (slideMoved.value) {
|
||||
emit("slide-end", { isSlide: true });
|
||||
|
||||
// 只有在滑动操作后才启动隐藏计时器
|
||||
startHideTooltipTimer();
|
||||
} else {
|
||||
// 只是点击,不是滑动,不自动隐藏tooltip
|
||||
emit("slide-end", { isSlide: false });
|
||||
}
|
||||
}
|
||||
|
||||
isSliding.value = false;
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", stopSliding);
|
||||
}
|
||||
|
||||
// 处理点击事件
|
||||
function handleClick(event) {
|
||||
// 对于纯点击操作,发出click事件
|
||||
if (!slideMoved.value) {
|
||||
clearHideTooltipTimer(); // 清除任何现有的隐藏计时器
|
||||
updateTooltipVisibility(true); // 确保tooltip在点击时显示
|
||||
emit("click");
|
||||
// 点击不启动隐藏计时器
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸事件处理
|
||||
function startTouchSliding(event) {
|
||||
isSliding.value = true;
|
||||
slideMoved.value = false; // 重置滑动状态
|
||||
updateTooltipVisibility(true);
|
||||
updateValueFromTouchPosition(event);
|
||||
|
||||
// 滑动开始的触感反馈
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(10); // 更轻微的振动反馈
|
||||
}
|
||||
|
||||
emit("slide-start");
|
||||
|
||||
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", stopTouchSliding);
|
||||
}
|
||||
|
||||
function handleTouchMove(event) {
|
||||
if (isSliding.value) {
|
||||
slideMoved.value = true; // 标记已经发生了滑动
|
||||
event.preventDefault(); // 阻止页面滚动
|
||||
updateValueFromTouchPosition(event);
|
||||
}
|
||||
}
|
||||
|
||||
function updateValueFromTouchPosition(event) {
|
||||
if (!event.touches || event.touches.length === 0) return;
|
||||
|
||||
const touch = event.touches[0];
|
||||
const rect = sliderTrack.value.getBoundingClientRect();
|
||||
const trackHeight = rect.height;
|
||||
const posY = touch.clientY - rect.top;
|
||||
|
||||
// 计算相对位置并转换为min-max范围的值
|
||||
let newValue = props.max - (posY / trackHeight) * (props.max - props.min);
|
||||
|
||||
// 边界检查
|
||||
newValue = Math.max(props.min, Math.min(props.max, newValue));
|
||||
|
||||
// 应用步进
|
||||
if (props.step > 0) {
|
||||
newValue = roundToStep(newValue);
|
||||
}
|
||||
|
||||
// 如果当前处于吸附锁定状态,并且移动不超过锁定阈值,则保持当前值不变
|
||||
if (isSnapping.value && snapLockValue.value !== null) {
|
||||
// 增加吸附力度:扩大保持吸附的阈值范围
|
||||
if (
|
||||
Math.abs(newValue - snapLockValue.value) <
|
||||
(props.snapThreshold / trackHeight) * (props.max - props.min) * 1.5
|
||||
) {
|
||||
newValue = snapLockValue.value;
|
||||
} else {
|
||||
// 移动距离超过阈值,解除吸附锁定,并提供反馈
|
||||
clearSnapLock();
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(8); // 解除吸附的轻微振动
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 检查是否可以进入吸附状态
|
||||
const snapPercentage =
|
||||
(props.snapThreshold / trackHeight) * (props.max - props.min);
|
||||
|
||||
// 检查是否接近预设值,增加吸附判定范围
|
||||
for (const preset of props.presets) {
|
||||
if (Math.abs(newValue - preset) < snapPercentage * 0.4) {
|
||||
// 增加判定范围
|
||||
// 进入吸附状态
|
||||
setSnapLock(preset);
|
||||
newValue = preset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否接近记忆值
|
||||
if (!isSnapping.value) {
|
||||
for (const memValue of props.memorizedValues) {
|
||||
if (Math.abs(newValue - memValue) < snapPercentage * 0.4) {
|
||||
// 增加判定范围
|
||||
// 进入吸附状态
|
||||
setSnapLock(memValue);
|
||||
newValue = memValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setValue(newValue);
|
||||
}
|
||||
|
||||
function stopTouchSliding() {
|
||||
// 滑动结束时的反馈
|
||||
if (slideMoved.value && navigator.vibrate) {
|
||||
navigator.vibrate(12); // 滑动结束的振动反馈
|
||||
}
|
||||
|
||||
// 清除吸附锁定
|
||||
clearSnapLock();
|
||||
|
||||
if (isSliding.value) {
|
||||
// 如果确实进行了滑动,则发送滑动结束事件
|
||||
if (slideMoved.value) {
|
||||
emit("slide-end", { isSlide: true });
|
||||
|
||||
// 只有在滑动操作后才启动隐藏计时器
|
||||
startHideTooltipTimer();
|
||||
} else {
|
||||
// 只是点击,不是滑动,不自动隐藏tooltip
|
||||
emit("slide-end", { isSlide: false });
|
||||
}
|
||||
}
|
||||
|
||||
isSliding.value = false;
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", stopTouchSliding);
|
||||
}
|
||||
|
||||
// 添加点击外部关闭tooltip的处理函数
|
||||
function handleOutsideClick(event) {
|
||||
if (!props.closeOnOutsideClick || !tooltipVisible.value) return;
|
||||
|
||||
// 如果正在滑动,不处理外部点击事件
|
||||
if (isSliding.value) return;
|
||||
|
||||
// 检查点击是否在slider组件外部
|
||||
const containerEl = sliderTrack.value?.parentElement;
|
||||
const tooltipEl = tooltip.value;
|
||||
|
||||
if (
|
||||
containerEl &&
|
||||
tooltipEl &&
|
||||
!containerEl.contains(event.target) &&
|
||||
!tooltipEl.contains(event.target)
|
||||
) {
|
||||
updateTooltipVisibility(false);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 添加全局点击事件监听
|
||||
if (props.closeOnOutsideClick) {
|
||||
// 使用 setTimeout 确保点击事件在其他处理程序之后执行
|
||||
window.addEventListener("click", (e) =>
|
||||
setTimeout(() => handleOutsideClick(e), 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理事件监听器
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", stopSliding);
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", stopTouchSliding);
|
||||
window.removeEventListener("click", handleOutsideClick);
|
||||
|
||||
// 清除任何剩余的计时器
|
||||
clearHideTooltipTimer();
|
||||
clearSnapLock();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.vertical-slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
height: 150px;
|
||||
position: relative;
|
||||
// margin-top: 8px;
|
||||
// margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
// background: linear-gradient(to top, #2196f3, #64b5f6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
// border: 1px solid #2196f3;
|
||||
border-radius: 3px;
|
||||
cursor: grab;
|
||||
box-shadow: 0 0px 4px rgba(0, 0, 0, 0.4);
|
||||
transition: transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275); // 更平滑的动画效果
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加iPad和移动设备的专有样式
|
||||
@media (pointer: coarse) {
|
||||
.slider-thumb {
|
||||
height: 20px; // 在触摸设备上增加滑块尺寸,更容易点击
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0px 6px rgba(0, 0, 0, 0.5); // 更明显的阴影
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
width: 40px; // 在触摸设备上增加宽度
|
||||
}
|
||||
|
||||
.slider-notch {
|
||||
width: 60%; // 增加刻度线宽度
|
||||
height: 3px; // 增加刻度线高度
|
||||
}
|
||||
}
|
||||
|
||||
// 当设备支持悬停时的效果 (通常是桌面设备)
|
||||
@media (hover: hover) {
|
||||
.slider-thumb:hover {
|
||||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.slider-notch {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 2px;
|
||||
background: #999;
|
||||
border-radius: 2px;
|
||||
transform: translateY(1px) translateX(50%);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
background: #2196f3;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-tooltip {
|
||||
position: absolute;
|
||||
left: calc(100% + 15px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
min-width: 120px;
|
||||
z-index: 10;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 8px 8px 8px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent rgba(255, 255, 255, 0.95) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义滑块颜色
|
||||
// .size-slider {
|
||||
// .slider-fill {
|
||||
// // background: linear-gradient(to top, #2196f3, #64b5f6);
|
||||
// }
|
||||
|
||||
// .slider-thumb {
|
||||
// border-color: #2196f3;
|
||||
// }
|
||||
|
||||
// .slider-notch.active {
|
||||
// background: #2196f3;
|
||||
// }
|
||||
// }
|
||||
|
||||
// .opacity-slider {
|
||||
// .slider-fill {
|
||||
// // background: linear-gradient(to top, #ff9800, #ffb74d);
|
||||
// }
|
||||
|
||||
// .slider-thumb {
|
||||
// border-color: #ff9800;
|
||||
// }
|
||||
|
||||
// .slider-notch.active {
|
||||
// background: #ff9800;
|
||||
// }
|
||||
// }
|
||||
|
||||
// 淡入淡出动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-height: 600px) {
|
||||
.vertical-slider-container {
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.slider-tooltip {
|
||||
left: calc(100% + 10px);
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
src/component/Canvas/CanvasEditor/config/canvasConfig.js
Normal file
32
src/component/Canvas/CanvasEditor/config/canvasConfig.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
|
||||
// 画布默认配置
|
||||
export const canvasConfig = {
|
||||
width: 1024, // 画布宽度
|
||||
height: 1024, // 画布高度
|
||||
backgroundColor: "#ffffff", // 背景颜色
|
||||
objectCaching: true, // 是否启用对象缓存
|
||||
enableRetinaScaling: true, // 是否启用视网膜缩放
|
||||
brushWidth: 5, // 画笔宽度
|
||||
brushOpacity: 1, // 画笔透明度
|
||||
brushColor: "#000000", // 画笔颜色
|
||||
brushType: "pencil", // 画笔类型
|
||||
brushTypeList: [
|
||||
{ name: "pencil", text: "铅笔" },
|
||||
// { name: "eraser", text: "橡皮擦" },
|
||||
{ name: "brush", text: "画笔" },
|
||||
{ name: "spray", text: "喷枪" },
|
||||
{ name: "rectangle", text: "矩形" },
|
||||
{ name: "circle", text: "圆形" },
|
||||
], // 画笔类型列表
|
||||
layerWidth: 250, // 图层宽度
|
||||
|
||||
// History settings
|
||||
maxHistorySteps: 50, // 最大历史记录步数
|
||||
onlyActiveLayerEditable: false, // 是否只有当前活动图层可编辑
|
||||
// 默认工具模式
|
||||
defaultTool: OperationType.DRAW || OperationType.SELECT, // 默认工具 TODO: 默认的地方可以陆续改成这个
|
||||
isCropBackground: true, // 是否要裁剪背景层以外的内容
|
||||
};
|
||||
|
||||
export default canvasConfig;
|
||||
1021
src/component/Canvas/CanvasEditor/index.vue
Normal file
1021
src/component/Canvas/CanvasEditor/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
1186
src/component/Canvas/CanvasEditor/managers/CanvasManager.js
Normal file
1186
src/component/Canvas/CanvasEditor/managers/CanvasManager.js
Normal file
File diff suppressed because it is too large
Load Diff
355
src/component/Canvas/CanvasEditor/managers/ExportManager.js
Normal file
355
src/component/Canvas/CanvasEditor/managers/ExportManager.js
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 图片导出管理器
|
||||
* 负责处理画布的图片导出功能,支持多种导出选项和图层过滤
|
||||
*/
|
||||
export class ExportManager {
|
||||
constructor(canvasManager, layerManager) {
|
||||
this.canvasManager = canvasManager;
|
||||
this.layerManager = layerManager;
|
||||
this.canvas = canvasManager.canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出图片
|
||||
* @param {Object} options 导出选项
|
||||
* @param {Boolean} options.isContainBg 是否包含背景图层
|
||||
* @param {Boolean} options.isContainFixed 是否包含固定图层
|
||||
* @param {String} options.layerId 导出具体图层ID
|
||||
* @param {Array} options.layerIdArray 导出多个图层ID数组
|
||||
* @param {String} options.expPicType 导出图片类型 (png/jpg/svg)
|
||||
* @returns {String} 导出的图片数据URL
|
||||
*/
|
||||
exportImage(options = {}) {
|
||||
const {
|
||||
isContainBg = false,
|
||||
isContainFixed = false,
|
||||
layerId = "",
|
||||
layerIdArray = [],
|
||||
expPicType = "png"
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// 如果指定了具体图层ID,导出指定图层
|
||||
if (layerId) {
|
||||
return this._exportSpecificLayer(layerId, expPicType);
|
||||
}
|
||||
|
||||
// 如果指定了多个图层ID,导出多个图层
|
||||
if (layerIdArray && layerIdArray.length > 0) {
|
||||
return this._exportMultipleLayers(layerIdArray, expPicType, isContainBg, isContainFixed);
|
||||
}
|
||||
|
||||
// 默认导出所有可见图层
|
||||
return this._exportAllLayers(expPicType, isContainBg, isContainFixed);
|
||||
|
||||
} catch (error) {
|
||||
console.error("导出图片失败:", error);
|
||||
throw new Error(`图片导出失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出指定单个图层
|
||||
* @param {String} layerId 图层ID
|
||||
* @param {String} expPicType 导出类型
|
||||
* @returns {String} 图片数据URL
|
||||
* @private
|
||||
*/
|
||||
_exportSpecificLayer(layerId, expPicType) {
|
||||
if (!this.layerManager) {
|
||||
throw new Error("图层管理器未初始化");
|
||||
}
|
||||
|
||||
const layer = this._getLayerById(layerId);
|
||||
if (!layer) {
|
||||
throw new Error(`未找到ID为 ${layerId} 的图层`);
|
||||
}
|
||||
|
||||
if (!layer.visible) {
|
||||
console.warn(`图层 ${layer.name} 不可见,将导出空白图片`);
|
||||
}
|
||||
|
||||
// 创建临时画布
|
||||
const tempCanvas = this._createExportCanvas();
|
||||
const tempFabricCanvas = this._createTempFabricCanvas(tempCanvas);
|
||||
|
||||
try {
|
||||
// 只添加指定图层的对象
|
||||
this._addLayerObjectsToCanvas(tempFabricCanvas, layer);
|
||||
|
||||
// 渲染并导出
|
||||
tempFabricCanvas.renderAll();
|
||||
return this._generateDataURL(tempCanvas, expPicType);
|
||||
|
||||
} finally {
|
||||
this._cleanupTempCanvas(tempFabricCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出多个指定图层
|
||||
* @param {Array} layerIdArray 图层ID数组
|
||||
* @param {String} expPicType 导出类型
|
||||
* @param {Boolean} isContainBg 是否包含背景图层
|
||||
* @param {Boolean} isContainFixed 是否包含固定图层
|
||||
* @returns {String} 图片数据URL
|
||||
* @private
|
||||
*/
|
||||
_exportMultipleLayers(layerIdArray, expPicType, isContainBg, isContainFixed) {
|
||||
if (!this.layerManager) {
|
||||
throw new Error("图层管理器未初始化");
|
||||
}
|
||||
|
||||
// 创建临时画布
|
||||
const tempCanvas = this._createExportCanvas();
|
||||
const tempFabricCanvas = this._createTempFabricCanvas(tempCanvas);
|
||||
|
||||
try {
|
||||
// 按照图层顺序添加指定的图层
|
||||
const allLayers = this._getAllLayers();
|
||||
|
||||
allLayers.forEach(layer => {
|
||||
if (!layerIdArray.includes(layer.id)) return;
|
||||
|
||||
// 检查图层类型过滤条件
|
||||
if (!this._shouldIncludeLayer(layer, isContainBg, isContainFixed)) return;
|
||||
|
||||
if (layer.visible) {
|
||||
this._addLayerObjectsToCanvas(tempFabricCanvas, layer);
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染并导出
|
||||
tempFabricCanvas.renderAll();
|
||||
return this._generateDataURL(tempCanvas, expPicType);
|
||||
|
||||
} finally {
|
||||
this._cleanupTempCanvas(tempFabricCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有图层
|
||||
* @param {String} expPicType 导出类型
|
||||
* @param {Boolean} isContainBg 是否包含背景图层
|
||||
* @param {Boolean} isContainFixed 是否包含固定图层
|
||||
* @returns {String} 图片数据URL
|
||||
* @private
|
||||
*/
|
||||
_exportAllLayers(expPicType, isContainBg, isContainFixed) {
|
||||
// 创建临时画布
|
||||
const tempCanvas = this._createExportCanvas();
|
||||
const tempFabricCanvas = this._createTempFabricCanvas(tempCanvas);
|
||||
|
||||
try {
|
||||
// 获取所有图层并按顺序添加
|
||||
const allLayers = this._getAllLayers();
|
||||
|
||||
allLayers.forEach(layer => {
|
||||
// 检查图层类型过滤条件
|
||||
if (!this._shouldIncludeLayer(layer, isContainBg, isContainFixed)) return;
|
||||
|
||||
if (layer.visible) {
|
||||
this._addLayerObjectsToCanvas(tempFabricCanvas, layer);
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染并导出
|
||||
tempFabricCanvas.renderAll();
|
||||
return this._generateDataURL(tempCanvas, expPicType);
|
||||
|
||||
} finally {
|
||||
this._cleanupTempCanvas(tempFabricCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该包含该图层
|
||||
* @param {Object} layer 图层对象
|
||||
* @param {Boolean} isContainBg 是否包含背景图层
|
||||
* @param {Boolean} isContainFixed 是否包含固定图层
|
||||
* @returns {Boolean} 是否包含
|
||||
* @private
|
||||
*/
|
||||
_shouldIncludeLayer(layer, isContainBg, isContainFixed) {
|
||||
// 背景图层处理
|
||||
if (layer.type === 'background' || layer.isBackground) {
|
||||
return isContainBg;
|
||||
}
|
||||
|
||||
// 固定图层处理
|
||||
if (layer.type === 'fixed' || layer.isFixed || layer.locked) {
|
||||
return isContainFixed;
|
||||
}
|
||||
|
||||
// 其他图层默认包含
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建导出用的临时画布
|
||||
* @returns {HTMLCanvasElement} 临时画布
|
||||
* @private
|
||||
*/
|
||||
_createExportCanvas() {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = this.canvas.width || 800;
|
||||
tempCanvas.height = this.canvas.height || 600;
|
||||
return tempCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时Fabric画布
|
||||
* @param {HTMLCanvasElement} tempCanvas 临时画布元素
|
||||
* @returns {fabric.StaticCanvas} 临时Fabric画布
|
||||
* @private
|
||||
*/
|
||||
_createTempFabricCanvas(tempCanvas) {
|
||||
const { fabric } = window;
|
||||
|
||||
const tempFabricCanvas = new fabric.StaticCanvas(tempCanvas, {
|
||||
width: this.canvas.width || 800,
|
||||
height: this.canvas.height || 600,
|
||||
backgroundColor: this.canvas.backgroundColor || 'transparent'
|
||||
});
|
||||
|
||||
// 设置高质量渲染选项
|
||||
tempFabricCanvas.enableRetinaScaling = true;
|
||||
tempFabricCanvas.imageSmoothingEnabled = true;
|
||||
|
||||
return tempFabricCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图层对象添加到临时画布
|
||||
* @param {fabric.StaticCanvas} tempCanvas 临时画布
|
||||
* @param {Object} layer 图层对象
|
||||
* @private
|
||||
*/
|
||||
_addLayerObjectsToCanvas(tempCanvas, layer) {
|
||||
if (!layer) return;
|
||||
|
||||
// 处理背景图层
|
||||
if (layer.type === 'background' && layer.fabricObject) {
|
||||
this._cloneAndAddObject(tempCanvas, layer.fabricObject);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理普通图层的对象
|
||||
if (layer.fabricObjects && Array.isArray(layer.fabricObjects)) {
|
||||
layer.fabricObjects.forEach(obj => {
|
||||
if (obj && obj.visible !== false) {
|
||||
this._cloneAndAddObject(tempCanvas, obj);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 处理单个fabricObject的情况
|
||||
if (layer.fabricObject && !layer.fabricObjects) {
|
||||
this._cloneAndAddObject(tempCanvas, layer.fabricObject);
|
||||
}
|
||||
|
||||
// 处理分组图层的子图层
|
||||
if (layer.children && Array.isArray(layer.children)) {
|
||||
layer.children.forEach(childLayerId => {
|
||||
const childLayer = this._getLayerById(childLayerId);
|
||||
if (childLayer && childLayer.visible) {
|
||||
this._addLayerObjectsToCanvas(tempCanvas, childLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆并添加对象到临时画布
|
||||
* @param {fabric.StaticCanvas} tempCanvas 临时画布
|
||||
* @param {fabric.Object} obj Fabric对象
|
||||
* @private
|
||||
*/
|
||||
_cloneAndAddObject(tempCanvas, obj) {
|
||||
if (!obj) return;
|
||||
|
||||
try {
|
||||
obj.clone((cloned) => {
|
||||
if (cloned) {
|
||||
// 确保克隆对象的属性正确
|
||||
cloned.set({
|
||||
selectable: false,
|
||||
evented: false,
|
||||
visible: true
|
||||
});
|
||||
tempCanvas.add(cloned);
|
||||
}
|
||||
}, ['id', 'layerId', 'name']); // 保留自定义属性
|
||||
} catch (error) {
|
||||
console.warn("克隆对象失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数据URL
|
||||
* @param {HTMLCanvasElement} canvas 画布元素
|
||||
* @param {String} expPicType 导出类型
|
||||
* @returns {String} 数据URL
|
||||
* @private
|
||||
*/
|
||||
_generateDataURL(canvas, expPicType) {
|
||||
const format = expPicType.toLowerCase();
|
||||
|
||||
switch (format) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return canvas.toDataURL('image/jpeg', 0.9);
|
||||
case 'svg':
|
||||
// SVG导出需要特殊处理,这里先返回PNG
|
||||
console.warn("SVG导出暂未实现,返回PNG格式");
|
||||
return canvas.toDataURL('image/png', 1.0);
|
||||
case 'png':
|
||||
default:
|
||||
return canvas.toDataURL('image/png', 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时画布资源
|
||||
* @param {fabric.StaticCanvas} tempFabricCanvas 临时Fabric画布
|
||||
* @private
|
||||
*/
|
||||
_cleanupTempCanvas(tempFabricCanvas) {
|
||||
if (tempFabricCanvas) {
|
||||
try {
|
||||
tempFabricCanvas.dispose();
|
||||
} catch (error) {
|
||||
console.warn("清理临时画布失败:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有图层
|
||||
* @returns {Array} 图层数组
|
||||
* @private
|
||||
*/
|
||||
_getAllLayers() {
|
||||
if (this.layerManager && this.layerManager.layers) {
|
||||
return this.layerManager.layers.value || [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取图层
|
||||
* @param {String} layerId 图层ID
|
||||
* @returns {Object|null} 图层对象
|
||||
* @private
|
||||
*/
|
||||
_getLayerById(layerId) {
|
||||
if (this.layerManager && this.layerManager.getLayerById) {
|
||||
return this.layerManager.getLayerById(layerId);
|
||||
}
|
||||
|
||||
// 备用方法:直接从图层数组中查找
|
||||
const allLayers = this._getAllLayers();
|
||||
return allLayers.find(layer => layer.id === layerId) || null;
|
||||
}
|
||||
}
|
||||
2198
src/component/Canvas/CanvasEditor/managers/LayerManager.js
Normal file
2198
src/component/Canvas/CanvasEditor/managers/LayerManager.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
//import { fabric } from "fabric-with-all";
|
||||
import { createLayer, LayerType, OperationType } from "../utils/layerHelper.js";
|
||||
import { BatchInitializeRedGreenModeCommand } from "../commands/RedGreenCommands.js";
|
||||
|
||||
/**
|
||||
* 红绿图模式管理器
|
||||
* 利用已有图层结构,不清除现有图层
|
||||
*/
|
||||
export class RedGreenModeManager {
|
||||
constructor(options = {}) {
|
||||
this.canvas = options.canvas;
|
||||
this.layerManager = options.layerManager;
|
||||
this.toolManager = options.toolManager;
|
||||
this.canvasManager = options.canvasManager;
|
||||
this.commandManager = options.commandManager;
|
||||
|
||||
// 红绿图模式状态
|
||||
this.isInitialized = false;
|
||||
this.normalLayerOpacity = 0.4; // 默认40%透明度 (0-1)
|
||||
|
||||
// 图片URL
|
||||
this.clothingImageUrl = null;
|
||||
this.redGreenImageUrl = null;
|
||||
|
||||
// 回调函数
|
||||
this.onImageGenerated = null;
|
||||
|
||||
console.log("RedGreenModeManager 已创建");
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化红绿图模式
|
||||
* @param {Object} options 配置选项
|
||||
* @param {String} options.clothingImageUrl 衣服底图URL
|
||||
* @param {String} options.redGreenImageUrl 红绿图URL
|
||||
* @param {Number} options.normalLayerOpacity 普通图层透明度 (0-1)
|
||||
* @param {Function} options.onImageGenerated 图片生成回调
|
||||
* @param {Boolean} options.useBatchMode 是否使用批量模式 (默认true,减少闪烁)
|
||||
* @returns {Promise<boolean>} 是否初始化成功
|
||||
*/
|
||||
async initialize(options = {}) {
|
||||
try {
|
||||
// 如果已经初始化,先清理状态
|
||||
if (this.isInitialized) {
|
||||
console.log("红绿图模式已初始化,重新初始化...");
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
this.clothingImageUrl = options.clothingImageUrl;
|
||||
this.redGreenImageUrl = options.redGreenImageUrl;
|
||||
this.onImageGenerated = options.onImageGenerated;
|
||||
|
||||
// 设置透明度 (支持0-100的百分比值或0-1的小数值)
|
||||
if (typeof options.normalLayerOpacity === "number") {
|
||||
if (options.normalLayerOpacity > 1) {
|
||||
// 如果大于1,认为是百分比值(0-100)
|
||||
this.normalLayerOpacity =
|
||||
Math.max(0, Math.min(100, options.normalLayerOpacity)) / 100;
|
||||
} else {
|
||||
// 如果小于等于1,认为是小数值(0-1)
|
||||
this.normalLayerOpacity = Math.max(
|
||||
0,
|
||||
Math.min(1, options.normalLayerOpacity)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证必需参数
|
||||
if (!this.clothingImageUrl || !this.redGreenImageUrl) {
|
||||
throw new Error("缺少必需的图片URL参数");
|
||||
}
|
||||
|
||||
// 使用批量模式或传统模式
|
||||
const useBatchMode = options.useBatchMode !== false; // 默认为true
|
||||
|
||||
let initCommand;
|
||||
|
||||
// 使用新的批量初始化命令,减少页面闪烁
|
||||
initCommand = new BatchInitializeRedGreenModeCommand({
|
||||
canvas: this.canvas,
|
||||
layerManager: this.layerManager,
|
||||
toolManager: this.toolManager,
|
||||
clothingImageUrl: this.clothingImageUrl,
|
||||
redGreenImageUrl: this.redGreenImageUrl,
|
||||
normalLayerOpacity: this.normalLayerOpacity,
|
||||
onImageGenerated: this.onImageGenerated,
|
||||
});
|
||||
initCommand.undoable = false; // 不可撤销
|
||||
|
||||
// 执行命令
|
||||
if (this.commandManager) {
|
||||
await this.commandManager.execute(initCommand);
|
||||
} else {
|
||||
await initCommand.execute();
|
||||
}
|
||||
|
||||
this.registerRedGreenMouseUpEvent();
|
||||
// 标记为已初始化
|
||||
this.isInitialized = true;
|
||||
|
||||
// 启用图层管理器的红绿图模式
|
||||
if (
|
||||
this.layerManager &&
|
||||
typeof this.layerManager.enableRedGreenMode === "function"
|
||||
) {
|
||||
this.layerManager.enableRedGreenMode();
|
||||
}
|
||||
|
||||
// 重置工具管理器状态
|
||||
// 默认红色笔刷
|
||||
if (this.toolManager) {
|
||||
this.toolManager.isRedGreenMode = true;
|
||||
}
|
||||
|
||||
console.log("红绿图模式初始化成功", {
|
||||
衣服底图: this.clothingImageUrl,
|
||||
红绿图: this.redGreenImageUrl,
|
||||
普通图层透明度: `${Math.round(this.normalLayerOpacity * 100)}%`,
|
||||
批量模式: useBatchMode ? "已启用" : "已禁用",
|
||||
画布背景: "白色",
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("红绿图模式初始化失败:", error);
|
||||
this.isInitialized = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 注册鼠标抬起事件
|
||||
registerRedGreenMouseUpEvent() {
|
||||
this.canvas.on("mouse:up", (event) => {
|
||||
if (!this.isInitialized) {
|
||||
console.warn("红绿图模式未初始化,无法处理鼠标事件");
|
||||
return;
|
||||
}
|
||||
|
||||
// 可以在这里添加更多逻辑,比如生成图片或更新状态
|
||||
if (this.onImageGenerated) {
|
||||
const imageData = this.canvasManager.exportImage();
|
||||
console.log("生成红绿图图片数据:", imageData);
|
||||
this.onImageGenerated(imageData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已初始化
|
||||
* @returns {boolean} 是否已初始化
|
||||
*/
|
||||
isReady() {
|
||||
return this.isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新普通图层透明度
|
||||
* @param {Number} opacity 透明度值 (0-100的百分比值或0-1的小数值)
|
||||
* @returns {boolean} 是否更新成功
|
||||
*/
|
||||
updateNormalLayerOpacity(opacity) {
|
||||
if (!this.isInitialized) {
|
||||
console.warn("红绿图模式未初始化,无法更新透明度");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 处理透明度值
|
||||
let normalizedOpacity;
|
||||
if (opacity > 1) {
|
||||
// 如果大于1,认为是百分比值(0-100)
|
||||
normalizedOpacity = Math.max(0, Math.min(100, opacity)) / 100;
|
||||
} else {
|
||||
// 如果小于等于1,认为是小数值(0-1)
|
||||
normalizedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
}
|
||||
|
||||
// 创建透明度更新命令
|
||||
const opacityCommand = new UpdateNormalLayerOpacityCommand({
|
||||
canvas: this.canvas,
|
||||
layerManager: this.layerManager,
|
||||
opacity: normalizedOpacity,
|
||||
});
|
||||
|
||||
// 执行命令
|
||||
if (this.commandManager) {
|
||||
this.commandManager.execute(opacityCommand);
|
||||
} else {
|
||||
opacityCommand.execute();
|
||||
}
|
||||
|
||||
// 更新内部状态
|
||||
this.normalLayerOpacity = normalizedOpacity;
|
||||
|
||||
console.log(
|
||||
`普通图层透明度已更新为: ${Math.round(normalizedOpacity * 100)}%`
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("更新普通图层透明度失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前普通图层透明度
|
||||
* @param {boolean} asPercentage 是否返回百分比值(0-100),默认返回小数值(0-1)
|
||||
* @returns {Number} 透明度值
|
||||
*/
|
||||
getNormalLayerOpacity(asPercentage = false) {
|
||||
if (asPercentage) {
|
||||
return Math.round(this.normalLayerOpacity * 100);
|
||||
}
|
||||
return this.normalLayerOpacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载图片
|
||||
* @param {Object} options 配置选项
|
||||
* @param {String} options.clothingImageUrl 新的衣服底图URL (可选)
|
||||
* @param {String} options.redGreenImageUrl 新的红绿图URL (可选)
|
||||
* @returns {Promise<boolean>} 是否重新加载成功
|
||||
*/
|
||||
async reloadImages(options = {}) {
|
||||
if (!this.isInitialized) {
|
||||
console.warn("红绿图模式未初始化,无法重新加载图片");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新图片URL
|
||||
if (options.clothingImageUrl) {
|
||||
this.clothingImageUrl = options.clothingImageUrl;
|
||||
}
|
||||
if (options.redGreenImageUrl) {
|
||||
this.redGreenImageUrl = options.redGreenImageUrl;
|
||||
}
|
||||
|
||||
// 重新初始化
|
||||
await this.initialize({
|
||||
clothingImageUrl: this.clothingImageUrl,
|
||||
redGreenImageUrl: this.redGreenImageUrl,
|
||||
normalLayerOpacity: this.normalLayerOpacity,
|
||||
onImageGenerated: this.onImageGenerated,
|
||||
});
|
||||
|
||||
console.log("图片重新加载成功");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("重新加载图片失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态信息
|
||||
* @returns {Object} 状态信息
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isInitialized: this.isInitialized,
|
||||
clothingImageUrl: this.clothingImageUrl,
|
||||
redGreenImageUrl: this.redGreenImageUrl,
|
||||
normalLayerOpacity: this.normalLayerOpacity,
|
||||
normalLayerOpacityPercentage: Math.round(this.normalLayerOpacity * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图层信息
|
||||
* @returns {Object} 图层信息
|
||||
*/
|
||||
getLayerInfo() {
|
||||
if (!this.layerManager || !this.layerManager.layers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layers = this.layerManager.layers.value || [];
|
||||
const backgroundLayer = layers.find((layer) => layer.isBackground);
|
||||
const fixedLayer = layers.find((layer) => layer.isFixed);
|
||||
const normalLayers = layers.filter(
|
||||
(layer) => !layer.isBackground && !layer.isFixed
|
||||
);
|
||||
|
||||
return {
|
||||
backgroundLayer:
|
||||
backgroundLayer &&
|
||||
Object.assign(
|
||||
{
|
||||
hasObject: !!backgroundLayer.fabricObject,
|
||||
},
|
||||
backgroundLayer
|
||||
),
|
||||
fixedLayer:
|
||||
fixedLayer &&
|
||||
Object.assign(
|
||||
{
|
||||
hasObject: !!fixedLayer.fabricObject,
|
||||
},
|
||||
fixedLayer
|
||||
),
|
||||
normalLayers: normalLayers.map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
visible: layer.visible,
|
||||
opacity: layer.opacity,
|
||||
objectCount: layer.fabricObjects ? layer.fabricObjects.length : 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理红绿图模式
|
||||
* 注意:这不会删除图层,只是清理红绿图模式的特定内容
|
||||
*/
|
||||
cleanup() {
|
||||
try {
|
||||
// 禁用图层管理器的红绿图模式
|
||||
if (
|
||||
this.layerManager &&
|
||||
typeof this.layerManager.disableRedGreenMode === "function"
|
||||
) {
|
||||
this.layerManager.disableRedGreenMode();
|
||||
}
|
||||
|
||||
// 重置工具管理器
|
||||
if (this.toolManager && this.toolManager.isRedGreenMode) {
|
||||
this.toolManager.isRedGreenMode = false;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
this.isInitialized = false;
|
||||
this.clothingImageUrl = null;
|
||||
this.redGreenImageUrl = null;
|
||||
this.onImageGenerated = null;
|
||||
|
||||
console.log("红绿图模式已清理");
|
||||
} catch (error) {
|
||||
console.error("清理红绿图模式失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
dispose() {
|
||||
this.cleanup();
|
||||
|
||||
// 清除引用
|
||||
this.canvas = null;
|
||||
this.layerManager = null;
|
||||
this.toolManager = null;
|
||||
this.commandManager = null;
|
||||
|
||||
console.log("RedGreenModeManager 已销毁");
|
||||
}
|
||||
}
|
||||
370
src/component/Canvas/CanvasEditor/managers/ThumbnailManager.js
Normal file
370
src/component/Canvas/CanvasEditor/managers/ThumbnailManager.js
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 缩略图管理器 - 负责生成和缓存图层和元素的预览缩略图
|
||||
*/
|
||||
export class ThumbnailManager {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.layers = options.layers || []; // 图层管理器
|
||||
this.layerThumbSize = options.layerThumbSize || { width: 48, height: 48 };
|
||||
this.elementThumbSize = options.elementThumbSize || {
|
||||
width: 36,
|
||||
height: 36,
|
||||
};
|
||||
this.layerThumbnails = new Map(); // 图层缩略图缓存
|
||||
this.elementThumbnails = new Map(); // 元素缩略图缓存
|
||||
this.defaultThumbnail =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; // 1x1 透明图
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图层缩略图
|
||||
* @param {Object} layer 图层对象ID
|
||||
*/
|
||||
generateLayerThumbnail(layerId) {
|
||||
// const layer = this?.layers.value?.find((layer) => layer.id === layerId);
|
||||
// if (!layer) return;
|
||||
// // 延迟执行,避免阻塞UI
|
||||
// requestAnimationFrame(() => {
|
||||
// this._generateLayerThumbnailNow(layer);
|
||||
// });
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即生成图层缩略图
|
||||
* @param {Object} layer 图层对象
|
||||
* @private
|
||||
*/
|
||||
_generateLayerThumbnailNow(layer) {
|
||||
if (
|
||||
!layer ||
|
||||
!this.canvas ||
|
||||
!layer.fabricObjects ||
|
||||
!layer.fabricObjects?.length
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
let thumbnail = null;
|
||||
|
||||
if (!layer.children?.length) {
|
||||
// 如果是元素图层,直接生成元素缩略图
|
||||
thumbnail = this._generateThumbnailFromObjects(
|
||||
layer.fabricObjects,
|
||||
this.layerThumbSize.width,
|
||||
this.layerThumbSize.height
|
||||
);
|
||||
} else if (layer.type === "group" || layer.children?.length) {
|
||||
const fabricObjects = layer.children.reduce((pre, next) => {
|
||||
if (next.fabricObjects.length) {
|
||||
pre.push(...next.fabricObjects);
|
||||
}
|
||||
return pre;
|
||||
}, []);
|
||||
// 如果是分组图层,合并所有子对象的缩略图
|
||||
thumbnail = this._generateThumbnailFromObjects(
|
||||
fabricObjects,
|
||||
this.layerThumbSize.width,
|
||||
this.layerThumbSize.height
|
||||
);
|
||||
}
|
||||
|
||||
// 保存到缩略图缓存
|
||||
if (thumbnail) {
|
||||
this.layerThumbnails.set(layer.id, thumbnail);
|
||||
} else {
|
||||
// 如果无法生成缩略图,使用默认缩略图
|
||||
this.layerThumbnails.set(layer.id, this.defaultThumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("生成图层缩略图出错:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成元素缩略图
|
||||
* @param {Object} element 元素对象
|
||||
* @param {Object} fabricObject fabric对象
|
||||
*/
|
||||
generateElementThumbnail(element, fabricObject) {
|
||||
if (!element || !element.id || !fabricObject) return;
|
||||
|
||||
// 延迟执行,避免阻塞UI
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
const thumbnail = this._generateThumbnailFromObject(
|
||||
fabricObject,
|
||||
this.elementThumbSize.width,
|
||||
this.elementThumbSize.height
|
||||
);
|
||||
|
||||
if (thumbnail) {
|
||||
this.elementThumbnails.set(element.id, thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("生成元素缩略图出错:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从fabric对象生成缩略图
|
||||
* @param {Object} obj fabric对象
|
||||
* @param {Number} width 缩略图宽度
|
||||
* @param {Number} height 缩略图高度
|
||||
* @returns {String} 缩略图数据URL
|
||||
* @private
|
||||
*/
|
||||
_generateThumbnailFromObject(obj, width, height) {
|
||||
if (!obj || !this.canvas) return null;
|
||||
|
||||
// 保存对象状态
|
||||
const originalState = {
|
||||
active: obj.active,
|
||||
visible: obj.visible,
|
||||
left: obj.left,
|
||||
top: obj.top,
|
||||
scaleX: obj.scaleX,
|
||||
scaleY: obj.scaleY,
|
||||
opacity: obj.opacity,
|
||||
};
|
||||
|
||||
// 临时修改对象状态
|
||||
obj.set({
|
||||
active: false,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
// 创建临时画布
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = width;
|
||||
tempCanvas.height = height;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
|
||||
// 获取对象边界
|
||||
const bounds = obj.getBoundingRect();
|
||||
|
||||
// 绘制缩略图
|
||||
try {
|
||||
// 清空画布
|
||||
tempCtx.clearRect(0, 0, width, height);
|
||||
|
||||
// 计算缩放比例
|
||||
const scaleFactorX = width / bounds.width;
|
||||
const scaleFactorY = height / bounds.height;
|
||||
const scaleFactor = Math.min(scaleFactorX, scaleFactorY) * 0.8; // 保留一些边距
|
||||
|
||||
// 居中绘制
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.translate(centerX, centerY);
|
||||
tempCtx.scale(scaleFactor, scaleFactor);
|
||||
tempCtx.translate(
|
||||
-bounds.left - bounds.width / 2,
|
||||
-bounds.top - bounds.height / 2
|
||||
);
|
||||
|
||||
// 绘制对象
|
||||
obj.render(tempCtx);
|
||||
|
||||
tempCtx.restore();
|
||||
|
||||
// 转换为数据URL
|
||||
const dataUrl = tempCanvas.toDataURL("image/png");
|
||||
|
||||
// 恢复对象状态
|
||||
obj.set(originalState);
|
||||
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error("绘制对象缩略图出错:", error);
|
||||
|
||||
// 恢复对象状态
|
||||
obj.set(originalState);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个fabric对象生成组合缩略图
|
||||
* @param {Array} objects fabric对象数组
|
||||
* @param {Number} width 缩略图宽度
|
||||
* @param {Number} height 缩略图高度
|
||||
* @returns {String} 缩略图数据URL
|
||||
* @private
|
||||
*/
|
||||
_generateThumbnailFromObjects(objects, width, height) {
|
||||
if (!objects || !objects.length || !this.canvas) return null;
|
||||
|
||||
// 创建临时画布
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = width;
|
||||
tempCanvas.height = height;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
|
||||
// 计算所有对象的总边界
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
objects.forEach((obj) => {
|
||||
if (!obj.visible) return;
|
||||
|
||||
const bounds = obj.getBoundingRect();
|
||||
minX = Math.min(minX, bounds.left);
|
||||
minY = Math.min(minY, bounds.top);
|
||||
maxX = Math.max(maxX, bounds.left + bounds.width);
|
||||
maxY = Math.max(maxY, bounds.top + bounds.height);
|
||||
});
|
||||
|
||||
const groupWidth = maxX - minX;
|
||||
const groupHeight = maxY - minY;
|
||||
|
||||
// 如果没有有效对象,返回null
|
||||
if (groupWidth <= 0 || groupHeight <= 0) return null;
|
||||
|
||||
// 保存对象状态
|
||||
const originalStates = objects.map((obj) => ({
|
||||
obj,
|
||||
state: {
|
||||
active: obj.active,
|
||||
visible: obj.visible,
|
||||
opacity: obj.opacity,
|
||||
},
|
||||
}));
|
||||
|
||||
// 临时修改对象状态
|
||||
originalStates.forEach((item) => {
|
||||
item.obj.set({
|
||||
active: false,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
});
|
||||
});
|
||||
|
||||
// 绘制缩略图
|
||||
try {
|
||||
// 清空画布
|
||||
tempCtx.clearRect(0, 0, width, height);
|
||||
|
||||
// 计算缩放比例
|
||||
const scaleFactorX = width / groupWidth;
|
||||
const scaleFactorY = height / groupHeight;
|
||||
const scaleFactor = Math.min(scaleFactorX, scaleFactorY) * 0.8; // 保留一些边距
|
||||
|
||||
// 居中绘制
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.translate(centerX, centerY);
|
||||
tempCtx.scale(scaleFactor, scaleFactor);
|
||||
tempCtx.translate(-(minX + groupWidth / 2), -(minY + groupHeight / 2));
|
||||
|
||||
// 按顺序绘制所有对象
|
||||
objects.forEach((obj) => {
|
||||
if (obj.visible) {
|
||||
obj.render(tempCtx);
|
||||
}
|
||||
});
|
||||
|
||||
tempCtx.restore();
|
||||
|
||||
// 转换为数据URL
|
||||
const dataUrl = tempCanvas.toDataURL("image/png");
|
||||
|
||||
// 恢复对象状态
|
||||
originalStates.forEach((item) => {
|
||||
item.obj.set(item.state);
|
||||
});
|
||||
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
console.error("绘制组合缩略图出错:", error);
|
||||
|
||||
// 恢复对象状态
|
||||
originalStates.forEach((item) => {
|
||||
item.obj.set(item.state);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成图层缩略图
|
||||
* @param {Array} layers 图层数组
|
||||
*/
|
||||
generateAllLayerThumbnails(layers) {
|
||||
if (!layers || !Array.isArray(layers)) return;
|
||||
|
||||
// 使用requestAnimationFrame批量生成,避免阻塞主线程
|
||||
requestAnimationFrame(() => {
|
||||
layers.forEach((layer) => {
|
||||
if (layer && layer.id) {
|
||||
this._generateLayerThumbnailNow(layer);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图层缩略图
|
||||
* @param {String} layerId 图层ID
|
||||
* @returns {String|null} 缩略图URL或null
|
||||
*/
|
||||
getLayerThumbnail(layerId) {
|
||||
if (!layerId) return null;
|
||||
return this.layerThumbnails.get(layerId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取元素缩略图
|
||||
* @param {String} elementId 元素ID
|
||||
* @returns {String|null} 缩略图URL或null
|
||||
*/
|
||||
getElementThumbnail(elementId) {
|
||||
if (!elementId) return null;
|
||||
return this.elementThumbnails.get(elementId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除图层缩略图
|
||||
* @param {String} layerId 图层ID
|
||||
*/
|
||||
clearLayerThumbnail(layerId) {
|
||||
if (layerId && this.layerThumbnails.has(layerId)) {
|
||||
this.layerThumbnails.delete(layerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除元素缩略图
|
||||
* @param {String} elementId 元素ID
|
||||
*/
|
||||
clearElementThumbnail(elementId) {
|
||||
if (elementId && this.elementThumbnails.has(elementId)) {
|
||||
this.elementThumbnails.delete(elementId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缩略图
|
||||
*/
|
||||
clearAllThumbnails() {
|
||||
this.layerThumbnails.clear();
|
||||
this.elementThumbnails.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
dispose() {
|
||||
this.clearAllThumbnails();
|
||||
this.canvas = null;
|
||||
}
|
||||
}
|
||||
1192
src/component/Canvas/CanvasEditor/managers/ToolManager.js
Normal file
1192
src/component/Canvas/CanvasEditor/managers/ToolManager.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,856 @@
|
||||
import { gsap } from "gsap";
|
||||
|
||||
/**
|
||||
* 画布动画管理器
|
||||
* 负责处理画布平移、缩放等动画效果
|
||||
*/
|
||||
export class AnimationManager {
|
||||
/**
|
||||
* 创建动画管理器
|
||||
* @param {fabric.Canvas} canvas fabric.js画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
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) {
|
||||
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: 0,
|
||||
panY: 0,
|
||||
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, 0, 0]);
|
||||
this.currentZoom.value = 100;
|
||||
this._zoomAnimation = null;
|
||||
this._panAnimation = null;
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
234
src/component/Canvas/CanvasEditor/managers/brushes/BaseBrush.js
Normal file
234
src/component/Canvas/CanvasEditor/managers/brushes/BaseBrush.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 笔刷基类
|
||||
* 所有笔刷类型应继承此基类并实现必要的方法
|
||||
*/
|
||||
export class BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 笔刷配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.options = options;
|
||||
|
||||
// 基本属性
|
||||
this.id = options.id || this.constructor.name;
|
||||
this.name = options.name || "未命名笔刷";
|
||||
this.description = options.description || "";
|
||||
this.icon = options.icon || null;
|
||||
this.category = options.category || "默认";
|
||||
|
||||
// 笔刷实例
|
||||
this.brush = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例(必须由子类实现)
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
throw new Error("必须由子类实现create方法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷(必须由子类实现)
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options) {
|
||||
throw new Error("必须由子类实现configure方法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷的元数据
|
||||
* @returns {Object} 笔刷元数据
|
||||
*/
|
||||
getMetadata() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
icon: this.icon,
|
||||
category: this.category,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷预览
|
||||
* @returns {String|null} 预览图URL或null
|
||||
*/
|
||||
getPreview() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* 这个方法返回一个对象数组,每个对象描述一个可配置属性
|
||||
* 每个属性对象包含:
|
||||
* - id: 属性标识符
|
||||
* - name: 属性显示名称
|
||||
* - type: 属性类型(例如:'slider', 'color', 'checkbox', 'select')
|
||||
* - defaultValue: 默认值
|
||||
* - min/max/step: 对于slider类型的限制值
|
||||
* - options: 对于select类型的选项
|
||||
* - description: 属性描述
|
||||
* - category: 属性分类
|
||||
* - order: 显示顺序(越小越靠前)
|
||||
* - visibleWhen: 函数或对象,定义何时显示该属性
|
||||
* - dynamicOptions: 函数,返回动态的选项列表
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 返回基础属性,所有笔刷都有这些属性
|
||||
return [
|
||||
{
|
||||
id: "size",
|
||||
name: "笔刷大小",
|
||||
type: "slider",
|
||||
defaultValue: 5,
|
||||
min: 0.5,
|
||||
max: 100,
|
||||
step: 0.5,
|
||||
description: "笔刷的大小(像素)",
|
||||
category: "基本",
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
name: "笔刷颜色",
|
||||
type: "color",
|
||||
defaultValue: "#000000",
|
||||
description: "笔刷的颜色",
|
||||
category: "基本",
|
||||
order: 20,
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
name: "不透明度",
|
||||
type: "slider",
|
||||
defaultValue: 1,
|
||||
min: 0.05,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
description: "笔刷的不透明度",
|
||||
category: "基本",
|
||||
order: 30,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并特有属性与基本属性
|
||||
* 子类应该调用此方法来合并自身特有属性与基类提供的基本属性
|
||||
* @param {Array} specificProperties 特有属性数组
|
||||
* @returns {Array} 合并后的属性数组
|
||||
*/
|
||||
mergeWithBaseProperties(specificProperties) {
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 过滤掉同名属性(子类优先)
|
||||
const basePropsFiltered = baseProperties.filter(
|
||||
(baseProp) =>
|
||||
!specificProperties.some(
|
||||
(specificProp) => specificProp.id === baseProp.id
|
||||
)
|
||||
);
|
||||
|
||||
return [...basePropsFiltered, ...specificProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 基础实现,可以被子类覆盖以处理特殊属性
|
||||
if (propId === "size") {
|
||||
if (this.brush) {
|
||||
this.brush.width = value;
|
||||
return true;
|
||||
}
|
||||
} else if (propId === "color") {
|
||||
if (this.brush) {
|
||||
this.brush.color = value;
|
||||
return true;
|
||||
}
|
||||
} else if (propId === "opacity") {
|
||||
if (this.brush) {
|
||||
this.brush.opacity = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查属性是否可见
|
||||
* @param {Object} property 属性对象
|
||||
* @param {Object} currentValues 当前所有属性的值
|
||||
* @returns {Boolean} 是否可见
|
||||
*/
|
||||
isPropertyVisible(property, currentValues) {
|
||||
// 如果没有visibleWhen条件,则始终显示
|
||||
if (!property.visibleWhen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果visibleWhen是函数,则调用函数判断
|
||||
if (typeof property.visibleWhen === "function") {
|
||||
return property.visibleWhen(currentValues);
|
||||
}
|
||||
|
||||
// 如果visibleWhen是对象,检查条件是否满足
|
||||
if (typeof property.visibleWhen === "object") {
|
||||
for (const [key, value] of Object.entries(property.visibleWhen)) {
|
||||
if (currentValues[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取动态选项
|
||||
* @param {Object} property 属性对象
|
||||
* @param {Object} currentValues 当前所有属性的值
|
||||
* @returns {Array} 选项数组
|
||||
*/
|
||||
getDynamicOptions(property, currentValues) {
|
||||
if (
|
||||
property.dynamicOptions &&
|
||||
typeof property.dynamicOptions === "function"
|
||||
) {
|
||||
return property.dynamicOptions(currentValues);
|
||||
}
|
||||
return property.options || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期方法:笔刷被选中
|
||||
*/
|
||||
onSelected() {
|
||||
// 可由子类覆盖
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期方法:笔刷被取消选中
|
||||
*/
|
||||
onDeselected() {
|
||||
// 可由子类覆盖
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁笔刷实例并清理资源
|
||||
*/
|
||||
destroy() {
|
||||
this.brush = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 笔刷注册表
|
||||
* 用于注册、获取和管理所有笔刷
|
||||
*/
|
||||
export class BrushRegistry {
|
||||
constructor() {
|
||||
// 存储所有注册的笔刷类
|
||||
this.brushes = new Map();
|
||||
|
||||
// 按类别组织的笔刷
|
||||
this.brushesByCategory = new Map();
|
||||
|
||||
// 事件监听器
|
||||
this.listeners = {
|
||||
register: [],
|
||||
unregister: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个笔刷
|
||||
* @param {String} id 笔刷唯一标识
|
||||
* @param {Class} brushClass 笔刷类(需要继承BaseBrush)
|
||||
* @param {Object} metadata 笔刷元数据(可选)
|
||||
* @returns {Boolean} 是否注册成功
|
||||
*/
|
||||
register(id, brushClass, metadata = {}) {
|
||||
if (this.brushes.has(id)) {
|
||||
console.warn(`笔刷 ${id} 已存在,请使用其他ID`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 存储笔刷信息
|
||||
const brushInfo = {
|
||||
id,
|
||||
class: brushClass,
|
||||
metadata: {
|
||||
...metadata,
|
||||
id,
|
||||
},
|
||||
};
|
||||
|
||||
this.brushes.set(id, brushInfo);
|
||||
|
||||
// 添加到分类
|
||||
const category = metadata.category || "默认";
|
||||
if (!this.brushesByCategory.has(category)) {
|
||||
this.brushesByCategory.set(category, []);
|
||||
}
|
||||
this.brushesByCategory.get(category).push(brushInfo);
|
||||
|
||||
// 触发事件
|
||||
this._triggerEvent("register", brushInfo);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消注册笔刷
|
||||
* @param {String} id 笔刷ID
|
||||
* @returns {Boolean} 是否成功
|
||||
*/
|
||||
unregister(id) {
|
||||
if (!this.brushes.has(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const brushInfo = this.brushes.get(id);
|
||||
this.brushes.delete(id);
|
||||
|
||||
// 从分类中移除
|
||||
const category = brushInfo.metadata.category || "默认";
|
||||
if (this.brushesByCategory.has(category)) {
|
||||
const brushes = this.brushesByCategory.get(category);
|
||||
const index = brushes.findIndex((b) => b.id === id);
|
||||
if (index !== -1) {
|
||||
brushes.splice(index, 1);
|
||||
}
|
||||
|
||||
// 如果分类为空,删除该分类
|
||||
if (brushes.length === 0) {
|
||||
this.brushesByCategory.delete(category);
|
||||
}
|
||||
}
|
||||
|
||||
// 触发事件
|
||||
this._triggerEvent("unregister", brushInfo);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷信息
|
||||
* @param {String} id 笔刷ID
|
||||
* @returns {Object|null} 笔刷信息或null
|
||||
*/
|
||||
getBrush(id) {
|
||||
return this.brushes.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有笔刷
|
||||
* @returns {Array} 笔刷信息数组
|
||||
*/
|
||||
getAllBrushes() {
|
||||
return Array.from(this.brushes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定分类的笔刷
|
||||
* @param {String} category 分类名称
|
||||
* @returns {Array} 笔刷信息数组
|
||||
*/
|
||||
getBrushesByCategory(category) {
|
||||
return this.brushesByCategory.get(category) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分类
|
||||
* @returns {Array} 分类名称数组
|
||||
*/
|
||||
getCategories() {
|
||||
return Array.from(this.brushesByCategory.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个笔刷实例
|
||||
* @param {String} id 笔刷ID
|
||||
* @param {Object} canvas 画布实例
|
||||
* @param {Object} options 配置选项
|
||||
* @returns {Object|null} 笔刷实例或null
|
||||
*/
|
||||
createBrushInstance(id, canvas, options = {}) {
|
||||
const brushInfo = this.getBrush(id);
|
||||
if (!brushInfo) {
|
||||
console.error(`笔刷 ${id} 不存在`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建笔刷实例
|
||||
return new brushInfo.class(canvas, {
|
||||
...options,
|
||||
id: brushInfo.id,
|
||||
...brushInfo.metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`创建笔刷 ${id} 失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
* @param {String} event 事件名称 ('register'|'unregister')
|
||||
* @param {Function} callback 回调函数
|
||||
*/
|
||||
addEventListener(event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
* @param {String} event 事件名称
|
||||
* @param {Function} callback 回调函数
|
||||
*/
|
||||
removeEventListener(event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
const index = this.listeners[event].indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.listeners[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param {String} event 事件名称
|
||||
* @param {*} data 事件数据
|
||||
* @private
|
||||
*/
|
||||
_triggerEvent(event, data) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`执行 ${event} 事件监听器出错:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const brushRegistry = new BrushRegistry();
|
||||
|
||||
// 默认导出单例
|
||||
export default brushRegistry;
|
||||
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* 材质预设管理器
|
||||
* 负责管理所有材质预设,包括内置预设和用户自定义预设
|
||||
*/
|
||||
export class TexturePresetManager {
|
||||
constructor() {
|
||||
// 内置材质预设
|
||||
this.builtInTextures = [];
|
||||
|
||||
// 用户自定义材质
|
||||
this.customTextures = [];
|
||||
|
||||
// 材质分类
|
||||
this.categories = new Map();
|
||||
|
||||
// 材质缓存
|
||||
this.textureCache = new Map();
|
||||
|
||||
// 事件监听器
|
||||
this.listeners = {
|
||||
textureAdded: [],
|
||||
textureRemoved: [],
|
||||
textureUpdated: [],
|
||||
};
|
||||
|
||||
// 初始化内置材质
|
||||
this._initBuiltInTextures();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化内置材质预设
|
||||
* @private
|
||||
*/
|
||||
_initBuiltInTextures() {
|
||||
// 基于项目中的texture文件夹内容创建预设
|
||||
const textureList = [
|
||||
// 基础纹理
|
||||
{
|
||||
id: "texture0",
|
||||
name: "纸质纹理",
|
||||
category: "基础纹理",
|
||||
path: "/src/assets/texture/texture0.webp",
|
||||
},
|
||||
{
|
||||
id: "texture1",
|
||||
name: "粗糙表面",
|
||||
category: "基础纹理",
|
||||
path: "/src/assets/texture/texture1.webp",
|
||||
},
|
||||
{
|
||||
id: "texture2",
|
||||
name: "细腻纹理",
|
||||
category: "基础纹理",
|
||||
path: "/src/assets/texture/texture2.webp",
|
||||
},
|
||||
{
|
||||
id: "texture3",
|
||||
name: "颗粒质感",
|
||||
category: "基础纹理",
|
||||
path: "/src/assets/texture/texture3.webp",
|
||||
},
|
||||
{
|
||||
id: "texture4",
|
||||
name: "布料纹理",
|
||||
category: "基础纹理",
|
||||
path: "/src/assets/texture/texture4.webp",
|
||||
},
|
||||
{
|
||||
id: "texture5",
|
||||
name: "木质纹理",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture5.webp",
|
||||
},
|
||||
{
|
||||
id: "texture6",
|
||||
name: "石材纹理",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture6.webp",
|
||||
},
|
||||
{
|
||||
id: "texture7",
|
||||
name: "金属质感",
|
||||
category: "金属纹理",
|
||||
path: "/src/assets/texture/texture7.webp",
|
||||
},
|
||||
{
|
||||
id: "texture8",
|
||||
name: "皮革纹理",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture8.webp",
|
||||
},
|
||||
{
|
||||
id: "texture9",
|
||||
name: "水彩纸质",
|
||||
category: "艺术纹理",
|
||||
path: "/src/assets/texture/texture9.webp",
|
||||
},
|
||||
{
|
||||
id: "texture10",
|
||||
name: "画布纹理",
|
||||
category: "艺术纹理",
|
||||
path: "/src/assets/texture/texture10.webp",
|
||||
},
|
||||
{
|
||||
id: "texture11",
|
||||
name: "沙砾质感",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture11.webp",
|
||||
},
|
||||
{
|
||||
id: "texture12",
|
||||
name: "水波纹理",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture12.webp",
|
||||
},
|
||||
{
|
||||
id: "texture13",
|
||||
name: "云朵纹理",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture13.webp",
|
||||
},
|
||||
{
|
||||
id: "texture14",
|
||||
name: "火焰纹理",
|
||||
category: "特效纹理",
|
||||
path: "/src/assets/texture/texture14.webp",
|
||||
},
|
||||
{
|
||||
id: "texture15",
|
||||
name: "烟雾效果",
|
||||
category: "特效纹理",
|
||||
path: "/src/assets/texture/texture15.webp",
|
||||
},
|
||||
{
|
||||
id: "texture16",
|
||||
name: "星空纹理",
|
||||
category: "特效纹理",
|
||||
path: "/src/assets/texture/texture16.webp",
|
||||
},
|
||||
{
|
||||
id: "texture17",
|
||||
name: "大理石纹",
|
||||
category: "石材纹理",
|
||||
path: "/src/assets/texture/texture17.webp",
|
||||
},
|
||||
{
|
||||
id: "texture18",
|
||||
name: "花岗岩纹",
|
||||
category: "石材纹理",
|
||||
path: "/src/assets/texture/texture18.webp",
|
||||
},
|
||||
{
|
||||
id: "texture19",
|
||||
name: "竹纹理",
|
||||
category: "自然纹理",
|
||||
path: "/src/assets/texture/texture19.webp",
|
||||
},
|
||||
{
|
||||
id: "texture20",
|
||||
name: "抽象图案",
|
||||
category: "艺术纹理",
|
||||
path: "/src/assets/texture/texture20.webp",
|
||||
},
|
||||
];
|
||||
|
||||
// 添加内置材质
|
||||
textureList.forEach((texture) => {
|
||||
this.builtInTextures.push({
|
||||
id: texture.id,
|
||||
name: texture.name,
|
||||
category: texture.category,
|
||||
path: texture.path,
|
||||
type: "builtin",
|
||||
preview: texture.path, // 使用原图作为预览
|
||||
description: `内置${texture.category} - ${texture.name}`,
|
||||
tags: [texture.category.replace("纹理", ""), "内置"],
|
||||
created: new Date().toISOString(),
|
||||
// 默认属性
|
||||
defaultSettings: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
repeat: "repeat",
|
||||
angle: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 添加到分类
|
||||
if (!this.categories.has(texture.category)) {
|
||||
this.categories.set(texture.category, []);
|
||||
}
|
||||
this.categories.get(texture.category).push(texture.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有材质(内置 + 自定义)
|
||||
* @returns {Array} 材质数组
|
||||
*/
|
||||
getAllTextures() {
|
||||
return [...this.builtInTextures, ...this.customTextures];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取材质
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Object|null} 材质对象
|
||||
*/
|
||||
getTextureById(textureId) {
|
||||
return (
|
||||
this.getAllTextures().find((texture) => texture.id === textureId) || null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类获取材质
|
||||
* @param {String} category 分类名称
|
||||
* @returns {Array} 材质数组
|
||||
*/
|
||||
getTexturesByCategory(category) {
|
||||
return this.getAllTextures().filter(
|
||||
(texture) => texture.category === category
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分类
|
||||
* @returns {Array} 分类名称数组
|
||||
*/
|
||||
getCategories() {
|
||||
const categories = new Set();
|
||||
this.getAllTextures().forEach((texture) => {
|
||||
categories.add(texture.category);
|
||||
});
|
||||
return Array.from(categories);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加自定义材质
|
||||
* @param {Object} textureData 材质数据
|
||||
* @returns {String} 材质ID
|
||||
*/
|
||||
addCustomTexture(textureData) {
|
||||
const textureId =
|
||||
textureData.id ||
|
||||
`custom_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const texture = {
|
||||
id: textureId,
|
||||
name: textureData.name || "自定义材质",
|
||||
category: textureData.category || "自定义材质",
|
||||
path: textureData.path || textureData.dataUrl,
|
||||
type: "custom",
|
||||
preview: textureData.preview || textureData.path || textureData.dataUrl,
|
||||
description: textureData.description || "用户自定义材质",
|
||||
tags: textureData.tags || ["自定义"],
|
||||
created: new Date().toISOString(),
|
||||
defaultSettings: {
|
||||
scale: textureData.scale || 1,
|
||||
opacity: textureData.opacity || 1,
|
||||
repeat: textureData.repeat || "repeat",
|
||||
angle: textureData.angle || 0,
|
||||
...textureData.defaultSettings,
|
||||
},
|
||||
// 保存原始文件信息
|
||||
file: textureData.file || null,
|
||||
dataUrl: textureData.dataUrl || null,
|
||||
};
|
||||
|
||||
this.customTextures.push(texture);
|
||||
|
||||
// 添加到分类
|
||||
if (!this.categories.has(texture.category)) {
|
||||
this.categories.set(texture.category, []);
|
||||
}
|
||||
this.categories.get(texture.category).push(textureId);
|
||||
|
||||
// 触发事件
|
||||
this._triggerEvent("textureAdded", texture);
|
||||
|
||||
return textureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除自定义材质
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Boolean} 是否删除成功
|
||||
*/
|
||||
removeCustomTexture(textureId) {
|
||||
const index = this.customTextures.findIndex(
|
||||
(texture) => texture.id === textureId
|
||||
);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const texture = this.customTextures[index];
|
||||
|
||||
// 只能删除自定义材质
|
||||
if (texture.type !== "custom") {
|
||||
console.warn("不能删除内置材质");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.customTextures.splice(index, 1);
|
||||
|
||||
// 从分类中移除
|
||||
if (this.categories.has(texture.category)) {
|
||||
const categoryTextures = this.categories.get(texture.category);
|
||||
const categoryIndex = categoryTextures.indexOf(textureId);
|
||||
if (categoryIndex !== -1) {
|
||||
categoryTextures.splice(categoryIndex, 1);
|
||||
}
|
||||
|
||||
// 如果分类为空且不是内置分类,删除分类
|
||||
if (categoryTextures.length === 0 && texture.category === "自定义材质") {
|
||||
this.categories.delete(texture.category);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
this.textureCache.delete(textureId);
|
||||
|
||||
// 触发事件
|
||||
this._triggerEvent("textureRemoved", texture);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新材质信息
|
||||
* @param {String} textureId 材质ID
|
||||
* @param {Object} updates 更新数据
|
||||
* @returns {Boolean} 是否更新成功
|
||||
*/
|
||||
updateTexture(textureId, updates) {
|
||||
const texture = this.getTextureById(textureId);
|
||||
if (!texture || texture.type === "builtin") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新材质属性
|
||||
Object.assign(texture, updates);
|
||||
|
||||
// 触发事件
|
||||
this._triggerEvent("textureUpdated", texture);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取材质预览URL
|
||||
* @param {Object} texture 材质对象
|
||||
* @returns {String} 预览URL
|
||||
*/
|
||||
getTexturePreviewUrl(texture) {
|
||||
if (!texture) return null;
|
||||
|
||||
// 如果有预览图,使用预览图
|
||||
if (texture.preview) {
|
||||
return texture.preview;
|
||||
}
|
||||
|
||||
// 否则使用原图
|
||||
return texture.path || texture.dataUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载材质图像
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Promise<HTMLImageElement>} 图像对象
|
||||
*/
|
||||
loadTextureImage(textureId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查缓存
|
||||
if (this.textureCache.has(textureId)) {
|
||||
resolve(this.textureCache.get(textureId));
|
||||
return;
|
||||
}
|
||||
|
||||
const texture = this.getTextureById(textureId);
|
||||
if (!texture) {
|
||||
reject(new Error(`材质 ${textureId} 不存在`));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
|
||||
img.onload = () => {
|
||||
// 缓存图像
|
||||
this.textureCache.set(textureId, img);
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error(`材质 ${textureId} 加载失败`));
|
||||
};
|
||||
|
||||
img.src = texture.path || texture.dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索材质
|
||||
* @param {String} query 搜索关键词
|
||||
* @returns {Array} 匹配的材质数组
|
||||
*/
|
||||
searchTextures(query) {
|
||||
if (!query) return this.getAllTextures();
|
||||
|
||||
const searchTerm = query.toLowerCase();
|
||||
return this.getAllTextures().filter((texture) => {
|
||||
return (
|
||||
texture.name.toLowerCase().includes(searchTerm) ||
|
||||
texture.category.toLowerCase().includes(searchTerm) ||
|
||||
texture.description.toLowerCase().includes(searchTerm) ||
|
||||
texture.tags.some((tag) => tag.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存自定义材质到本地存储
|
||||
*/
|
||||
saveCustomTexturesToStorage() {
|
||||
try {
|
||||
const customTexturesData = this.customTextures.map((texture) => ({
|
||||
...texture,
|
||||
// 不保存file对象到localStorage
|
||||
file: null,
|
||||
}));
|
||||
|
||||
localStorage.setItem(
|
||||
"canvasEditor_customTextures",
|
||||
JSON.stringify(customTexturesData)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("保存自定义材质失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储加载自定义材质
|
||||
*/
|
||||
loadCustomTexturesFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem("canvasEditor_customTextures");
|
||||
if (stored) {
|
||||
const customTexturesData = JSON.parse(stored);
|
||||
this.customTextures = customTexturesData;
|
||||
|
||||
// 重建分类索引
|
||||
this.customTextures.forEach((texture) => {
|
||||
if (!this.categories.has(texture.category)) {
|
||||
this.categories.set(texture.category, []);
|
||||
}
|
||||
if (!this.categories.get(texture.category).includes(texture.id)) {
|
||||
this.categories.get(texture.category).push(texture.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载自定义材质失败:", error);
|
||||
this.customTextures = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建材质预设
|
||||
* @param {String} name 预设名称
|
||||
* @param {Object} settings 材质设置
|
||||
* @returns {String} 预设ID
|
||||
*/
|
||||
createTexturePreset(name, settings) {
|
||||
const presetId = `preset_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`;
|
||||
|
||||
const preset = {
|
||||
id: presetId,
|
||||
name: name,
|
||||
type: "preset",
|
||||
category: "材质预设",
|
||||
created: new Date().toISOString(),
|
||||
settings: {
|
||||
textureId: settings.textureId,
|
||||
scale: settings.scale || 1,
|
||||
opacity: settings.opacity || 1,
|
||||
repeat: settings.repeat || "repeat",
|
||||
angle: settings.angle || 0,
|
||||
brushSize: settings.brushSize || 5,
|
||||
brushOpacity: settings.brushOpacity || 1,
|
||||
brushColor: settings.brushColor || "#000000",
|
||||
},
|
||||
};
|
||||
|
||||
this.customTextures.push(preset);
|
||||
this._triggerEvent("textureAdded", preset);
|
||||
|
||||
return presetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用材质预设
|
||||
* @param {String} presetId 预设ID
|
||||
* @returns {Object|null} 预设设置
|
||||
*/
|
||||
applyTexturePreset(presetId) {
|
||||
const preset = this.getTextureById(presetId);
|
||||
if (!preset || preset.type !== "preset") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preset.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
* @param {String} event 事件名称
|
||||
* @param {Function} callback 回调函数
|
||||
*/
|
||||
addEventListener(event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
* @param {String} event 事件名称
|
||||
* @param {Function} callback 回调函数
|
||||
*/
|
||||
removeEventListener(event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
const index = this.listeners[event].indexOf(callback);
|
||||
if (index !== -1) {
|
||||
this.listeners[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param {String} event 事件名称
|
||||
* @param {*} data 事件数据
|
||||
* @private
|
||||
*/
|
||||
_triggerEvent(event, data) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`执行 ${event} 事件监听器出错:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
clearCache() {
|
||||
this.textureCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
builtInCount: this.builtInTextures.length,
|
||||
customCount: this.customTextures.length,
|
||||
totalCount: this.getAllTextures().length,
|
||||
categoriesCount: this.getCategories().length,
|
||||
cacheSize: this.textureCache.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const texturePresetManager = new TexturePresetManager();
|
||||
|
||||
// 导出单例
|
||||
export default texturePresetManager;
|
||||
@@ -0,0 +1,720 @@
|
||||
//import { fabric } from "fabric-with-all";
|
||||
import { BrushStore } from "../../store/BrushStore";
|
||||
import { brushRegistry } from "./BrushRegistry";
|
||||
|
||||
// 导入基础笔刷类型
|
||||
import { PencilBrush } from "./types/PencilBrush";
|
||||
import { TextureBrush } from "./types/TextureBrush";
|
||||
|
||||
// 导入集成的笔刷类型
|
||||
import { CrayonBrush } from "./types/CrayonBrush";
|
||||
import { FurBrush } from "./types/FurBrush";
|
||||
import { InkBrush } from "./types/InkBrush";
|
||||
import { LongfurBrush } from "./types/LongfurBrush";
|
||||
import { WritingBrush } from "./types/WritingBrush";
|
||||
import { MarkerBrush } from "./types/MarkerBrush";
|
||||
import { CustomPenBrush } from "./types/CustomPenBrush";
|
||||
import { RibbonBrush } from "./types/RibbonBrush";
|
||||
import { ShadedBrush } from "./types/ShadedBrush";
|
||||
import { SketchyBrush } from "./types/SketchyBrush";
|
||||
import { SpraypaintBrush } from "./types/SpraypaintBrush";
|
||||
|
||||
/**
|
||||
* 笔刷管理器
|
||||
* 负责管理和切换不同的笔刷类型
|
||||
*/
|
||||
export class BrushManager {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Object} options.canvas fabric.js画布实例
|
||||
* @param {Object} options.brushStore 笔刷数据存储(可选)
|
||||
* @param {Object} options.layerManager 图层管理器实例(可选)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.canvas = options.canvas;
|
||||
this.brushStore = options.brushStore || BrushStore;
|
||||
this.layerManager = options.layerManager; // 添加图层管理器引用
|
||||
|
||||
// 当前活动笔刷
|
||||
this.activeBrush = null;
|
||||
this.activeBrushId = null;
|
||||
|
||||
// 初始化笔刷注册
|
||||
this._registerDefaultBrushes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册默认笔刷
|
||||
* @private
|
||||
*/
|
||||
_registerDefaultBrushes() {
|
||||
// 注册铅笔笔刷
|
||||
brushRegistry.register("pencil", PencilBrush, {
|
||||
name: "铅笔",
|
||||
description: "基础铅笔工具,适合精细线条绘制",
|
||||
category: "基础笔刷",
|
||||
});
|
||||
|
||||
// 注册材质笔刷
|
||||
brushRegistry.register("texture", TextureBrush);
|
||||
|
||||
// 注册集成的笔刷类型
|
||||
brushRegistry.register("crayon", CrayonBrush);
|
||||
brushRegistry.register("fur", FurBrush);
|
||||
brushRegistry.register("ink", InkBrush);
|
||||
brushRegistry.register("longfur", LongfurBrush);
|
||||
brushRegistry.register("writing", WritingBrush);
|
||||
brushRegistry.register("marker", MarkerBrush);
|
||||
brushRegistry.register("pen", CustomPenBrush);
|
||||
brushRegistry.register("ribbon", RibbonBrush);
|
||||
brushRegistry.register("shaded", ShadedBrush);
|
||||
brushRegistry.register("sketchy", SketchyBrush);
|
||||
brushRegistry.register("spraypaint", SpraypaintBrush);
|
||||
|
||||
// 注册喷枪笔刷
|
||||
brushRegistry.register(
|
||||
"spray",
|
||||
class SprayBrush extends PencilBrush {
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "spray",
|
||||
name: "喷枪",
|
||||
description: "模拟喷枪效果,创建散点效果",
|
||||
category: "基础笔刷",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
this.brush = new fabric.SprayBrush(this.canvas);
|
||||
this.configure(this.brush, this.options);
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
configure(brush, options = {}) {
|
||||
super.configure(brush, options);
|
||||
|
||||
if (options.density !== undefined) {
|
||||
brush.density = options.density;
|
||||
}
|
||||
|
||||
if (options.randomOpacity !== undefined) {
|
||||
brush.randomOpacity = options.randomOpacity;
|
||||
}
|
||||
|
||||
if (options.dotWidth !== undefined) {
|
||||
brush.dotWidth = options.dotWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
// 注册橡皮擦笔刷
|
||||
brushRegistry.register(
|
||||
"eraser",
|
||||
class EraserBrush extends PencilBrush {
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "eraser",
|
||||
name: "橡皮擦",
|
||||
description: "擦除已绘制的内容",
|
||||
category: "工具",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
// 直接使用 fabric-with-erasing 库提供的 EraserBrush
|
||||
this.brush = new fabric.EraserBrush(this.canvas);
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
// 配置橡皮擦特有属性
|
||||
this.brush.inverted = this.options.inverted || false; // 是否反向擦除(恢复擦除)
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
configure(brush, options = {}) {
|
||||
super.configure(brush, options);
|
||||
|
||||
// 橡皮擦特有配置
|
||||
if (options.inverted !== undefined) {
|
||||
brush.inverted = options.inverted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置反向擦除模式
|
||||
* @param {Boolean} inverted 是否启用反向擦除(撤销擦除效果)
|
||||
*/
|
||||
setInverted(inverted) {
|
||||
if (this.brush) {
|
||||
this.brush.inverted = inverted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取橡皮擦配置属性
|
||||
* @returns {Array} 可配置属性数组
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
return [
|
||||
...super.getConfigurableProperties(),
|
||||
{
|
||||
id: "inverted",
|
||||
name: "反向擦除",
|
||||
type: "boolean",
|
||||
description: "启用时可以恢复已擦除的内容",
|
||||
defaultValue: false,
|
||||
min: null,
|
||||
max: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新橡皮擦属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
if (propId === "inverted") {
|
||||
this.setInverted(value);
|
||||
} else {
|
||||
super.updateProperty(propId, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 注册水彩笔刷
|
||||
brushRegistry.register(
|
||||
"watercolor",
|
||||
class WatercolorBrush extends PencilBrush {
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "watercolor",
|
||||
name: "水彩",
|
||||
description: "模拟水彩效果,带有流动感和透明感",
|
||||
category: "特效笔刷",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
// 创建一个自定义的PencilBrush来模拟水彩效果
|
||||
this.brush = new fabric.PencilBrush(this.canvas);
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
// 水彩效果特有的属性
|
||||
this.brush.globalCompositeOperation = "multiply";
|
||||
this.brush.shadow = new fabric.Shadow({
|
||||
color: this.options.color || "#000",
|
||||
blur: 5,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
configure(brush, options = {}) {
|
||||
super.configure(brush, options);
|
||||
|
||||
// 水彩笔刷特有的配置
|
||||
brush.opacity = Math.min(0.5, options.opacity || 0.3); // 默认透明度30%
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 注册粉笔笔刷
|
||||
brushRegistry.register(
|
||||
"chalk",
|
||||
class ChalkBrush extends PencilBrush {
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "chalk",
|
||||
name: "粉笔",
|
||||
description: "模拟粉笔效果,有颗粒感和不连续性",
|
||||
category: "特效笔刷",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
this.brush = new fabric.PencilBrush(this.canvas);
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
// 自定义绘画方法来模拟粉笔效果
|
||||
const originalOnMouseMove = this.brush.onMouseMove;
|
||||
this.brush.onMouseMove = function (pointer, options) {
|
||||
// 随机调整坐标位置,增加粉笔质感
|
||||
const jitter = 2;
|
||||
pointer.x += (Math.random() - 0.5) * jitter;
|
||||
pointer.y += (Math.random() - 0.5) * jitter;
|
||||
|
||||
// 调用原始方法
|
||||
originalOnMouseMove.call(this, pointer, options);
|
||||
};
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
configure(brush, options = {}) {
|
||||
super.configure(brush, options);
|
||||
|
||||
// 粉笔特有的设置
|
||||
brush.strokeDashArray = [5, 5]; // 虚线效果
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用笔刷类型
|
||||
* @returns {Array} 笔刷类型数组,包含id、name和description
|
||||
*/
|
||||
getBrushTypes() {
|
||||
// 从注册表获取所有笔刷信息
|
||||
const brushes = brushRegistry.getAllBrushes();
|
||||
|
||||
// 将笔刷信息转换为期望的格式
|
||||
return brushes.map((brushInfo) => ({
|
||||
id: brushInfo.id,
|
||||
name: brushInfo.metadata.name || brushInfo.id,
|
||||
description: brushInfo.metadata.description || "",
|
||||
category: brushInfo.metadata.category || "默认",
|
||||
icon: brushInfo.metadata.icon || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化笔刷列表并更新BrushStore
|
||||
*/
|
||||
initializeBrushes() {
|
||||
// 获取所有笔刷
|
||||
const allBrushes = this.getBrushTypes();
|
||||
|
||||
// 更新BrushStore中的可用笔刷列表
|
||||
this.brushStore.setAvailableBrushes(allBrushes);
|
||||
|
||||
// 设置默认笔刷
|
||||
if (!this.activeBrushId && allBrushes.length > 0) {
|
||||
this.setBrushType(allBrushes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔刷类型
|
||||
* @param {String} brushId 笔刷ID
|
||||
* @returns {Object|null} 设置的笔刷实例
|
||||
*/
|
||||
setBrushType(brushId) {
|
||||
// 如果相同笔刷,不做处理
|
||||
if (this.activeBrushId === brushId) {
|
||||
return this.activeBrush;
|
||||
}
|
||||
|
||||
// 销毁当前笔刷
|
||||
if (this.activeBrush) {
|
||||
// 调用生命周期方法
|
||||
if (this.activeBrush.onDeselected) {
|
||||
this.activeBrush.onDeselected();
|
||||
}
|
||||
this.activeBrush.destroy();
|
||||
}
|
||||
// 创建新笔刷实例
|
||||
try {
|
||||
const brushInstance = brushRegistry.createBrushInstance(
|
||||
brushId,
|
||||
this.canvas,
|
||||
{
|
||||
color: brushId === "eraser" ? this.brushStore.state.color : undefined,
|
||||
width: this.brushStore.state.size,
|
||||
opacity: this.brushStore.state.opacity,
|
||||
|
||||
// 材质笔刷特有配置
|
||||
textureEnabled: this.brushStore.state.textureEnabled,
|
||||
texturePath: this.brushStore.state.texturePath,
|
||||
textureScale: this.brushStore.state.textureScale,
|
||||
}
|
||||
);
|
||||
|
||||
if (brushInstance) {
|
||||
// 创建笔刷
|
||||
const fabricBrush = brushInstance.create();
|
||||
|
||||
// 更新画布的当前笔刷
|
||||
if (fabricBrush) {
|
||||
this.canvas.freeDrawingBrush = fabricBrush;
|
||||
}
|
||||
|
||||
// 更新当前笔刷引用
|
||||
this.activeBrush = brushInstance;
|
||||
this.activeBrushId = brushId;
|
||||
|
||||
// 调用生命周期方法
|
||||
if (this.activeBrush.onSelected) {
|
||||
this.activeBrush.onSelected();
|
||||
}
|
||||
|
||||
// 更新Store的笔刷类型
|
||||
this.brushStore.setBrushType(brushId);
|
||||
|
||||
// 更新Store的当前笔刷实例,用于动态属性系统
|
||||
this.brushStore.setCurrentBrushInstance(brushInstance);
|
||||
|
||||
return brushInstance;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`创建笔刷 ${brushId} 失败:`, error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔刷颜色
|
||||
* @param {String} color 十六进制颜色值
|
||||
*/
|
||||
setBrushColor(color) {
|
||||
if (!this.canvas.freeDrawingBrush) return;
|
||||
|
||||
// 更新笔刷颜色
|
||||
this.canvas.freeDrawingBrush.color = color;
|
||||
|
||||
// 更新活动笔刷
|
||||
if (this.activeBrush) {
|
||||
this.activeBrush.configure(this.canvas.freeDrawingBrush, { color });
|
||||
}
|
||||
|
||||
// 更新Store
|
||||
this.brushStore.setBrushColor(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔刷大小
|
||||
* @param {Number} size 笔刷大小
|
||||
*/
|
||||
setBrushSize(size) {
|
||||
if (!this.canvas.freeDrawingBrush) return;
|
||||
|
||||
// 限制大小范围
|
||||
const brushSize = Math.max(0.1, Math.min(100, size));
|
||||
|
||||
// 更新笔刷大小
|
||||
this.canvas.freeDrawingBrush.width = brushSize;
|
||||
|
||||
// 更新活动笔刷
|
||||
if (this.activeBrush) {
|
||||
this.activeBrush.configure(this.canvas.freeDrawingBrush, {
|
||||
width: brushSize,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新Store
|
||||
this.brushStore.setBrushSize(brushSize);
|
||||
|
||||
return brushSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加笔刷大小
|
||||
* @param {Number} amount 增加量
|
||||
* @returns {Number} 新的笔刷大小
|
||||
*/
|
||||
increaseBrushSize(amount = 1) {
|
||||
const currentSize = this.brushStore.state.size;
|
||||
return this.setBrushSize(currentSize + amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少笔刷大小
|
||||
* @param {Number} amount 减少量
|
||||
* @returns {Number} 新的笔刷大小
|
||||
*/
|
||||
decreaseBrushSize(amount = 1) {
|
||||
const currentSize = this.brushStore.state.size;
|
||||
return this.setBrushSize(currentSize - amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加笔刷透明度
|
||||
* @param {Number} amount 增加量
|
||||
* @returns {Number} 新的笔刷大小
|
||||
*/
|
||||
increaseBrushOpacity(amount = 0.01) {
|
||||
const currentSize = this.brushStore.state.opacity;
|
||||
return this.setBrushOpacity(currentSize + amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少笔刷大小
|
||||
* @param {Number} amount 减少量
|
||||
* @returns {Number} 新的笔刷大小
|
||||
*/
|
||||
decreaseBrushOpacity(amount = 0.01) {
|
||||
const currentSize = this.brushStore.state.opacity;
|
||||
return this.setBrushOpacity(currentSize - amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔刷透明度
|
||||
* @param {Number} opacity 透明度 (0-1)
|
||||
*/
|
||||
setBrushOpacity(opacity) {
|
||||
if (!this.canvas.freeDrawingBrush) return;
|
||||
|
||||
// 限制透明度范围
|
||||
const brushOpacity = Math.max(0.05, Math.min(1, opacity));
|
||||
|
||||
// 更新笔刷透明度
|
||||
this.canvas.freeDrawingBrush.opacity = brushOpacity;
|
||||
|
||||
// 更新活动笔刷
|
||||
if (this.activeBrush) {
|
||||
this.activeBrush.configure(this.canvas.freeDrawingBrush, {
|
||||
opacity: brushOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新Store
|
||||
this.brushStore.setBrushOpacity(brushOpacity);
|
||||
|
||||
return brushOpacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置材质缩放
|
||||
* @param {Number} scale 缩放比例
|
||||
*/
|
||||
setTextureScale(scale) {
|
||||
// 限制缩放范围
|
||||
const textureScale = Math.max(0.1, Math.min(10, scale));
|
||||
|
||||
// 更新活动笔刷
|
||||
if (this.activeBrush && this.activeBrush.setTextureScale) {
|
||||
this.activeBrush.setTextureScale(textureScale);
|
||||
}
|
||||
|
||||
// 更新Store
|
||||
this.brushStore.setTextureScale(textureScale);
|
||||
|
||||
return textureScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加材质缩放
|
||||
* @param {Number} amount 增加量
|
||||
*/
|
||||
increaseTextureScale(amount = 0.1) {
|
||||
const currentScale = this.brushStore.state.textureScale;
|
||||
return this.setTextureScale(currentScale + amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少材质缩放
|
||||
* @param {Number} amount 减少量
|
||||
*/
|
||||
decreaseTextureScale(amount = 0.1) {
|
||||
const currentScale = this.brushStore.state.textureScale;
|
||||
return this.setTextureScale(currentScale - amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置材质路径
|
||||
* @param {String} path 材质图片路径
|
||||
*/
|
||||
setTexturePath(path) {
|
||||
// 更新活动笔刷
|
||||
if (this.activeBrush && this.activeBrush.setTexturePath) {
|
||||
this.activeBrush.setTexturePath(path);
|
||||
}
|
||||
|
||||
// 更新Store
|
||||
this.brushStore.setTexturePath(path);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用材质
|
||||
* @param {Boolean} enabled 是否启用
|
||||
*/
|
||||
setTextureEnabled(enabled) {
|
||||
// 更新Store
|
||||
this.brushStore.setTextureEnabled(enabled);
|
||||
|
||||
// 如果启用材质,且当前不是材质笔刷,需要切换
|
||||
if (enabled && this.activeBrushId !== "texture") {
|
||||
this.setBrushType("texture");
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册新笔刷
|
||||
* @param {String} id 笔刷ID
|
||||
* @param {Class} brushClass 笔刷类
|
||||
* @param {Object} metadata 笔刷元数据
|
||||
* @returns {Boolean} 是否注册成功
|
||||
*/
|
||||
registerBrush(id, brushClass, metadata = {}) {
|
||||
const success = brushRegistry.register(id, brushClass, metadata);
|
||||
|
||||
if (success) {
|
||||
// 更新可用笔刷列表
|
||||
this.initializeBrushes();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前笔刷类型
|
||||
* @returns {String} 当前笔刷类型ID
|
||||
*/
|
||||
getCurrentBrushType() {
|
||||
return this.activeBrushId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前笔刷大小
|
||||
* @returns {Number} 当前笔刷大小
|
||||
*/
|
||||
getBrushSize() {
|
||||
return this.brushStore.state.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前笔刷颜色
|
||||
* @returns {String} 当前笔刷颜色
|
||||
*/
|
||||
getBrushColor() {
|
||||
return this.brushStore.state.color;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前笔刷透明度
|
||||
* @returns {Number} 当前笔刷透明度
|
||||
*/
|
||||
getBrushOpacity() {
|
||||
return this.brushStore.state.opacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取材质缩放
|
||||
* @returns {Number} 材质缩放比例
|
||||
*/
|
||||
getTextureScale() {
|
||||
return this.brushStore.state.textureScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建材质笔刷
|
||||
* 这个方法保留用于向下兼容
|
||||
* @deprecated 请使用setBrushType('texture')代替
|
||||
* @returns {Object} 材质笔刷实例
|
||||
*/
|
||||
createTextureBrush() {
|
||||
console.warn('createTextureBrush方法已废弃,请使用setBrushType("texture")');
|
||||
return this.setBrushType("texture");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷
|
||||
* 根据当前设置应用笔刷属性到画布
|
||||
*/
|
||||
updateBrush() {
|
||||
if (!this.canvas) return;
|
||||
|
||||
// 如果有活动的笔刷实例,重新配置它
|
||||
if (this.activeBrush && this.canvas.freeDrawingBrush) {
|
||||
this.activeBrush.configure(this.canvas.freeDrawingBrush, {
|
||||
color: this.brushStore.state.color,
|
||||
width: this.brushStore.state.size,
|
||||
opacity: this.brushStore.state.opacity,
|
||||
});
|
||||
} else {
|
||||
// 如果没有活动笔刷,创建一个默认的
|
||||
this.setBrushType(this.activeBrushId || "pencil");
|
||||
}
|
||||
|
||||
// 更新画布状态
|
||||
this?.canvas?.renderAll?.();
|
||||
|
||||
return this.canvas.freeDrawingBrush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建橡皮擦
|
||||
* @returns {Object} 橡皮擦笔刷
|
||||
*/
|
||||
createEraser() {
|
||||
return this.setBrushType("eraser");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建吸色工具
|
||||
* @param {Function} callback 选择颜色后的回调函数
|
||||
*/
|
||||
createEyedropper(callback) {
|
||||
// 保存当前状态
|
||||
const previousBrushId = this.activeBrushId;
|
||||
|
||||
// 一次性事件处理程序
|
||||
const handleMouseDown = (event) => {
|
||||
const pointer = this.canvas.getPointer(event.e);
|
||||
const ctx = this.canvas.getContext();
|
||||
|
||||
// 获取点击位置的像素
|
||||
const imageData = ctx.getImageData(pointer.x, pointer.y, 1, 1).data;
|
||||
|
||||
// 将RGB转换为十六进制颜色
|
||||
const color = `#${(
|
||||
(1 << 24) +
|
||||
(imageData[0] << 16) +
|
||||
(imageData[1] << 8) +
|
||||
imageData[2]
|
||||
)
|
||||
.toString(16)
|
||||
.slice(1)}`;
|
||||
|
||||
// 调用回调函数
|
||||
if (typeof callback === "function") {
|
||||
callback(color);
|
||||
}
|
||||
|
||||
// 恢复之前的笔刷
|
||||
this.setBrushType(previousBrushId);
|
||||
|
||||
// 移除事件监听器
|
||||
this.canvas.off("mouse:down", handleMouseDown);
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
this.canvas.on("mouse:down", handleMouseDown);
|
||||
|
||||
// 设置吸色光标
|
||||
this.canvas.defaultCursor = "crosshair";
|
||||
|
||||
console.log("吸色工具已激活,点击画布选择颜色");
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁资源
|
||||
*/
|
||||
dispose() {
|
||||
// 销毁当前笔刷
|
||||
if (this.activeBrush) {
|
||||
this.activeBrush.destroy();
|
||||
this.activeBrush = null;
|
||||
}
|
||||
|
||||
this.canvas = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export default BrushManager;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,244 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 蜡笔笔刷
|
||||
* 模拟蜡笔效果,具有颗粒感和纹理
|
||||
*/
|
||||
export class CrayonBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "crayon",
|
||||
name: "蜡笔",
|
||||
description: "模拟蜡笔效果,具有颗粒感和纹理",
|
||||
category: "特效笔刷",
|
||||
icon: "crayon",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 蜡笔笔刷特有属性
|
||||
this._baseWidth = options._baseWidth || 15;
|
||||
this._size = options._size || 0;
|
||||
this._sep = options._sep || options._sep === 0 ? options._sep : 3;
|
||||
this._inkAmount = options._inkAmount || 10;
|
||||
this.randomness = options.randomness || 0.5; // 随机性
|
||||
this.texture = options.texture || "default"; // 纹理类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生蜡笔笔刷
|
||||
this.brush = new fabric.CrayonBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
// 更新笔刷相关属性
|
||||
this._baseWidth = options.width / 2;
|
||||
this._size = options.width / 2 + this._baseWidth;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 蜡笔笔刷特有属性
|
||||
if (options._baseWidth !== undefined) {
|
||||
brush._baseWidth = options._baseWidth;
|
||||
this._baseWidth = options._baseWidth;
|
||||
this._size = this.width / 2 + this._baseWidth;
|
||||
}
|
||||
|
||||
if (options._sep !== undefined) {
|
||||
brush._sep = options._sep;
|
||||
this._sep = options._sep;
|
||||
}
|
||||
|
||||
if (options._inkAmount !== undefined) {
|
||||
brush._inkAmount = options._inkAmount;
|
||||
this._inkAmount = options._inkAmount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置颗粒分离度
|
||||
* @param {Number} sep 分离度值
|
||||
*/
|
||||
setSeparation(sep) {
|
||||
this._sep = Math.max(0.5, Math.min(10, sep));
|
||||
|
||||
if (this.brush) {
|
||||
this.brush._sep = this._sep;
|
||||
}
|
||||
|
||||
return this._sep;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置墨量
|
||||
* @param {Number} amount 墨量值
|
||||
*/
|
||||
setInkAmount(amount) {
|
||||
this._inkAmount = Math.max(1, Math.min(50, amount));
|
||||
|
||||
if (this.brush) {
|
||||
this.brush._inkAmount = this._inkAmount;
|
||||
}
|
||||
|
||||
return this._inkAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置随机性
|
||||
* @param {Number} value 随机性值(0-1)
|
||||
*/
|
||||
setRandomness(value) {
|
||||
this.randomness = Math.max(0, Math.min(1, value));
|
||||
return this.randomness;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理类型
|
||||
* @param {String} type 纹理类型
|
||||
*/
|
||||
setTexture(type) {
|
||||
this.texture = type;
|
||||
// 实际应用可能需要更多的实现逻辑
|
||||
return this.texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义蜡笔笔刷特有属性
|
||||
const crayonProperties = [
|
||||
{
|
||||
id: "separation",
|
||||
name: "颗粒分离度",
|
||||
type: "slider",
|
||||
defaultValue: this._sep,
|
||||
min: 0.5,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
description: "控制蜡笔颗粒的分离程度",
|
||||
category: "蜡笔设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "inkAmount",
|
||||
name: "墨量",
|
||||
type: "slider",
|
||||
defaultValue: this._inkAmount,
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
description: "控制蜡笔的颜料量",
|
||||
category: "蜡笔设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "randomness",
|
||||
name: "随机性",
|
||||
type: "slider",
|
||||
defaultValue: this.randomness,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制蜡笔纹理的随机程度",
|
||||
category: "蜡笔设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "texture",
|
||||
name: "纹理类型",
|
||||
type: "select",
|
||||
defaultValue: this.texture,
|
||||
options: [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "rough", label: "粗糙" },
|
||||
{ value: "smooth", label: "平滑" },
|
||||
],
|
||||
description: "设置蜡笔的纹理类型",
|
||||
category: "蜡笔设置",
|
||||
order: 130,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...crayonProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理蜡笔笔刷特有属性
|
||||
if (propId === "separation") {
|
||||
this.setSeparation(value);
|
||||
return true;
|
||||
} else if (propId === "inkAmount") {
|
||||
this.setInkAmount(value);
|
||||
return true;
|
||||
} else if (propId === "randomness") {
|
||||
this.setRandomness(value);
|
||||
return true;
|
||||
} else if (propId === "texture") {
|
||||
this.setTexture(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSI4MCIgaGVpZ2h0PSI4MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiLz48cmVjdCB4PSIyMCIgeT0iMjAiIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiLz48cmVjdCB4PSIzMCIgeT0iMzAiIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4=";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 钢笔笔刷
|
||||
* 模拟钢笔效果,具有变化的透明度
|
||||
*/
|
||||
export class CustomPenBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "pen",
|
||||
name: "钢笔",
|
||||
description: "模拟钢笔效果,具有变化的透明度",
|
||||
category: "基础笔刷",
|
||||
icon: "pen",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 钢笔笔刷特有属性
|
||||
this._baseWidth = options._baseWidth || 15;
|
||||
this._lineWidth = options._lineWidth || 2;
|
||||
this.inkOpacityMin = options.inkOpacityMin || 0.2;
|
||||
this.inkOpacityMax = options.inkOpacityMax || 0.6;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生钢笔笔刷
|
||||
this.brush = new fabric.PenBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
// 更新笔刷相关属性
|
||||
this._baseWidth = options.width / 2;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 钢笔笔刷特有属性
|
||||
if (options._baseWidth !== undefined) {
|
||||
brush._baseWidth = options._baseWidth;
|
||||
this._baseWidth = options._baseWidth;
|
||||
}
|
||||
|
||||
if (options._lineWidth !== undefined) {
|
||||
brush._lineWidth = options._lineWidth;
|
||||
this._lineWidth = options._lineWidth;
|
||||
}
|
||||
|
||||
// 确保线条连接设置正确
|
||||
brush.canvas.contextTop.lineJoin = "round";
|
||||
brush.canvas.contextTop.lineCap = "round";
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最小墨水透明度
|
||||
* @param {Number} opacity 透明度值(0-1)
|
||||
*/
|
||||
setInkOpacityMin(opacity) {
|
||||
this.inkOpacityMin = Math.max(0.1, Math.min(0.5, opacity));
|
||||
return this.inkOpacityMin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最大墨水透明度
|
||||
* @param {Number} opacity 透明度值(0-1)
|
||||
*/
|
||||
setInkOpacityMax(opacity) {
|
||||
this.inkOpacityMax = Math.max(0.3, Math.min(1, opacity));
|
||||
return this.inkOpacityMax;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义钢笔笔刷特有属性
|
||||
const penProperties = [
|
||||
{
|
||||
id: "lineWidth",
|
||||
name: "线条宽度",
|
||||
type: "slider",
|
||||
defaultValue: this._lineWidth,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
description: "控制钢笔线条的宽度",
|
||||
category: "钢笔设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "inkOpacityMin",
|
||||
name: "最小墨水透明度",
|
||||
type: "slider",
|
||||
defaultValue: this.inkOpacityMin,
|
||||
min: 0.1,
|
||||
max: 0.5,
|
||||
step: 0.05,
|
||||
description: "控制钢笔墨水最小透明度",
|
||||
category: "钢笔设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "inkOpacityMax",
|
||||
name: "最大墨水透明度",
|
||||
type: "slider",
|
||||
defaultValue: this.inkOpacityMax,
|
||||
min: 0.3,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制钢笔墨水最大透明度",
|
||||
category: "钢笔设置",
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...penProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理钢笔笔刷特有属性
|
||||
if (propId === "lineWidth") {
|
||||
this._lineWidth = value;
|
||||
if (this.brush) {
|
||||
this.brush._lineWidth = value;
|
||||
}
|
||||
return true;
|
||||
} else if (propId === "inkOpacityMin") {
|
||||
this.setInkOpacityMin(value);
|
||||
return true;
|
||||
} else if (propId === "inkOpacityMax") {
|
||||
this.setInkOpacityMax(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjAgMjBMODAgODBNMjAgODBMODAgMjAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 毛发笔刷
|
||||
* 创建类似于毛发或草的效果
|
||||
*/
|
||||
export class FurBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "fur",
|
||||
name: "毛发笔刷",
|
||||
description: "创建类似于毛发或草的纹理效果",
|
||||
category: "特效笔刷",
|
||||
icon: "fur",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 毛发笔刷特有属性
|
||||
this.furLength = options.furLength || 10;
|
||||
this.furDensity = options.furDensity || 0.7;
|
||||
this.furRandomness = options.furRandomness || 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生毛发笔刷
|
||||
this.brush = new fabric.FurBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 这里可以添加对毛发笔刷特有属性的配置
|
||||
// 由于fabric.FurBrush的原始实现可能没有直接暴露这些属性,
|
||||
// 我们可能需要在onMouseMove等事件中动态调整行为
|
||||
|
||||
// 存储特有属性,供后续使用
|
||||
if (options.furLength !== undefined) {
|
||||
this.furLength = options.furLength;
|
||||
}
|
||||
|
||||
if (options.furDensity !== undefined) {
|
||||
this.furDensity = options.furDensity;
|
||||
}
|
||||
|
||||
if (options.furRandomness !== undefined) {
|
||||
this.furRandomness = options.furRandomness;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发长度
|
||||
* @param {Number} length 长度值
|
||||
*/
|
||||
setFurLength(length) {
|
||||
this.furLength = Math.max(1, Math.min(50, length));
|
||||
return this.furLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发密度
|
||||
* @param {Number} density 密度值(0-1)
|
||||
*/
|
||||
setFurDensity(density) {
|
||||
this.furDensity = Math.max(0.1, Math.min(1, density));
|
||||
return this.furDensity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发随机性
|
||||
* @param {Number} randomness 随机性值(0-1)
|
||||
*/
|
||||
setFurRandomness(randomness) {
|
||||
this.furRandomness = Math.max(0, Math.min(1, randomness));
|
||||
return this.furRandomness;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义毛发笔刷特有属性
|
||||
const furProperties = [
|
||||
{
|
||||
id: "furLength",
|
||||
name: "毛发长度",
|
||||
type: "slider",
|
||||
defaultValue: this.furLength,
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
description: "控制毛发的长度",
|
||||
category: "毛发设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "furDensity",
|
||||
name: "毛发密度",
|
||||
type: "slider",
|
||||
defaultValue: this.furDensity,
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制毛发的密度",
|
||||
category: "毛发设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "furRandomness",
|
||||
name: "随机性",
|
||||
type: "slider",
|
||||
defaultValue: this.furRandomness,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制毛发的随机分布程度",
|
||||
category: "毛发设置",
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...furProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理毛发笔刷特有属性
|
||||
if (propId === "furLength") {
|
||||
this.setFurLength(value);
|
||||
return true;
|
||||
} else if (propId === "furDensity") {
|
||||
this.setFurDensity(value);
|
||||
return true;
|
||||
} else if (propId === "furRandomness") {
|
||||
this.setFurRandomness(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMTAgODBMNTAgMjBNMjAgODBMNjAgMjBNMzAgODBMNzAgMjBNNDAgODBMODAgMjBNNTAgODBMOTAgMjAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIi8+PC9zdmc+";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 水墨笔刷
|
||||
* 模拟中国传统水墨画效果
|
||||
*/
|
||||
export class InkBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "ink",
|
||||
name: "水墨笔刷",
|
||||
description: "模拟中国传统水墨画效果,墨色深浅不一",
|
||||
category: "特效笔刷",
|
||||
icon: "ink",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 水墨笔刷特有属性
|
||||
this._baseWidth = options._baseWidth || 15;
|
||||
this._inkAmount = options._inkAmount || 7;
|
||||
this._range = options._range || 10;
|
||||
this.splashEnabled =
|
||||
options.splashEnabled !== undefined ? options.splashEnabled : true;
|
||||
this.splashSize = options.splashSize || 5;
|
||||
this.splashDistance = options.splashDistance || 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生水墨笔刷
|
||||
this.brush = new fabric.InkBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
this._baseWidth = options.width / 2;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 水墨笔刷特有属性
|
||||
if (options._inkAmount !== undefined) {
|
||||
brush._inkAmount = options._inkAmount;
|
||||
this._inkAmount = options._inkAmount;
|
||||
}
|
||||
|
||||
if (options._range !== undefined) {
|
||||
brush._range = options._range;
|
||||
this._range = options._range;
|
||||
}
|
||||
|
||||
// 更新溅墨相关配置(这需要修改原始InkBrush的drawSplash方法)
|
||||
if (this.splashEnabled !== undefined) {
|
||||
// 由于原始InkBrush没有直接暴露这个配置,我们可能需要覆盖方法
|
||||
// 这里仅保存配置,实际逻辑需要在brush创建后处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置墨量
|
||||
* @param {Number} amount 墨量值
|
||||
*/
|
||||
setInkAmount(amount) {
|
||||
this._inkAmount = Math.max(1, Math.min(20, amount));
|
||||
|
||||
if (this.brush) {
|
||||
this.brush._inkAmount = this._inkAmount;
|
||||
}
|
||||
|
||||
return this._inkAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔触范围
|
||||
* @param {Number} range 范围值
|
||||
*/
|
||||
setRange(range) {
|
||||
this._range = Math.max(5, Math.min(50, range));
|
||||
|
||||
if (this.brush) {
|
||||
this.brush._range = this._range;
|
||||
}
|
||||
|
||||
return this._range;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用溅墨效果
|
||||
* @param {Boolean} enabled 是否启用
|
||||
*/
|
||||
setSplashEnabled(enabled) {
|
||||
this.splashEnabled = enabled;
|
||||
|
||||
// 实际应用需要更多的逻辑来支持这个功能
|
||||
// 由于需要修改fabric.InkBrush的内部行为
|
||||
|
||||
return this.splashEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置溅墨大小
|
||||
* @param {Number} size 大小值
|
||||
*/
|
||||
setSplashSize(size) {
|
||||
this.splashSize = Math.max(1, Math.min(20, size));
|
||||
return this.splashSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置溅墨距离
|
||||
* @param {Number} distance 距离值
|
||||
*/
|
||||
setSplashDistance(distance) {
|
||||
this.splashDistance = Math.max(10, Math.min(100, distance));
|
||||
return this.splashDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义水墨笔刷特有属性
|
||||
const inkProperties = [
|
||||
{
|
||||
id: "inkAmount",
|
||||
name: "墨量",
|
||||
type: "slider",
|
||||
defaultValue: this._inkAmount,
|
||||
min: 1,
|
||||
max: 20,
|
||||
step: 1,
|
||||
description: "控制水墨的浓度",
|
||||
category: "水墨设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
name: "笔触范围",
|
||||
type: "slider",
|
||||
defaultValue: this._range,
|
||||
min: 5,
|
||||
max: 50,
|
||||
step: 1,
|
||||
description: "控制水墨扩散的范围",
|
||||
category: "水墨设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "splashEnabled",
|
||||
name: "溅墨效果",
|
||||
type: "checkbox",
|
||||
defaultValue: this.splashEnabled,
|
||||
description: "是否启用溅墨效果",
|
||||
category: "水墨设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "splashSize",
|
||||
name: "溅墨大小",
|
||||
type: "slider",
|
||||
defaultValue: this.splashSize,
|
||||
min: 1,
|
||||
max: 20,
|
||||
step: 1,
|
||||
description: "溅墨点的大小",
|
||||
category: "水墨设置",
|
||||
order: 130,
|
||||
visibleWhen: { splashEnabled: true },
|
||||
},
|
||||
{
|
||||
id: "splashDistance",
|
||||
name: "溅墨距离",
|
||||
type: "slider",
|
||||
defaultValue: this.splashDistance,
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 5,
|
||||
description: "溅墨可扩散的最大距离",
|
||||
category: "水墨设置",
|
||||
order: 140,
|
||||
visibleWhen: { splashEnabled: true },
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...inkProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理水墨笔刷特有属性
|
||||
if (propId === "inkAmount") {
|
||||
this.setInkAmount(value);
|
||||
return true;
|
||||
} else if (propId === "range") {
|
||||
this.setRange(value);
|
||||
return true;
|
||||
} else if (propId === "splashEnabled") {
|
||||
this.setSplashEnabled(value);
|
||||
return true;
|
||||
} else if (propId === "splashSize") {
|
||||
this.setSplashSize(value);
|
||||
return true;
|
||||
} else if (propId === "splashDistance") {
|
||||
this.setSplashDistance(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjAgODBDNDAgNjAgNjAgNDAgODAgMjAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSI1IiBzdHJva2UtbGluZWNhcD0icm91bmQiIGZpbGw9Im5vbmUiLz48Y2lyY2xlIGN4PSI3MCIgY3k9IjMwIiByPSI1IiBmaWxsPSIjMDAwIi8+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iMyIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjMwIiBjeT0iNzAiIHI9IjYiIGZpbGw9IiMwMDAiLz48L3N2Zz4=";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 长毛发笔刷
|
||||
* 创建类似于长毛、毛皮、草或头发的效果
|
||||
*/
|
||||
export class LongfurBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "longfur",
|
||||
name: "长毛发",
|
||||
description: "创建流动的长毛发效果,适合绘制动物毛皮、草或头发",
|
||||
category: "特效笔刷",
|
||||
icon: "longfur",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 长毛发笔刷特有属性
|
||||
this.furLength = options.furLength || 20;
|
||||
this.furDensity = options.furDensity || 0.7;
|
||||
this.furFlowFactor = options.furFlowFactor || 0.5;
|
||||
this.furCurvature = options.furCurvature || 0.3;
|
||||
this.randomizeDirection =
|
||||
options.randomizeDirection !== undefined
|
||||
? options.randomizeDirection
|
||||
: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生长毛发笔刷
|
||||
this.brush = new fabric.LongfurBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 长毛发笔刷特有属性
|
||||
if (options.furLength !== undefined) {
|
||||
this.furLength = options.furLength;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.furLength !== undefined) {
|
||||
brush.furLength = this.furLength;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.furDensity !== undefined) {
|
||||
this.furDensity = options.furDensity;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.furDensity !== undefined) {
|
||||
brush.furDensity = this.furDensity;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.furFlowFactor !== undefined) {
|
||||
this.furFlowFactor = options.furFlowFactor;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.furFlowFactor !== undefined) {
|
||||
brush.furFlowFactor = this.furFlowFactor;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.furCurvature !== undefined) {
|
||||
this.furCurvature = options.furCurvature;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.furCurvature !== undefined) {
|
||||
brush.furCurvature = this.furCurvature;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.randomizeDirection !== undefined) {
|
||||
this.randomizeDirection = options.randomizeDirection;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.randomizeDirection !== undefined) {
|
||||
brush.randomizeDirection = this.randomizeDirection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发长度
|
||||
* @param {Number} length 长度值
|
||||
*/
|
||||
setFurLength(length) {
|
||||
this.furLength = Math.max(5, Math.min(100, length));
|
||||
|
||||
if (this.brush && this.brush.furLength !== undefined) {
|
||||
this.brush.furLength = this.furLength;
|
||||
}
|
||||
|
||||
return this.furLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发密度
|
||||
* @param {Number} density 密度值(0-1)
|
||||
*/
|
||||
setFurDensity(density) {
|
||||
this.furDensity = Math.max(0.1, Math.min(1, density));
|
||||
|
||||
if (this.brush && this.brush.furDensity !== undefined) {
|
||||
this.brush.furDensity = this.furDensity;
|
||||
}
|
||||
|
||||
return this.furDensity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发流动系数
|
||||
* @param {Number} factor 流动系数(0-1)
|
||||
*/
|
||||
setFurFlowFactor(factor) {
|
||||
this.furFlowFactor = Math.max(0, Math.min(1, factor));
|
||||
|
||||
if (this.brush && this.brush.furFlowFactor !== undefined) {
|
||||
this.brush.furFlowFactor = this.furFlowFactor;
|
||||
}
|
||||
|
||||
return this.furFlowFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置毛发弯曲度
|
||||
* @param {Number} curvature 弯曲度(0-1)
|
||||
*/
|
||||
setFurCurvature(curvature) {
|
||||
this.furCurvature = Math.max(0, Math.min(1, curvature));
|
||||
|
||||
if (this.brush && this.brush.furCurvature !== undefined) {
|
||||
this.brush.furCurvature = this.furCurvature;
|
||||
}
|
||||
|
||||
return this.furCurvature;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否随机化方向
|
||||
* @param {Boolean} randomize 是否随机化
|
||||
*/
|
||||
setRandomizeDirection(randomize) {
|
||||
this.randomizeDirection = randomize;
|
||||
|
||||
if (this.brush && this.brush.randomizeDirection !== undefined) {
|
||||
this.brush.randomizeDirection = this.randomizeDirection;
|
||||
}
|
||||
|
||||
return this.randomizeDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义长毛发笔刷特有属性
|
||||
const longfurProperties = [
|
||||
{
|
||||
id: "furLength",
|
||||
name: "毛发长度",
|
||||
type: "slider",
|
||||
defaultValue: this.furLength,
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 1,
|
||||
description: "控制毛发的长度",
|
||||
category: "长毛发设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "furDensity",
|
||||
name: "毛发密度",
|
||||
type: "slider",
|
||||
defaultValue: this.furDensity,
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制毛发的密度",
|
||||
category: "长毛发设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "furFlowFactor",
|
||||
name: "流动系数",
|
||||
type: "slider",
|
||||
defaultValue: this.furFlowFactor,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制毛发的流动感",
|
||||
category: "长毛发设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "furCurvature",
|
||||
name: "弯曲度",
|
||||
type: "slider",
|
||||
defaultValue: this.furCurvature,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制毛发的弯曲程度",
|
||||
category: "长毛发设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "randomizeDirection",
|
||||
name: "随机方向",
|
||||
type: "checkbox",
|
||||
defaultValue: this.randomizeDirection,
|
||||
description: "是否随机化毛发方向",
|
||||
category: "长毛发设置",
|
||||
order: 140,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...longfurProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理长毛发笔刷特有属性
|
||||
if (propId === "furLength") {
|
||||
this.setFurLength(value);
|
||||
return true;
|
||||
} else if (propId === "furDensity") {
|
||||
this.setFurDensity(value);
|
||||
return true;
|
||||
} else if (propId === "furFlowFactor") {
|
||||
this.setFurFlowFactor(value);
|
||||
return true;
|
||||
} else if (propId === "furCurvature") {
|
||||
this.setFurCurvature(value);
|
||||
return true;
|
||||
} else if (propId === "randomizeDirection") {
|
||||
this.setRandomizeDirection(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjUgNTBDMjUgNTAgNTAgMTAgNTAgNTBDNTAgNTAgNTAgOTAgNzUgNTAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIi8+PGxpbmUgeDE9IjMwIiB5MT0iNDUiIHgyPSIzMCIgeTI9IjEwIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMSIvPjxsaW5lIHgxPSI0MCIgeTE9IjQwIiB4Mj0iNDAiIHkyPSI1IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMSIvPjxsaW5lIHgxPSI1MCIgeTE9IjQwIiB4Mj0iNTAiIHkyPSIxMCIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEiLz48bGluZSB4MT0iNjAiIHkxPSI0MCIgeDI9IjYwIiB5Mj0iNSIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEiLz48bGluZSB4MT0iNzAiIHkxPSI0NSIgeDI9IjcwIiB5Mj0iMTAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxIi8+PC9zdmc+";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 马克笔笔刷
|
||||
* 模拟马克笔效果,具有半透明平头笔触
|
||||
*/
|
||||
export class MarkerBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "marker",
|
||||
name: "马克笔",
|
||||
description: "模拟马克笔效果,具有半透明平头笔触",
|
||||
category: "基础笔刷",
|
||||
icon: "marker",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 马克笔特有属性
|
||||
this._baseWidth = options._baseWidth || 15;
|
||||
this._lineWidth = options._lineWidth || 2;
|
||||
this.capStyle = options.capStyle || "round"; // "round" 或 "square"
|
||||
this.blendMode = options.blendMode || "multiply"; // 混合模式
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生马克笔笔刷
|
||||
this.brush = new fabric.MarkerBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
// 更新笔刷相关属性
|
||||
this._baseWidth = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
// 马克笔的透明度默认不要太高
|
||||
brush.opacity = Math.min(0.8, options.opacity || 0.6);
|
||||
}
|
||||
|
||||
// 马克笔笔刷特有属性
|
||||
if (options._baseWidth !== undefined) {
|
||||
brush._baseWidth = options._baseWidth;
|
||||
this._baseWidth = options._baseWidth;
|
||||
}
|
||||
|
||||
if (options._lineWidth !== undefined) {
|
||||
brush._lineWidth = options._lineWidth;
|
||||
this._lineWidth = options._lineWidth;
|
||||
}
|
||||
|
||||
// 笔触样式设置
|
||||
brush.canvas.contextTop.lineJoin = "round";
|
||||
brush.canvas.contextTop.lineCap = this.capStyle || "round";
|
||||
|
||||
// 马克笔的混合模式设置
|
||||
if (this.blendMode === "multiply") {
|
||||
brush.canvas.contextTop.globalCompositeOperation = "multiply";
|
||||
} else {
|
||||
brush.canvas.contextTop.globalCompositeOperation = "source-over";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔触线宽
|
||||
* @param {Number} width 线宽值
|
||||
*/
|
||||
setLineWidth(width) {
|
||||
this._lineWidth = Math.max(1, Math.min(10, width));
|
||||
|
||||
if (this.brush) {
|
||||
this.brush._lineWidth = this._lineWidth;
|
||||
}
|
||||
|
||||
return this._lineWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔头样式
|
||||
* @param {String} style 笔头样式 ('round' 或 'square')
|
||||
*/
|
||||
setCapStyle(style) {
|
||||
if (style === "round" || style === "square") {
|
||||
this.capStyle = style;
|
||||
|
||||
if (this.brush && this.brush.canvas) {
|
||||
this.brush.canvas.contextTop.lineCap = style;
|
||||
}
|
||||
}
|
||||
|
||||
return this.capStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置混合模式
|
||||
* @param {String} mode 混合模式 ('multiply' 或 'normal')
|
||||
*/
|
||||
setBlendMode(mode) {
|
||||
this.blendMode = mode;
|
||||
|
||||
if (this.brush && this.brush.canvas) {
|
||||
this.brush.canvas.contextTop.globalCompositeOperation =
|
||||
mode === "multiply" ? "multiply" : "source-over";
|
||||
}
|
||||
|
||||
return this.blendMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义马克笔笔刷特有属性
|
||||
const markerProperties = [
|
||||
{
|
||||
id: "lineWidth",
|
||||
name: "笔触宽度",
|
||||
type: "slider",
|
||||
defaultValue: this._lineWidth,
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
description: "控制马克笔笔触的宽度",
|
||||
category: "马克笔设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "capStyle",
|
||||
name: "笔头样式",
|
||||
type: "select",
|
||||
defaultValue: this.capStyle,
|
||||
options: [
|
||||
{ value: "round", label: "圆形" },
|
||||
{ value: "square", label: "方形" },
|
||||
],
|
||||
description: "设置马克笔笔头的形状",
|
||||
category: "马克笔设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "blendMode",
|
||||
name: "混合模式",
|
||||
type: "select",
|
||||
defaultValue: this.blendMode,
|
||||
options: [
|
||||
{ value: "multiply", label: "正片叠底" },
|
||||
{ value: "normal", label: "正常" },
|
||||
],
|
||||
description: "设置马克笔的颜色混合方式",
|
||||
category: "马克笔设置",
|
||||
order: 120,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...markerProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理马克笔笔刷特有属性
|
||||
if (propId === "lineWidth") {
|
||||
this.setLineWidth(value);
|
||||
return true;
|
||||
} else if (propId === "capStyle") {
|
||||
this.setCapStyle(value);
|
||||
return true;
|
||||
} else if (propId === "blendMode") {
|
||||
this.setBlendMode(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMTAgNTBIOTAiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyMCIgc3Ryb2tlLW9wYWNpdHk9IjAuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 铅笔笔刷
|
||||
* fabric原生铅笔笔刷的包装类
|
||||
*/
|
||||
export class PencilBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "pencil",
|
||||
name: "铅笔",
|
||||
description: "基础铅笔工具,适合精细线条绘制",
|
||||
category: "基础笔刷",
|
||||
icon: "pencil",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 铅笔笔刷特有属性
|
||||
this.decimate = options.decimate || 0.4;
|
||||
this.strokeLineCap = options.strokeLineCap || "round";
|
||||
this.strokeLineJoin = options.strokeLineJoin || "round";
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric.PencilBrush实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生铅笔笔刷
|
||||
this.brush = new fabric.PencilBrush(this.canvas);
|
||||
|
||||
// 重写 _finalizeAndAddPath 方法,使其调用 convertToImg 而不是创建 Path 对象
|
||||
const originalFinalizeAndAddPath = this.brush._finalizeAndAddPath.bind(
|
||||
this.brush
|
||||
);
|
||||
const self = this; // 保存外部this引用
|
||||
|
||||
this.brush._finalizeAndAddPath = function () {
|
||||
console.log("PencilBrush: _finalizeAndAddPath called");
|
||||
const ctx = this.canvas.contextTop;
|
||||
ctx.closePath();
|
||||
|
||||
// 应用点简化
|
||||
if (this.decimate) {
|
||||
this._points = this.decimatePoints(this._points, this.decimate);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"PencilBrush: points count =",
|
||||
this._points ? this._points.length : 0
|
||||
);
|
||||
|
||||
// 检查是否有有效的路径数据
|
||||
if (!this._points || this._points.length < 2) {
|
||||
// 如果点数不足,直接请求重新渲染
|
||||
console.log("PencilBrush: Not enough points, skipping");
|
||||
this.canvas.requestRenderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const pathData = this.convertPointsToSVGPath(this._points);
|
||||
|
||||
const isEmpty = self._isEmptySVGPath(pathData);
|
||||
console.log("PencilBrush: isEmpty =", isEmpty);
|
||||
|
||||
if (isEmpty) {
|
||||
// 如果路径为空,直接请求重新渲染
|
||||
console.log("PencilBrush: Path is empty, skipping");
|
||||
this.canvas.requestRenderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// 先触发事件,模拟原生行为
|
||||
const path = this.createPath(pathData);
|
||||
this.canvas.fire("before:path:created", { path: path });
|
||||
|
||||
console.log("PencilBrush: Calling convertToImg");
|
||||
|
||||
// 调用 convertToImg 方法将绘制内容转换为图片
|
||||
if (typeof this.convertToImg === "function") {
|
||||
this.convertToImg();
|
||||
console.log("PencilBrush: convertToImg called successfully");
|
||||
} else {
|
||||
console.warn(
|
||||
"convertToImg method not found, falling back to original behavior"
|
||||
);
|
||||
// 如果没有convertToImg方法,回退到原始行为
|
||||
this.canvas.add(path);
|
||||
this.canvas.fire("path:created", { path: path });
|
||||
this.canvas.clearContext(this.canvas.contextTop);
|
||||
}
|
||||
|
||||
// 重置阴影
|
||||
this._resetShadow();
|
||||
};
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 SVG 路径是否为空
|
||||
* @private
|
||||
* @param {Array} pathData SVG 路径数据
|
||||
* @returns {Boolean} 是否为空路径
|
||||
*/
|
||||
_isEmptySVGPath(pathData) {
|
||||
if (!pathData || pathData.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查路径是否只包含移动命令或者是一个点
|
||||
let hasDrawing = false;
|
||||
let moveCount = 0;
|
||||
|
||||
for (let i = 0; i < pathData.length; i++) {
|
||||
const command = pathData[i];
|
||||
if (command[0] === "M") {
|
||||
moveCount++;
|
||||
} else if (
|
||||
command[0] === "L" ||
|
||||
command[0] === "Q" ||
|
||||
command[0] === "C"
|
||||
) {
|
||||
hasDrawing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果只有移动命令且超过1个,或者没有绘制命令,则认为是空路径
|
||||
return !hasDrawing || (moveCount > 0 && pathData.length <= moveCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric.PencilBrush实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 特殊属性配置
|
||||
if (options.decimate !== undefined) {
|
||||
brush.decimate = options.decimate;
|
||||
this.decimate = options.decimate;
|
||||
}
|
||||
|
||||
if (options.strokeLineCap !== undefined) {
|
||||
brush.strokeLineCap = options.strokeLineCap;
|
||||
this.strokeLineCap = options.strokeLineCap;
|
||||
}
|
||||
|
||||
if (options.strokeLineJoin !== undefined) {
|
||||
brush.strokeLineJoin = options.strokeLineJoin;
|
||||
this.strokeLineJoin = options.strokeLineJoin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义铅笔笔刷特有属性
|
||||
const pencilProperties = [
|
||||
{
|
||||
id: "decimate",
|
||||
name: "精细度",
|
||||
type: "slider",
|
||||
defaultValue: this.decimate,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
description: "控制笔触路径的简化程度,值越小路径越精细",
|
||||
category: "铅笔设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "strokeLineCap",
|
||||
name: "线条端点",
|
||||
type: "select",
|
||||
defaultValue: this.strokeLineCap,
|
||||
options: [
|
||||
{ value: "round", label: "圆形" },
|
||||
{ value: "butt", label: "平直" },
|
||||
{ value: "square", label: "方形" },
|
||||
],
|
||||
description: "线条端点的形状",
|
||||
category: "铅笔设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "strokeLineJoin",
|
||||
name: "线条连接",
|
||||
type: "select",
|
||||
defaultValue: this.strokeLineJoin,
|
||||
options: [
|
||||
{ value: "round", label: "圆角" },
|
||||
{ value: "bevel", label: "斜角" },
|
||||
{ value: "miter", label: "尖角" },
|
||||
],
|
||||
description: "线条拐角的连接方式",
|
||||
category: "铅笔设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "smoothingEnabled",
|
||||
name: "启用平滑",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
description: "是否对线条进行平滑处理",
|
||||
category: "铅笔设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "smoothingFactor",
|
||||
name: "平滑程度",
|
||||
type: "slider",
|
||||
defaultValue: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "线条平滑的强度",
|
||||
category: "铅笔设置",
|
||||
order: 140,
|
||||
// 只有当smoothingEnabled为true时才显示
|
||||
visibleWhen: { smoothingEnabled: true },
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...pencilProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理铅笔特有属性
|
||||
if (propId === "decimate") {
|
||||
this.decimate = value;
|
||||
if (this.brush) {
|
||||
this.brush.decimate = value;
|
||||
return true;
|
||||
}
|
||||
} else if (propId === "strokeLineCap") {
|
||||
this.strokeLineCap = value;
|
||||
if (this.brush) {
|
||||
this.brush.strokeLineCap = value;
|
||||
return true;
|
||||
}
|
||||
} else if (propId === "strokeLineJoin") {
|
||||
this.strokeLineJoin = value;
|
||||
if (this.brush) {
|
||||
this.brush.strokeLineJoin = value;
|
||||
return true;
|
||||
}
|
||||
} else if (propId === "smoothingEnabled") {
|
||||
this.smoothingEnabled = value;
|
||||
// 实现平滑逻辑...
|
||||
return true;
|
||||
} else if (propId === "smoothingFactor") {
|
||||
this.smoothingFactor = value;
|
||||
// 实现平滑度调整...
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
// 实际项目中可以返回一个实际的预览图URL
|
||||
return "data:image/svg+xml;base64,..."; // 示例SVG
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 丝带笔刷
|
||||
* 创建流畅的飘带状线条
|
||||
*/
|
||||
export class RibbonBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "ribbon",
|
||||
name: "飘带",
|
||||
description: "创建流畅的飘带状线条,具有动态宽度变化和曲线美感",
|
||||
category: "特效笔刷",
|
||||
icon: "ribbon",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 丝带笔刷特有属性
|
||||
this.ribbonWidth = options.ribbonWidth || 20;
|
||||
this.widthVariation = options.widthVariation || 0.5;
|
||||
this.ribbonSmoothness = options.ribbonSmoothness || 0.7;
|
||||
this.gradient = options.gradient !== undefined ? options.gradient : true;
|
||||
this.gradientColors = options.gradientColors || ["#000000", "#555555"];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生丝带笔刷
|
||||
this.brush = new fabric.RibbonBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
// 基于主宽度更新丝带宽度
|
||||
this.ribbonWidth = options.width * 2;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
|
||||
// 如果启用渐变,更新渐变的第一个颜色
|
||||
if (this.gradient && this.gradientColors.length > 0) {
|
||||
this.gradientColors[0] = options.color;
|
||||
this.updateGradient();
|
||||
}
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 丝带笔刷特有属性
|
||||
if (options.ribbonWidth !== undefined) {
|
||||
this.ribbonWidth = options.ribbonWidth;
|
||||
|
||||
// 如果原生笔刷支持此属性
|
||||
if (brush.ribbonWidth !== undefined) {
|
||||
brush.ribbonWidth = this.ribbonWidth;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.widthVariation !== undefined) {
|
||||
this.widthVariation = options.widthVariation;
|
||||
|
||||
// 如果原生笔刷支持此属性
|
||||
if (brush.widthVariation !== undefined) {
|
||||
brush.widthVariation = this.widthVariation;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ribbonSmoothness !== undefined) {
|
||||
this.ribbonSmoothness = options.ribbonSmoothness;
|
||||
|
||||
// 如果原生笔刷支持此属性
|
||||
if (brush.ribbonSmoothness !== undefined) {
|
||||
brush.ribbonSmoothness = this.ribbonSmoothness;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.gradient !== undefined) {
|
||||
this.gradient = options.gradient;
|
||||
this.updateGradient();
|
||||
}
|
||||
|
||||
if (options.gradientColors !== undefined) {
|
||||
this.gradientColors = options.gradientColors;
|
||||
this.updateGradient();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新渐变设置
|
||||
* @private
|
||||
*/
|
||||
updateGradient() {
|
||||
if (!this.brush || !this.canvas) return;
|
||||
|
||||
if (this.gradient && this.gradientColors.length >= 2) {
|
||||
// 创建渐变对象
|
||||
const ctx = this.canvas.contextTop;
|
||||
const gradient = ctx.createLinearGradient(0, 0, this.ribbonWidth, 0);
|
||||
|
||||
// 添加渐变色
|
||||
const colorCount = this.gradientColors.length;
|
||||
this.gradientColors.forEach((color, index) => {
|
||||
gradient.addColorStop(index / (colorCount - 1), color);
|
||||
});
|
||||
|
||||
// 如果原生笔刷支持渐变
|
||||
if (typeof this.brush.setGradient === "function") {
|
||||
this.brush.setGradient(gradient);
|
||||
} else if (this.brush.gradient !== undefined) {
|
||||
this.brush.gradient = gradient;
|
||||
}
|
||||
|
||||
// 如果原生笔刷支持渐变标志
|
||||
if (this.brush.useGradient !== undefined) {
|
||||
this.brush.useGradient = true;
|
||||
}
|
||||
} else if (this.brush.useGradient !== undefined) {
|
||||
// 禁用渐变
|
||||
this.brush.useGradient = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置丝带宽度
|
||||
* @param {Number} width 宽度值
|
||||
*/
|
||||
setRibbonWidth(width) {
|
||||
this.ribbonWidth = Math.max(5, Math.min(100, width));
|
||||
|
||||
if (this.brush && this.brush.ribbonWidth !== undefined) {
|
||||
this.brush.ribbonWidth = this.ribbonWidth;
|
||||
}
|
||||
|
||||
// 更新渐变(因为宽度变了)
|
||||
if (this.gradient) {
|
||||
this.updateGradient();
|
||||
}
|
||||
|
||||
return this.ribbonWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置宽度变化率
|
||||
* @param {Number} variation 变化率(0-1)
|
||||
*/
|
||||
setWidthVariation(variation) {
|
||||
this.widthVariation = Math.max(0, Math.min(1, variation));
|
||||
|
||||
if (this.brush && this.brush.widthVariation !== undefined) {
|
||||
this.brush.widthVariation = this.widthVariation;
|
||||
}
|
||||
|
||||
return this.widthVariation;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置丝带平滑度
|
||||
* @param {Number} smoothness 平滑度值(0-1)
|
||||
*/
|
||||
setRibbonSmoothness(smoothness) {
|
||||
this.ribbonSmoothness = Math.max(0, Math.min(1, smoothness));
|
||||
|
||||
if (this.brush && this.brush.ribbonSmoothness !== undefined) {
|
||||
this.brush.ribbonSmoothness = this.ribbonSmoothness;
|
||||
}
|
||||
|
||||
return this.ribbonSmoothness;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用渐变效果
|
||||
* @param {Boolean} enabled 是否启用
|
||||
*/
|
||||
setGradient(enabled) {
|
||||
this.gradient = enabled;
|
||||
this.updateGradient();
|
||||
return this.gradient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置渐变颜色
|
||||
* @param {Array} colors 颜色数组
|
||||
*/
|
||||
setGradientColors(colors) {
|
||||
if (Array.isArray(colors) && colors.length >= 2) {
|
||||
this.gradientColors = colors;
|
||||
this.updateGradient();
|
||||
}
|
||||
return this.gradientColors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义丝带笔刷特有属性
|
||||
const ribbonProperties = [
|
||||
{
|
||||
id: "ribbonWidth",
|
||||
name: "飘带宽度",
|
||||
type: "slider",
|
||||
defaultValue: this.ribbonWidth,
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 5,
|
||||
description: "控制飘带的最大宽度",
|
||||
category: "飘带设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "widthVariation",
|
||||
name: "宽度变化",
|
||||
type: "slider",
|
||||
defaultValue: this.widthVariation,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制飘带宽度的变化程度",
|
||||
category: "飘带设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "ribbonSmoothness",
|
||||
name: "平滑度",
|
||||
type: "slider",
|
||||
defaultValue: this.ribbonSmoothness,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制飘带曲线的平滑程度",
|
||||
category: "飘带设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "gradient",
|
||||
name: "启用渐变",
|
||||
type: "checkbox",
|
||||
defaultValue: this.gradient,
|
||||
description: "是否启用渐变效果",
|
||||
category: "飘带设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "gradientColor1",
|
||||
name: "渐变起始颜色",
|
||||
type: "color",
|
||||
defaultValue: this.gradientColors[0] || "#000000",
|
||||
description: "设置渐变的起始颜色",
|
||||
category: "飘带设置",
|
||||
order: 140,
|
||||
visibleWhen: { gradient: true },
|
||||
},
|
||||
{
|
||||
id: "gradientColor2",
|
||||
name: "渐变结束颜色",
|
||||
type: "color",
|
||||
defaultValue: this.gradientColors[1] || "#555555",
|
||||
description: "设置渐变的结束颜色",
|
||||
category: "飘带设置",
|
||||
order: 150,
|
||||
visibleWhen: { gradient: true },
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...ribbonProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理丝带笔刷特有属性
|
||||
if (propId === "ribbonWidth") {
|
||||
this.setRibbonWidth(value);
|
||||
return true;
|
||||
} else if (propId === "widthVariation") {
|
||||
this.setWidthVariation(value);
|
||||
return true;
|
||||
} else if (propId === "ribbonSmoothness") {
|
||||
this.setRibbonSmoothness(value);
|
||||
return true;
|
||||
} else if (propId === "gradient") {
|
||||
this.setGradient(value);
|
||||
return true;
|
||||
} else if (propId === "gradientColor1") {
|
||||
const colors = [...this.gradientColors];
|
||||
colors[0] = value;
|
||||
this.setGradientColors(colors);
|
||||
return true;
|
||||
} else if (propId === "gradientColor2") {
|
||||
const colors = [...this.gradientColors];
|
||||
colors[1] = value;
|
||||
this.setGradientColors(colors);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImdyYWQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjAlIj48c3RvcCBvZmZzZXQ9IjAlIiBzdG9wLWNvbG9yPSIjMDAwIi8+PHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjNTU1Ii8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIwIDUwQzMwIDMwIDUwIDMwIDYwIDUwQzcwIDcwIDgwIDcwIDkwIDUwIiBzdHJva2U9InVybCgjZ3JhZCkiIHN0cm9rZS13aWR0aD0iMTAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgZmlsbD0ibm9uZSIvPjwvc3ZnPg==";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 阴影笔刷
|
||||
* 创建带有阴影效果的绘制,有深浅变化
|
||||
*/
|
||||
export class ShadedBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "shaded",
|
||||
name: "阴影笔",
|
||||
description: "创建带有阴影效果的绘制,适合素描和明暗表现",
|
||||
category: "绘画笔刷",
|
||||
icon: "shaded",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 阴影笔刷特有属性
|
||||
this.shadowColor = options.shadowColor || "#000000";
|
||||
this.shadowBlur = options.shadowBlur || 5;
|
||||
this.shadowOffsetX = options.shadowOffsetX || 2;
|
||||
this.shadowOffsetY = options.shadowOffsetY || 2;
|
||||
this.blendMode = options.blendMode || "multiply";
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生阴影笔刷
|
||||
this.brush = new fabric.ShadedBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 阴影笔刷特有属性
|
||||
if (options.shadowColor !== undefined) {
|
||||
this.shadowColor = options.shadowColor;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.shadow) {
|
||||
brush.shadow.color = this.shadowColor;
|
||||
} else {
|
||||
brush.shadow = new fabric.Shadow({
|
||||
color: this.shadowColor,
|
||||
blur: this.shadowBlur,
|
||||
offsetX: this.shadowOffsetX,
|
||||
offsetY: this.shadowOffsetY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.shadowBlur !== undefined) {
|
||||
this.shadowBlur = options.shadowBlur;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.shadow) {
|
||||
brush.shadow.blur = this.shadowBlur;
|
||||
} else {
|
||||
brush.shadow = new fabric.Shadow({
|
||||
color: this.shadowColor,
|
||||
blur: this.shadowBlur,
|
||||
offsetX: this.shadowOffsetX,
|
||||
offsetY: this.shadowOffsetY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.shadowOffsetX !== undefined) {
|
||||
this.shadowOffsetX = options.shadowOffsetX;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.shadow) {
|
||||
brush.shadow.offsetX = this.shadowOffsetX;
|
||||
} else {
|
||||
brush.shadow = new fabric.Shadow({
|
||||
color: this.shadowColor,
|
||||
blur: this.shadowBlur,
|
||||
offsetX: this.shadowOffsetX,
|
||||
offsetY: this.shadowOffsetY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.shadowOffsetY !== undefined) {
|
||||
this.shadowOffsetY = options.shadowOffsetY;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.shadow) {
|
||||
brush.shadow.offsetY = this.shadowOffsetY;
|
||||
} else {
|
||||
brush.shadow = new fabric.Shadow({
|
||||
color: this.shadowColor,
|
||||
blur: this.shadowBlur,
|
||||
offsetX: this.shadowOffsetX,
|
||||
offsetY: this.shadowOffsetY,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.blendMode !== undefined) {
|
||||
this.blendMode = options.blendMode;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.globalCompositeOperation !== undefined) {
|
||||
brush.globalCompositeOperation = this.blendMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置阴影颜色
|
||||
* @param {String} color 颜色值
|
||||
*/
|
||||
setShadowColor(color) {
|
||||
this.shadowColor = color;
|
||||
|
||||
if (this.brush && this.brush.shadow) {
|
||||
this.brush.shadow.color = this.shadowColor;
|
||||
}
|
||||
|
||||
return this.shadowColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置阴影模糊值
|
||||
* @param {Number} blur 模糊值
|
||||
*/
|
||||
setShadowBlur(blur) {
|
||||
this.shadowBlur = Math.max(0, Math.min(50, blur));
|
||||
|
||||
if (this.brush && this.brush.shadow) {
|
||||
this.brush.shadow.blur = this.shadowBlur;
|
||||
}
|
||||
|
||||
return this.shadowBlur;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置阴影X偏移
|
||||
* @param {Number} offset X偏移值
|
||||
*/
|
||||
setShadowOffsetX(offset) {
|
||||
this.shadowOffsetX = Math.max(-20, Math.min(20, offset));
|
||||
|
||||
if (this.brush && this.brush.shadow) {
|
||||
this.brush.shadow.offsetX = this.shadowOffsetX;
|
||||
}
|
||||
|
||||
return this.shadowOffsetX;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置阴影Y偏移
|
||||
* @param {Number} offset Y偏移值
|
||||
*/
|
||||
setShadowOffsetY(offset) {
|
||||
this.shadowOffsetY = Math.max(-20, Math.min(20, offset));
|
||||
|
||||
if (this.brush && this.brush.shadow) {
|
||||
this.brush.shadow.offsetY = this.shadowOffsetY;
|
||||
}
|
||||
|
||||
return this.shadowOffsetY;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置混合模式
|
||||
* @param {String} mode 混合模式
|
||||
*/
|
||||
setBlendMode(mode) {
|
||||
const validModes = [
|
||||
"normal",
|
||||
"multiply",
|
||||
"screen",
|
||||
"overlay",
|
||||
"darken",
|
||||
"lighten",
|
||||
"color-dodge",
|
||||
"color-burn",
|
||||
"hard-light",
|
||||
"soft-light",
|
||||
"difference",
|
||||
"exclusion",
|
||||
"hue",
|
||||
"saturation",
|
||||
"color",
|
||||
"luminosity",
|
||||
];
|
||||
|
||||
if (validModes.includes(mode)) {
|
||||
this.blendMode = mode;
|
||||
|
||||
if (this.brush && this.brush.globalCompositeOperation !== undefined) {
|
||||
this.brush.globalCompositeOperation = this.blendMode;
|
||||
}
|
||||
}
|
||||
|
||||
return this.blendMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义阴影笔刷特有属性
|
||||
const shadedProperties = [
|
||||
{
|
||||
id: "shadowColor",
|
||||
name: "阴影颜色",
|
||||
type: "color",
|
||||
defaultValue: this.shadowColor,
|
||||
description: "设置阴影的颜色",
|
||||
category: "阴影设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "shadowBlur",
|
||||
name: "阴影模糊",
|
||||
type: "slider",
|
||||
defaultValue: this.shadowBlur,
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
description: "控制阴影的模糊程度",
|
||||
category: "阴影设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "shadowOffsetX",
|
||||
name: "阴影X偏移",
|
||||
type: "slider",
|
||||
defaultValue: this.shadowOffsetX,
|
||||
min: -20,
|
||||
max: 20,
|
||||
step: 1,
|
||||
description: "控制阴影的水平偏移",
|
||||
category: "阴影设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "shadowOffsetY",
|
||||
name: "阴影Y偏移",
|
||||
type: "slider",
|
||||
defaultValue: this.shadowOffsetY,
|
||||
min: -20,
|
||||
max: 20,
|
||||
step: 1,
|
||||
description: "控制阴影的垂直偏移",
|
||||
category: "阴影设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "blendMode",
|
||||
name: "混合模式",
|
||||
type: "select",
|
||||
defaultValue: this.blendMode,
|
||||
options: [
|
||||
{ value: "normal", label: "正常" },
|
||||
{ value: "multiply", label: "正片叠底" },
|
||||
{ value: "screen", label: "滤色" },
|
||||
{ value: "overlay", label: "叠加" },
|
||||
{ value: "darken", label: "变暗" },
|
||||
{ value: "lighten", label: "变亮" },
|
||||
{ value: "color-dodge", label: "颜色减淡" },
|
||||
{ value: "color-burn", label: "颜色加深" },
|
||||
{ value: "hard-light", label: "强光" },
|
||||
{ value: "soft-light", label: "柔光" },
|
||||
{ value: "difference", label: "差值" },
|
||||
{ value: "exclusion", label: "排除" },
|
||||
],
|
||||
description: "设置阴影的混合模式",
|
||||
category: "阴影设置",
|
||||
order: 140,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...shadedProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理阴影笔刷特有属性
|
||||
if (propId === "shadowColor") {
|
||||
this.setShadowColor(value);
|
||||
return true;
|
||||
} else if (propId === "shadowBlur") {
|
||||
this.setShadowBlur(value);
|
||||
return true;
|
||||
} else if (propId === "shadowOffsetX") {
|
||||
this.setShadowOffsetX(value);
|
||||
return true;
|
||||
} else if (propId === "shadowOffsetY") {
|
||||
this.setShadowOffsetY(value);
|
||||
return true;
|
||||
} else if (propId === "blendMode") {
|
||||
this.setBlendMode(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI0MCIgY3k9IjQwIiByPSIyMCIgZmlsbD0iIzY2NiIvPjxjaXJjbGUgY3g9IjQ1IiBjeT0iNDUiIHI9IjIwIiBmaWxsPSIjMDAwIi8+PHBhdGggZD0iTTIwIDgwQzMwIDYwIDUwIDcwIDcwIDUwIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iOCIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PHBhdGggZD0iTTIzIDgzQzMzIDYzIDUzIDczIDczIDUzIiBzdHJva2U9IiM2NjYiIHN0cm9rZS13aWR0aD0iOCIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 素描笔刷
|
||||
* 创建手绘素描效果,有不规则的线条和纹理
|
||||
*/
|
||||
export class SketchyBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "sketchy",
|
||||
name: "素描",
|
||||
description: "创建手绘素描效果,有不规则的线条和纹理",
|
||||
category: "绘画笔刷",
|
||||
icon: "sketchy",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 素描笔刷特有属性
|
||||
this.roughness = options.roughness || 0.7;
|
||||
this.bowing = options.bowing || 0.5;
|
||||
this.stroke = options.stroke !== undefined ? options.stroke : true;
|
||||
this.hachureAngle = options.hachureAngle || 60;
|
||||
this.dashOffset = options.dashOffset || 0;
|
||||
this.dashArray = options.dashArray || [6, 2];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生素描笔刷
|
||||
this.brush = new fabric.SketchyBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 素描笔刷特有属性
|
||||
if (options.roughness !== undefined) {
|
||||
this.roughness = options.roughness;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.roughness !== undefined) {
|
||||
brush.roughness = this.roughness;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.bowing !== undefined) {
|
||||
this.bowing = options.bowing;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.bowing !== undefined) {
|
||||
brush.bowing = this.bowing;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.stroke !== undefined) {
|
||||
this.stroke = options.stroke;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.stroke !== undefined) {
|
||||
brush.stroke = this.stroke;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.hachureAngle !== undefined) {
|
||||
this.hachureAngle = options.hachureAngle;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.hachureAngle !== undefined) {
|
||||
brush.hachureAngle = this.hachureAngle;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dashOffset !== undefined) {
|
||||
this.dashOffset = options.dashOffset;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.dashOffset !== undefined) {
|
||||
brush.dashOffset = this.dashOffset;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dashArray !== undefined) {
|
||||
this.dashArray = options.dashArray;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.dashArray !== undefined) {
|
||||
brush.dashArray = this.dashArray;
|
||||
}
|
||||
}
|
||||
|
||||
// 为笔刷设置手绘效果
|
||||
const originalOnMouseMove = brush.onMouseMove;
|
||||
brush.onMouseMove = function (pointer, options) {
|
||||
// 添加微小随机偏移,模拟手绘效果
|
||||
const jitter = (this.width / 4) * this.roughness;
|
||||
pointer.x += (Math.random() - 0.5) * jitter;
|
||||
pointer.y += (Math.random() - 0.5) * jitter;
|
||||
|
||||
// 调用原始方法
|
||||
if (originalOnMouseMove) {
|
||||
originalOnMouseMove.call(this, pointer, options);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置粗糙度
|
||||
* @param {Number} value 粗糙度值(0-1)
|
||||
*/
|
||||
setRoughness(value) {
|
||||
this.roughness = Math.max(0, Math.min(1, value));
|
||||
|
||||
if (this.brush && this.brush.roughness !== undefined) {
|
||||
this.brush.roughness = this.roughness;
|
||||
}
|
||||
|
||||
return this.roughness;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置弯曲度
|
||||
* @param {Number} value 弯曲度值(0-1)
|
||||
*/
|
||||
setBowing(value) {
|
||||
this.bowing = Math.max(0, Math.min(1, value));
|
||||
|
||||
if (this.brush && this.brush.bowing !== undefined) {
|
||||
this.brush.bowing = this.bowing;
|
||||
}
|
||||
|
||||
return this.bowing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否描边
|
||||
* @param {Boolean} value 是否描边
|
||||
*/
|
||||
setStroke(value) {
|
||||
this.stroke = value;
|
||||
|
||||
if (this.brush && this.brush.stroke !== undefined) {
|
||||
this.brush.stroke = this.stroke;
|
||||
}
|
||||
|
||||
return this.stroke;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置素描线条角度
|
||||
* @param {Number} value 角度值(0-180)
|
||||
*/
|
||||
setHachureAngle(value) {
|
||||
this.hachureAngle = Math.max(0, Math.min(180, value));
|
||||
|
||||
if (this.brush && this.brush.hachureAngle !== undefined) {
|
||||
this.brush.hachureAngle = this.hachureAngle;
|
||||
}
|
||||
|
||||
return this.hachureAngle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置虚线偏移量
|
||||
* @param {Number} value 偏移量
|
||||
*/
|
||||
setDashOffset(value) {
|
||||
this.dashOffset = value;
|
||||
|
||||
if (this.brush && this.brush.dashOffset !== undefined) {
|
||||
this.brush.dashOffset = this.dashOffset;
|
||||
}
|
||||
|
||||
return this.dashOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置虚线数组
|
||||
* @param {Array} value 虚线数组[线长, 间隔]
|
||||
*/
|
||||
setDashArray(value) {
|
||||
if (Array.isArray(value) && value.length >= 2) {
|
||||
this.dashArray = value;
|
||||
|
||||
if (this.brush && this.brush.dashArray !== undefined) {
|
||||
this.brush.dashArray = this.dashArray;
|
||||
}
|
||||
}
|
||||
|
||||
return this.dashArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义素描笔刷特有属性
|
||||
const sketchyProperties = [
|
||||
{
|
||||
id: "roughness",
|
||||
name: "粗糙度",
|
||||
type: "slider",
|
||||
defaultValue: this.roughness,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制素描线条的粗糙程度",
|
||||
category: "素描设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "bowing",
|
||||
name: "弯曲度",
|
||||
type: "slider",
|
||||
defaultValue: this.bowing,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制素描线条的弯曲程度",
|
||||
category: "素描设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "stroke",
|
||||
name: "描边",
|
||||
type: "checkbox",
|
||||
defaultValue: this.stroke,
|
||||
description: "是否使用描边",
|
||||
category: "素描设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "hachureAngle",
|
||||
name: "线条角度",
|
||||
type: "slider",
|
||||
defaultValue: this.hachureAngle,
|
||||
min: 0,
|
||||
max: 180,
|
||||
step: 5,
|
||||
description: "控制素描线条的角度",
|
||||
category: "素描设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "dashOffset",
|
||||
name: "虚线偏移",
|
||||
type: "slider",
|
||||
defaultValue: this.dashOffset,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 1,
|
||||
description: "控制虚线的偏移量",
|
||||
category: "素描设置",
|
||||
order: 140,
|
||||
},
|
||||
{
|
||||
id: "dashArray",
|
||||
name: "虚线模式",
|
||||
type: "select",
|
||||
defaultValue: JSON.stringify(this.dashArray),
|
||||
options: [
|
||||
{ value: JSON.stringify([0]), label: "实线" },
|
||||
{ value: JSON.stringify([6, 2]), label: "短虚线" },
|
||||
{ value: JSON.stringify([10, 5]), label: "长虚线" },
|
||||
{ value: JSON.stringify([2, 2]), label: "点线" },
|
||||
{ value: JSON.stringify([10, 5, 2, 5]), label: "点划线" },
|
||||
],
|
||||
description: "设置虚线的模式",
|
||||
category: "素描设置",
|
||||
order: 150,
|
||||
parseValue: (value) => JSON.parse(value),
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...sketchyProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理素描笔刷特有属性
|
||||
if (propId === "roughness") {
|
||||
this.setRoughness(value);
|
||||
return true;
|
||||
} else if (propId === "bowing") {
|
||||
this.setBowing(value);
|
||||
return true;
|
||||
} else if (propId === "stroke") {
|
||||
this.setStroke(value);
|
||||
return true;
|
||||
} else if (propId === "hachureAngle") {
|
||||
this.setHachureAngle(value);
|
||||
return true;
|
||||
} else if (propId === "dashOffset") {
|
||||
this.setDashOffset(value);
|
||||
return true;
|
||||
} else if (propId === "dashArray") {
|
||||
let parsedValue = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.error("Invalid dashArray value:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.setDashArray(parsedValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMjMgMzBDMjUgMjggNTIgMzggNzUgMzciIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIzIiBmaWxsPSJub25lIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48cGF0aCBkPSJNMjIgNDBDMjIgMzggNTkgNDYgNzYgNDMiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48cGF0aCBkPSJNMjAgNTBDMjIgNDggNTYgNTYgNzYgNTIiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48cGF0aCBkPSJNMjQgNjBDMjMgNTggNDYgNjQgNzUgNjQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIyLjgiIGZpbGw9Im5vbmUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjxwYXRoIGQ9Ik0yNiA3MkMyNyA2OSA0OSA3NCA3NSA3MiIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjIuNCIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 喷漆笔刷
|
||||
* 创建喷漆效果,点状分散的绘制风格
|
||||
*/
|
||||
export class SpraypaintBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "spraypaint",
|
||||
name: "喷漆笔刷",
|
||||
description: "创建喷漆效果,点状分散的绘制风格",
|
||||
category: "绘画笔刷",
|
||||
icon: "spraypaint",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 喷漆笔刷特有属性
|
||||
this.density = options.density || 20;
|
||||
this.sprayRadius = options.sprayRadius || 10;
|
||||
this.randomOpacity =
|
||||
options.randomOpacity !== undefined ? options.randomOpacity : true;
|
||||
this.dotSize = options.dotSize || 1;
|
||||
this.dotShape = options.dotShape || "circle";
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生喷漆笔刷
|
||||
this.brush = new fabric.SprayBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 设置基本属性
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 喷漆笔刷特有属性
|
||||
if (options.density !== undefined) {
|
||||
this.density = options.density;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.density !== undefined) {
|
||||
brush.density = this.density;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.sprayRadius !== undefined) {
|
||||
this.sprayRadius = options.sprayRadius;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.sprayWidth !== undefined) {
|
||||
brush.sprayWidth = this.sprayRadius;
|
||||
} else if (brush.width !== undefined) {
|
||||
brush.width = this.sprayRadius;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.randomOpacity !== undefined) {
|
||||
this.randomOpacity = options.randomOpacity;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.randomOpacity !== undefined) {
|
||||
brush.randomOpacity = this.randomOpacity;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dotSize !== undefined) {
|
||||
this.dotSize = options.dotSize;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.dotWidth !== undefined) {
|
||||
brush.dotWidth = this.dotSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.dotShape !== undefined) {
|
||||
this.dotShape = options.dotShape;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.dotShape !== undefined) {
|
||||
brush.dotShape = this.dotShape;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置喷漆密度
|
||||
* @param {Number} value 密度值
|
||||
*/
|
||||
setDensity(value) {
|
||||
this.density = Math.max(1, Math.min(100, value));
|
||||
|
||||
if (this.brush && this.brush.density !== undefined) {
|
||||
this.brush.density = this.density;
|
||||
}
|
||||
|
||||
return this.density;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置喷漆半径
|
||||
* @param {Number} value 半径值
|
||||
*/
|
||||
setSprayRadius(value) {
|
||||
this.sprayRadius = Math.max(1, value);
|
||||
|
||||
if (this.brush) {
|
||||
if (this.brush.sprayWidth !== undefined) {
|
||||
this.brush.sprayWidth = this.sprayRadius;
|
||||
} else if (this.brush.width !== undefined) {
|
||||
this.brush.width = this.sprayRadius;
|
||||
}
|
||||
}
|
||||
|
||||
return this.sprayRadius;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置是否随机透明度
|
||||
* @param {Boolean} value 是否随机透明度
|
||||
*/
|
||||
setRandomOpacity(value) {
|
||||
this.randomOpacity = value;
|
||||
|
||||
if (this.brush && this.brush.randomOpacity !== undefined) {
|
||||
this.brush.randomOpacity = this.randomOpacity;
|
||||
}
|
||||
|
||||
return this.randomOpacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置点大小
|
||||
* @param {Number} value 点大小
|
||||
*/
|
||||
setDotSize(value) {
|
||||
this.dotSize = Math.max(0.1, value);
|
||||
|
||||
if (this.brush && this.brush.dotWidth !== undefined) {
|
||||
this.brush.dotWidth = this.dotSize;
|
||||
}
|
||||
|
||||
return this.dotSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置点形状
|
||||
* @param {String} value 点形状,如 'circle', 'square', 'diamond'
|
||||
*/
|
||||
setDotShape(value) {
|
||||
const validShapes = ["circle", "square", "diamond", "random"];
|
||||
|
||||
if (validShapes.includes(value)) {
|
||||
this.dotShape = value;
|
||||
|
||||
if (this.brush && this.brush.dotShape !== undefined) {
|
||||
this.brush.dotShape = this.dotShape;
|
||||
}
|
||||
}
|
||||
|
||||
return this.dotShape;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义喷漆笔刷特有属性
|
||||
const spraypaintProperties = [
|
||||
{
|
||||
id: "density",
|
||||
name: "喷漆密度",
|
||||
type: "slider",
|
||||
defaultValue: this.density,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
description: "控制喷漆点的密度",
|
||||
category: "喷漆设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "sprayRadius",
|
||||
name: "喷漆半径",
|
||||
type: "slider",
|
||||
defaultValue: this.sprayRadius,
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
description: "控制喷漆的覆盖半径",
|
||||
category: "喷漆设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "randomOpacity",
|
||||
name: "随机透明度",
|
||||
type: "checkbox",
|
||||
defaultValue: this.randomOpacity,
|
||||
description: "使喷漆点有随机透明度",
|
||||
category: "喷漆设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "dotSize",
|
||||
name: "点大小",
|
||||
type: "slider",
|
||||
defaultValue: this.dotSize,
|
||||
min: 0.1,
|
||||
max: 10,
|
||||
step: 0.1,
|
||||
description: "控制喷漆点的大小",
|
||||
category: "喷漆设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "dotShape",
|
||||
name: "点形状",
|
||||
type: "select",
|
||||
defaultValue: this.dotShape,
|
||||
options: [
|
||||
{ value: "circle", label: "圆形" },
|
||||
{ value: "square", label: "方形" },
|
||||
{ value: "diamond", label: "菱形" },
|
||||
{ value: "random", label: "随机" },
|
||||
],
|
||||
description: "设置喷漆点的形状",
|
||||
category: "喷漆设置",
|
||||
order: 140,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...spraypaintProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理喷漆笔刷特有属性
|
||||
if (propId === "density") {
|
||||
this.setDensity(value);
|
||||
return true;
|
||||
} else if (propId === "sprayRadius") {
|
||||
this.setSprayRadius(value);
|
||||
return true;
|
||||
} else if (propId === "randomOpacity") {
|
||||
this.setRandomOpacity(value);
|
||||
return true;
|
||||
} else if (propId === "dotSize") {
|
||||
this.setDotSize(value);
|
||||
return true;
|
||||
} else if (propId === "dotShape") {
|
||||
this.setDotShape(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48Y2lyY2xlIGN4PSI1NSIgY3k9IjUwIiByPSIyMCIgZmlsbD0icmdiYSgwLDAsMCwwLjEpIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMC41Ii8+PGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iMSIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjU1IiBjeT0iNTUiIHI9IjAuOCIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjYwIiBjeT0iNDUiIHI9IjEuMiIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjQ1IiBjeT0iNTUiIHI9IjAuNyIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjQ3IiBjeT0iNDgiIHI9IjAuOSIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjU4IiBjeT0iNTMiIHI9IjEuMSIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjYyIiBjeT0iNTYiIHI9IjAuNiIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjUyIiBjeT0iNTgiIHI9IjAuOCIgZmlsbD0iIzAwMCIvPjxjaXJjbGUgY3g9IjU0IiBjeT0iNDMiIHI9IjEiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI0OSIgY3k9IjQzIiByPSIwLjYiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI0MyIgY3k9IjQ3IiByPSIwLjciIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI2NSIgY3k9IjQ4IiByPSIwLjkiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI2MiIgY3k9IjQxIiByPSIwLjUiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI0OSIgY3k9IjYxIiByPSIwLjgiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI2NiIgY3k9IjUyIiByPSIwLjciIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI0MSIgY3k9IjUxIiByPSIwLjYiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI2OCIgY3k9IjU3IiByPSIwLjQiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI0NSIgY3k9IjQwIiByPSIwLjUiIGZpbGw9IiMwMDAiLz48Y2lyY2xlIGN4PSI1NyIgY3k9IjYxIiByPSIwLjciIGZpbGw9IiMwMDAiLz48L3N2Zz4=";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
//import { fabric } from "fabric-with-all";
|
||||
import texturePresetManager from "../TexturePresetManager";
|
||||
|
||||
/**
|
||||
* 纹理笔刷
|
||||
* 使用图像纹理进行绘制的笔刷
|
||||
*/
|
||||
export class TextureBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "texture",
|
||||
name: "纹理笔刷",
|
||||
description: "使用图像纹理进行绘制的笔刷",
|
||||
category: "特效笔刷",
|
||||
icon: "texture",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 纹理笔刷特有属性
|
||||
this.textureSource = options.textureSource || null;
|
||||
this.textureRepeat = options.textureRepeat || "repeat";
|
||||
this.textureScale = options.textureScale || 1;
|
||||
this.textureAngle = options.textureAngle || 0;
|
||||
this.textureOpacity =
|
||||
options.textureOpacity !== undefined ? options.textureOpacity : 1;
|
||||
|
||||
// 预设材质相关
|
||||
this.selectedTextureId = options.selectedTextureId || null;
|
||||
this.texturePresets = [];
|
||||
|
||||
// 加载预设材质
|
||||
this._loadTexturePresets();
|
||||
|
||||
// 当前选中的材质索引
|
||||
this.currentTextureIndex = options.currentTextureIndex || 0;
|
||||
|
||||
// 从预设管理器加载自定义材质
|
||||
texturePresetManager.loadCustomTexturesFromStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载材质预设
|
||||
* @private
|
||||
*/
|
||||
_loadTexturePresets() {
|
||||
// 从预设管理器获取所有材质
|
||||
this.texturePresets = texturePresetManager.getAllTextures();
|
||||
|
||||
// 如果没有选中的材质ID,使用第一个预设材质
|
||||
if (!this.selectedTextureId && this.texturePresets.length > 0) {
|
||||
this.selectedTextureId = this.texturePresets[0].id;
|
||||
this.currentTextureIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生纹理笔刷
|
||||
this.brush = new fabric.PatternBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
// 如果有选中的材质,则设置纹理
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 设置基本属性
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 纹理笔刷特有属性
|
||||
if (options.textureRepeat !== undefined) {
|
||||
this.textureRepeat = options.textureRepeat;
|
||||
// 需要重新应用纹理以应用重复模式
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.textureScale !== undefined) {
|
||||
this.textureScale = options.textureScale;
|
||||
// 需要重新应用纹理以应用缩放
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.textureAngle !== undefined) {
|
||||
this.textureAngle = options.textureAngle;
|
||||
// 需要重新应用纹理以应用旋转角度
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.textureOpacity !== undefined) {
|
||||
this.textureOpacity = options.textureOpacity;
|
||||
// 需要重新应用纹理以应用透明度
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据材质ID设置纹理
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
setTextureById(textureId) {
|
||||
const texture = texturePresetManager.getTextureById(textureId);
|
||||
if (!texture) {
|
||||
return Promise.reject(new Error(`材质 ${textureId} 不存在`));
|
||||
}
|
||||
|
||||
this.selectedTextureId = textureId;
|
||||
|
||||
// 更新当前材质索引
|
||||
const allTextures = texturePresetManager.getAllTextures();
|
||||
this.currentTextureIndex = allTextures.findIndex((t) => t.id === textureId);
|
||||
|
||||
return this.setTexture(texture.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理
|
||||
* @param {String|Object} source 纹理源(URL或Image对象)
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
setTexture(source) {
|
||||
this.textureSource = source;
|
||||
|
||||
if (!this.brush) {
|
||||
return Promise.reject(new Error("笔刷实例不存在"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof source === "string") {
|
||||
// 如果是URL,加载图像
|
||||
fabric.util.loadImage(source, (img) => {
|
||||
if (!img) {
|
||||
reject(new Error("纹理加载失败"));
|
||||
return;
|
||||
}
|
||||
this._applyTextureToPatternBrush(img);
|
||||
resolve(img);
|
||||
});
|
||||
} else if (
|
||||
source instanceof Image ||
|
||||
source instanceof HTMLCanvasElement
|
||||
) {
|
||||
// 如果已经是Image或Canvas对象,直接使用
|
||||
this._applyTextureToPatternBrush(source);
|
||||
resolve(source);
|
||||
} else {
|
||||
reject(new Error("无效的纹理源"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将纹理应用到PatternBrush
|
||||
* @param {Object} img 图像对象
|
||||
* @private
|
||||
*/
|
||||
_applyTextureToPatternBrush(img) {
|
||||
if (!this.brush || !img) return;
|
||||
|
||||
// 创建Canvas来处理纹理
|
||||
const canvasTexture = document.createElement("canvas");
|
||||
const ctx = canvasTexture.getContext("2d");
|
||||
|
||||
// 根据缩放设置Canvas大小
|
||||
const width = img.width * this.textureScale;
|
||||
const height = img.height * this.textureScale;
|
||||
canvasTexture.width = width;
|
||||
canvasTexture.height = height;
|
||||
|
||||
// 绘制前应用旋转
|
||||
if (this.textureAngle !== 0) {
|
||||
ctx.save();
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.rotate((this.textureAngle * Math.PI) / 180);
|
||||
ctx.translate(-width / 2, -height / 2);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
ctx.restore();
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
}
|
||||
|
||||
// 应用透明度
|
||||
if (this.textureOpacity < 1) {
|
||||
ctx.globalAlpha = this.textureOpacity;
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
// 创建Pattern对象
|
||||
const pattern = new fabric.Pattern({
|
||||
source: canvasTexture,
|
||||
repeat: this.textureRepeat,
|
||||
});
|
||||
|
||||
// 设置笔刷源纹理
|
||||
if (typeof this.brush.setSource === "function") {
|
||||
this.brush.setSource(pattern);
|
||||
} else if (typeof this.brush.source === "object") {
|
||||
this.brush.source = pattern;
|
||||
} else if (typeof this.brush.pattern === "object") {
|
||||
this.brush.pattern = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理重复模式
|
||||
* @param {String} mode 重复模式:'repeat', 'repeat-x', 'repeat-y', 'no-repeat'
|
||||
* @returns {String} 设置后的重复模式
|
||||
*/
|
||||
setTextureRepeat(mode) {
|
||||
const validModes = ["repeat", "repeat-x", "repeat-y", "no-repeat"];
|
||||
if (validModes.includes(mode)) {
|
||||
this.textureRepeat = mode;
|
||||
|
||||
// 重新应用纹理以更新重复模式
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
}
|
||||
|
||||
return this.textureRepeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理缩放比例
|
||||
* @param {Number} scale 缩放比例
|
||||
* @returns {Number} 设置后的缩放比例
|
||||
*/
|
||||
setTextureScale(scale) {
|
||||
this.textureScale = Math.max(0.1, scale);
|
||||
|
||||
// 重新应用纹理以更新缩放
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
|
||||
return this.textureScale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理旋转角度
|
||||
* @param {Number} angle 旋转角度(度)
|
||||
* @returns {Number} 设置后的旋转角度
|
||||
*/
|
||||
setTextureAngle(angle) {
|
||||
this.textureAngle = angle % 360;
|
||||
|
||||
// 重新应用纹理以更新旋转角度
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
|
||||
return this.textureAngle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置纹理透明度
|
||||
* @param {Number} opacity 透明度
|
||||
* @returns {Number} 设置后的透明度
|
||||
*/
|
||||
setTextureOpacity(opacity) {
|
||||
this.textureOpacity = Math.min(1, Math.max(0, opacity));
|
||||
|
||||
// 重新应用纹理以更新透明度
|
||||
if (this.selectedTextureId) {
|
||||
this.setTextureById(this.selectedTextureId);
|
||||
} else if (this.textureSource) {
|
||||
this.setTexture(this.textureSource);
|
||||
}
|
||||
|
||||
return this.textureOpacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到下一个预设材质
|
||||
* @returns {Promise} 切换完成的Promise
|
||||
*/
|
||||
nextTexture() {
|
||||
const textures = texturePresetManager.getAllTextures();
|
||||
if (textures.length === 0) return Promise.resolve();
|
||||
|
||||
this.currentTextureIndex = (this.currentTextureIndex + 1) % textures.length;
|
||||
const nextTexture = textures[this.currentTextureIndex];
|
||||
|
||||
return this.setTextureById(nextTexture.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到上一个预设材质
|
||||
* @returns {Promise} 切换完成的Promise
|
||||
*/
|
||||
previousTexture() {
|
||||
const textures = texturePresetManager.getAllTextures();
|
||||
if (textures.length === 0) return Promise.resolve();
|
||||
|
||||
this.currentTextureIndex =
|
||||
this.currentTextureIndex === 0
|
||||
? textures.length - 1
|
||||
: this.currentTextureIndex - 1;
|
||||
const prevTexture = textures[this.currentTextureIndex];
|
||||
|
||||
return this.setTextureById(prevTexture.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用索引切换纹理
|
||||
* @param {Number} index 纹理索引
|
||||
*/
|
||||
switchTexture(index) {
|
||||
const textures = texturePresetManager.getAllTextures();
|
||||
if (index >= 0 && index < textures.length) {
|
||||
this.currentTextureIndex = index;
|
||||
const texture = textures[index];
|
||||
return this.setTextureById(texture.id);
|
||||
}
|
||||
return Promise.reject(new Error("无效的纹理索引"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前选中的材质信息
|
||||
* @returns {Object|null} 材质信息
|
||||
*/
|
||||
getCurrentTexture() {
|
||||
if (this.selectedTextureId) {
|
||||
return texturePresetManager.getTextureById(this.selectedTextureId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 获取所有可用材质
|
||||
const allTextures = texturePresetManager.getAllTextures();
|
||||
const textureOptions = allTextures.map((texture, index) => ({
|
||||
value: texture.id,
|
||||
label: texture.name,
|
||||
preview: texturePresetManager.getTexturePreviewUrl(texture),
|
||||
category: texture.category,
|
||||
}));
|
||||
|
||||
// 定义纹理笔刷特有属性
|
||||
const textureProperties = [
|
||||
{
|
||||
id: "textureSelector",
|
||||
name: "材质选择",
|
||||
type: "texture-grid",
|
||||
defaultValue: this.selectedTextureId,
|
||||
options: textureOptions,
|
||||
description: "选择要使用的纹理",
|
||||
category: "纹理设置",
|
||||
order: 100,
|
||||
hidden: allTextures.length === 0,
|
||||
},
|
||||
{
|
||||
id: "textureRepeat",
|
||||
name: "纹理重复模式",
|
||||
type: "select",
|
||||
defaultValue: this.textureRepeat,
|
||||
options: [
|
||||
{ value: "repeat", label: "双向重复" },
|
||||
{ value: "repeat-x", label: "水平重复" },
|
||||
{ value: "repeat-y", label: "垂直重复" },
|
||||
{ value: "no-repeat", label: "不重复" },
|
||||
],
|
||||
description: "设置纹理的重复模式",
|
||||
category: "纹理设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "textureScale",
|
||||
name: "纹理缩放",
|
||||
type: "slider",
|
||||
defaultValue: this.textureScale,
|
||||
min: 0.1,
|
||||
max: 5,
|
||||
step: 0.1,
|
||||
description: "调整纹理的缩放比例",
|
||||
category: "纹理设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "textureAngle",
|
||||
name: "纹理旋转",
|
||||
type: "slider",
|
||||
defaultValue: this.textureAngle,
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 5,
|
||||
description: "调整纹理的旋转角度",
|
||||
category: "纹理设置",
|
||||
order: 130,
|
||||
},
|
||||
{
|
||||
id: "textureOpacity",
|
||||
name: "纹理透明度",
|
||||
type: "slider",
|
||||
defaultValue: this.textureOpacity,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "调整纹理的透明度",
|
||||
category: "纹理设置",
|
||||
order: 140,
|
||||
},
|
||||
{
|
||||
id: "uploadTexture",
|
||||
name: "上传纹理",
|
||||
type: "button",
|
||||
action: "uploadTexture",
|
||||
description: "上传自定义纹理",
|
||||
category: "纹理设置",
|
||||
order: 150,
|
||||
},
|
||||
{
|
||||
id: "texturePreview",
|
||||
name: "纹理预览",
|
||||
type: "preview",
|
||||
description: "当前纹理预览",
|
||||
category: "纹理设置",
|
||||
order: 160,
|
||||
getValue: () => {
|
||||
const currentTexture = this.getCurrentTexture();
|
||||
return currentTexture
|
||||
? texturePresetManager.getTexturePreviewUrl(currentTexture)
|
||||
: null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...textureProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理纹理笔刷特有属性
|
||||
if (propId === "textureSelector") {
|
||||
this.setTextureById(value);
|
||||
return true;
|
||||
} else if (propId === "textureRepeat") {
|
||||
this.setTextureRepeat(value);
|
||||
return true;
|
||||
} else if (propId === "textureScale") {
|
||||
this.setTextureScale(value);
|
||||
return true;
|
||||
} else if (propId === "textureAngle") {
|
||||
this.setTextureAngle(value);
|
||||
return true;
|
||||
} else if (propId === "textureOpacity") {
|
||||
this.setTextureOpacity(value);
|
||||
return true;
|
||||
} else if (propId === "uploadTexture") {
|
||||
// 触发上传纹理事件
|
||||
// 这里通常由外部处理,返回true表示属性被处理
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加自定义材质
|
||||
* @param {Object} textureData 材质数据
|
||||
* @returns {String} 材质ID
|
||||
*/
|
||||
addCustomTexture(textureData) {
|
||||
const textureId = texturePresetManager.addCustomTexture(textureData);
|
||||
|
||||
// 重新加载材质预设
|
||||
this._loadTexturePresets();
|
||||
|
||||
// 保存到本地存储
|
||||
texturePresetManager.saveCustomTexturesToStorage();
|
||||
|
||||
return textureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除自定义材质
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Boolean} 是否删除成功
|
||||
*/
|
||||
removeCustomTexture(textureId) {
|
||||
const success = texturePresetManager.removeCustomTexture(textureId);
|
||||
|
||||
if (success) {
|
||||
// 如果删除的是当前选中的材质,切换到第一个可用材质
|
||||
if (this.selectedTextureId === textureId) {
|
||||
const allTextures = texturePresetManager.getAllTextures();
|
||||
if (allTextures.length > 0) {
|
||||
this.setTextureById(allTextures[0].id);
|
||||
} else {
|
||||
this.selectedTextureId = null;
|
||||
this.currentTextureIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载材质预设
|
||||
this._loadTexturePresets();
|
||||
|
||||
// 保存到本地存储
|
||||
texturePresetManager.saveCustomTexturesToStorage();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
const currentTexture = this.getCurrentTexture();
|
||||
if (currentTexture) {
|
||||
return texturePresetManager.getTexturePreviewUrl(currentTexture);
|
||||
}
|
||||
|
||||
// 返回默认纹理预览
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48ZGVmcz48cGF0dGVybiBpZD0icGF0dGVybiIgcGF0dGVyblVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIj48cmVjdCB3aWR0aD0iNSIgaGVpZ2h0PSI1IiBmaWxsPSIjZGRkIi8+PHJlY3QgeD0iNSIgeT0iNSIgd2lkdGg9IjUiIGhlaWdodD0iNSIgZmlsbD0iI2RkZCIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiIGZpbGw9InVybCgjcGF0dGVybikiLz48L3N2Zz4=";
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔刷被选中时调用
|
||||
* @override
|
||||
*/
|
||||
onSelected() {
|
||||
// 重新加载材质预设(可能有新的自定义材质)
|
||||
this._loadTexturePresets();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁笔刷实例并清理资源
|
||||
* @override
|
||||
*/
|
||||
destroy() {
|
||||
super.destroy();
|
||||
this.textureSource = null;
|
||||
this.selectedTextureId = null;
|
||||
this.texturePresets = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置材质属性
|
||||
* @param {String} property 属性名称
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否设置成功
|
||||
*/
|
||||
setTextureProperty(property, value) {
|
||||
switch (property) {
|
||||
case "scale":
|
||||
return this.setTextureScale(value);
|
||||
case "opacity":
|
||||
return this.setTextureOpacity(value);
|
||||
case "repeat":
|
||||
return this.setTextureRepeat(value);
|
||||
case "angle":
|
||||
return this.setTextureAngle(value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取材质属性
|
||||
* @param {String} property 属性名称
|
||||
* @returns {any} 属性值
|
||||
*/
|
||||
getTextureProperty(property) {
|
||||
switch (property) {
|
||||
case "scale":
|
||||
return this.textureScale;
|
||||
case "opacity":
|
||||
return this.textureOpacity;
|
||||
case "repeat":
|
||||
return this.textureRepeat;
|
||||
case "angle":
|
||||
return this.textureAngle;
|
||||
case "textureId":
|
||||
return this.selectedTextureId;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用材质预设
|
||||
* @param {String|Object} preset 预设ID或预设对象
|
||||
* @returns {Boolean} 是否应用成功
|
||||
*/
|
||||
applyTexturePreset(preset) {
|
||||
let presetData = null;
|
||||
|
||||
if (typeof preset === "string") {
|
||||
// 如果是预设ID,从预设管理器获取
|
||||
presetData = texturePresetManager.applyTexturePreset(preset);
|
||||
} else if (typeof preset === "object") {
|
||||
// 如果是预设对象,直接使用
|
||||
presetData = preset;
|
||||
}
|
||||
|
||||
if (!presetData) {
|
||||
console.warn("无效的材质预设:", preset);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 应用预设设置
|
||||
if (presetData.textureId) {
|
||||
this.setTextureById(presetData.textureId);
|
||||
}
|
||||
|
||||
if (presetData.scale !== undefined) {
|
||||
this.setTextureScale(presetData.scale);
|
||||
}
|
||||
|
||||
if (presetData.opacity !== undefined) {
|
||||
this.setTextureOpacity(presetData.opacity);
|
||||
}
|
||||
|
||||
if (presetData.repeat !== undefined) {
|
||||
this.setTextureRepeat(presetData.repeat);
|
||||
}
|
||||
|
||||
if (presetData.angle !== undefined) {
|
||||
this.setTextureAngle(presetData.angle);
|
||||
}
|
||||
|
||||
// 如果预设包含笔刷属性,也一并应用
|
||||
if (presetData.brushSize !== undefined && this.brush) {
|
||||
this.brush.width = presetData.brushSize;
|
||||
}
|
||||
|
||||
if (presetData.brushOpacity !== undefined && this.brush) {
|
||||
this.brush.opacity = presetData.brushOpacity;
|
||||
}
|
||||
|
||||
if (presetData.brushColor !== undefined && this.brush) {
|
||||
this.brush.color = presetData.brushColor;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前材质状态
|
||||
* @returns {Object} 当前材质状态
|
||||
*/
|
||||
getCurrentTextureState() {
|
||||
return {
|
||||
textureId: this.selectedTextureId,
|
||||
scale: this.textureScale,
|
||||
opacity: this.textureOpacity,
|
||||
repeat: this.textureRepeat,
|
||||
angle: this.textureAngle,
|
||||
// 包含笔刷状态
|
||||
brushSize: this.brush ? this.brush.width : this.options.width,
|
||||
brushOpacity: this.brush ? this.brush.opacity : this.options.opacity,
|
||||
brushColor: this.brush ? this.brush.color : this.options.color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复材质状态
|
||||
* @param {Object} state 要恢复的状态
|
||||
* @returns {Boolean} 是否恢复成功
|
||||
*/
|
||||
restoreTextureState(state) {
|
||||
if (!state) return false;
|
||||
|
||||
try {
|
||||
// 恢复材质属性
|
||||
if (state.textureId) {
|
||||
this.setTextureById(state.textureId);
|
||||
}
|
||||
|
||||
if (state.scale !== undefined) {
|
||||
this.setTextureScale(state.scale);
|
||||
}
|
||||
|
||||
if (state.opacity !== undefined) {
|
||||
this.setTextureOpacity(state.opacity);
|
||||
}
|
||||
|
||||
if (state.repeat !== undefined) {
|
||||
this.setTextureRepeat(state.repeat);
|
||||
}
|
||||
|
||||
if (state.angle !== undefined) {
|
||||
this.setTextureAngle(state.angle);
|
||||
}
|
||||
|
||||
// 恢复笔刷属性
|
||||
if (this.brush) {
|
||||
if (state.brushSize !== undefined) {
|
||||
this.brush.width = state.brushSize;
|
||||
}
|
||||
|
||||
if (state.brushOpacity !== undefined) {
|
||||
this.brush.opacity = state.brushOpacity;
|
||||
}
|
||||
|
||||
if (state.brushColor !== undefined) {
|
||||
this.brush.color = state.brushColor;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("恢复材质状态失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建材质预设
|
||||
* @param {String} name 预设名称
|
||||
* @returns {String} 预设ID
|
||||
*/
|
||||
createTexturePreset(name) {
|
||||
const currentState = this.getCurrentTextureState();
|
||||
return texturePresetManager.createTexturePreset(name, currentState);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的材质分类
|
||||
* @returns {Array} 分类数组
|
||||
*/
|
||||
getTextureCategories() {
|
||||
return texturePresetManager.getCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分类获取材质
|
||||
* @param {String} category 分类名称
|
||||
* @returns {Array} 材质数组
|
||||
*/
|
||||
getTexturesByCategory(category) {
|
||||
return texturePresetManager.getTexturesByCategory(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索材质
|
||||
* @param {String} query 搜索关键词
|
||||
* @returns {Array} 匹配的材质数组
|
||||
*/
|
||||
searchTextures(query) {
|
||||
return texturePresetManager.searchTextures(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载材质图像
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Promise<HTMLImageElement>} 图像对象
|
||||
*/
|
||||
preloadTexture(textureId) {
|
||||
return texturePresetManager.loadTextureImage(textureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量预加载材质
|
||||
* @param {Array} textureIds 材质ID数组
|
||||
* @returns {Promise<Array>} 加载结果数组
|
||||
*/
|
||||
preloadTextures(textureIds) {
|
||||
const loadPromises = textureIds.map((id) =>
|
||||
this.preloadTexture(id).catch((error) => ({ id, error }))
|
||||
);
|
||||
return Promise.all(loadPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取材质统计信息
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
getTextureStats() {
|
||||
return texturePresetManager.getStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { BaseBrush } from "../BaseBrush";
|
||||
|
||||
/**
|
||||
* 书法笔刷
|
||||
* 模拟中国传统书法效果,具有笔锋和墨色变化
|
||||
*/
|
||||
export class WritingBrush extends BaseBrush {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} canvas fabric画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(canvas, options = {}) {
|
||||
super(canvas, {
|
||||
id: "writing",
|
||||
name: "书法笔",
|
||||
description: "模拟中国传统书法毛笔效果,具有笔锋和墨色变化",
|
||||
category: "特效笔刷",
|
||||
icon: "writing",
|
||||
...options,
|
||||
});
|
||||
|
||||
// 书法笔刷特有属性
|
||||
this.brushPressure = options.brushPressure || 0.7;
|
||||
this.inkAmount = options.inkAmount || 20;
|
||||
this.brushTaperFactor = options.brushTaperFactor || 0.6;
|
||||
this.enableInkDripping =
|
||||
options.enableInkDripping !== undefined
|
||||
? options.enableInkDripping
|
||||
: true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔刷实例
|
||||
* @returns {Object} fabric笔刷实例
|
||||
*/
|
||||
create() {
|
||||
if (!this.canvas) {
|
||||
throw new Error("画布实例不存在");
|
||||
}
|
||||
|
||||
// 创建fabric原生书法笔刷
|
||||
this.brush = new fabric.WritingBrush(this.canvas);
|
||||
|
||||
// 配置笔刷
|
||||
this.configure(this.brush, this.options);
|
||||
|
||||
return this.brush;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置笔刷
|
||||
* @param {Object} brush fabric笔刷实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
configure(brush, options = {}) {
|
||||
if (!brush) return;
|
||||
|
||||
// 基础属性配置
|
||||
if (options.width !== undefined) {
|
||||
brush.width = options.width;
|
||||
}
|
||||
|
||||
if (options.color !== undefined) {
|
||||
brush.color = options.color;
|
||||
}
|
||||
|
||||
if (options.opacity !== undefined) {
|
||||
brush.opacity = options.opacity;
|
||||
}
|
||||
|
||||
// 书法笔刷特有属性
|
||||
if (options.brushPressure !== undefined) {
|
||||
this.brushPressure = options.brushPressure;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.brushPressure !== undefined) {
|
||||
brush.brushPressure = this.brushPressure;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.inkAmount !== undefined) {
|
||||
this.inkAmount = options.inkAmount;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.inkAmount !== undefined) {
|
||||
brush.inkAmount = this.inkAmount;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.brushTaperFactor !== undefined) {
|
||||
this.brushTaperFactor = options.brushTaperFactor;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.brushTaperFactor !== undefined) {
|
||||
brush.brushTaperFactor = this.brushTaperFactor;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.enableInkDripping !== undefined) {
|
||||
this.enableInkDripping = options.enableInkDripping;
|
||||
|
||||
// 如果原生笔刷支持此属性,则设置
|
||||
if (brush.enableInkDripping !== undefined) {
|
||||
brush.enableInkDripping = this.enableInkDripping;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔压感应
|
||||
* @param {Number} pressure 笔压值(0-1)
|
||||
*/
|
||||
setBrushPressure(pressure) {
|
||||
this.brushPressure = Math.max(0.1, Math.min(1, pressure));
|
||||
|
||||
if (this.brush && this.brush.brushPressure !== undefined) {
|
||||
this.brush.brushPressure = this.brushPressure;
|
||||
}
|
||||
|
||||
return this.brushPressure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置墨量
|
||||
* @param {Number} amount 墨量值
|
||||
*/
|
||||
setInkAmount(amount) {
|
||||
this.inkAmount = Math.max(1, Math.min(50, amount));
|
||||
|
||||
if (this.brush && this.brush.inkAmount !== undefined) {
|
||||
this.brush.inkAmount = this.inkAmount;
|
||||
}
|
||||
|
||||
return this.inkAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置笔锋系数
|
||||
* @param {Number} factor 笔锋系数(0-1)
|
||||
*/
|
||||
setBrushTaperFactor(factor) {
|
||||
this.brushTaperFactor = Math.max(0, Math.min(1, factor));
|
||||
|
||||
if (this.brush && this.brush.brushTaperFactor !== undefined) {
|
||||
this.brush.brushTaperFactor = this.brushTaperFactor;
|
||||
}
|
||||
|
||||
return this.brushTaperFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用墨滴效果
|
||||
* @param {Boolean} enabled 是否启用
|
||||
*/
|
||||
setInkDripping(enabled) {
|
||||
this.enableInkDripping = enabled;
|
||||
|
||||
if (this.brush && this.brush.enableInkDripping !== undefined) {
|
||||
this.brush.enableInkDripping = this.enableInkDripping;
|
||||
}
|
||||
|
||||
return this.enableInkDripping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔刷可配置属性
|
||||
* @returns {Array} 可配置属性描述数组
|
||||
* @override
|
||||
*/
|
||||
getConfigurableProperties() {
|
||||
// 获取基础属性
|
||||
const baseProperties = super.getConfigurableProperties();
|
||||
|
||||
// 定义书法笔刷特有属性
|
||||
const writingProperties = [
|
||||
{
|
||||
id: "brushPressure",
|
||||
name: "笔压感应",
|
||||
type: "slider",
|
||||
defaultValue: this.brushPressure,
|
||||
min: 0.1,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制笔触的力度感应",
|
||||
category: "书法设置",
|
||||
order: 100,
|
||||
},
|
||||
{
|
||||
id: "inkAmount",
|
||||
name: "墨量",
|
||||
type: "slider",
|
||||
defaultValue: this.inkAmount,
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
description: "控制笔触中的墨水量",
|
||||
category: "书法设置",
|
||||
order: 110,
|
||||
},
|
||||
{
|
||||
id: "brushTaperFactor",
|
||||
name: "笔锋系数",
|
||||
type: "slider",
|
||||
defaultValue: this.brushTaperFactor,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
description: "控制笔锋的尖锐程度",
|
||||
category: "书法设置",
|
||||
order: 120,
|
||||
},
|
||||
{
|
||||
id: "enableInkDripping",
|
||||
name: "墨滴效果",
|
||||
type: "checkbox",
|
||||
defaultValue: this.enableInkDripping,
|
||||
description: "是否启用墨滴效果",
|
||||
category: "书法设置",
|
||||
order: 130,
|
||||
},
|
||||
];
|
||||
|
||||
// 合并并返回所有属性
|
||||
return [...baseProperties, ...writingProperties];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔刷属性
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
* @returns {Boolean} 是否更新成功
|
||||
* @override
|
||||
*/
|
||||
updateProperty(propId, value) {
|
||||
// 先检查基类能否处理此属性
|
||||
if (super.updateProperty(propId, value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理书法笔刷特有属性
|
||||
if (propId === "brushPressure") {
|
||||
this.setBrushPressure(value);
|
||||
return true;
|
||||
} else if (propId === "inkAmount") {
|
||||
this.setInkAmount(value);
|
||||
return true;
|
||||
} else if (propId === "brushTaperFactor") {
|
||||
this.setBrushTaperFactor(value);
|
||||
return true;
|
||||
} else if (propId === "enableInkDripping") {
|
||||
this.setInkDripping(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览图
|
||||
* @returns {String} 预览图URL
|
||||
*/
|
||||
getPreview() {
|
||||
return "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48cGF0aCBkPSJNMzAgMzBDNTAgMzAgNjAgNzAgODAgNzAiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLXdpZHRoPSIxMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTc1IDYwQzc4IDcwIDg1IDY1IDkwIDcwIiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBmaWxsPSJub25lIi8+PC9zdmc+";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { CompositeCommand } from "../../commands/Command.js";
|
||||
import { PerformanceManager } from "./PerformanceManager.js";
|
||||
|
||||
/**
|
||||
* 简化版命令管理器
|
||||
* 基于经典撤销/重做模式,支持命令队列
|
||||
* 使用复合命令替代事务处理
|
||||
*/
|
||||
export class CommandManager {
|
||||
constructor(options = {}) {
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this.maxHistorySize = options.maxHistorySize || 50;
|
||||
this.executing = false;
|
||||
|
||||
// 命令执行队列
|
||||
this.commandQueue = [];
|
||||
this.processing = false;
|
||||
|
||||
// 可选的性能管理器
|
||||
this.performanceManager = options.performanceManager || null;
|
||||
|
||||
// 状态变化回调
|
||||
this.onStateChange = null;
|
||||
}
|
||||
|
||||
// 兼容旧的executeCommand方法
|
||||
async executeCommand(command) {
|
||||
return this.execute(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行命令并添加到撤销栈
|
||||
*/
|
||||
async execute(command) {
|
||||
if (!command || typeof command.execute !== "function") {
|
||||
throw new Error("无效的命令对象");
|
||||
}
|
||||
|
||||
return this._executeDirectly(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接执行命令(绕过事务检查)
|
||||
* @private
|
||||
*/
|
||||
async _executeDirectly(command) {
|
||||
// 返回Promise,等待命令执行完成
|
||||
return new Promise((resolve, reject) => {
|
||||
// 将命令添加到队列
|
||||
this.commandQueue.push({
|
||||
type: "execute",
|
||||
command,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
// 开始处理队列
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销最后一个命令
|
||||
*/
|
||||
async undo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查是否可以撤销
|
||||
if (this.undoStack.length === 0) {
|
||||
console.warn("无法撤销:撤销栈为空");
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将撤销操作添加到队列
|
||||
this.commandQueue.push({
|
||||
type: "undo",
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
// 开始处理队列
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重做最后一个撤销的命令
|
||||
*/
|
||||
async redo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 检查是否可以重做
|
||||
if (this.redoStack.length === 0) {
|
||||
console.warn("无法重做:重做栈为空");
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将重做操作添加到队列
|
||||
this.commandQueue.push({
|
||||
type: "redo",
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
// 开始处理队列
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理命令队列
|
||||
* @private
|
||||
*/
|
||||
async _processQueue() {
|
||||
// 如果正在处理或队列为空,直接返回
|
||||
if (this.processing || this.commandQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
try {
|
||||
while (this.commandQueue.length > 0) {
|
||||
const task = this.commandQueue.shift();
|
||||
|
||||
try {
|
||||
let result = null;
|
||||
|
||||
switch (task.type) {
|
||||
case "execute":
|
||||
result = await this._executeCommandInternal(task.command);
|
||||
break;
|
||||
case "undo":
|
||||
result = await this._undoInternal();
|
||||
break;
|
||||
case "redo":
|
||||
result = await this._redoInternal();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`未知的任务类型: ${task.type}`);
|
||||
}
|
||||
|
||||
task.resolve(result);
|
||||
} catch (error) {
|
||||
task.reject(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部执行命令方法
|
||||
* @private
|
||||
*/
|
||||
async _executeCommandInternal(command) {
|
||||
this.executing = true;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
console.log(`🔄 执行命令: ${command.constructor.name}`);
|
||||
|
||||
// 执行命令
|
||||
const result = await this._executeCommand(command);
|
||||
|
||||
// 只有可撤销的命令才加入撤销栈
|
||||
if (command.undoable !== false) {
|
||||
this.undoStack.push(command);
|
||||
this.redoStack = []; // 清空重做栈
|
||||
|
||||
// 限制历史记录大小
|
||||
this._trimHistory();
|
||||
}
|
||||
|
||||
// 记录性能
|
||||
const duration = performance.now() - startTime;
|
||||
this._recordPerformance("execute", command.constructor.name, duration);
|
||||
|
||||
// 通知状态变化
|
||||
this._notifyStateChange();
|
||||
|
||||
console.log(`✅ 命令执行成功: ${command.constructor.name}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 命令执行失败: ${command.constructor.name}`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.executing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部撤销方法
|
||||
* @private
|
||||
*/
|
||||
async _undoInternal() {
|
||||
if (this.undoStack.length === 0) {
|
||||
console.warn("无法撤销:撤销栈为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.executing = true;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const command = this.undoStack.pop();
|
||||
console.log(`↩️ 撤销命令: ${command.constructor.name}`);
|
||||
|
||||
const result = await this._undoCommand(command);
|
||||
|
||||
this.redoStack.push(command);
|
||||
|
||||
// 记录性能
|
||||
const duration = performance.now() - startTime;
|
||||
this._recordPerformance("undo", command.constructor.name, duration);
|
||||
|
||||
// 通知状态变化
|
||||
this._notifyStateChange();
|
||||
|
||||
console.log(`✅ 命令撤销成功: ${command.constructor.name}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 命令撤销失败`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.executing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部重做方法
|
||||
* @private
|
||||
*/
|
||||
async _redoInternal() {
|
||||
if (this.redoStack.length === 0) {
|
||||
console.warn("无法重做:重做栈为空");
|
||||
return null;
|
||||
}
|
||||
|
||||
this.executing = true;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
const command = this.redoStack.pop();
|
||||
console.log(`↪️ 重做命令: ${command.constructor.name}`);
|
||||
|
||||
const result = await this._executeCommand(command);
|
||||
|
||||
this.undoStack.push(command);
|
||||
|
||||
// 记录性能
|
||||
const duration = performance.now() - startTime;
|
||||
this._recordPerformance("redo", command.constructor.name, duration);
|
||||
|
||||
// 通知状态变化
|
||||
this._notifyStateChange();
|
||||
|
||||
console.log(`✅ 命令重做成功: ${command.constructor.name}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`❌ 命令重做失败`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.executing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量执行命令(使用 CompositeCommand)
|
||||
* 推荐使用此方法替代原来的事务机制
|
||||
*/
|
||||
async executeBatch(commands, batchName = "批量操作") {
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
throw new Error("命令数组不能为空");
|
||||
}
|
||||
|
||||
const compositeCommand = new CompositeCommand(commands, {
|
||||
name: batchName,
|
||||
});
|
||||
|
||||
return this.execute(compositeCommand);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空历史记录
|
||||
*/
|
||||
clear() {
|
||||
// 清空队列中的所有任务
|
||||
while (this.commandQueue.length > 0) {
|
||||
const task = this.commandQueue.shift();
|
||||
task.reject(new Error("命令管理器已被清空"));
|
||||
}
|
||||
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
this._notifyStateChange();
|
||||
console.log("📝 命令历史已清空");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理器状态
|
||||
*/
|
||||
getState() {
|
||||
return {
|
||||
canUndo: this.undoStack.length > 0,
|
||||
canRedo: this.redoStack.length > 0,
|
||||
undoCount: this.undoStack.length,
|
||||
redoCount: this.redoStack.length,
|
||||
isExecuting: this.executing,
|
||||
isProcessing: this.processing,
|
||||
queueLength: this.commandQueue.length,
|
||||
|
||||
lastCommand:
|
||||
this.undoStack.length > 0
|
||||
? this.undoStack[this.undoStack.length - 1].constructor.name
|
||||
: null,
|
||||
nextRedoCommand:
|
||||
this.redoStack.length > 0
|
||||
? this.redoStack[this.redoStack.length - 1].constructor.name
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命令历史信息
|
||||
*/
|
||||
getHistory() {
|
||||
return {
|
||||
undoHistory: this.undoStack.map((cmd) => ({
|
||||
name: cmd.constructor.name,
|
||||
info: cmd.getInfo ? cmd.getInfo() : {},
|
||||
timestamp: cmd.timestamp,
|
||||
})),
|
||||
redoHistory: this.redoStack.map((cmd) => ({
|
||||
name: cmd.constructor.name,
|
||||
info: cmd.getInfo ? cmd.getInfo() : {},
|
||||
timestamp: cmd.timestamp,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
setChangeCallback(callback) {
|
||||
if (typeof callback === "function") {
|
||||
this.onStateChange = callback;
|
||||
} else {
|
||||
throw new Error("回调必须是一个函数");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个命令
|
||||
* @private
|
||||
*/
|
||||
async _executeCommand(command) {
|
||||
const result = command.execute();
|
||||
return this._isPromise(result) ? await result : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销单个命令
|
||||
* @private
|
||||
*/
|
||||
async _undoCommand(command) {
|
||||
if (typeof command.undo !== "function") {
|
||||
throw new Error(`命令 ${command.constructor.name} 不支持撤销`);
|
||||
}
|
||||
|
||||
const result = command.undo();
|
||||
return this._isPromise(result) ? await result : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为Promise
|
||||
* @private
|
||||
*/
|
||||
_isPromise(value) {
|
||||
return (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof value.then === "function" &&
|
||||
typeof value.catch === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制历史记录大小
|
||||
* @private
|
||||
*/
|
||||
_trimHistory() {
|
||||
if (this.undoStack.length > this.maxHistorySize) {
|
||||
this.undoStack.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录性能数据
|
||||
* @private
|
||||
*/
|
||||
_recordPerformance(type, commandName, duration) {
|
||||
if (this.performanceManager) {
|
||||
if (type === "execute") {
|
||||
this.performanceManager.recordExecution(commandName, duration);
|
||||
} else if (type === "undo") {
|
||||
this.performanceManager.recordUndo(commandName, duration);
|
||||
} else if (type === "redo") {
|
||||
this.performanceManager.recordRedo(commandName, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知状态变化
|
||||
* @private
|
||||
*/
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
try {
|
||||
this.onStateChange(this.getState());
|
||||
} catch (error) {
|
||||
console.error("状态变化回调执行失败:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命令管理器实例的工厂函数
|
||||
*/
|
||||
export function createCommandManager(options = {}) {
|
||||
return new CommandManager(options);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 简化版性能管理器
|
||||
* 提供基础的性能统计功能
|
||||
*/
|
||||
export class PerformanceManager {
|
||||
constructor() {
|
||||
this.stats = {
|
||||
totalExecutions: 0,
|
||||
totalUndos: 0,
|
||||
totalRedos: 0,
|
||||
totalExecutionTime: 0,
|
||||
totalUndoTime: 0,
|
||||
totalRedoTime: 0,
|
||||
commandStats: new Map(), // 每个命令的统计信息
|
||||
recentOperations: [], // 最近的操作记录
|
||||
};
|
||||
|
||||
this.maxRecentOperations = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录命令执行
|
||||
*/
|
||||
recordExecution(commandName, duration) {
|
||||
this.stats.totalExecutions++;
|
||||
this.stats.totalExecutionTime += duration;
|
||||
|
||||
this._updateCommandStats(commandName, "executions", duration);
|
||||
this._addRecentOperation("execute", commandName, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录撤销操作
|
||||
*/
|
||||
recordUndo(commandName, duration) {
|
||||
this.stats.totalUndos++;
|
||||
this.stats.totalUndoTime += duration;
|
||||
|
||||
this._updateCommandStats(commandName, "undos", duration);
|
||||
this._addRecentOperation("undo", commandName, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录重做操作
|
||||
*/
|
||||
recordRedo(commandName, duration) {
|
||||
this.stats.totalRedos++;
|
||||
this.stats.totalRedoTime += duration;
|
||||
|
||||
this._updateCommandStats(commandName, "redos", duration);
|
||||
this._addRecentOperation("redo", commandName, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
*/
|
||||
getStats() {
|
||||
const avgExecutionTime =
|
||||
this.stats.totalExecutions > 0
|
||||
? this.stats.totalExecutionTime / this.stats.totalExecutions
|
||||
: 0;
|
||||
|
||||
const avgUndoTime =
|
||||
this.stats.totalUndos > 0
|
||||
? this.stats.totalUndoTime / this.stats.totalUndos
|
||||
: 0;
|
||||
|
||||
const avgRedoTime =
|
||||
this.stats.totalRedos > 0
|
||||
? this.stats.totalRedoTime / this.stats.totalRedos
|
||||
: 0;
|
||||
|
||||
return {
|
||||
overview: {
|
||||
totalExecutions: this.stats.totalExecutions,
|
||||
totalUndos: this.stats.totalUndos,
|
||||
totalRedos: this.stats.totalRedos,
|
||||
avgExecutionTime: Number(avgExecutionTime.toFixed(2)),
|
||||
avgUndoTime: Number(avgUndoTime.toFixed(2)),
|
||||
avgRedoTime: Number(avgRedoTime.toFixed(2)),
|
||||
},
|
||||
commandBreakdown: Array.from(this.stats.commandStats.entries()).map(
|
||||
([name, stats]) => ({
|
||||
commandName: name,
|
||||
executions: stats.executions,
|
||||
undos: stats.undos,
|
||||
redos: stats.redos,
|
||||
avgExecutionTime:
|
||||
stats.executions > 0
|
||||
? Number((stats.totalExecutionTime / stats.executions).toFixed(2))
|
||||
: 0,
|
||||
avgUndoTime:
|
||||
stats.undos > 0
|
||||
? Number((stats.totalUndoTime / stats.undos).toFixed(2))
|
||||
: 0,
|
||||
avgRedoTime:
|
||||
stats.redos > 0
|
||||
? Number((stats.totalRedoTime / stats.redos).toFixed(2))
|
||||
: 0,
|
||||
})
|
||||
),
|
||||
recentOperations: this.stats.recentOperations.slice(-20), // 最近20个操作
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取慢命令报告
|
||||
*/
|
||||
getSlowCommandsReport(threshold = 100) {
|
||||
const slowCommands = [];
|
||||
|
||||
for (const [name, stats] of this.stats.commandStats.entries()) {
|
||||
const avgExecTime =
|
||||
stats.executions > 0 ? stats.totalExecutionTime / stats.executions : 0;
|
||||
const avgUndoTime =
|
||||
stats.undos > 0 ? stats.totalUndoTime / stats.undos : 0;
|
||||
|
||||
if (avgExecTime > threshold || avgUndoTime > threshold) {
|
||||
slowCommands.push({
|
||||
commandName: name,
|
||||
avgExecutionTime: Number(avgExecTime.toFixed(2)),
|
||||
avgUndoTime: Number(avgUndoTime.toFixed(2)),
|
||||
executions: stats.executions,
|
||||
undos: stats.undos,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slowCommands.sort(
|
||||
(a, b) =>
|
||||
Math.max(b.avgExecutionTime, b.avgUndoTime) -
|
||||
Math.max(a.avgExecutionTime, a.avgUndoTime)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置统计信息
|
||||
*/
|
||||
reset() {
|
||||
this.stats = {
|
||||
totalExecutions: 0,
|
||||
totalUndos: 0,
|
||||
totalRedos: 0,
|
||||
totalExecutionTime: 0,
|
||||
totalUndoTime: 0,
|
||||
totalRedoTime: 0,
|
||||
commandStats: new Map(),
|
||||
recentOperations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新命令统计信息
|
||||
* @private
|
||||
*/
|
||||
_updateCommandStats(commandName, type, duration) {
|
||||
if (!this.stats.commandStats.has(commandName)) {
|
||||
this.stats.commandStats.set(commandName, {
|
||||
executions: 0,
|
||||
undos: 0,
|
||||
redos: 0,
|
||||
totalExecutionTime: 0,
|
||||
totalUndoTime: 0,
|
||||
totalRedoTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const stats = this.stats.commandStats.get(commandName);
|
||||
|
||||
if (type === "executions") {
|
||||
stats.executions++;
|
||||
stats.totalExecutionTime += duration;
|
||||
} else if (type === "undos") {
|
||||
stats.undos++;
|
||||
stats.totalUndoTime += duration;
|
||||
} else if (type === "redos") {
|
||||
stats.redos++;
|
||||
stats.totalRedoTime += duration;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加最近操作记录
|
||||
* @private
|
||||
*/
|
||||
_addRecentOperation(type, commandName, duration) {
|
||||
this.stats.recentOperations.push({
|
||||
type,
|
||||
commandName,
|
||||
duration: Number(duration.toFixed(2)),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 限制记录数量
|
||||
if (this.stats.recentOperations.length > this.maxRecentOperations) {
|
||||
this.stats.recentOperations.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import { TransformCommand } from "../../commands/StateCommands";
|
||||
import { generateId } from "../../utils/helper";
|
||||
import { OperationType, OperationTypes } from "../../utils/layerHelper";
|
||||
|
||||
export class CanvasEventManager {
|
||||
constructor(canvas, options = {}) {
|
||||
this.canvas = canvas;
|
||||
this.toolManager = options.toolManager || null;
|
||||
this.animationManager = options.animationManager;
|
||||
this.thumbnailManager = options.thumbnailManager;
|
||||
this.editorMode = options.editorMode || OperationType.SELECT;
|
||||
this.activeElementId = options.activeElementId || { value: null };
|
||||
this.layerManager = options.layerManager || null;
|
||||
this.layers = options.layers || null;
|
||||
|
||||
// 事件处理的内部状态 - 优化设备检测
|
||||
this.deviceInfo = this._detectDeviceType();
|
||||
this.dragStartTime = 0;
|
||||
this.lastMousePositions = [];
|
||||
this.positionHistoryLimit = 5; // 追踪鼠标位置的历史记录,用于计算速度
|
||||
this.longPressTimer = null;
|
||||
this.longPressThreshold = 500;
|
||||
|
||||
// 初始化所有事件
|
||||
this.initEvents();
|
||||
}
|
||||
|
||||
initEvents() {
|
||||
this.setupZoomEvents();
|
||||
|
||||
// 优化三端设备的事件处理逻辑
|
||||
if (this.deviceInfo.isMobile || this.deviceInfo.isTablet) {
|
||||
// 真正的移动设备和平板设备使用触摸事件
|
||||
this.setupTouchEvents();
|
||||
} else {
|
||||
// PC 和 Mac 设备主要使用鼠标事件
|
||||
this.setupMouseEvents();
|
||||
}
|
||||
|
||||
// Mac 设备需要额外的触摸手势支持(用于特殊场景)
|
||||
if (this.deviceInfo.isMac && this.deviceInfo.hasTouchSupport) {
|
||||
this.setupMacTouchGestures();
|
||||
}
|
||||
|
||||
// 共享事件
|
||||
this.setupSelectionEvents();
|
||||
this.setupObjectEvents();
|
||||
this.setupDoubleClickEvents();
|
||||
|
||||
// this.setupHandlePathCreated();
|
||||
}
|
||||
|
||||
setupZoomEvents() {
|
||||
// 水平/垂直滚动相关状态
|
||||
this._scrollWheelEvents = [];
|
||||
this._scrollAccumulatedDelta = { x: 0, y: 0 };
|
||||
this._scrollAccumulationTimeout = null;
|
||||
this._scrollAccumulationTime = 100; // 降低滚轮累积时间窗口
|
||||
this._lastScrollTime = 0; // 跟踪上次滚动时间
|
||||
this._scrollThrottleDelay = 5; // 滚动节流延迟(毫秒)
|
||||
|
||||
// 缩放处理 - 使用动画管理器,针对 Mac 设备优化
|
||||
this.canvas.on("mouse:wheel", (opt) => {
|
||||
// Mac 设备双指滚动优化:确保滚动事件正确处理
|
||||
if (this.deviceInfo.isMac) {
|
||||
// Mac设备的简化处理逻辑,减少不必要的动画中断
|
||||
// 让动画管理器自行处理冲突,避免过度干预
|
||||
} else {
|
||||
// 非 Mac 设备的标准处理
|
||||
if (
|
||||
this.animationManager._panAnimation ||
|
||||
this.animationManager._zoomAnimation
|
||||
) {
|
||||
this.animationManager._wasPanning =
|
||||
!!this.animationManager._panAnimation;
|
||||
this.animationManager._wasZooming =
|
||||
!!this.animationManager._zoomAnimation;
|
||||
this.animationManager.smoothStopAnimations({ duration: 0.1 });
|
||||
}
|
||||
}
|
||||
|
||||
// 按住 Ctrl 键时实现垂直滚动(Mac 下是 Cmd 键)
|
||||
const isCtrlOrCmd = this.deviceInfo.isMac ? opt.e.metaKey : opt.e.ctrlKey;
|
||||
if (isCtrlOrCmd) {
|
||||
this.handleScrollWheel(opt, "vertical");
|
||||
opt.e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// 按住 Shift 键时实现水平滚动
|
||||
if (opt.e.shiftKey) {
|
||||
this.handleScrollWheel(opt, "horizontal");
|
||||
opt.e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// 标准缩放行为 - 让 AnimationManager 处理平滑过渡
|
||||
// Mac 设备下的双指滚动将直接进入这里进行缩放
|
||||
this.animationManager.handleMouseWheel(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理滚轮滚动事件
|
||||
* @param {Object} opt 滚轮事件对象
|
||||
* @param {String} direction 滚动方向: 'vertical' 或 'horizontal'
|
||||
*/
|
||||
handleScrollWheel(opt, direction) {
|
||||
// 获取当前视图变换
|
||||
const vpt = this.canvas.viewportTransform.slice(0); // 创建副本避免直接修改
|
||||
const zoom = this.canvas.getZoom();
|
||||
|
||||
// 计算滚动量 - 根据方向决定是水平还是垂直滚动
|
||||
let deltaX = 0;
|
||||
let deltaY = 0;
|
||||
|
||||
// 设置滚动方向和距离
|
||||
if (direction === "horizontal") {
|
||||
deltaX = opt.e.deltaY; // 水平滚动
|
||||
} else {
|
||||
deltaY = opt.e.deltaY; // 垂直滚动
|
||||
}
|
||||
|
||||
// 计算滚动因子,基于缩放级别和设备类型调整
|
||||
let scrollFactor = Math.max(0.4, Math.min(1, 1 / zoom));
|
||||
|
||||
// Mac 设备优化:触控板滚动通常比鼠标滚轮更敏感
|
||||
if (this.deviceInfo.isMac) {
|
||||
const isMacTrackpadScroll =
|
||||
Math.abs(opt.e.deltaY) < 100 && opt.e.deltaMode === 0;
|
||||
if (isMacTrackpadScroll) {
|
||||
// Mac 触控板滚动更细腻,需要调整滚动因子
|
||||
scrollFactor *= 0.8; // 降低滚动敏感度
|
||||
}
|
||||
}
|
||||
|
||||
// 直接应用滚动变化,不使用累积和计时器
|
||||
vpt[4] -= deltaX * scrollFactor;
|
||||
vpt[5] -= deltaY * scrollFactor;
|
||||
|
||||
// 直接设置新的视图变换,不使用动画
|
||||
this.canvas.setViewportTransform(vpt);
|
||||
|
||||
// 请求重新渲染画布
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理累积的滚轮滚动事件并应用平移
|
||||
* @private
|
||||
* @param {String} direction 滚动方向
|
||||
*/
|
||||
_processAccumulatedScroll(direction) {
|
||||
// 这个函数不再需要,但为了兼容性保留空实现
|
||||
// 所有滚动逻辑已经移到 handleScrollWheel 中直接处理
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有惯性动画
|
||||
* @param {boolean} smooth 是否平滑过渡,默认为 false(立即停止)
|
||||
*/
|
||||
stopInertiaAnimation(smooth = false) {
|
||||
if (this.animationManager) {
|
||||
if (this.animationManager._panAnimation && !smooth) {
|
||||
this.animationManager._panAnimation.kill();
|
||||
this.animationManager._panAnimation = null;
|
||||
}
|
||||
if (this.animationManager._zoomAnimation && !smooth) {
|
||||
this.animationManager._zoomAnimation.kill();
|
||||
this.animationManager._zoomAnimation = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置鼠标事件处理
|
||||
*/
|
||||
setupMouseEvents() {
|
||||
// 鼠标按下事件
|
||||
this.canvas.on("mouse:down", (opt) => {
|
||||
// 平滑停止任何正在进行的惯性动画
|
||||
this.stopInertiaAnimation(true);
|
||||
|
||||
if (
|
||||
opt.e.altKey ||
|
||||
opt.e.which === 2 ||
|
||||
this.editorMode === OperationType.PAN
|
||||
) {
|
||||
this.canvas.isDragging = true;
|
||||
this.canvas.lastPosX = opt.e.clientX;
|
||||
this.canvas.lastPosY = opt.e.clientY;
|
||||
this.canvas.defaultCursor = "grabbing";
|
||||
|
||||
// 记录拖动开始时间和位置,用于计算速度
|
||||
this.dragStartTime = Date.now();
|
||||
this.lastMousePositions = []; // 重置位置历史
|
||||
|
||||
if (this.canvas.isDragging) {
|
||||
this.canvas.selection = false;
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标移动事件
|
||||
this.canvas.on("mouse:move", (opt) => {
|
||||
if (!this.canvas.isDragging) return;
|
||||
|
||||
const vpt = this.canvas.viewportTransform;
|
||||
vpt[4] += opt.e.clientX - this.canvas.lastPosX;
|
||||
vpt[5] += opt.e.clientY - this.canvas.lastPosY;
|
||||
|
||||
// 记录鼠标位置和时间,用于计算惯性
|
||||
const now = Date.now();
|
||||
this.lastMousePositions.push({
|
||||
x: opt.e.clientX,
|
||||
y: opt.e.clientY,
|
||||
time: now,
|
||||
});
|
||||
|
||||
// 保持历史记录在限定数量内
|
||||
if (this.lastMousePositions.length > this.positionHistoryLimit) {
|
||||
this.lastMousePositions.shift();
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
this.canvas.lastPosX = opt.e.clientX;
|
||||
this.canvas.lastPosY = opt.e.clientY;
|
||||
});
|
||||
|
||||
// 鼠标抬起事件
|
||||
this.canvas.on("mouse:up", (opt) => {
|
||||
this.handleDragEnd(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置触摸事件处理
|
||||
*/
|
||||
setupTouchEvents() {
|
||||
// 触摸开始事件
|
||||
this.canvas.on("touch:gesture", (opt) => {
|
||||
// 平滑停止任何正在进行的惯性动画
|
||||
this.stopInertiaAnimation(true);
|
||||
|
||||
if (opt.e.touches && opt.e.touches.length === 2) {
|
||||
this.canvas.isDragging = true;
|
||||
this.canvas.lastPosX =
|
||||
(opt.e.touches[0].clientX + opt.e.touches[1].clientX) / 2;
|
||||
this.canvas.lastPosY =
|
||||
(opt.e.touches[0].clientY + opt.e.touches[1].clientY) / 2;
|
||||
|
||||
// 重置触摸位置历史
|
||||
this.dragStartTime = Date.now();
|
||||
this.lastMousePositions = [];
|
||||
|
||||
if (this.canvas.isDragging) {
|
||||
this.canvas.selection = false;
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
opt.e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// 单指触摸开始 - 处理拖动
|
||||
this.canvas.on("touch:drag", (opt) => {
|
||||
// 平滑停止任何正在进行的惯性动画
|
||||
this.stopInertiaAnimation(true);
|
||||
|
||||
if (this.editorMode === OperationType.PAN) {
|
||||
this.canvas.isDragging = true;
|
||||
this.canvas.lastPosX = opt.e.touches[0].clientX;
|
||||
this.canvas.lastPosY = opt.e.touches[0].clientY;
|
||||
|
||||
this.dragStartTime = Date.now();
|
||||
this.lastMousePositions = [];
|
||||
|
||||
if (this.canvas.isDragging) {
|
||||
this.canvas.selection = false;
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
opt.e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// 触摸移动事件
|
||||
this.canvas.on("touch:gesture:update", (opt) => {
|
||||
if (!this.canvas.isDragging) return;
|
||||
|
||||
if (opt.e.touches && opt.e.touches.length === 2) {
|
||||
const currentX =
|
||||
(opt.e.touches[0].clientX + opt.e.touches[1].clientX) / 2;
|
||||
const currentY =
|
||||
(opt.e.touches[0].clientY + opt.e.touches[1].clientY) / 2;
|
||||
|
||||
const vpt = this.canvas.viewportTransform;
|
||||
vpt[4] += currentX - this.canvas.lastPosX;
|
||||
vpt[5] += currentY - this.canvas.lastPosY;
|
||||
|
||||
// 记录触摸位置和时间
|
||||
const now = Date.now();
|
||||
this.lastMousePositions.push({
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
time: now,
|
||||
});
|
||||
|
||||
// 保持历史记录在限定数量内
|
||||
if (this.lastMousePositions.length > this.positionHistoryLimit) {
|
||||
this.lastMousePositions.shift();
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
this.canvas.lastPosX = currentX;
|
||||
this.canvas.lastPosY = currentY;
|
||||
opt.e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// 单指拖动更新
|
||||
this.canvas.on("touch:drag:update", (opt) => {
|
||||
if (!this.canvas.isDragging || this.editorMode !== OperationType.PAN)
|
||||
return;
|
||||
|
||||
const currentX = opt.e.touches[0].clientX;
|
||||
const currentY = opt.e.touches[0].clientY;
|
||||
|
||||
const vpt = this.canvas.viewportTransform;
|
||||
vpt[4] += currentX - this.canvas.lastPosX;
|
||||
vpt[5] += currentY - this.canvas.lastPosY;
|
||||
|
||||
// 记录触摸位置和时间
|
||||
const now = Date.now();
|
||||
this.lastMousePositions.push({
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
time: now,
|
||||
});
|
||||
|
||||
if (this.lastMousePositions.length > this.positionHistoryLimit) {
|
||||
this.lastMousePositions.shift();
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
this.canvas.lastPosX = currentX;
|
||||
this.canvas.lastPosY = currentY;
|
||||
opt.e.preventDefault();
|
||||
});
|
||||
|
||||
// 触摸结束事件
|
||||
this.canvas.on("touch:gesture:end", (opt) => {
|
||||
this.handleDragEnd(opt, true);
|
||||
});
|
||||
|
||||
// 单指拖动结束
|
||||
this.canvas.on("touch:drag:end", (opt) => {
|
||||
this.handleDragEnd(opt, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拖动结束(鼠标抬起或触摸结束)
|
||||
*/
|
||||
handleDragEnd(opt, isTouch = false) {
|
||||
if (this.canvas.isDragging) {
|
||||
// 使用动画管理器处理惯性效果
|
||||
if (this.lastMousePositions.length > 1 && opt && opt.e) {
|
||||
this.animationManager.applyInertiaEffect(
|
||||
this.lastMousePositions,
|
||||
isTouch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas.isDragging = false;
|
||||
|
||||
if (this.toolManager) {
|
||||
this.toolManager.restoreSelectionState(); // 恢复选择状态
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
setupSelectionEvents() {
|
||||
// 监听对象选择事件
|
||||
this.canvas.on("selection:created", (opt) => this.updateSelectedLayer(opt));
|
||||
|
||||
this.canvas.on("selection:updated", (opt) => this.updateSelectedLayer(opt));
|
||||
|
||||
// this.canvas.on("selection:cleared", () => this.clearSelectedElements());
|
||||
}
|
||||
|
||||
setupObjectEvents() {
|
||||
// 监听对象变化事件,用于更新缩略图
|
||||
this.canvas.on("object:added", (e) => {
|
||||
if (this.thumbnailManager && e.target && e.target.id) {
|
||||
// 延迟更新以确保对象完全添加
|
||||
setTimeout(() => {
|
||||
// 现在图层就是元素本身,直接更新元素的缩略图
|
||||
this.thumbnailManager.generateLayerThumbnail(
|
||||
e.target.layerId,
|
||||
e.target
|
||||
);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加对象开始变换时的状态捕获
|
||||
this.canvas.on(
|
||||
"object:moving",
|
||||
this._captureInitialTransformState.bind(this)
|
||||
);
|
||||
this.canvas.on(
|
||||
"object:scaling",
|
||||
this._captureInitialTransformState.bind(this)
|
||||
);
|
||||
this.canvas.on(
|
||||
"object:rotating",
|
||||
this._captureInitialTransformState.bind(this)
|
||||
);
|
||||
this.canvas.on(
|
||||
"object:skewing",
|
||||
this._captureInitialTransformState.bind(this)
|
||||
);
|
||||
|
||||
this.canvas.on("object:modified", (e) => {
|
||||
// 移除调试日志
|
||||
// console.log("object:modified", e);
|
||||
|
||||
const activeObj = e.target || this.canvas.getActiveObject();
|
||||
|
||||
if (activeObj && this.layerManager?.commandManager) {
|
||||
// 使用新的轻量级 TransformCommand 替代完整状态保存
|
||||
// 检查对象是否有初始变换状态记录
|
||||
if (activeObj._initialTransformState) {
|
||||
// 创建并执行 TransformCommand,只记录变换属性的变化
|
||||
const transformCmd = new TransformCommand({
|
||||
canvas: this.canvas,
|
||||
objectId: activeObj.id,
|
||||
initialState: activeObj._initialTransformState,
|
||||
finalState: TransformCommand.captureTransformState(activeObj),
|
||||
objectType: activeObj.type,
|
||||
name: `变换 ${activeObj.type || "对象"}`,
|
||||
});
|
||||
|
||||
// 执行并将命令添加到历史栈
|
||||
this.layerManager.commandManager.execute(transformCmd, {
|
||||
name: "对象修改",
|
||||
});
|
||||
|
||||
// 清除临时状态记录
|
||||
delete activeObj._initialTransformState;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.thumbnailManager && e.target) {
|
||||
if (e.target.id) {
|
||||
this.updateLayerThumbnail(e.target.id, e.target);
|
||||
|
||||
// 如果该元素是分组图层的一部分,也更新分组图层的缩略图
|
||||
if (e.target.parentId) {
|
||||
this.updateLayerThumbnail(e.target.parentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.on("object:removed", (e) => {
|
||||
if (this.thumbnailManager && e.target) {
|
||||
if (e.target.id) {
|
||||
this.thumbnailManager.clearElementThumbnail(e.target.id);
|
||||
|
||||
// 如果该元素是分组图层的一部分,也更新分组图层的缩略图
|
||||
if (e.target.parentId) {
|
||||
setTimeout(() => this.updateLayerThumbnail(e.target.parentId), 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// // 鼠标抬起时,检查是否需要保存状态
|
||||
// this.canvas.on("mouse:up", (e) => {
|
||||
// // 只在选择模式下处理对象变换的状态保存
|
||||
// if (this.editorMode !== OperationType.SELECT) {
|
||||
// // 绘画、擦除等模式通过各自的命令管理状态,不需要在这里保存
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const activeObj = this.canvas.getActiveObject();
|
||||
// if (
|
||||
// activeObj &&
|
||||
// activeObj._stateRecord &&
|
||||
// activeObj._stateRecord.isModifying
|
||||
// ) {
|
||||
// const original = activeObj._stateRecord.originalState;
|
||||
|
||||
// // 检查是否是真正的变换操作(移动、缩放、旋转)
|
||||
// const hasTransformChanged =
|
||||
// original.left !== activeObj.left ||
|
||||
// original.top !== activeObj.top ||
|
||||
// original.scaleX !== activeObj.scaleX ||
|
||||
// original.scaleY !== activeObj.scaleY ||
|
||||
// original.angle !== activeObj.angle;
|
||||
|
||||
// // 只有在对象发生变换且不是命令执行过程中时才保存状态
|
||||
// if (hasTransformChanged && this.layerManager) {
|
||||
// // 立即保存状态,而不是延迟执行
|
||||
// this.layerManager.saveCanvasState();
|
||||
// delete activeObj._stateRecord;
|
||||
// } else {
|
||||
// // 清理状态记录,即使没有保存状态
|
||||
// delete activeObj._stateRecord;
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
setupDoubleClickEvents() {
|
||||
// 双击处理
|
||||
this.canvas.on("mouse:dblclick", (opt) => {
|
||||
if (opt.target) {
|
||||
// 双击对象的特殊处理
|
||||
} else {
|
||||
// 双击空白处重置缩放
|
||||
if (this.animationManager) {
|
||||
this.animationManager.resetZoom(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupLongPress(callback) {
|
||||
this.canvas.on("mouse:down", (opt) => {
|
||||
if (!opt.target) return;
|
||||
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
callback(opt);
|
||||
}, this.longPressThreshold);
|
||||
});
|
||||
|
||||
this.canvas.on("mouse:up", () => {
|
||||
clearTimeout(this.longPressTimer);
|
||||
});
|
||||
|
||||
this.canvas.on("mouse:move", () => {
|
||||
clearTimeout(this.longPressTimer);
|
||||
});
|
||||
}
|
||||
|
||||
// 设置路径创建事件
|
||||
setupHandlePathCreated() {
|
||||
// 在 CanvasEventManager 的构造函数或初始化方法中
|
||||
// this.canvas.on("path:created", this._handlePathCreated.bind(this));
|
||||
}
|
||||
|
||||
_handlePathCreated(e) {
|
||||
// // 获取新创建的路径对象
|
||||
// const path = e.path;
|
||||
// // 设置路径的ID和其他属性
|
||||
// path.id = generateId(); // 生成唯一ID
|
||||
// // 获取当前活动图层
|
||||
// const activeLayer = this.layerManager.getActiveLayer();
|
||||
// // 将路径对象绑定到当前活动图层
|
||||
// if (activeLayer) {
|
||||
// // 设置路径的图层ID
|
||||
// path.layerId = activeLayer.id;
|
||||
// // 更新图层对象列表
|
||||
// if (!activeLayer.fabricObjects) activeLayer.fabricObjects = [];
|
||||
// activeLayer.fabricObjects.push(path);
|
||||
// // 更新图层缩略图
|
||||
// if (this.thumbnailManager) {
|
||||
// this.thumbnailManager.generateLayerThumbnail(activeLayer.id);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并图层中的对象为图像以提高性能
|
||||
* @param {Object} options 合并选项
|
||||
* @param {fabric.Image} options.fabricImage 新的图像对象
|
||||
* @param {Object} options.activeLayer 当前活动图层
|
||||
* @private
|
||||
*/
|
||||
async mergeLayerObjectsForPerformance({ fabricImage, activeLayer }) {
|
||||
// 确保有命令管理器
|
||||
if (!this.layerManager || !this.layerManager.commandManager) {
|
||||
console.warn("合并对象失败:没有命令管理器");
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保有活动图层
|
||||
if (!activeLayer) {
|
||||
console.warn("合并对象失败:没有活动图层");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证是否需要合并
|
||||
const hasExistingObjects =
|
||||
Array.isArray(activeLayer.fabricObjects) &&
|
||||
activeLayer.fabricObjects.length > 0;
|
||||
const hasNewImage = !!fabricImage;
|
||||
|
||||
if (!hasExistingObjects && !hasNewImage) {
|
||||
console.log("没有对象需要合并");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果只有一个新图像且图层为空,直接添加到图层
|
||||
if (hasNewImage && !hasExistingObjects) {
|
||||
this.layerManager.addObjectToLayer(fabricImage, activeLayer.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行高保真合并操作
|
||||
try {
|
||||
console.log(`开始合并图层 ${activeLayer.name} 中的对象为组...`);
|
||||
|
||||
const command = await this.layerManager.LayerObjectsToGroup(
|
||||
activeLayer,
|
||||
fabricImage
|
||||
);
|
||||
|
||||
this.layerManager?.commandManager?.execute?.(command, {
|
||||
name: `合并图层 ${activeLayer.name} 中的对象为组`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("合并图层对象时发生错误:", error);
|
||||
|
||||
// 降级处理:如果合并失败,至少保证新图像能添加到图层
|
||||
if (fabricImage && this.layerManager) {
|
||||
console.log("执行降级处理:直接添加图像到图层");
|
||||
this.layerManager.addObjectToLayer(fabricImage, activeLayer.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedLayer(opt) {
|
||||
const selected = opt.selected[0];
|
||||
if (selected) {
|
||||
this.layerManager.activeLayerId.value = selected.layerId;
|
||||
}
|
||||
}
|
||||
|
||||
// clearSelectedElements() {
|
||||
// this.activeElementId.value = null;
|
||||
// }
|
||||
|
||||
// 更新图层缩略图
|
||||
updateLayerThumbnail(layerId) {
|
||||
if (!this.thumbnailManager || !layerId || !this.layers) return;
|
||||
|
||||
const layer = this.layers.value.find((l) => l.id === layerId);
|
||||
if (layer) {
|
||||
this.thumbnailManager.generateLayerThumbnail(layer);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新子元素组合缩略图
|
||||
updateLayerChidrenThumbnail(layerId, fabricObject) {
|
||||
if (!this.thumbnailManager || !fabricObject || !this.layers) return;
|
||||
|
||||
// 查找对应的图层(现在元素就是图层)
|
||||
const layer = this.layers.value.find(
|
||||
(l) =>
|
||||
l.id === elementId ||
|
||||
(l.fabricObjects && l.fabricObjects?.[0]?.id === layerId)
|
||||
);
|
||||
|
||||
if (layer) {
|
||||
// 生成图层缩略图
|
||||
this.thumbnailManager.generateLayerThumbnail(layer);
|
||||
}
|
||||
|
||||
// 同时也维护元素缩略图,以保持向后兼容性
|
||||
this.thumbnailManager.generateElementThumbnail(
|
||||
{ id: elementId, type: fabricObject.type },
|
||||
fabricObject
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置编辑器模式
|
||||
* @param {string} mode 编辑器模式
|
||||
*/
|
||||
setEditorMode(mode) {
|
||||
if (!OperationTypes.includes(mode)) {
|
||||
console.warn(`不支持的编辑器模式: ${mode}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换工具时,立即停止任何惯性动画,但使用平滑过渡
|
||||
this.stopInertiaAnimation(true);
|
||||
|
||||
this.editorMode = mode;
|
||||
|
||||
// 如果切换到选择模式,还原鼠标指针
|
||||
if (mode === OperationType.SELECT) {
|
||||
this.canvas.defaultCursor = "default";
|
||||
} else if (mode === OperationType.PAN) {
|
||||
this.canvas.defaultCursor = "grab";
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// 移除所有事件监听
|
||||
this.canvas.off();
|
||||
|
||||
// 清理 Mac 专用的原生事件监听器
|
||||
if (this.deviceInfo.isMac && this.canvas.upperCanvasEl) {
|
||||
const upperCanvas = this.canvas.upperCanvasEl;
|
||||
|
||||
// 移除手势事件监听器
|
||||
upperCanvas.removeEventListener("gesturestart", null);
|
||||
upperCanvas.removeEventListener("gesturechange", null);
|
||||
upperCanvas.removeEventListener("gestureend", null);
|
||||
upperCanvas.removeEventListener("wheel", null);
|
||||
}
|
||||
|
||||
// 清除计时器
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
|
||||
// 停止所有动画
|
||||
this.stopInertiaAnimation();
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获对象开始变换时的初始状态
|
||||
* @private
|
||||
* @param {Object} e 事件对象
|
||||
*/
|
||||
_captureInitialTransformState(e) {
|
||||
const obj = e.target;
|
||||
|
||||
// 只在首次触发变换事件时记录初始状态
|
||||
if (obj && !obj._initialTransformState && obj.id) {
|
||||
// 捕获对象的初始变换状态
|
||||
obj._initialTransformState = TransformCommand.captureTransformState(obj);
|
||||
|
||||
// 添加调试日志(可选)
|
||||
// console.log(`捕获对象 ${obj.id} (${obj.type}) 的初始变换状态`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 精确检测设备类型,区分 PC、Mac、平板和移动设备
|
||||
* @private
|
||||
* @returns {Object} 设备信息对象
|
||||
*/
|
||||
_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);
|
||||
|
||||
// 检测设备类型
|
||||
const isMobile = /mobile|phone|android.*mobile|iphone/.test(userAgent);
|
||||
const isTablet = /tablet|ipad|android(?!.*mobile)/.test(userAgent);
|
||||
const isDesktop = !isMobile && !isTablet;
|
||||
|
||||
// 检测浏览器类型(用于特定优化)
|
||||
const isSafari = /safari/.test(userAgent) && !/chrome/.test(userAgent);
|
||||
const isChrome = /chrome/.test(userAgent);
|
||||
const isFirefox = /firefox/.test(userAgent);
|
||||
|
||||
return {
|
||||
isMac,
|
||||
isWindows,
|
||||
isLinux,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isSafari,
|
||||
isChrome,
|
||||
isFirefox,
|
||||
hasTouchSupport,
|
||||
// 判断是否应该使用触摸事件作为主要交互方式
|
||||
preferTouchEvents: (isMobile || isTablet) && !isDesktop,
|
||||
// 判断是否需要特殊的 Mac 触控板处理
|
||||
needsMacTrackpadOptimization: isMac && isDesktop && hasTouchSupport,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Mac 专用的触摸手势处理
|
||||
* 主要用于处理触控板的多指手势,但不干扰双指滚动的缩放功能
|
||||
*/
|
||||
setupMacTouchGestures() {
|
||||
// Mac 触控板专用:三指拖拽进行画布平移
|
||||
let macGestureState = {
|
||||
isThreeFingerDrag: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
};
|
||||
|
||||
// 监听 Mac 专用的手势事件
|
||||
this.canvas.upperCanvasEl.addEventListener(
|
||||
"gesturestart",
|
||||
(e) => {
|
||||
// 阻止浏览器默认的手势行为,但保留双指缩放
|
||||
if (e.scale !== 1) {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
this.canvas.upperCanvasEl.addEventListener(
|
||||
"gesturechange",
|
||||
(e) => {
|
||||
// 只处理三指以上的手势,保留双指缩放给 mouse:wheel 事件
|
||||
if (e.touches && e.touches.length >= 3) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!macGestureState.isThreeFingerDrag) {
|
||||
macGestureState.isThreeFingerDrag = true;
|
||||
macGestureState.startX = e.pageX;
|
||||
macGestureState.startY = e.pageY;
|
||||
this.canvas.isDragging = true;
|
||||
this.canvas.lastPosX = e.pageX;
|
||||
this.canvas.lastPosY = e.pageY;
|
||||
this.stopInertiaAnimation(true);
|
||||
} else {
|
||||
// 执行三指拖拽平移
|
||||
const vpt = this.canvas.viewportTransform;
|
||||
vpt[4] += e.pageX - this.canvas.lastPosX;
|
||||
vpt[5] += e.pageY - this.canvas.lastPosY;
|
||||
|
||||
this.canvas.renderAll();
|
||||
this.canvas.lastPosX = e.pageX;
|
||||
this.canvas.lastPosY = e.pageY;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
this.canvas.upperCanvasEl.addEventListener(
|
||||
"gestureend",
|
||||
(e) => {
|
||||
if (macGestureState.isThreeFingerDrag) {
|
||||
macGestureState.isThreeFingerDrag = false;
|
||||
this.canvas.isDragging = false;
|
||||
|
||||
if (this.toolManager) {
|
||||
this.toolManager.restoreSelectionState();
|
||||
}
|
||||
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// 添加 Mac 专用的鼠标滚轮优化,确保双指滚动正常工作
|
||||
this.setupMacScrollOptimization();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mac 滚轮优化:确保双指滚动正确触发缩放
|
||||
*/
|
||||
setupMacScrollOptimization() {
|
||||
if (!this.deviceInfo.isMac) return;
|
||||
|
||||
// Mac 下的滚轮事件优化
|
||||
let macScrollState = {
|
||||
lastWheelTime: 0,
|
||||
wheelTimeout: null,
|
||||
};
|
||||
|
||||
// 监听原生滚轮事件,确保 Mac 双指滚动正确处理
|
||||
this.canvas.upperCanvasEl.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Mac 双指滚动的特征:通常有较高的 deltaY 精度和连续性
|
||||
const isMacTrackpadScroll =
|
||||
this.deviceInfo.isMac &&
|
||||
Math.abs(e.deltaY) < 100 && // 像素模式
|
||||
e.deltaMode === 0; // 像素模式
|
||||
|
||||
if (isMacTrackpadScroll) {
|
||||
// 清除之前的超时
|
||||
if (macScrollState.wheelTimeout) {
|
||||
clearTimeout(macScrollState.wheelTimeout);
|
||||
}
|
||||
|
||||
// 确保这个事件会被 Fabric.js 的 mouse:wheel 正确处理
|
||||
macScrollState.lastWheelTime = now;
|
||||
|
||||
// 设置短暂延迟,防止与触摸事件冲突
|
||||
macScrollState.wheelTimeout = setTimeout(() => {
|
||||
// 滚轮事件处理完成
|
||||
}, 16); // 约一帧的时间
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* 键盘管理器
|
||||
* 负责处理编辑器中的键盘事件和快捷键
|
||||
* 支持PC、Mac和iPad三端适配
|
||||
*/
|
||||
export class KeyboardManager {
|
||||
/**
|
||||
* 创建键盘管理器
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Object} options.toolManager 工具管理器实例
|
||||
* @param {Object} options.commandManager 命令管理器实例
|
||||
* @param {Object} options.layerManager 图层管理器实例
|
||||
* @param {HTMLElement} options.container 容器元素,用于添加事件监听
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.toolManager = options.toolManager;
|
||||
this.commandManager = options.commandManager;
|
||||
this.layerManager = options.layerManager;
|
||||
this.container = options.container || document;
|
||||
|
||||
// 检测平台类型
|
||||
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._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: "粘贴" },
|
||||
[`${cmdOrCtrl}+x`]: { action: "cut", description: "剪切" },
|
||||
|
||||
// 删除
|
||||
delete: { action: "delete", description: "删除" },
|
||||
backspace: { action: "delete", 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: "重做" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化并开始监听键盘事件
|
||||
*/
|
||||
init() {
|
||||
// 添加键盘事件监听
|
||||
this.container.addEventListener("keydown", this._handleKeyDown);
|
||||
this.container.addEventListener("keyup", this._handleKeyUp);
|
||||
|
||||
// 如果是触摸设备,添加触摸事件监听
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理键盘按下事件
|
||||
* @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}+`)) {
|
||||
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("复制当前选中图层");
|
||||
this.layerManager.copyLayer(this.layerManager.activeLayerId.value);
|
||||
break;
|
||||
|
||||
case "paste":
|
||||
// 粘贴逻辑
|
||||
console.log("粘贴");
|
||||
this.layerManager.pasteLayer();
|
||||
break;
|
||||
|
||||
case "cut":
|
||||
// 剪切逻辑
|
||||
console.log("剪切");
|
||||
this.layerManager.cutLayer(this.layerManager.activeLayerId.value);
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
// 删除逻辑
|
||||
console.log("删除");
|
||||
this.layerManager.removeLayer(this.layerManager.activeLayerId.value);
|
||||
break;
|
||||
|
||||
case "selectAll":
|
||||
// 全选逻辑
|
||||
console.log("全选");
|
||||
// 这里需要实现全选逻辑 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 "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.container.removeEventListener("keydown", this._handleKeyDown);
|
||||
this.container.removeEventListener("keyup", this._handleKeyUp);
|
||||
|
||||
// 如果有触摸事件,也移除它们
|
||||
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);
|
||||
}
|
||||
|
||||
// 清除引用
|
||||
this.toolManager = null;
|
||||
this.commandManager = null;
|
||||
this.layerManager = null;
|
||||
this.container = null;
|
||||
this.customHandlers = {};
|
||||
this.tempToolState = { active: false, originalTool: null };
|
||||
this.touchState = {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,702 @@
|
||||
/**
|
||||
* 增强版液化管理器
|
||||
* 整合WebGL和CPU实现,智能选择最佳渲染方式
|
||||
*/
|
||||
import { LiquifyWebGLManager } from "./LiquifyWebGLManager";
|
||||
import { LiquifyCPUManager } from "./LiquifyCPUManager";
|
||||
|
||||
export class EnhancedLiquifyManager {
|
||||
/**
|
||||
* 创建增强版液化管理器
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.config = {
|
||||
// 性能阈值:图像超过此尺寸会尝试使用WebGL
|
||||
webglSizeThreshold: options.webglSizeThreshold || 1000 * 1000, // 默认100万像素
|
||||
// 是否强制使用CPU模式
|
||||
forceCPU: options.forceCPU || false,
|
||||
// 是否强制使用WebGL模式
|
||||
forceWebGL: options.forceWebGL || false,
|
||||
// 网格大小
|
||||
gridSize: options.gridSize || 15,
|
||||
// 最大变形强度
|
||||
maxStrength: options.maxStrength || 100,
|
||||
// 平滑迭代次数
|
||||
smoothingIterations: options.smoothingIterations || 2,
|
||||
// 网格弹性因子
|
||||
relaxFactor: options.relaxFactor || 0.25,
|
||||
// WebGL网格精度
|
||||
meshResolution: options.meshResolution || 64,
|
||||
};
|
||||
|
||||
// 性能监控
|
||||
this.performance = {
|
||||
lastOperationTime: 0,
|
||||
renderTimes: [], // 最近的渲染时间记录
|
||||
isPerformanceIssue: false, // 是否存在性能问题
|
||||
operationCount: 0, // 操作计数
|
||||
};
|
||||
|
||||
// 初始化标志
|
||||
this.initialized = false;
|
||||
|
||||
// 当前参数
|
||||
this.params = {
|
||||
size: 50, // 工具尺寸
|
||||
pressure: 0.5, // 压力大小 (0-1)
|
||||
distortion: 0, // 失真程度 (0-1)
|
||||
power: 0.5, // 动力/强度 (0-1)
|
||||
};
|
||||
|
||||
// 液化工具模式
|
||||
this.modes = {
|
||||
PUSH: "push",
|
||||
CLOCKWISE: "clockwise",
|
||||
COUNTERCLOCKWISE: "counterclockwise",
|
||||
PINCH: "pinch",
|
||||
EXPAND: "expand",
|
||||
CRYSTAL: "crystal",
|
||||
EDGE: "edge",
|
||||
RECONSTRUCT: "reconstruct",
|
||||
};
|
||||
|
||||
// 当前模式
|
||||
this.currentMode = this.modes.PUSH;
|
||||
|
||||
// 图像数据和目标对象
|
||||
this.originalImageData = null;
|
||||
this.currentImageData = null;
|
||||
this.targetObject = null;
|
||||
this.targetLayerId = null;
|
||||
|
||||
// 创建渲染器实例
|
||||
this.webglRenderer = null;
|
||||
this.cpuRenderer = null;
|
||||
|
||||
// 当前激活的渲染器
|
||||
this.activeRenderer = null;
|
||||
this.renderMode = "unknown"; // 'webgl', 'cpu', 'unknown'
|
||||
|
||||
// 画布和管理器引用
|
||||
this.canvas = options.canvas || null;
|
||||
this.layerManager = options.layerManager || null;
|
||||
|
||||
// 渲染器状态
|
||||
this.isWebGLAvailable = LiquifyWebGLManager.isSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化液化管理器
|
||||
* @param {Object} options 配置选项
|
||||
* @returns {Boolean} 是否初始化成功
|
||||
*/
|
||||
initialize(options = {}) {
|
||||
if (options.canvas) this.canvas = options.canvas;
|
||||
if (options.layerManager) this.layerManager = options.layerManager;
|
||||
|
||||
if (!this.canvas || !this.layerManager) {
|
||||
console.error("液化管理器初始化失败:缺少canvas或layerManager");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录初始化时间,用于性能监控
|
||||
this.performance.lastInitTime = Date.now();
|
||||
|
||||
// 创建CPU渲染器 (始终创建作为备选)
|
||||
this.cpuRenderer = new LiquifyCPUManager({
|
||||
gridSize: this.config.gridSize,
|
||||
maxStrength: this.config.maxStrength,
|
||||
smoothingIterations: this.config.smoothingIterations,
|
||||
relaxFactor: this.config.relaxFactor,
|
||||
});
|
||||
|
||||
// 检查是否应创建WebGL渲染器
|
||||
if (this.isWebGLAvailable && !this.config.forceCPU) {
|
||||
this.webglRenderer = new LiquifyWebGLManager({
|
||||
gridSize: this.config.gridSize,
|
||||
maxStrength: this.config.maxStrength,
|
||||
meshResolution: this.config.meshResolution,
|
||||
});
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为液化操作准备图像
|
||||
* @param {Object|String} target 目标对象或图层ID
|
||||
* @returns {Promise<Object>} 准备结果
|
||||
*/
|
||||
async prepareForLiquify(target) {
|
||||
if (!this.initialized) {
|
||||
throw new Error("液化管理器未初始化");
|
||||
}
|
||||
|
||||
let targetObject, targetLayerId;
|
||||
|
||||
// 处理传入的是图层ID的情况
|
||||
if (typeof target === "string") {
|
||||
targetLayerId = target;
|
||||
const layer = this.layerManager.getLayerById(targetLayerId);
|
||||
|
||||
// 检查图层是否存在和是否有对象
|
||||
let hasObjects = false;
|
||||
if (layer) {
|
||||
if (layer.type === "background" && layer.fabricObject) {
|
||||
hasObjects = true;
|
||||
targetObject = layer.fabricObject;
|
||||
} else if (layer.fabricObjects && layer.fabricObjects.length > 0) {
|
||||
hasObjects = true;
|
||||
targetObject = layer.fabricObjects[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasObjects) {
|
||||
throw new Error("目标图层为空或不存在");
|
||||
}
|
||||
} else if (typeof target === "object") {
|
||||
// 传入的是对象
|
||||
targetObject = target;
|
||||
const layer = this.layerManager.findLayerByObject(targetObject);
|
||||
if (layer) {
|
||||
targetLayerId = layer.id;
|
||||
} else {
|
||||
throw new Error("无法找到目标对象所属图层");
|
||||
}
|
||||
} else {
|
||||
throw new Error("无效的目标参数");
|
||||
}
|
||||
|
||||
// 检查是否为图像对象
|
||||
if (!targetObject || targetObject.type !== "image") {
|
||||
throw new Error("目标对象不是图像,无法进行液化操作");
|
||||
}
|
||||
|
||||
// 保存目标对象引用
|
||||
this.targetObject = targetObject;
|
||||
this.targetLayerId = targetLayerId;
|
||||
|
||||
// 获取图像数据
|
||||
const imageData = await this._getImageData(targetObject);
|
||||
if (!imageData) {
|
||||
throw new Error("无法获取图像数据");
|
||||
}
|
||||
|
||||
// 保存原始图像数据
|
||||
this.originalImageData = imageData;
|
||||
this.currentImageData = this._cloneImageData(imageData);
|
||||
|
||||
// 检查图像大小,选择适合的渲染器
|
||||
await this._selectRenderer(imageData);
|
||||
|
||||
// 预热选定的渲染器
|
||||
await this._warmupRenderer(imageData);
|
||||
|
||||
return {
|
||||
targetObject: this.targetObject,
|
||||
targetLayerId: this.targetLayerId,
|
||||
imageData: this.currentImageData,
|
||||
originalImageData: this.originalImageData,
|
||||
renderMode: this.renderMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据图像大小和设备性能选择渲染器
|
||||
* @param {ImageData} imageData 图像数据
|
||||
* @private
|
||||
*/
|
||||
async _selectRenderer(imageData) {
|
||||
// 计算图像大小
|
||||
const pixelCount = imageData.width * imageData.height;
|
||||
|
||||
console.log(
|
||||
`液化选择渲染器: 图像大小=${pixelCount}像素, WebGL可用=${this.isWebGLAvailable}`
|
||||
);
|
||||
|
||||
// 默认使用CPU渲染器
|
||||
this.activeRenderer = this.cpuRenderer;
|
||||
this.renderMode = "cpu";
|
||||
|
||||
// 如果配置强制使用WebGL
|
||||
if (this.config.forceWebGL && this.isWebGLAvailable && this.webglRenderer) {
|
||||
console.log("液化功能: 强制使用WebGL渲染模式");
|
||||
this.activeRenderer = this.webglRenderer;
|
||||
this.renderMode = "webgl";
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果配置强制使用CPU
|
||||
if (this.config.forceCPU) {
|
||||
console.log("液化功能: 强制使用CPU渲染模式");
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据图像大小和WebGL可用性决定
|
||||
if (
|
||||
pixelCount > this.config.webglSizeThreshold / 2 && // 降低阈值,让更多尺寸的图像使用WebGL
|
||||
this.isWebGLAvailable &&
|
||||
this.webglRenderer
|
||||
) {
|
||||
// 切换到WebGL渲染器
|
||||
console.log("液化功能: 自动选择WebGL渲染模式(基于图像尺寸)");
|
||||
this.activeRenderer = this.webglRenderer;
|
||||
this.renderMode = "webgl";
|
||||
} else {
|
||||
console.log(
|
||||
`液化功能: 使用CPU渲染模式${
|
||||
!this.isWebGLAvailable ? " (WebGL不可用)" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预热渲染器
|
||||
* @param {ImageData} imageData 图像数据
|
||||
* @private
|
||||
*/
|
||||
async _warmupRenderer(imageData) {
|
||||
// 创建图像元素
|
||||
const img = document.createElement("img");
|
||||
|
||||
// 将ImageData转换为URL
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// 使用Promise等待图像加载
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
|
||||
// 初始化当前渲染器
|
||||
if (this.activeRenderer) {
|
||||
if (this.renderMode === "webgl") {
|
||||
this.activeRenderer.initialize(img);
|
||||
} else {
|
||||
this.activeRenderer.initialize(imageData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置液化模式
|
||||
* @param {String} mode 模式名称
|
||||
*/
|
||||
setMode(mode) {
|
||||
if (Object.values(this.modes).includes(mode)) {
|
||||
this.currentMode = mode;
|
||||
|
||||
// 同步更新当前渲染器
|
||||
if (this.activeRenderer) {
|
||||
this.activeRenderer.setMode(mode);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置液化参数
|
||||
* @param {String} param 参数名称
|
||||
* @param {Number} value 参数值
|
||||
*/
|
||||
setParam(param, value) {
|
||||
if (param in this.params) {
|
||||
this.params[param] = value;
|
||||
|
||||
// 同步更新当前渲染器
|
||||
if (this.activeRenderer) {
|
||||
this.activeRenderer.setParam(param, value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前参数
|
||||
* @returns {Object} 当前参数对象
|
||||
*/
|
||||
getParams() {
|
||||
return { ...this.params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置参数为默认值
|
||||
*/
|
||||
resetParams() {
|
||||
this.params = {
|
||||
size: 50,
|
||||
pressure: 0.5,
|
||||
distortion: 0,
|
||||
power: 0.5,
|
||||
};
|
||||
|
||||
// 同步更新当前渲染器
|
||||
if (this.activeRenderer) {
|
||||
this.activeRenderer.resetParams();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用液化变形
|
||||
* @param {Object} target 目标对象
|
||||
* @param {String} mode 液化模式
|
||||
* @param {Object} params 液化参数
|
||||
* @param {Number} x 操作中心点X坐标 (图像像素坐标)
|
||||
* @param {Number} y 操作中心点Y坐标 (图像像素坐标)
|
||||
* @returns {Promise<ImageData>} 处理后的图像数据
|
||||
*/
|
||||
async applyLiquify(target, mode, params, x, y) {
|
||||
// 性能追踪开始
|
||||
const startTime = performance.now();
|
||||
|
||||
// 如果首次调用,先准备环境
|
||||
if (!this.targetObject || this.targetObject !== target) {
|
||||
await this.prepareForLiquify(target);
|
||||
}
|
||||
|
||||
// 更新模式和参数
|
||||
if (mode) this.setMode(mode);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
this.setParam(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证坐标是否在图像范围内
|
||||
if (!this.originalImageData) {
|
||||
console.error("缺少原始图像数据");
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageWidth = this.originalImageData.width;
|
||||
const imageHeight = this.originalImageData.height;
|
||||
|
||||
// 坐标边界检查
|
||||
if (x < 0 || x >= imageWidth || y < 0 || y >= imageHeight) {
|
||||
console.warn(
|
||||
`液化坐标超出图像范围: (${x}, ${y}), 图像尺寸: ${imageWidth}x${imageHeight}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`应用液化变形: 模式=${mode}, 图像坐标=(${x}, ${y}), 图像尺寸=${imageWidth}x${imageHeight}`
|
||||
);
|
||||
|
||||
// 检查并应用变形
|
||||
if (this.activeRenderer && typeof x === "number" && typeof y === "number") {
|
||||
// 应用变形
|
||||
let result;
|
||||
|
||||
if (this.renderMode === "webgl") {
|
||||
// WebGL渲染器:传入图像像素坐标
|
||||
result = this.activeRenderer.applyDeformation(x, y);
|
||||
} else {
|
||||
// CPU渲染器:传入图像像素坐标
|
||||
result = this.activeRenderer.applyDeformation(x, y);
|
||||
}
|
||||
|
||||
// 更新当前图像数据
|
||||
if (result) {
|
||||
this.currentImageData = result;
|
||||
}
|
||||
|
||||
// 性能追踪结束
|
||||
const endTime = performance.now();
|
||||
this._trackPerformance(endTime - startTime);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
console.error("无法应用液化变形:渲染器未初始化或坐标无效");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪性能数据
|
||||
* @param {Number} time 操作耗时(毫秒)
|
||||
* @private
|
||||
*/
|
||||
_trackPerformance(time) {
|
||||
this.performance.lastOperationTime = time;
|
||||
this.performance.operationCount++;
|
||||
|
||||
// 维护最近10次操作的耗时记录
|
||||
this.performance.renderTimes.push(time);
|
||||
if (this.performance.renderTimes.length > 10) {
|
||||
this.performance.renderTimes.shift();
|
||||
}
|
||||
|
||||
// 计算平均耗时
|
||||
const avgTime =
|
||||
this.performance.renderTimes.reduce((sum, t) => sum + t, 0) /
|
||||
this.performance.renderTimes.length;
|
||||
|
||||
// 检测性能问题
|
||||
this.performance.isPerformanceIssue = avgTime > 100; // 如果平均耗时超过100毫秒
|
||||
|
||||
// 输出性能信息(调试用)
|
||||
if (this.performance.operationCount % 10 === 0) {
|
||||
console.log(
|
||||
`液化性能数据: 模式=${this.renderMode}, 平均耗时=${avgTime.toFixed(
|
||||
2
|
||||
)}ms, 图像尺寸=${this.originalImageData?.width}x${
|
||||
this.originalImageData?.height
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// 如果使用WebGL但性能差,可以考虑切换到优化的CPU实现
|
||||
if (
|
||||
this.renderMode === "webgl" &&
|
||||
this.performance.isPerformanceIssue &&
|
||||
this.performance.operationCount > 5
|
||||
) {
|
||||
console.warn("WebGL液化性能不佳,考虑切换到CPU模式");
|
||||
// 注意:这里不自动切换,因为可能会导致中途渲染结果不一致
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置液化操作
|
||||
* @returns {ImageData} 重置后的图像数据
|
||||
*/
|
||||
reset() {
|
||||
if (!this.activeRenderer) return null;
|
||||
|
||||
// 使用当前渲染器重置
|
||||
const result = this.activeRenderer.reset();
|
||||
|
||||
// 更新当前图像数据
|
||||
if (result) {
|
||||
this.currentImageData = result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查图层是否可以液化
|
||||
* @param {String} layerId 图层ID
|
||||
* @returns {Object} 检查结果
|
||||
*/
|
||||
checkLayerForLiquify(layerId) {
|
||||
if (!this.layerManager) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "图层管理器未初始化",
|
||||
needsRasterization: false,
|
||||
isImage: false,
|
||||
isEmpty: true,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 获取图层
|
||||
const layer = this.layerManager.getLayerById(layerId);
|
||||
if (!layer) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "图层不存在",
|
||||
needsRasterization: false,
|
||||
isImage: false,
|
||||
isEmpty: true,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查图层是否为空
|
||||
let objectsToCheck = [];
|
||||
if (layer.isBackground || layer.type === "background") {
|
||||
// 背景图层使用 fabricObject (单数)
|
||||
if (layer.fabricObject) {
|
||||
objectsToCheck = [layer.fabricObject];
|
||||
}
|
||||
} else {
|
||||
// 普通图层使用 fabricObjects (复数)
|
||||
objectsToCheck = layer.fabricObjects || [];
|
||||
}
|
||||
|
||||
if (objectsToCheck.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: "图层为空,无法进行液化操作",
|
||||
needsRasterization: false,
|
||||
isImage: false,
|
||||
isEmpty: true,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否为单一图像
|
||||
const singleObject = objectsToCheck.length === 1;
|
||||
const isImage =
|
||||
singleObject &&
|
||||
(objectsToCheck[0].type === "image" ||
|
||||
objectsToCheck[0].type === "rasterized-layer");
|
||||
|
||||
// 检查是否为组
|
||||
const isGroup = objectsToCheck.some((obj) => obj.type === "group");
|
||||
|
||||
// 如果不是单一图像,需要栅格化
|
||||
const needsRasterization = !isImage || isGroup;
|
||||
|
||||
return {
|
||||
valid: isImage && !isGroup,
|
||||
message: isImage ? "图层可以进行液化操作" : "需要先将图层栅格化",
|
||||
needsRasterization: needsRasterization,
|
||||
isImage: isImage,
|
||||
isEmpty: false,
|
||||
isGroup: isGroup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图像数据
|
||||
* @param {Object} fabricObject Fabric图像对象
|
||||
* @returns {Promise<ImageData>} 图像数据
|
||||
* @private
|
||||
*/
|
||||
async _getImageData(fabricObject) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 创建临时canvas
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = fabricObject.width * fabricObject.scaleX;
|
||||
tempCanvas.height = fabricObject.height * fabricObject.scaleY;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
|
||||
// 如果对象有图像元素
|
||||
if (fabricObject._element) {
|
||||
tempCtx.drawImage(
|
||||
fabricObject._element,
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height
|
||||
);
|
||||
} else if (fabricObject.getSrc) {
|
||||
// 通过URL创建图像
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
tempCtx.drawImage(img, 0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const imageData = tempCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height
|
||||
);
|
||||
resolve(imageData);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = fabricObject.getSrc();
|
||||
return;
|
||||
} else {
|
||||
reject(new Error("无法获取图像数据"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取图像数据
|
||||
const imageData = tempCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height
|
||||
);
|
||||
resolve(imageData);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆图像数据
|
||||
* @param {ImageData} imageData 原始图像数据
|
||||
* @returns {ImageData} 克隆的图像数据
|
||||
* @private
|
||||
*/
|
||||
_cloneImageData(imageData) {
|
||||
if (!imageData) return null;
|
||||
|
||||
// 使用新的浏览器API直接复制
|
||||
if (typeof ImageData.prototype.constructor === "function") {
|
||||
try {
|
||||
return new ImageData(
|
||||
new Uint8ClampedArray(imageData.data),
|
||||
imageData.width,
|
||||
imageData.height
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("使用备选方法克隆ImageData");
|
||||
}
|
||||
}
|
||||
|
||||
// 备选方法
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return ctx.getImageData(0, 0, imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
dispose() {
|
||||
// 释放渲染器资源
|
||||
if (this.webglRenderer) {
|
||||
this.webglRenderer.dispose();
|
||||
this.webglRenderer = null;
|
||||
}
|
||||
|
||||
if (this.cpuRenderer) {
|
||||
this.cpuRenderer.dispose();
|
||||
this.cpuRenderer = null;
|
||||
}
|
||||
|
||||
// 清除引用
|
||||
this.activeRenderer = null;
|
||||
this.canvas = null;
|
||||
this.layerManager = null;
|
||||
this.targetObject = null;
|
||||
this.originalImageData = null;
|
||||
this.currentImageData = null;
|
||||
this.initialized = false;
|
||||
this.renderMode = "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态信息
|
||||
* @returns {Object} 状态信息
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
renderMode: this.renderMode,
|
||||
isWebGLAvailable: this.isWebGLAvailable,
|
||||
currentMode: this.currentMode,
|
||||
params: { ...this.params },
|
||||
performance: { ...this.performance },
|
||||
imageSize: this.originalImageData
|
||||
? `${this.originalImageData.width}x${this.originalImageData.height}`
|
||||
: "N/A",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
/**
|
||||
* CPU版本的液化管理器
|
||||
* 修复版本 - 解决三角形网格失真问题
|
||||
*/
|
||||
export class LiquifyCPUManager {
|
||||
constructor(options = {}) {
|
||||
this.config = {
|
||||
gridSize: options.gridSize || 16, // 稍微增大网格提高性能
|
||||
maxStrength: options.maxStrength || 200, // 适度降低最大强度
|
||||
smoothingIterations: options.smoothingIterations || 1, // 增加平滑处理
|
||||
relaxFactor: options.relaxFactor || 0.1, // 适度松弛
|
||||
};
|
||||
|
||||
this.params = {
|
||||
size: 80, // 增大默认尺寸
|
||||
pressure: 0.8, // 增大默认压力
|
||||
distortion: 0,
|
||||
power: 0.8, // 增大默认动力
|
||||
};
|
||||
|
||||
this.modes = {
|
||||
PUSH: "push",
|
||||
CLOCKWISE: "clockwise",
|
||||
COUNTERCLOCKWISE: "counterclockwise",
|
||||
PINCH: "pinch",
|
||||
EXPAND: "expand",
|
||||
CRYSTAL: "crystal",
|
||||
EDGE: "edge",
|
||||
RECONSTRUCT: "reconstruct",
|
||||
};
|
||||
|
||||
this.currentMode = this.modes.PUSH;
|
||||
this.originalImageData = null;
|
||||
this.currentImageData = null;
|
||||
this.mesh = null;
|
||||
this.initialized = false;
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.ctx = this.canvas.getContext("2d");
|
||||
this.deformHistory = [];
|
||||
|
||||
// 性能优化相关
|
||||
this.lastUpdateTime = 0;
|
||||
this.updateThrottle = 16; // 限制更新频率约60fps
|
||||
this.isProcessing = false;
|
||||
|
||||
// 鼠标位置跟踪(用于推拉模式)
|
||||
this.lastMouseX = 0;
|
||||
this.lastMouseY = 0;
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
this.isFirstApply = true; // 标记是否是首次应用
|
||||
}
|
||||
|
||||
initialize(imageSource) {
|
||||
try {
|
||||
if (imageSource instanceof ImageData) {
|
||||
this.originalImageData = new ImageData(
|
||||
new Uint8ClampedArray(imageSource.data),
|
||||
imageSource.width,
|
||||
imageSource.height
|
||||
);
|
||||
} else if (imageSource instanceof HTMLImageElement) {
|
||||
this.canvas.width = imageSource.width;
|
||||
this.canvas.height = imageSource.height;
|
||||
this.ctx.drawImage(imageSource, 0, 0);
|
||||
this.originalImageData = this.ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
imageSource.width,
|
||||
imageSource.height
|
||||
);
|
||||
} else {
|
||||
throw new Error("不支持的图像类型");
|
||||
}
|
||||
|
||||
this.currentImageData = new ImageData(
|
||||
new Uint8ClampedArray(this.originalImageData.data),
|
||||
this.originalImageData.width,
|
||||
this.originalImageData.height
|
||||
);
|
||||
|
||||
this._initMesh(
|
||||
this.originalImageData.width,
|
||||
this.originalImageData.height
|
||||
);
|
||||
this.initialized = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("液化管理器初始化失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_initMesh(width, height) {
|
||||
const gridSize = this.config.gridSize;
|
||||
const cols = Math.ceil(width / gridSize);
|
||||
const rows = Math.ceil(height / gridSize);
|
||||
|
||||
this.mesh = {
|
||||
cols,
|
||||
rows,
|
||||
gridSize,
|
||||
width,
|
||||
height,
|
||||
originalPoints: [],
|
||||
deformedPoints: [],
|
||||
};
|
||||
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
for (let x = 0; x <= cols; x++) {
|
||||
const point = { x: x * gridSize, y: y * gridSize };
|
||||
this.mesh.originalPoints.push({ ...point });
|
||||
this.mesh.deformedPoints.push({ ...point });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
if (Object.values(this.modes).includes(mode)) {
|
||||
this.currentMode = mode;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setParam(param, value) {
|
||||
if (param in this.params) {
|
||||
this.params[param] = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getParams() {
|
||||
return { ...this.params };
|
||||
}
|
||||
|
||||
resetParams() {
|
||||
this.params = {
|
||||
size: 80, // 增大默认尺寸
|
||||
pressure: 0.8, // 增大默认压力
|
||||
distortion: 0,
|
||||
power: 0.8, // 增大默认动力
|
||||
};
|
||||
}
|
||||
|
||||
applyDeformation(x, y) {
|
||||
// 计算鼠标移动方向
|
||||
if (!this.isFirstApply) {
|
||||
this.mouseMovementX = x - this.lastMouseX;
|
||||
this.mouseMovementY = y - this.lastMouseY;
|
||||
} else {
|
||||
// 首次应用时不计算移动,避免初始变形
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
this.isFirstApply = false;
|
||||
}
|
||||
|
||||
this.lastMouseX = x;
|
||||
this.lastMouseY = y;
|
||||
|
||||
// 性能优化:限制更新频率
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdateTime < this.updateThrottle || this.isProcessing) {
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
this.lastUpdateTime = now;
|
||||
|
||||
if (!this.initialized || !this.mesh) {
|
||||
this.isProcessing = false;
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
const { size, pressure, distortion, power } = this.params;
|
||||
const mode = this.currentMode;
|
||||
const radius = size * 1.2; // 稍微增大影响半径
|
||||
const strength = (pressure * power * this.config.maxStrength) / 20; // 调整基础强度
|
||||
|
||||
this._applyDeformation(x, y, radius, strength, mode, distortion);
|
||||
|
||||
if (this.config.smoothingIterations > 0) {
|
||||
this._smoothMesh();
|
||||
}
|
||||
|
||||
const result = this._applyMeshToImage();
|
||||
this.isProcessing = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
_applyDeformation(x, y, radius, strength, mode, distortion) {
|
||||
if (!this.mesh) return;
|
||||
|
||||
const points = this.mesh.deformedPoints;
|
||||
const originalPoints = this.mesh.originalPoints;
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const point = points[i];
|
||||
const originalPoint = originalPoints[i];
|
||||
const dx = point.x - x;
|
||||
const dy = point.y - y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < radius && distance > 0) {
|
||||
// 使用平方衰减函数
|
||||
const factor = Math.pow(1 - distance / radius, 2) * strength * 0.1; // 大幅降低基础系数
|
||||
|
||||
switch (mode) {
|
||||
case this.modes.PUSH: {
|
||||
// 推拉模式 - 真正的拖拽效果
|
||||
// 计算实际移动距离
|
||||
const movementLength = Math.sqrt(
|
||||
this.mouseMovementX * this.mouseMovementX +
|
||||
this.mouseMovementY * this.mouseMovementY
|
||||
);
|
||||
|
||||
// 只有在有足够移动距离时才应用效果
|
||||
if (movementLength > 1.0) {
|
||||
// 提高阈值,确保有明显移动
|
||||
// 归一化移动方向
|
||||
const moveX = this.mouseMovementX / movementLength;
|
||||
const moveY = this.mouseMovementY / movementLength;
|
||||
|
||||
// 计算衰减(距离中心越近,效果越强)
|
||||
const radiusRatio = distance / radius;
|
||||
const falloff = Math.pow(1 - radiusRatio, 2.0); // 使用更强的衰减
|
||||
|
||||
// 基于实际移动距离计算强度
|
||||
const { pressure, power } = this.params;
|
||||
const moveStrength = pressure * power * movementLength * 0.3; // 降低移动强度系数
|
||||
|
||||
// 计算最终拖拽强度
|
||||
const dragStrength = moveStrength * falloff * factor;
|
||||
|
||||
// 向鼠标移动方向拖拽
|
||||
const dragX = moveX * dragStrength;
|
||||
const dragY = moveY * dragStrength;
|
||||
|
||||
// 应用变形,但限制最大变形量
|
||||
const maxDeform = 2.0; // 限制单次最大变形量
|
||||
point.x += Math.max(-maxDeform, Math.min(maxDeform, dragX));
|
||||
point.y += Math.max(-maxDeform, Math.min(maxDeform, dragY));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case this.modes.CLOCKWISE:
|
||||
case this.modes.COUNTERCLOCKWISE: {
|
||||
// 旋转模式 - 保持原有效果
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const direction = mode === this.modes.CLOCKWISE ? 1 : -1;
|
||||
const rotationAngle = angle + direction * factor;
|
||||
const newX = x + Math.cos(rotationAngle) * distance;
|
||||
const newY = y + Math.sin(rotationAngle) * distance;
|
||||
|
||||
point.x += (newX - point.x) * 0.8;
|
||||
point.y += (newY - point.y) * 0.8;
|
||||
break;
|
||||
}
|
||||
case this.modes.PINCH: {
|
||||
// 捏合模式 - 保持原有效果
|
||||
const pinchStrength = factor * 1.2;
|
||||
point.x -= dx * pinchStrength;
|
||||
point.y -= dy * pinchStrength;
|
||||
break;
|
||||
}
|
||||
case this.modes.EXPAND: {
|
||||
// 展开模式 - 参考捏合的反向操作
|
||||
const expandFactor = factor * 1.5;
|
||||
point.x += dx * expandFactor;
|
||||
point.y += dy * expandFactor;
|
||||
break;
|
||||
}
|
||||
case this.modes.CRYSTAL: {
|
||||
// 水晶模式 - 参考旋转算法创建多重波形
|
||||
const crystalAngle = Math.atan2(dy, dx);
|
||||
const crystalRadius = distance / radius;
|
||||
|
||||
// 确保有基础效果
|
||||
const baseDistortion = Math.max(distortion, 0.3);
|
||||
|
||||
// 多重波形 - 类似旋转的角度调制
|
||||
const wave1 = Math.sin(crystalAngle * 8) * 0.6;
|
||||
const wave2 = Math.cos(crystalAngle * 12) * 0.4;
|
||||
const waveAngle = crystalAngle + (wave1 + wave2) * baseDistortion;
|
||||
|
||||
// 径向调制 - 类似旋转的距离调制
|
||||
const radialMod = 1 + Math.sin(crystalRadius * Math.PI * 2) * 0.3;
|
||||
const modDistance = distance * radialMod;
|
||||
|
||||
const crystalX = x + Math.cos(waveAngle) * modDistance;
|
||||
const crystalY = y + Math.sin(waveAngle) * modDistance;
|
||||
|
||||
const crystalFactor = factor * baseDistortion;
|
||||
point.x += (crystalX - point.x) * crystalFactor;
|
||||
point.y += (crystalY - point.y) * crystalFactor;
|
||||
break;
|
||||
}
|
||||
case this.modes.EDGE: {
|
||||
// 边缘模式 - 参考旋转算法创建垂直波纹
|
||||
const edgeAngle = Math.atan2(dy, dx);
|
||||
const edgeRadius = distance / radius;
|
||||
|
||||
// 确保有基础效果
|
||||
const baseEdgeDistortion = Math.max(distortion, 0.5);
|
||||
|
||||
// 边缘波纹 - 垂直于径向的调制
|
||||
const edgeWave =
|
||||
Math.sin(edgeRadius * Math.PI * 4) * Math.cos(edgeAngle * 6);
|
||||
const perpAngle = edgeAngle + Math.PI / 2; // 垂直角度
|
||||
|
||||
const edgeFactor = edgeWave * factor * baseEdgeDistortion;
|
||||
const edgeOffsetX = Math.cos(perpAngle) * edgeFactor;
|
||||
const edgeOffsetY = Math.sin(perpAngle) * edgeFactor;
|
||||
|
||||
point.x += edgeOffsetX;
|
||||
point.y += edgeOffsetY;
|
||||
break;
|
||||
}
|
||||
case this.modes.RECONSTRUCT: {
|
||||
// 重建模式 - 向原始位置恢复
|
||||
const restoreFactor = factor * 0.15;
|
||||
point.x += (originalPoint.x - point.x) * restoreFactor;
|
||||
point.y += (originalPoint.y - point.y) * restoreFactor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优化衰减函数,使过渡更平滑
|
||||
_smoothFalloff(t) {
|
||||
if (t >= 1) return 0;
|
||||
// 使用更平滑的衰减曲线
|
||||
const smoothT = 1 - t;
|
||||
return smoothT * smoothT * smoothT * (3 - 2 * smoothT);
|
||||
}
|
||||
|
||||
_smoothMesh() {
|
||||
const { rows, cols } = this.mesh;
|
||||
const points = this.mesh.deformedPoints;
|
||||
const tempPoints = points.map((p) => ({ x: p.x, y: p.y }));
|
||||
|
||||
for (
|
||||
let iteration = 0;
|
||||
iteration < this.config.smoothingIterations;
|
||||
iteration++
|
||||
) {
|
||||
for (let y = 1; y < rows; y++) {
|
||||
for (let x = 1; x < cols; x++) {
|
||||
const idx = y * (cols + 1) + x;
|
||||
const left = points[y * (cols + 1) + (x - 1)];
|
||||
const right = points[y * (cols + 1) + (x + 1)];
|
||||
const top = points[(y - 1) * (cols + 1) + x];
|
||||
const bottom = points[(y + 1) * (cols + 1) + x];
|
||||
|
||||
const centerX = (left.x + right.x + top.x + bottom.x) / 4;
|
||||
const centerY = (left.y + right.y + top.y + bottom.y) / 4;
|
||||
|
||||
const relaxFactor = this.config.relaxFactor;
|
||||
tempPoints[idx].x += (centerX - points[idx].x) * relaxFactor;
|
||||
tempPoints[idx].y += (centerY - points[idx].y) * relaxFactor;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
points[i].x = tempPoints[i].x;
|
||||
points[i].y = tempPoints[i].y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_applyMeshToImage() {
|
||||
if (!this.mesh || !this.originalImageData) {
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
const width = this.originalImageData.width;
|
||||
const height = this.originalImageData.height;
|
||||
const result = new ImageData(width, height);
|
||||
const srcData = this.originalImageData.data;
|
||||
const dstData = result.data;
|
||||
|
||||
// 性能优化:使用步长采样减少计算量
|
||||
const step = width > 1000 || height > 1000 ? 2 : 1;
|
||||
|
||||
for (let y = 0; y < height; y += step) {
|
||||
for (let x = 0; x < width; x += step) {
|
||||
const srcPos = this._mapPointBack(x, y);
|
||||
|
||||
if (
|
||||
srcPos.x >= 0 &&
|
||||
srcPos.x < width &&
|
||||
srcPos.y >= 0 &&
|
||||
srcPos.y < height
|
||||
) {
|
||||
const color = this._bilinearInterpolate(
|
||||
srcData,
|
||||
width,
|
||||
height,
|
||||
srcPos.x,
|
||||
srcPos.y
|
||||
);
|
||||
|
||||
// 如果使用步长采样,需要填充相邻像素
|
||||
for (let dy = 0; dy < step && y + dy < height; dy++) {
|
||||
for (let dx = 0; dx < step && x + dx < width; dx++) {
|
||||
const dstIdx = ((y + dy) * width + (x + dx)) * 4;
|
||||
dstData[dstIdx] = color[0];
|
||||
dstData[dstIdx + 1] = color[1];
|
||||
dstData[dstIdx + 2] = color[2];
|
||||
dstData[dstIdx + 3] = color[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentImageData = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
// 添加异步处理方法用于大图像
|
||||
async applyDeformationAsync(x, y) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const result = this.applyDeformation(x, y);
|
||||
resolve(result);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// 批量处理方法
|
||||
applyDeformationBatch(positions) {
|
||||
if (!this.initialized || !this.mesh || positions.length === 0) {
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
const { size, pressure, distortion, power } = this.params;
|
||||
const mode = this.currentMode;
|
||||
const radius = size * 1.0;
|
||||
const strength = (pressure * power * this.config.maxStrength) / 60;
|
||||
|
||||
// 批量应用所有变形
|
||||
positions.forEach((pos) => {
|
||||
this._applyDeformation(
|
||||
pos.x,
|
||||
pos.y,
|
||||
radius * 0.5,
|
||||
strength * 0.3,
|
||||
mode,
|
||||
distortion
|
||||
);
|
||||
});
|
||||
|
||||
if (this.config.smoothingIterations > 0) {
|
||||
this._smoothMesh();
|
||||
}
|
||||
|
||||
return this._applyMeshToImage();
|
||||
}
|
||||
|
||||
_mapPointBack(x, y) {
|
||||
const { cols, rows, gridSize } = this.mesh;
|
||||
const gridX = x / gridSize;
|
||||
const gridY = y / gridSize;
|
||||
|
||||
const x1 = Math.floor(gridX);
|
||||
const y1 = Math.floor(gridY);
|
||||
const x2 = Math.min(x1 + 1, cols);
|
||||
const y2 = Math.min(y1 + 1, rows);
|
||||
|
||||
const fx = gridX - x1;
|
||||
const fy = gridY - y1;
|
||||
|
||||
// 获取四个网格点的变形和原始坐标
|
||||
const deformed = [
|
||||
this.mesh.deformedPoints[y1 * (cols + 1) + x1],
|
||||
this.mesh.deformedPoints[y1 * (cols + 1) + x2],
|
||||
this.mesh.deformedPoints[y2 * (cols + 1) + x1],
|
||||
this.mesh.deformedPoints[y2 * (cols + 1) + x2],
|
||||
];
|
||||
|
||||
const original = [
|
||||
this.mesh.originalPoints[y1 * (cols + 1) + x1],
|
||||
this.mesh.originalPoints[y1 * (cols + 1) + x2],
|
||||
this.mesh.originalPoints[y2 * (cols + 1) + x1],
|
||||
this.mesh.originalPoints[y2 * (cols + 1) + x2],
|
||||
];
|
||||
|
||||
// 双线性插值计算变形后的位置
|
||||
const deformedX =
|
||||
(1 - fx) * (1 - fy) * deformed[0].x +
|
||||
fx * (1 - fy) * deformed[1].x +
|
||||
(1 - fx) * fy * deformed[2].x +
|
||||
fx * fy * deformed[3].x;
|
||||
const deformedY =
|
||||
(1 - fx) * (1 - fy) * deformed[0].y +
|
||||
fx * (1 - fy) * deformed[1].y +
|
||||
(1 - fx) * fy * deformed[2].y +
|
||||
fx * fy * deformed[3].y;
|
||||
|
||||
// 计算原始网格位置
|
||||
const originalX = x1 * gridSize + fx * gridSize;
|
||||
const originalY = y1 * gridSize + fy * gridSize;
|
||||
|
||||
// 计算偏移量并应用反向映射
|
||||
const offsetX = deformedX - originalX;
|
||||
const offsetY = deformedY - originalY;
|
||||
|
||||
return {
|
||||
x: x - offsetX,
|
||||
y: y - offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
_bilinearInterpolate(data, width, height, x, y) {
|
||||
const x1 = Math.floor(x);
|
||||
const y1 = Math.floor(y);
|
||||
const x2 = Math.min(x1 + 1, width - 1);
|
||||
const y2 = Math.min(y1 + 1, height - 1);
|
||||
|
||||
const fx = x - x1;
|
||||
const fy = y - y1;
|
||||
|
||||
const getPixel = (px, py) => {
|
||||
const idx = (py * width + px) * 4;
|
||||
return [data[idx], data[idx + 1], data[idx + 2], data[idx + 3]];
|
||||
};
|
||||
|
||||
const p1 = getPixel(x1, y1);
|
||||
const p2 = getPixel(x2, y1);
|
||||
const p3 = getPixel(x1, y2);
|
||||
const p4 = getPixel(x2, y2);
|
||||
|
||||
return [
|
||||
Math.round(
|
||||
(1 - fx) * (1 - fy) * p1[0] +
|
||||
fx * (1 - fy) * p2[0] +
|
||||
(1 - fx) * fy * p3[0] +
|
||||
fx * fy * p4[0]
|
||||
),
|
||||
Math.round(
|
||||
(1 - fx) * (1 - fy) * p1[1] +
|
||||
fx * (1 - fy) * p2[1] +
|
||||
(1 - fx) * fy * p3[1] +
|
||||
fx * fy * p4[1]
|
||||
),
|
||||
Math.round(
|
||||
(1 - fx) * (1 - fy) * p1[2] +
|
||||
fx * (1 - fy) * p2[2] +
|
||||
(1 - fx) * fy * p3[2] +
|
||||
fx * fy * p4[2]
|
||||
),
|
||||
Math.round(
|
||||
(1 - fx) * (1 - fy) * p1[3] +
|
||||
fx * (1 - fy) * p2[3] +
|
||||
(1 - fx) * fy * p3[3] +
|
||||
fx * fy * p4[3]
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
reset() {
|
||||
if (!this.mesh || !this.originalImageData) return false;
|
||||
|
||||
for (let i = 0; i < this.mesh.deformedPoints.length; i++) {
|
||||
this.mesh.deformedPoints[i].x = this.mesh.originalPoints[i].x;
|
||||
this.mesh.deformedPoints[i].y = this.mesh.originalPoints[i].y;
|
||||
}
|
||||
|
||||
this.currentImageData = new ImageData(
|
||||
new Uint8ClampedArray(this.originalImageData.data),
|
||||
this.originalImageData.width,
|
||||
this.originalImageData.height
|
||||
);
|
||||
|
||||
this.deformHistory = [];
|
||||
return true;
|
||||
}
|
||||
|
||||
getCurrentImageData() {
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.originalImageData = null;
|
||||
this.currentImageData = null;
|
||||
this.mesh = null;
|
||||
this.deformHistory = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 液化管理器
|
||||
* 负责管理液化操作的核心算法和变形处理
|
||||
*
|
||||
* 此版本使用增强的液化算法,支持GPU加速和优化的CPU处理
|
||||
*/
|
||||
import { EnhancedLiquifyManager } from "./EnhancedLiquifyManager";
|
||||
|
||||
export class LiquifyManager {
|
||||
/**
|
||||
* 创建液化管理器
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
// 将核心属性暴露给外部,保持API兼容性
|
||||
this.canvas = options.canvas || null;
|
||||
this.layerManager = options.layerManager || null;
|
||||
|
||||
// 配置参数
|
||||
this.config = {
|
||||
gridSize: options.gridSize || 20,
|
||||
maxStrength: options.maxStrength || 100,
|
||||
defaultParams: {
|
||||
size: 50,
|
||||
pressure: 0.5,
|
||||
distortion: 0,
|
||||
power: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
// 创建增强版液化管理器实例
|
||||
this.enhancedManager = new EnhancedLiquifyManager({
|
||||
// 配置选项
|
||||
gridSize: options.gridSize || 15,
|
||||
maxStrength: options.maxStrength || 100,
|
||||
smoothingIterations: options.smoothingIterations || 2,
|
||||
relaxFactor: options.relaxFactor || 0.25,
|
||||
meshResolution: options.meshResolution || 64,
|
||||
// 根据环境选择合适的渲染模式
|
||||
forceCPU: true, // 默认不强制使用CPU
|
||||
forceWebGL: false, // 优先使用WebGL模式
|
||||
webglSizeThreshold: options.webglSizeThreshold || 500 * 500, // 降低阈值以更倾向使用WebGL
|
||||
layerManager: options.layerManager || null,
|
||||
canvas: options.canvas || null,
|
||||
});
|
||||
|
||||
// 初始化液化管理器
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化液化管理器
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
initialize(options = {}) {
|
||||
// 更新基础属性
|
||||
if (options.canvas) this.canvas = options.canvas;
|
||||
if (options.layerManager) this.layerManager = options.layerManager;
|
||||
|
||||
// 初始化增强液化管理器
|
||||
return this.enhancedManager.initialize({
|
||||
canvas: this.canvas,
|
||||
layerManager: this.layerManager,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 为液化操作准备图像
|
||||
* @param {Object|String} target 目标对象或图层ID
|
||||
* @returns {Promise<Object>} 准备结果
|
||||
*/
|
||||
async prepareForLiquify(target) {
|
||||
return this.enhancedManager.prepareForLiquify(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置液化模式
|
||||
* @param {String} mode 液化模式
|
||||
*/
|
||||
setMode(mode) {
|
||||
return this.enhancedManager.setMode(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置液化参数
|
||||
* @param {String} param 参数名称
|
||||
* @param {Number} value 参数值
|
||||
*/
|
||||
setParam(param, value) {
|
||||
return this.enhancedManager.setParam(param, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前参数
|
||||
* @returns {Object} 当前参数对象
|
||||
*/
|
||||
getParams() {
|
||||
return this.enhancedManager.getParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置参数为默认值
|
||||
*/
|
||||
resetParams() {
|
||||
return this.enhancedManager.resetParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用液化效果
|
||||
* @param {fabric.Object} targetObject 目标对象
|
||||
* @param {String} mode 液化模式
|
||||
* @param {Object} params 参数
|
||||
* @param {Number} x X坐标
|
||||
* @param {Number} y Y坐标
|
||||
* @returns {ImageData} 处理后的图像数据
|
||||
*/
|
||||
async applyLiquify(targetObject, mode, params, x, y) {
|
||||
if (!this.enhancedManager || !targetObject) {
|
||||
console.error("液化管理器未正确初始化");
|
||||
return null;
|
||||
}
|
||||
|
||||
// 确保设置正确的模式和参数
|
||||
if (mode) {
|
||||
this.enhancedManager.setMode(mode);
|
||||
}
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
this.enhancedManager.setParam(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
// 应用液化变形
|
||||
console.log(`应用液化变形, 模式=${mode}, 坐标=(${x}, ${y}), 参数=`, params);
|
||||
try {
|
||||
// 直接调用EnhancedLiquifyManager的applyLiquify方法
|
||||
const resultData = await this.enhancedManager.applyLiquify(
|
||||
targetObject,
|
||||
mode,
|
||||
params,
|
||||
x,
|
||||
y
|
||||
);
|
||||
|
||||
// 确保返回结果数据
|
||||
if (!resultData) {
|
||||
console.warn("液化变形没有返回结果数据");
|
||||
}
|
||||
|
||||
return resultData;
|
||||
} catch (error) {
|
||||
console.error("液化变形应用失败:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置液化操作
|
||||
* @returns {ImageData} 重置后的图像数据
|
||||
*/
|
||||
reset() {
|
||||
return this.enhancedManager.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查图层是否可以液化
|
||||
* @param {String} layerId 图层ID
|
||||
* @returns {Object} 检查结果
|
||||
*/
|
||||
checkLayerForLiquify(layerId) {
|
||||
return this.enhancedManager.checkLayerForLiquify(layerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态信息
|
||||
* @returns {Object} 状态信息
|
||||
*/
|
||||
getStatus() {
|
||||
return this.enhancedManager.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
dispose() {
|
||||
if (this.enhancedManager) {
|
||||
this.enhancedManager.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
/**
|
||||
* WebGL加速的液化管理器
|
||||
* 使用WebGL技术进行加速液化变形处理
|
||||
*/
|
||||
export class LiquifyWebGLManager {
|
||||
/**
|
||||
* 创建WebGL液化管理器
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.canvas = null;
|
||||
this.gl = null;
|
||||
this.program = null;
|
||||
this.texture = null;
|
||||
this.mesh = null;
|
||||
this.initialized = false;
|
||||
this.originalImageData = null;
|
||||
this.currentImageData = null;
|
||||
|
||||
// 变形配置
|
||||
this.config = {
|
||||
gridSize: options.gridSize || 20,
|
||||
maxStrength: options.maxStrength || 100,
|
||||
textureSize: 0,
|
||||
meshResolution: options.meshResolution || 64,
|
||||
};
|
||||
|
||||
// 当前参数
|
||||
this.params = {
|
||||
size: 80, // 增大默认尺寸
|
||||
pressure: 0.8, // 增大默认压力
|
||||
distortion: 0,
|
||||
power: 0.8, // 增大默认动力
|
||||
};
|
||||
|
||||
// 鼠标位置跟踪(用于推拉模式)
|
||||
this.lastMouseX = 0;
|
||||
this.lastMouseY = 0;
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
this.isFirstApply = true; // 标记是否是首次应用
|
||||
|
||||
// 液化工具模式
|
||||
this.modes = {
|
||||
PUSH: "push",
|
||||
CLOCKWISE: "clockwise",
|
||||
COUNTERCLOCKWISE: "counterclockwise",
|
||||
PINCH: "pinch",
|
||||
EXPAND: "expand",
|
||||
CRYSTAL: "crystal",
|
||||
EDGE: "edge",
|
||||
RECONSTRUCT: "reconstruct",
|
||||
};
|
||||
this.currentMode = this.modes.PUSH;
|
||||
|
||||
// 变形点历史记录
|
||||
this.deformHistory = [];
|
||||
|
||||
// WebGL着色器程序
|
||||
this.vertexShaderSource = `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_texCoord;
|
||||
|
||||
uniform mat3 u_matrix;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
|
||||
v_texCoord = a_texCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
this.fragmentShaderSource = `
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_image;
|
||||
uniform vec2 u_textureSize;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
|
||||
vec4 color = texture2D(u_image, v_texCoord);
|
||||
|
||||
// 简单的边缘检查,保证边缘渲染正确
|
||||
if(v_texCoord.x < 0.0 || v_texCoord.x > 1.0 ||
|
||||
v_texCoord.y < 0.0 || v_texCoord.y > 1.0) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
gl_FragColor = color;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// 变形网格着色器程序
|
||||
this.deformVertexShaderSource = `
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_texCoord;
|
||||
attribute vec2 a_deformation;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
vec2 position = a_position + a_deformation;
|
||||
gl_Position = vec4(position * 2.0 - 1.0, 0, 1);
|
||||
v_texCoord = a_texCoord;
|
||||
}
|
||||
`;
|
||||
|
||||
this.deformFragmentShaderSource = `
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_image;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(u_image, v_texCoord);
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化WebGL环境
|
||||
* @param {HTMLImageElement} image 图像元素
|
||||
* @returns {Boolean} 是否初始化成功
|
||||
*/
|
||||
initialize(image) {
|
||||
// 创建WebGL Canvas
|
||||
this.canvas = document.createElement("canvas");
|
||||
|
||||
// 设置canvas大小与图像相同
|
||||
this.canvas.width = image.width;
|
||||
this.canvas.height = image.height;
|
||||
|
||||
// 尝试获取WebGL上下文
|
||||
try {
|
||||
this.gl =
|
||||
this.canvas.getContext("webgl") ||
|
||||
this.canvas.getContext("experimental-webgl");
|
||||
} catch (e) {
|
||||
console.error("WebGL初始化失败:", e);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.gl) {
|
||||
console.error("WebGL不可用");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置视口
|
||||
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// 编译着色器程序
|
||||
if (!this._createShaderProgram()) {
|
||||
console.error("着色器程序创建失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建纹理
|
||||
this.texture = this._createTexture(image);
|
||||
if (!this.texture) {
|
||||
console.error("纹理创建失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录原始图像数据
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = image.width;
|
||||
tempCanvas.height = image.height;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
tempCtx.drawImage(image, 0, 0);
|
||||
this.originalImageData = tempCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
image.width,
|
||||
image.height
|
||||
);
|
||||
this.currentImageData = new ImageData(
|
||||
new Uint8ClampedArray(this.originalImageData.data),
|
||||
this.originalImageData.width,
|
||||
this.originalImageData.height
|
||||
);
|
||||
|
||||
// 创建变形网格
|
||||
this._createDeformMesh();
|
||||
|
||||
this.config.textureSize = [image.width, image.height];
|
||||
this.initialized = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建着色器程序
|
||||
* @returns {Boolean} 是否创建成功
|
||||
* @private
|
||||
*/
|
||||
_createShaderProgram() {
|
||||
// 创建标准渲染程序
|
||||
const vertexShader = this._compileShader(
|
||||
this.vertexShaderSource,
|
||||
this.gl.VERTEX_SHADER
|
||||
);
|
||||
const fragmentShader = this._compileShader(
|
||||
this.fragmentShaderSource,
|
||||
this.gl.FRAGMENT_SHADER
|
||||
);
|
||||
|
||||
if (!vertexShader || !fragmentShader) return false;
|
||||
|
||||
// 创建程序
|
||||
this.program = this.gl.createProgram();
|
||||
this.gl.attachShader(this.program, vertexShader);
|
||||
this.gl.attachShader(this.program, fragmentShader);
|
||||
this.gl.linkProgram(this.program);
|
||||
|
||||
if (!this.gl.getProgramParameter(this.program, this.gl.LINK_STATUS)) {
|
||||
console.error(
|
||||
"着色器程序链接失败:",
|
||||
this.gl.getProgramInfoLog(this.program)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建变形渲染程序
|
||||
const deformVertexShader = this._compileShader(
|
||||
this.deformVertexShaderSource,
|
||||
this.gl.VERTEX_SHADER
|
||||
);
|
||||
const deformFragmentShader = this._compileShader(
|
||||
this.deformFragmentShaderSource,
|
||||
this.gl.FRAGMENT_SHADER
|
||||
);
|
||||
|
||||
if (!deformVertexShader || !deformFragmentShader) return false;
|
||||
|
||||
// 创建变形程序
|
||||
this.deformProgram = this.gl.createProgram();
|
||||
this.gl.attachShader(this.deformProgram, deformVertexShader);
|
||||
this.gl.attachShader(this.deformProgram, deformFragmentShader);
|
||||
this.gl.linkProgram(this.deformProgram);
|
||||
|
||||
if (!this.gl.getProgramParameter(this.deformProgram, this.gl.LINK_STATUS)) {
|
||||
console.error(
|
||||
"变形着色器程序链接失败:",
|
||||
this.gl.getProgramInfoLog(this.deformProgram)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编译着色器
|
||||
* @param {String} source 着色器源码
|
||||
* @param {Number} type 着色器类型
|
||||
* @returns {WebGLShader} 编译后的着色器
|
||||
* @private
|
||||
*/
|
||||
_compileShader(source, type) {
|
||||
const shader = this.gl.createShader(type);
|
||||
this.gl.shaderSource(shader, source);
|
||||
this.gl.compileShader(shader);
|
||||
|
||||
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||
console.error(
|
||||
"着色器编译失败:",
|
||||
this.gl.getShaderInfoLog(shader),
|
||||
"shader type:",
|
||||
type === this.gl.VERTEX_SHADER ? "VERTEX_SHADER" : "FRAGMENT_SHADER",
|
||||
"source:",
|
||||
source
|
||||
);
|
||||
this.gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建WebGL纹理
|
||||
* @param {HTMLImageElement} image 图像元素
|
||||
* @returns {WebGLTexture} WebGL纹理
|
||||
* @private
|
||||
*/
|
||||
_createTexture(image) {
|
||||
const texture = this.gl.createTexture();
|
||||
this.gl.bindTexture(this.gl.TEXTURE_2D, texture);
|
||||
|
||||
// 设置参数,使我们可以渲染任何尺寸的图像
|
||||
this.gl.texParameteri(
|
||||
this.gl.TEXTURE_2D,
|
||||
this.gl.TEXTURE_WRAP_S,
|
||||
this.gl.CLAMP_TO_EDGE
|
||||
);
|
||||
this.gl.texParameteri(
|
||||
this.gl.TEXTURE_2D,
|
||||
this.gl.TEXTURE_WRAP_T,
|
||||
this.gl.CLAMP_TO_EDGE
|
||||
);
|
||||
this.gl.texParameteri(
|
||||
this.gl.TEXTURE_2D,
|
||||
this.gl.TEXTURE_MIN_FILTER,
|
||||
this.gl.LINEAR
|
||||
);
|
||||
this.gl.texParameteri(
|
||||
this.gl.TEXTURE_2D,
|
||||
this.gl.TEXTURE_MAG_FILTER,
|
||||
this.gl.LINEAR
|
||||
);
|
||||
|
||||
// 上传图像到纹理
|
||||
try {
|
||||
this.gl.texImage2D(
|
||||
this.gl.TEXTURE_2D,
|
||||
0,
|
||||
this.gl.RGBA,
|
||||
this.gl.RGBA,
|
||||
this.gl.UNSIGNED_BYTE,
|
||||
image
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("纹理上传失败:", e);
|
||||
return null;
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建变形网格
|
||||
* @private
|
||||
*/
|
||||
_createDeformMesh() {
|
||||
const { meshResolution } = this.config;
|
||||
|
||||
// 创建网格顶点
|
||||
const vertices = [];
|
||||
const texCoords = [];
|
||||
const indices = [];
|
||||
const deformations = [];
|
||||
|
||||
// 创建顶点和纹理坐标
|
||||
for (let y = 0; y <= meshResolution; y++) {
|
||||
for (let x = 0; x <= meshResolution; x++) {
|
||||
const xPos = x / meshResolution;
|
||||
const yPos = y / meshResolution;
|
||||
|
||||
// 顶点位置
|
||||
vertices.push(xPos, yPos);
|
||||
|
||||
// 纹理坐标
|
||||
texCoords.push(xPos, yPos);
|
||||
|
||||
// 初始无变形
|
||||
deformations.push(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建索引(三角形)
|
||||
for (let y = 0; y < meshResolution; y++) {
|
||||
for (let x = 0; x < meshResolution; x++) {
|
||||
const i0 = y * (meshResolution + 1) + x;
|
||||
const i1 = i0 + 1;
|
||||
const i2 = i0 + meshResolution + 1;
|
||||
const i3 = i2 + 1;
|
||||
|
||||
// 三角形1
|
||||
indices.push(i0, i2, i1);
|
||||
|
||||
// 三角形2
|
||||
indices.push(i1, i2, i3);
|
||||
}
|
||||
}
|
||||
|
||||
this.mesh = {
|
||||
vertices: new Float32Array(vertices),
|
||||
texCoords: new Float32Array(texCoords),
|
||||
indices: new Uint16Array(indices),
|
||||
deformations: new Float32Array(deformations),
|
||||
resolution: meshResolution,
|
||||
};
|
||||
|
||||
// 创建顶点缓冲区
|
||||
this.vertexBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ARRAY_BUFFER,
|
||||
this.mesh.vertices,
|
||||
this.gl.STATIC_DRAW
|
||||
);
|
||||
|
||||
// 创建纹理坐标缓冲区
|
||||
this.texCoordBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.texCoordBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ARRAY_BUFFER,
|
||||
this.mesh.texCoords,
|
||||
this.gl.STATIC_DRAW
|
||||
);
|
||||
|
||||
// 创建变形缓冲区
|
||||
this.deformBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.deformBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ARRAY_BUFFER,
|
||||
this.mesh.deformations,
|
||||
this.gl.DYNAMIC_DRAW
|
||||
);
|
||||
|
||||
// 创建索引缓冲区
|
||||
this.indexBuffer = this.gl.createBuffer();
|
||||
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ELEMENT_ARRAY_BUFFER,
|
||||
this.mesh.indices,
|
||||
this.gl.STATIC_DRAW
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用液化变形
|
||||
* @param {Number} x 变形中心X坐标 (图像像素坐标)
|
||||
* @param {Number} y 变形中心Y坐标 (图像像素坐标)
|
||||
*/
|
||||
applyDeformation(x, y) {
|
||||
if (!this.initialized || !this.mesh) return;
|
||||
|
||||
// 计算鼠标移动方向
|
||||
if (!this.isFirstApply) {
|
||||
this.mouseMovementX = x - this.lastMouseX;
|
||||
this.mouseMovementY = y - this.lastMouseY;
|
||||
} else {
|
||||
// 首次应用时不计算移动,避免初始变形
|
||||
this.mouseMovementX = 0;
|
||||
this.mouseMovementY = 0;
|
||||
this.isFirstApply = false;
|
||||
}
|
||||
|
||||
this.lastMouseX = x;
|
||||
this.lastMouseY = y;
|
||||
|
||||
// 将图像像素坐标转换为纹理坐标 (0-1范围)
|
||||
// 使用原始图像数据的尺寸进行归一化,而不是WebGL canvas的尺寸
|
||||
const imageWidth = this.originalImageData
|
||||
? this.originalImageData.width
|
||||
: this.canvas.width;
|
||||
const imageHeight = this.originalImageData
|
||||
? this.originalImageData.height
|
||||
: this.canvas.height;
|
||||
|
||||
const tx = x / imageWidth;
|
||||
const ty = y / imageHeight;
|
||||
|
||||
console.log(
|
||||
`WebGL变形: 像素坐标(${x}, ${y}) -> 纹理坐标(${tx.toFixed(
|
||||
3
|
||||
)}, ${ty.toFixed(3)}), 图像尺寸(${imageWidth}x${imageHeight})`
|
||||
);
|
||||
|
||||
// 获取当前参数
|
||||
const { size, pressure, distortion, power } = this.params;
|
||||
const mode = this.currentMode;
|
||||
|
||||
// 计算影响半径 (纹理坐标空间)
|
||||
const radius = (size / 100) * 0.2; // 调整半径计算,使效果更自然
|
||||
const strength = (pressure * power * this.config.maxStrength) / 800; // 进一步降低基础强度
|
||||
|
||||
// 保存当前变形点,用于重建功能
|
||||
this.deformHistory.push({
|
||||
x: tx,
|
||||
y: ty,
|
||||
radius,
|
||||
strength,
|
||||
mode,
|
||||
distortion,
|
||||
});
|
||||
|
||||
// 对网格顶点应用变形
|
||||
const { resolution } = this.mesh;
|
||||
const deformations = this.mesh.deformations;
|
||||
|
||||
for (let i = 0; i <= resolution; i++) {
|
||||
for (let j = 0; j <= resolution; j++) {
|
||||
const idx = (i * (resolution + 1) + j) * 2;
|
||||
|
||||
// 顶点在纹理空间中的位置
|
||||
const vx = j / resolution;
|
||||
const vy = i / resolution;
|
||||
|
||||
// 计算到变形中心的距离
|
||||
const dx = vx - tx;
|
||||
const dy = vy - ty;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// 只影响半径内的点
|
||||
if (distance < radius) {
|
||||
// 计算影响因子
|
||||
const factor = Math.pow(1 - distance / radius, 2) * strength;
|
||||
|
||||
// 根据不同模式应用变形
|
||||
switch (mode) {
|
||||
case this.modes.PUSH:
|
||||
// 推拉模式 - 真正的拖拽效果
|
||||
// 计算鼠标移动距离(转换为纹理坐标空间)
|
||||
const movementX = this.mouseMovementX / imageWidth;
|
||||
const movementY = this.mouseMovementY / imageHeight;
|
||||
const movementLength = Math.sqrt(
|
||||
movementX * movementX + movementY * movementY
|
||||
);
|
||||
|
||||
// 只有在有足够移动距离时才应用效果
|
||||
if (movementLength > 0.002) {
|
||||
// 提高阈值,确保有明显移动
|
||||
// 归一化移动方向
|
||||
const moveX = movementX / movementLength;
|
||||
const moveY = movementY / movementLength;
|
||||
|
||||
// 计算衰减(距离中心越近,效果越强)
|
||||
const radiusRatio = distance / radius;
|
||||
const falloff = Math.pow(1 - radiusRatio, 2.0); // 使用更强的衰减
|
||||
|
||||
// 基于实际移动距离计算强度
|
||||
const moveStrength = pressure * power * movementLength * 0.5; // 降低移动强度系数
|
||||
|
||||
// 计算最终拖拽强度
|
||||
const dragStrength = moveStrength * falloff * factor;
|
||||
|
||||
// 向鼠标移动方向拖拽
|
||||
const dragX = moveX * dragStrength;
|
||||
const dragY = moveY * dragStrength;
|
||||
|
||||
// 应用变形,但限制最大变形量
|
||||
const maxDeform = 0.01; // 限制单次最大变形量(纹理坐标空间)
|
||||
deformations[idx] += Math.max(
|
||||
-maxDeform,
|
||||
Math.min(maxDeform, dragX)
|
||||
);
|
||||
deformations[idx + 1] += Math.max(
|
||||
-maxDeform,
|
||||
Math.min(maxDeform, dragY)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case this.modes.CLOCKWISE:
|
||||
// 顺时针旋转
|
||||
const angle = Math.atan2(dy, dx) + factor;
|
||||
const len = distance;
|
||||
deformations[idx] += Math.cos(angle) * len - dx;
|
||||
deformations[idx + 1] += Math.sin(angle) * len - dy;
|
||||
break;
|
||||
|
||||
case this.modes.COUNTERCLOCKWISE:
|
||||
// 逆时针旋转
|
||||
const angle2 = Math.atan2(dy, dx) - factor;
|
||||
const len2 = distance;
|
||||
deformations[idx] += Math.cos(angle2) * len2 - dx;
|
||||
deformations[idx + 1] += Math.sin(angle2) * len2 - dy;
|
||||
break;
|
||||
|
||||
case this.modes.PINCH:
|
||||
// 捏合效果 - 向中心收缩
|
||||
deformations[idx] -= dx * factor;
|
||||
deformations[idx + 1] -= dy * factor;
|
||||
break;
|
||||
|
||||
case this.modes.EXPAND:
|
||||
// 展开效果 - 参考捏合算法的反向操作
|
||||
const expandFactor = factor * 1.5;
|
||||
deformations[idx] += dx * expandFactor;
|
||||
deformations[idx + 1] += dy * expandFactor;
|
||||
break;
|
||||
|
||||
case this.modes.CRYSTAL:
|
||||
// 水晶效果 - 参考旋转算法创建多重角度变形
|
||||
const crystalAngle = Math.atan2(dy, dx);
|
||||
const crystalRadius = distance / radius;
|
||||
|
||||
// 确保有基础效果
|
||||
const baseDistortion = Math.max(distortion, 0.3);
|
||||
|
||||
// 创建多重波形 - 类似旋转但加入波形调制
|
||||
const wave1 = Math.sin(crystalAngle * 8) * 0.6;
|
||||
const wave2 = Math.cos(crystalAngle * 12) * 0.4;
|
||||
const waveAngle = crystalAngle + (wave1 + wave2) * baseDistortion;
|
||||
|
||||
// 径向扭曲 - 类似旋转的距离调制
|
||||
const radialMod = 1 + Math.sin(crystalRadius * Math.PI * 2) * 0.3;
|
||||
const modDistance = distance * radialMod;
|
||||
|
||||
const crystalX = Math.cos(waveAngle) * modDistance;
|
||||
const crystalY = Math.sin(waveAngle) * modDistance;
|
||||
|
||||
deformations[idx] += (crystalX - (tx + dx)) * factor;
|
||||
deformations[idx + 1] += (crystalY - (ty + dy)) * factor;
|
||||
break;
|
||||
|
||||
case this.modes.EDGE:
|
||||
// 边缘效果 - 参考旋转算法创建垂直于径向的波纹
|
||||
const edgeAngle = Math.atan2(dy, dx);
|
||||
const edgeRadius = distance / radius;
|
||||
|
||||
// 确保有基础效果
|
||||
const baseEdgeDistortion = Math.max(distortion, 0.5);
|
||||
|
||||
// 创建边缘波纹 - 垂直于径向方向的调制
|
||||
const edgeWave =
|
||||
Math.sin(edgeRadius * Math.PI * 4) * Math.cos(edgeAngle * 6);
|
||||
const perpAngle = edgeAngle + Math.PI / 2; // 垂直角度
|
||||
|
||||
const edgeFactor = edgeWave * factor * baseEdgeDistortion;
|
||||
const edgeX = Math.cos(perpAngle) * edgeFactor;
|
||||
const edgeY = Math.sin(perpAngle) * edgeFactor;
|
||||
|
||||
deformations[idx] += edgeX;
|
||||
deformations[idx + 1] += edgeY;
|
||||
break;
|
||||
|
||||
case this.modes.RECONSTRUCT:
|
||||
// 重建 - 向原始位置恢复
|
||||
deformations[idx] *= 0.9;
|
||||
deformations[idx + 1] *= 0.9;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新变形缓冲区
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.deformBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ARRAY_BUFFER,
|
||||
deformations,
|
||||
this.gl.DYNAMIC_DRAW
|
||||
);
|
||||
|
||||
// 重新渲染
|
||||
this._render();
|
||||
|
||||
// 更新当前图像数据
|
||||
this.currentImageData = this._getImageData();
|
||||
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染变形后的图像
|
||||
* @private
|
||||
*/
|
||||
_render() {
|
||||
if (!this.initialized) return;
|
||||
|
||||
// 清除画布
|
||||
this.gl.clearColor(0, 0, 0, 0);
|
||||
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
|
||||
|
||||
// 使用变形程序
|
||||
this.gl.useProgram(this.deformProgram);
|
||||
|
||||
// 设置纹理
|
||||
this.gl.activeTexture(this.gl.TEXTURE0);
|
||||
this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
|
||||
const u_image = this.gl.getUniformLocation(this.deformProgram, "u_image");
|
||||
this.gl.uniform1i(u_image, 0);
|
||||
|
||||
// 设置顶点位置属性
|
||||
const a_position = this.gl.getAttribLocation(
|
||||
this.deformProgram,
|
||||
"a_position"
|
||||
);
|
||||
this.gl.enableVertexAttribArray(a_position);
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
this.gl.vertexAttribPointer(a_position, 2, this.gl.FLOAT, false, 0, 0);
|
||||
|
||||
// 设置纹理坐标属性
|
||||
const a_texCoord = this.gl.getAttribLocation(
|
||||
this.deformProgram,
|
||||
"a_texCoord"
|
||||
);
|
||||
this.gl.enableVertexAttribArray(a_texCoord);
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.texCoordBuffer);
|
||||
this.gl.vertexAttribPointer(a_texCoord, 2, this.gl.FLOAT, false, 0, 0);
|
||||
|
||||
// 设置变形属性
|
||||
const a_deformation = this.gl.getAttribLocation(
|
||||
this.deformProgram,
|
||||
"a_deformation"
|
||||
);
|
||||
this.gl.enableVertexAttribArray(a_deformation);
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.deformBuffer);
|
||||
this.gl.vertexAttribPointer(a_deformation, 2, this.gl.FLOAT, false, 0, 0);
|
||||
|
||||
// 绘制三角形
|
||||
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
|
||||
this.gl.drawElements(
|
||||
this.gl.TRIANGLES,
|
||||
this.mesh.indices.length,
|
||||
this.gl.UNSIGNED_SHORT,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图像数据
|
||||
* @returns {ImageData} 当前图像数据
|
||||
* @private
|
||||
*/
|
||||
_getImageData() {
|
||||
const width = this.canvas.width;
|
||||
const height = this.canvas.height;
|
||||
|
||||
// 读取WebGL画布像素
|
||||
const pixels = new Uint8Array(width * height * 4);
|
||||
this.gl.readPixels(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
this.gl.RGBA,
|
||||
this.gl.UNSIGNED_BYTE,
|
||||
pixels
|
||||
);
|
||||
|
||||
// 直接创建ImageData,不进行翻转
|
||||
// WebGL和Canvas2D的坐标系不同,但这里我们保持WebGL的原始输出
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(pixels),
|
||||
width,
|
||||
height
|
||||
);
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有变形
|
||||
* @returns {ImageData} 重置后的图像数据
|
||||
*/
|
||||
reset() {
|
||||
if (!this.initialized) return null;
|
||||
|
||||
// 清除变形历史
|
||||
this.deformHistory = [];
|
||||
|
||||
// 重置所有变形
|
||||
const deformations = new Float32Array(this.mesh.deformations.length);
|
||||
this.mesh.deformations = deformations;
|
||||
|
||||
// 更新变形缓冲区
|
||||
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.deformBuffer);
|
||||
this.gl.bufferData(
|
||||
this.gl.ARRAY_BUFFER,
|
||||
deformations,
|
||||
this.gl.DYNAMIC_DRAW
|
||||
);
|
||||
|
||||
// 重新渲染
|
||||
this._render();
|
||||
|
||||
// 更新当前图像数据
|
||||
this.currentImageData = this._getImageData();
|
||||
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置液化模式
|
||||
* @param {String} mode 液化模式
|
||||
*/
|
||||
setMode(mode) {
|
||||
if (Object.values(this.modes).includes(mode)) {
|
||||
this.currentMode = mode;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置液化参数
|
||||
* @param {String} param 参数名
|
||||
* @param {Number} value 参数值
|
||||
*/
|
||||
setParam(param, value) {
|
||||
if (param in this.params) {
|
||||
this.params[param] = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前参数
|
||||
* @returns {Object} 当前参数
|
||||
*/
|
||||
getParams() {
|
||||
return { ...this.params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置参数为默认值
|
||||
*/
|
||||
resetParams() {
|
||||
this.params = {
|
||||
size: 50,
|
||||
pressure: 0.5,
|
||||
distortion: 0,
|
||||
power: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始图像数据
|
||||
* @returns {ImageData} 原始图像数据
|
||||
*/
|
||||
getOriginalImageData() {
|
||||
return this.originalImageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前图像数据
|
||||
* @returns {ImageData} 当前图像数据
|
||||
*/
|
||||
getCurrentImageData() {
|
||||
return this.currentImageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放资源
|
||||
*/
|
||||
dispose() {
|
||||
if (!this.gl) return;
|
||||
|
||||
// 删除缓冲区
|
||||
if (this.vertexBuffer) this.gl.deleteBuffer(this.vertexBuffer);
|
||||
if (this.texCoordBuffer) this.gl.deleteBuffer(this.texCoordBuffer);
|
||||
if (this.deformBuffer) this.gl.deleteBuffer(this.deformBuffer);
|
||||
if (this.indexBuffer) this.gl.deleteBuffer(this.indexBuffer);
|
||||
|
||||
// 删除纹理
|
||||
if (this.texture) this.gl.deleteTexture(this.texture);
|
||||
|
||||
// 删除着色器程序
|
||||
if (this.program) this.gl.deleteProgram(this.program);
|
||||
if (this.deformProgram) this.gl.deleteProgram(this.deformProgram);
|
||||
|
||||
// 重置属性
|
||||
this.canvas = null;
|
||||
this.gl = null;
|
||||
this.program = null;
|
||||
this.deformProgram = null;
|
||||
this.texture = null;
|
||||
this.mesh = null;
|
||||
this.initialized = false;
|
||||
this.deformHistory = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持WebGL
|
||||
* @returns {Boolean} 是否支持WebGL
|
||||
*/
|
||||
static isSupported() {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
return !!(
|
||||
window.WebGLRenderingContext &&
|
||||
(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"))
|
||||
);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
//import { fabric } from "fabric-with-all";
|
||||
|
||||
/**
|
||||
* 小地图管理器类
|
||||
* 实现画布的小地图功能,展示当前视窗位置和内容概览
|
||||
*/
|
||||
export class MinimapManager {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {fabric.Canvas} mainCanvas 主画布实例
|
||||
* @param {Object} options 配置选项
|
||||
*/
|
||||
constructor(mainCanvas, options = {}) {
|
||||
this.mainCanvas = mainCanvas;
|
||||
this.minimapCanvas = null;
|
||||
this.minimapCtx = null;
|
||||
this.container = null;
|
||||
this.minimapSize = options.size || { width: 200, height: 120 };
|
||||
this.visible = options.visible !== undefined ? options.visible : true;
|
||||
this.isDragging = false;
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = options.renderInterval || 100; // 增加渲染间隔到100ms,降低频率
|
||||
this.initialized = false;
|
||||
this.eventHandlers = {};
|
||||
|
||||
// 内容边界,用于确定小地图显示范围
|
||||
this.contentBounds = {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: 0,
|
||||
maxY: 0,
|
||||
};
|
||||
|
||||
// 缓存上一次视口大小,用于减少抖动
|
||||
this.lastViewportSize = { width: 0, height: 0 };
|
||||
// 添加缓存标志,避免频繁重新计算
|
||||
this.contentBoundsDirty = true;
|
||||
|
||||
// 预先绑定方法,避免上下文丢失
|
||||
this.render = this.render.bind(this);
|
||||
this.handleMainCanvasChange = this.handleMainCanvasChange.bind(this);
|
||||
this.handleMinimapMouseDown = this.handleMinimapMouseDown.bind(this);
|
||||
this.handleMinimapMouseMove = this.handleMinimapMouseMove.bind(this);
|
||||
this.handleMinimapMouseUp = this.handleMinimapMouseUp.bind(this);
|
||||
this.calculateViewportRect = this.calculateViewportRect.bind(this);
|
||||
this.calculateContentBounds = this.calculateContentBounds.bind(this);
|
||||
this.moveViewport = this.moveViewport.bind(this);
|
||||
|
||||
// 创建canvas元素
|
||||
this._createCanvas();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建小地图的canvas元素
|
||||
* @private
|
||||
*/
|
||||
_createCanvas() {
|
||||
// 创建canvas元素
|
||||
this.minimapCanvas = document.createElement("canvas");
|
||||
this.minimapCanvas.width = this.minimapSize.width;
|
||||
this.minimapCanvas.height = this.minimapSize.height;
|
||||
this.minimapCanvas.style.width = "100%";
|
||||
this.minimapCanvas.style.height = "100%";
|
||||
this.minimapCanvas.style.display = this.visible ? "block" : "none";
|
||||
|
||||
// 获取绘图上下文
|
||||
this.minimapCtx = this.minimapCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将小地图挂载到指定的DOM容器中
|
||||
* @param {HTMLElement} containerElement 容器DOM元素
|
||||
* @returns {MinimapManager} 返回实例自身,支持链式调用
|
||||
*/
|
||||
mount(containerElement) {
|
||||
if (!containerElement) {
|
||||
console.error("小地图挂载失败:未提供有效的容器元素");
|
||||
return this;
|
||||
}
|
||||
|
||||
// 保存容器引用
|
||||
this.container = containerElement;
|
||||
|
||||
// 清空容器,防止重复挂载
|
||||
while (containerElement.firstChild) {
|
||||
containerElement.removeChild(containerElement.firstChild);
|
||||
}
|
||||
|
||||
// 将canvas添加到容器
|
||||
containerElement.appendChild(this.minimapCanvas);
|
||||
|
||||
// 初始化小地图
|
||||
if (!this.initialized) {
|
||||
// 计算初始内容边界
|
||||
this.calculateContentBounds();
|
||||
|
||||
// 添加事件监听器
|
||||
this.addEventListeners();
|
||||
|
||||
// 首次渲染
|
||||
this.render();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加事件监听器
|
||||
*/
|
||||
addEventListeners() {
|
||||
if (!this.mainCanvas || !this.minimapCanvas) return;
|
||||
|
||||
// 监听主画布变化事件
|
||||
this.mainCanvas.on("after:render", this.handleMainCanvasChange);
|
||||
// 仅在缩放时重新计算内容边界,避免频繁计算
|
||||
this.mainCanvas.on("zoom:change", () => {
|
||||
this.contentBoundsDirty = true;
|
||||
this.handleMainCanvasChange();
|
||||
});
|
||||
// 仅当对象添加、删除或修改时重新计算内容边界
|
||||
this.mainCanvas.on("object:added", () => {
|
||||
this.contentBoundsDirty = true;
|
||||
this.handleMainCanvasChange();
|
||||
});
|
||||
this.mainCanvas.on("object:removed", () => {
|
||||
this.contentBoundsDirty = true;
|
||||
this.handleMainCanvasChange();
|
||||
});
|
||||
this.mainCanvas.on("object:modified", () => {
|
||||
this.contentBoundsDirty = true;
|
||||
this.handleMainCanvasChange();
|
||||
});
|
||||
|
||||
// 移动、缩放、旋转操作时使用更强的节流,不重新计算内容边界
|
||||
this.mainCanvas.on("object:moving", this.handleMainCanvasChange);
|
||||
this.mainCanvas.on("object:scaling", this.handleMainCanvasChange);
|
||||
this.mainCanvas.on("object:rotating", this.handleMainCanvasChange);
|
||||
|
||||
// 小地图交互事件 - 鼠标
|
||||
this.eventHandlers.mousedown = this.handleMinimapMouseDown;
|
||||
this.eventHandlers.mousemove = this.handleMinimapMouseMove;
|
||||
this.eventHandlers.mouseup = this.handleMinimapMouseUp;
|
||||
// 移除mouseout事件处理,允许拖动操作持续到鼠标释放
|
||||
|
||||
this.minimapCanvas.addEventListener(
|
||||
"mousedown",
|
||||
this.eventHandlers.mousedown
|
||||
);
|
||||
document.addEventListener("mousemove", this.eventHandlers.mousemove);
|
||||
document.addEventListener("mouseup", this.eventHandlers.mouseup);
|
||||
// 移除mouseout事件监听
|
||||
|
||||
// 小地图交互事件 - 触摸
|
||||
this.eventHandlers.touchstart = (e) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
this.handleMinimapMouseDown({
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
this.eventHandlers.touchmove = (e) => {
|
||||
e.preventDefault();
|
||||
if (this.isDragging) {
|
||||
const touch = e.touches[0];
|
||||
this.handleMinimapMouseMove({
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
preventDefault: () => {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.eventHandlers.touchend = this.handleMinimapMouseUp;
|
||||
|
||||
this.minimapCanvas.addEventListener(
|
||||
"touchstart",
|
||||
this.eventHandlers.touchstart
|
||||
);
|
||||
document.addEventListener("touchmove", this.eventHandlers.touchmove, {
|
||||
passive: false,
|
||||
});
|
||||
document.addEventListener("touchend", this.eventHandlers.touchend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
*/
|
||||
removeEventListeners() {
|
||||
if (!this.mainCanvas || !this.minimapCanvas) return;
|
||||
|
||||
// 移除画布事件监听
|
||||
this.mainCanvas.off("after:render", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("zoom:change", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("object:added", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("object:removed", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("object:modified", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("object:moving", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("object:scaling", this.handleMainCanvasChange);
|
||||
this.mainCanvas.off("object:rotating", this.handleMainCanvasChange);
|
||||
|
||||
// 移除鼠标事件监听
|
||||
this.minimapCanvas.removeEventListener(
|
||||
"mousedown",
|
||||
this.eventHandlers.mousedown
|
||||
);
|
||||
document.removeEventListener("mousemove", this.eventHandlers.mousemove);
|
||||
document.removeEventListener("mouseup", this.eventHandlers.mouseup);
|
||||
|
||||
// 移除触摸事件监听
|
||||
this.minimapCanvas.removeEventListener(
|
||||
"touchstart",
|
||||
this.eventHandlers.touchstart
|
||||
);
|
||||
document.removeEventListener("touchmove", this.eventHandlers.touchmove);
|
||||
document.removeEventListener("touchend", this.eventHandlers.touchend);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理主画布变化事件
|
||||
* 使用节流限制渲染频率
|
||||
*/
|
||||
handleMainCanvasChange() {
|
||||
const now = Date.now();
|
||||
if (now - this.lastRenderTime > this.renderInterval) {
|
||||
this.lastRenderTime = now;
|
||||
|
||||
// 只在内容边界标记为脏时才重新计算
|
||||
if (this.contentBoundsDirty) {
|
||||
this.calculateContentBounds();
|
||||
this.contentBoundsDirty = false;
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算画布内容的边界范围
|
||||
* 包括所有可见对象和画布本身
|
||||
*/
|
||||
calculateContentBounds() {
|
||||
if (!this.mainCanvas) return;
|
||||
|
||||
const objects = this.mainCanvas.getObjects();
|
||||
|
||||
// 初始化为画布尺寸
|
||||
let minX = 0;
|
||||
let minY = 0;
|
||||
let maxX = this.mainCanvas.getWidth();
|
||||
let maxY = this.mainCanvas.getHeight();
|
||||
|
||||
// 如果有对象,则计算所有对象的边界
|
||||
if (objects.length > 0) {
|
||||
// 重置为极值
|
||||
minX = Infinity;
|
||||
minY = Infinity;
|
||||
maxX = -Infinity;
|
||||
maxY = -Infinity;
|
||||
|
||||
// 考虑所有可见对象的边界
|
||||
objects.forEach((obj) => {
|
||||
if (!obj.visible) return;
|
||||
|
||||
const rect = obj.getBoundingRect(true, true);
|
||||
minX = Math.min(minX, rect.left);
|
||||
minY = Math.min(minY, rect.top);
|
||||
maxX = Math.max(maxX, rect.left + rect.width);
|
||||
maxY = Math.max(maxY, rect.top + rect.height);
|
||||
});
|
||||
|
||||
// 确保边界至少包含画布尺寸
|
||||
minX = Math.min(minX, 0);
|
||||
minY = Math.min(minY, 0);
|
||||
maxX = Math.max(maxX, this.mainCanvas.getWidth());
|
||||
maxY = Math.max(maxY, this.mainCanvas.getHeight());
|
||||
}
|
||||
|
||||
// 添加边距
|
||||
const padding =
|
||||
Math.max(this.mainCanvas.getWidth(), this.mainCanvas.getHeight()) * 0.1;
|
||||
this.contentBounds = {
|
||||
minX: minX - padding,
|
||||
minY: minY - padding,
|
||||
maxX: maxX + padding,
|
||||
maxY: maxY + padding,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理小地图鼠标按下事件
|
||||
*/
|
||||
handleMinimapMouseDown(e) {
|
||||
if (!this.visible || !this.minimapCanvas) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const rect = this.minimapCanvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
// 检查点击是否在视口矩形内
|
||||
const vpRect = this.calculateViewportRect();
|
||||
|
||||
// 在视口矩形内点击开始拖拽,否则直接跳转到点击位置
|
||||
if (
|
||||
x >= vpRect.x &&
|
||||
x <= vpRect.x + vpRect.width &&
|
||||
y >= vpRect.y &&
|
||||
y <= vpRect.y + vpRect.height
|
||||
) {
|
||||
this.isDragging = true;
|
||||
this.dragStart = { x, y };
|
||||
this.dragStartViewport = { ...vpRect };
|
||||
|
||||
// 缓存当前视口大小,确保拖动过程中大小不变
|
||||
this.lastViewportSize = {
|
||||
width: vpRect.width,
|
||||
height: vpRect.height,
|
||||
};
|
||||
} else {
|
||||
// 直接移动视口中心到点击位置
|
||||
this.moveViewport(x, y, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理小地图鼠标移动事件
|
||||
*/
|
||||
handleMinimapMouseMove(e) {
|
||||
if (!this.isDragging || !this.visible) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const rect = this.minimapCanvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
const deltaX = x - this.dragStart.x;
|
||||
const deltaY = y - this.dragStart.y;
|
||||
|
||||
// 更新拖拽起始位置
|
||||
this.dragStart = { x, y };
|
||||
|
||||
// 移动画布视口
|
||||
this.moveViewport(
|
||||
this.dragStartViewport.x + deltaX,
|
||||
this.dragStartViewport.y + deltaY,
|
||||
false
|
||||
);
|
||||
|
||||
// 更新拖拽起始视口位置
|
||||
this.dragStartViewport = this.calculateViewportRect();
|
||||
|
||||
// 立即渲染小地图,提升拖动流畅度
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理小地图鼠标抬起事件
|
||||
*/
|
||||
handleMinimapMouseUp() {
|
||||
this.isDragging = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动主画布视口到指定位置
|
||||
*/
|
||||
moveViewport(x, y, isCentered) {
|
||||
if (!this.mainCanvas) return;
|
||||
|
||||
// 获取主画布的当前视图信息
|
||||
const vpt = this.mainCanvas.viewportTransform;
|
||||
const zoom = this.mainCanvas.getZoom();
|
||||
|
||||
// 计算内容边界在小地图上的比例
|
||||
const contentWidth = this.contentBounds.maxX - this.contentBounds.minX;
|
||||
const contentHeight = this.contentBounds.maxY - this.contentBounds.minY;
|
||||
|
||||
const scaleX = this.minimapSize.width / contentWidth;
|
||||
const scaleY = this.minimapSize.height / contentHeight;
|
||||
|
||||
// 计算视口在小地图上的宽高
|
||||
let viewportWidth, viewportHeight;
|
||||
if (this.isDragging && this.lastViewportSize.width > 0) {
|
||||
viewportWidth = this.lastViewportSize.width;
|
||||
viewportHeight = this.lastViewportSize.height;
|
||||
} else {
|
||||
viewportWidth = Math.round((this.mainCanvas.getWidth() / zoom) * scaleX);
|
||||
viewportHeight = Math.round(
|
||||
(this.mainCanvas.getHeight() / zoom) * scaleY
|
||||
);
|
||||
}
|
||||
|
||||
// 添加边界限制,确保视口不会超出小地图
|
||||
x = Math.max(0, Math.min(x, this.minimapSize.width - viewportWidth));
|
||||
y = Math.max(0, Math.min(y, this.minimapSize.height - viewportHeight));
|
||||
|
||||
// 将小地图坐标转换为主画布坐标
|
||||
let targetX = x / scaleX + this.contentBounds.minX;
|
||||
let targetY = y / scaleY + this.contentBounds.minY;
|
||||
|
||||
if (isCentered) {
|
||||
// 如果是直接点击,则将点击位置设为视口中心
|
||||
targetX -= this.mainCanvas.getWidth() / zoom / 2;
|
||||
targetY -= this.mainCanvas.getHeight() / zoom / 2;
|
||||
}
|
||||
|
||||
// 设置主画布的位置
|
||||
this.mainCanvas.setViewportTransform([
|
||||
vpt[0],
|
||||
vpt[1],
|
||||
vpt[2],
|
||||
vpt[3],
|
||||
-targetX * zoom,
|
||||
-targetY * zoom,
|
||||
]);
|
||||
|
||||
// 触发主画布重新渲染
|
||||
this.mainCanvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前视口在小地图中的位置和大小
|
||||
*/
|
||||
calculateViewportRect() {
|
||||
if (!this.mainCanvas) return { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
// 获取主画布的视图变换信息
|
||||
const vpt = this.mainCanvas.viewportTransform;
|
||||
const zoom = this.mainCanvas.getZoom();
|
||||
|
||||
// 计算内容边界在小地图上的比例
|
||||
const contentWidth = this.contentBounds.maxX - this.contentBounds.minX;
|
||||
const contentHeight = this.contentBounds.maxY - this.contentBounds.minY;
|
||||
|
||||
const scaleX = this.minimapSize.width / contentWidth;
|
||||
const scaleY = this.minimapSize.height / contentHeight;
|
||||
|
||||
// 计算当前视口区域相对于内容边界的位置
|
||||
const viewLeft = -vpt[4] / zoom - this.contentBounds.minX;
|
||||
const viewTop = -vpt[5] / zoom - this.contentBounds.minY;
|
||||
|
||||
// 转换为小地图上的坐标,使用取整减少精度误差
|
||||
const x = Math.round(viewLeft * scaleX);
|
||||
const y = Math.round(viewTop * scaleY);
|
||||
|
||||
// 如果正在拖动,则使用缓存的大小避免抖动
|
||||
let width, height;
|
||||
if (this.isDragging && this.lastViewportSize.width > 0) {
|
||||
width = this.lastViewportSize.width;
|
||||
height = this.lastViewportSize.height;
|
||||
} else {
|
||||
width = Math.round((this.mainCanvas.getWidth() / zoom) * scaleX);
|
||||
height = Math.round((this.mainCanvas.getHeight() / zoom) * scaleY);
|
||||
|
||||
// 更新缓存的视口大小
|
||||
if (!this.isDragging) {
|
||||
this.lastViewportSize = { width, height };
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染小地图
|
||||
* 使用高性能的离屏渲染
|
||||
*/
|
||||
render() {
|
||||
if (!this.visible || !this.minimapCanvas || !this.mainCanvas) return;
|
||||
|
||||
try {
|
||||
// 清空小地图
|
||||
this.minimapCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this.minimapSize.width,
|
||||
this.minimapSize.height
|
||||
);
|
||||
|
||||
// 绘制小地图背景
|
||||
this.minimapCtx.fillStyle = this.mainCanvas.backgroundColor || "#f0f0f0";
|
||||
this.minimapCtx.fillRect(
|
||||
0,
|
||||
0,
|
||||
this.minimapSize.width,
|
||||
this.minimapSize.height
|
||||
);
|
||||
|
||||
// 计算内容边界尺寸
|
||||
const contentWidth = this.contentBounds.maxX - this.contentBounds.minX;
|
||||
const contentHeight = this.contentBounds.maxY - this.contentBounds.minY;
|
||||
|
||||
// 检查是否有内容需要渲染
|
||||
const objects = this.mainCanvas.getObjects();
|
||||
if (objects.length === 0) {
|
||||
// 如果没有对象,只需绘制视口框
|
||||
this.drawViewportBox();
|
||||
return;
|
||||
}
|
||||
|
||||
// 优化离屏渲染尺寸计算
|
||||
const maxSize = 1000; // 限制离屏canvas最大尺寸,提高性能
|
||||
let offscreenWidth = contentWidth;
|
||||
let offscreenHeight = contentHeight;
|
||||
let scale = 1;
|
||||
|
||||
if (contentWidth > maxSize || contentHeight > maxSize) {
|
||||
scale = Math.min(maxSize / contentWidth, maxSize / contentHeight);
|
||||
offscreenWidth *= scale;
|
||||
offscreenHeight *= scale;
|
||||
}
|
||||
|
||||
const offscreenCanvas = document.createElement("canvas");
|
||||
offscreenCanvas.width = offscreenWidth;
|
||||
offscreenCanvas.height = offscreenHeight;
|
||||
const offCtx = offscreenCanvas.getContext("2d");
|
||||
|
||||
// 创建临时fabric.Canvas用于渲染全内容
|
||||
const tempFabricCanvas = new fabric.StaticCanvas();
|
||||
tempFabricCanvas.setWidth(offscreenWidth);
|
||||
tempFabricCanvas.setHeight(offscreenHeight);
|
||||
tempFabricCanvas.backgroundColor = this.mainCanvas.backgroundColor;
|
||||
|
||||
// 复制主画布对象到临时画布
|
||||
objects.forEach((obj) => {
|
||||
if (!obj.visible) return;
|
||||
|
||||
try {
|
||||
// 使用浅克隆,避免深度克隆带来的性能开销
|
||||
const clonedObj = fabric.util.object.clone(obj);
|
||||
|
||||
// 调整对象位置和大小,使其相对于内容边界并适应缩放
|
||||
clonedObj.set({
|
||||
left: (obj.left - this.contentBounds.minX) * scale,
|
||||
top: (obj.top - this.contentBounds.minY) * scale,
|
||||
scaleX: obj.scaleX * scale,
|
||||
scaleY: obj.scaleY * scale,
|
||||
// 禁用对象的交互属性,提高性能
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hasControls: false,
|
||||
hasBorders: false,
|
||||
});
|
||||
|
||||
tempFabricCanvas.add(clonedObj);
|
||||
} catch (err) {
|
||||
console.warn("无法克隆对象到小地图", err);
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染临时画布
|
||||
tempFabricCanvas.renderAll();
|
||||
|
||||
// 将临时画布内容绘制到离屏canvas
|
||||
offCtx.drawImage(tempFabricCanvas.getElement(), 0, 0);
|
||||
|
||||
// 将离屏canvas缩放绘制到小地图
|
||||
this.minimapCtx.drawImage(
|
||||
offscreenCanvas,
|
||||
0,
|
||||
0,
|
||||
offscreenWidth,
|
||||
offscreenHeight,
|
||||
0,
|
||||
0,
|
||||
this.minimapSize.width,
|
||||
this.minimapSize.height
|
||||
);
|
||||
|
||||
// 释放临时画布资源
|
||||
// tempFabricCanvas.dispose();
|
||||
|
||||
// 绘制视口框
|
||||
this.drawViewportBox();
|
||||
} catch (error) {
|
||||
console.error("小地图渲染出错:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制视口框,从render方法中分离出来提高代码清晰度
|
||||
*/
|
||||
drawViewportBox() {
|
||||
// 计算当前视口范围
|
||||
const vpRect = this.calculateViewportRect();
|
||||
|
||||
// 视口矩形边框
|
||||
this.minimapCtx.strokeStyle = "#ff3333";
|
||||
this.minimapCtx.lineWidth = 2;
|
||||
this.minimapCtx.strokeRect(vpRect.x, vpRect.y, vpRect.width, vpRect.height);
|
||||
|
||||
// 视口矩形半透明填充
|
||||
this.minimapCtx.fillStyle = "rgba(255, 0, 0, 0.1)";
|
||||
this.minimapCtx.fillRect(vpRect.x, vpRect.y, vpRect.width, vpRect.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置小地图可见性
|
||||
*/
|
||||
setVisibility(visible) {
|
||||
this.visible = visible;
|
||||
|
||||
// 更新canvas显示状态
|
||||
if (this.minimapCanvas) {
|
||||
this.minimapCanvas.style.display = visible ? "block" : "none";
|
||||
}
|
||||
|
||||
if (visible && this.initialized) {
|
||||
this.contentBoundsDirty = true; // 标记需要重新计算内容边界
|
||||
this.calculateContentBounds();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新小地图
|
||||
* 重新读取大画布数据并渲染
|
||||
*/
|
||||
refresh() {
|
||||
this.contentBoundsDirty = true;
|
||||
this.calculateContentBounds();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整小地图大小
|
||||
* @param {Object} size 小地图尺寸,{width, height}
|
||||
*/
|
||||
resize(size) {
|
||||
if (!size || !size.width || !size.height) return;
|
||||
|
||||
this.minimapSize = {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
|
||||
if (this.minimapCanvas) {
|
||||
this.minimapCanvas.width = size.width;
|
||||
this.minimapCanvas.height = size.height;
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源,释放内存
|
||||
*/
|
||||
dispose() {
|
||||
this.removeEventListeners();
|
||||
|
||||
// 从DOM中移除canvas
|
||||
if (
|
||||
this.container &&
|
||||
this.minimapCanvas &&
|
||||
this.minimapCanvas.parentNode === this.container
|
||||
) {
|
||||
this.container.removeChild(this.minimapCanvas);
|
||||
}
|
||||
|
||||
this.mainCanvas = null;
|
||||
this.minimapCanvas = null;
|
||||
this.minimapCtx = null;
|
||||
this.container = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小地图
|
||||
* 使用更高效的渲染策略,减少不必要的重绘
|
||||
*/
|
||||
update() {
|
||||
if (!this.enabled || !this.minimapCanvas) return;
|
||||
|
||||
// 使用节流来控制更新频率
|
||||
if (this._updateTimeout) {
|
||||
clearTimeout(this._updateTimeout);
|
||||
}
|
||||
|
||||
this._updateTimeout = setTimeout(() => {
|
||||
this._renderMinimap();
|
||||
}, 100); // 100ms 的节流,避免频繁渲染
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染小地图
|
||||
* 优化渲染性能,只在必要时重绘
|
||||
*/
|
||||
_renderMinimap() {
|
||||
if (!this.minimapCanvas || !this.canvas) return;
|
||||
|
||||
const ctx = this.minimapCanvas.getContext("2d");
|
||||
const ratio = this.minimapCanvas.width / this.canvas.width;
|
||||
|
||||
// 清除小地图
|
||||
ctx.clearRect(0, 0, this.minimapCanvas.width, this.minimapCanvas.height);
|
||||
|
||||
// 使用缓存策略
|
||||
if (!this._minimapCache || this._shouldUpdateCache()) {
|
||||
// 创建离屏画布作为缓存
|
||||
if (!this._offscreenCanvas) {
|
||||
this._offscreenCanvas = document.createElement("canvas");
|
||||
this._offscreenCanvas.width = this.minimapCanvas.width;
|
||||
this._offscreenCanvas.height = this.minimapCanvas.height;
|
||||
}
|
||||
|
||||
const offCtx = this._offscreenCanvas.getContext("2d");
|
||||
offCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this._offscreenCanvas.width,
|
||||
this._offscreenCanvas.height
|
||||
);
|
||||
|
||||
// 绘制图层内容到离屏画布
|
||||
this._renderLayersToMinimap(offCtx, ratio);
|
||||
|
||||
// 保存渲染时间戳
|
||||
this._lastCacheUpdate = Date.now();
|
||||
this._minimapCache = true;
|
||||
}
|
||||
|
||||
// 将缓存的内容渲染到实际小地图画布
|
||||
if (this._offscreenCanvas) {
|
||||
ctx.drawImage(this._offscreenCanvas, 0, 0);
|
||||
}
|
||||
|
||||
// 绘制可视区域指示器
|
||||
this._renderViewportIndicator(ctx, ratio);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该更新小地图缓存
|
||||
*/
|
||||
_shouldUpdateCache() {
|
||||
// 如果没有缓存或缓存时间超过500ms,则更新
|
||||
return !this._lastCacheUpdate || Date.now() - this._lastCacheUpdate > 500;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染图层内容到小地图
|
||||
*/
|
||||
_renderLayersToMinimap(ctx, ratio) {
|
||||
// 获取画布上所有可见的图层
|
||||
const visibleLayers = [];
|
||||
|
||||
// 安全地访问图层数据,避免 "forEach is not a function" 错误
|
||||
if (this.canvas && this.canvas.layers) {
|
||||
// 检查 layers 是否是响应式对象 (有 value 属性)
|
||||
const layersArray =
|
||||
typeof this.canvas.layers.value !== "undefined"
|
||||
? this.canvas.layers.value
|
||||
: Array.isArray(this.canvas.layers)
|
||||
? this.canvas.layers
|
||||
: [];
|
||||
|
||||
// 过滤出可见图层
|
||||
layersArray.forEach((layer) => {
|
||||
if (layer.visible) {
|
||||
visibleLayers.push(layer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 按照图层顺序渲染到小地图
|
||||
for (const layer of visibleLayers) {
|
||||
let objectsToRender = [];
|
||||
|
||||
// 根据图层类型获取要渲染的对象
|
||||
if (layer.type === "background" && layer.fabricObject) {
|
||||
objectsToRender = [layer.fabricObject];
|
||||
} else if (layer.fabricObjects && Array.isArray(layer.fabricObjects)) {
|
||||
objectsToRender = layer.fabricObjects;
|
||||
}
|
||||
|
||||
for (const fabricObj of objectsToRender) {
|
||||
if (!fabricObj.visible) continue;
|
||||
|
||||
// 根据对象类型渲染到小地图
|
||||
if (fabricObj.type === "image" && fabricObj._element) {
|
||||
ctx.globalAlpha = fabricObj.opacity || 1;
|
||||
const left = fabricObj.left * ratio;
|
||||
const top = fabricObj.top * ratio;
|
||||
const width = fabricObj.width * fabricObj.scaleX * ratio;
|
||||
const height = fabricObj.height * fabricObj.scaleY * ratio;
|
||||
|
||||
ctx.drawImage(fabricObj._element, left, top, width, height);
|
||||
} else if (
|
||||
fabricObj.type === "path" ||
|
||||
fabricObj.type === "rect" ||
|
||||
fabricObj.type === "circle"
|
||||
) {
|
||||
// 简单地用颜色块表示其他类型的对象
|
||||
ctx.fillStyle = fabricObj.fill || "#888";
|
||||
ctx.globalAlpha = fabricObj.opacity || 0.5;
|
||||
|
||||
const left = fabricObj.left * ratio;
|
||||
const top = fabricObj.top * ratio;
|
||||
const width =
|
||||
(fabricObj.width || 20) * (fabricObj.scaleX || 1) * ratio;
|
||||
const height =
|
||||
(fabricObj.height || 20) * (fabricObj.scaleY || 1) * ratio;
|
||||
|
||||
ctx.fillRect(left, top, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染视口指示器
|
||||
*/
|
||||
_renderViewportIndicator(ctx, ratio) {
|
||||
if (!this.canvas) return;
|
||||
|
||||
const vpt = this.canvas.viewportTransform;
|
||||
if (!vpt) return;
|
||||
|
||||
// 计算可视区域在小地图上的位置和大小
|
||||
const zoom = this.canvas.getZoom();
|
||||
const viewportWidth = this.canvas.width / zoom;
|
||||
const viewportHeight = this.canvas.height / zoom;
|
||||
|
||||
const x = (-vpt[4] / zoom) * ratio;
|
||||
const y = (-vpt[5] / zoom) * ratio;
|
||||
const width = viewportWidth * ratio;
|
||||
const height = viewportHeight * ratio;
|
||||
|
||||
// 绘制视口指示器
|
||||
ctx.strokeStyle = "#ff0000";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x, y, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制完全更新小地图
|
||||
*/
|
||||
forceUpdate() {
|
||||
this._minimapCache = false;
|
||||
this.update();
|
||||
}
|
||||
}
|
||||
|
||||
export default MinimapManager;
|
||||
@@ -0,0 +1,951 @@
|
||||
//import { fabric } from "fabric-with-all";
|
||||
import { generateId } from "../../utils/helper";
|
||||
import { OperationType } from "../../utils/layerHelper";
|
||||
import {
|
||||
ClearSelectionCommand,
|
||||
CreateSelectionCommand,
|
||||
} from "../../commands/SelectionCommands";
|
||||
|
||||
/**
|
||||
* 选区管理器
|
||||
* 负责管理画布上的选区操作
|
||||
*/
|
||||
export class SelectionManager {
|
||||
/**
|
||||
* 创建选区管理器
|
||||
* @param {Object} options 配置选项
|
||||
* @param {Object} options.canvas fabric.js画布实例
|
||||
* @param {Object} options.commandManager 命令管理器实例
|
||||
* @param {Object} options.layerManager 图层管理实例
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this.canvas = options.canvas;
|
||||
this.commandManager = options.commandManager;
|
||||
this.layerManager = options.layerManager;
|
||||
|
||||
// 选区状态
|
||||
this.isActive = false;
|
||||
this.selectionType = OperationType.LASSO_RECTANGLE; // 使用常量而不是字符串
|
||||
this.selectionObject = null; // 当前选区对象
|
||||
this.selectionId = "selection_" + Date.now();
|
||||
this.featherAmount = 0; // 羽化值
|
||||
|
||||
// 选区样式配置
|
||||
this.selectionStyle = {
|
||||
stroke: "#0096ff",
|
||||
strokeWidth: 1,
|
||||
strokeDashArray: [5, 5],
|
||||
fill: "rgba(0, 150, 255, 0.1)",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
excludeFromExport: true,
|
||||
hoverCursor: "default",
|
||||
moveCursor: "default",
|
||||
};
|
||||
|
||||
// 绘制状态
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
this.selectionPath = null; // 存储选区路径数据
|
||||
|
||||
// 自由选区相关状态
|
||||
this.drawingPoints = null;
|
||||
this.currentPathString = null;
|
||||
|
||||
// 不再直接绑定事件处理函数
|
||||
this._mouseDownHandler = null;
|
||||
this._mouseMoveHandler = null;
|
||||
this._mouseUpHandler = null;
|
||||
this._keyDownHandler = null;
|
||||
|
||||
// 选区相关的工具类型
|
||||
this.selectionTools = [
|
||||
OperationType.LASSO,
|
||||
OperationType.LASSO_RECTANGLE,
|
||||
OperationType.LASSO_ELLIPSE,
|
||||
];
|
||||
|
||||
// 当前工具
|
||||
this.currentTool = OperationType.SELECT;
|
||||
|
||||
// 选区状态变化回调
|
||||
this.onSelectionChanged = null;
|
||||
|
||||
// 不再自动初始化事件,改为手动控制
|
||||
// this.initEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前工具
|
||||
* @param {String} toolId 工具ID
|
||||
*/
|
||||
setCurrentTool(toolId) {
|
||||
this.currentTool = toolId;
|
||||
|
||||
// 检查是否为选区工具
|
||||
const wasActive = this.isActive;
|
||||
this.isActive = this.selectionTools.includes(toolId);
|
||||
|
||||
// 如果从非选区工具切换到选区工具,初始化事件
|
||||
if (!wasActive && this.isActive) {
|
||||
this.initEvents();
|
||||
}
|
||||
// 如果从选区工具切换到非选区工具,清理事件和选区
|
||||
else if (wasActive && !this.isActive) {
|
||||
this.cleanupEvents();
|
||||
this.clearSelection();
|
||||
}
|
||||
|
||||
// 根据工具类型设置选区类型
|
||||
if (this.isActive) {
|
||||
this.selectionType = toolId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化选区相关事件
|
||||
*/
|
||||
initEvents() {
|
||||
if (!this.canvas || this._mouseDownHandler) return; // 避免重复初始化
|
||||
|
||||
// 保存实例引用,用于事件处理函数中
|
||||
const self = this;
|
||||
|
||||
// 鼠标按下事件处理
|
||||
this._mouseDownHandler = (options) => {
|
||||
// 如果选区功能未激活,不处理事件
|
||||
if (!this.isActive) return;
|
||||
|
||||
// 如果点击的是已有对象且不是选区对象,则不处理
|
||||
if (
|
||||
options.target &&
|
||||
options.target.id !== this.selectionId &&
|
||||
options.target.selectable !== false &&
|
||||
options.target.type !== "selection"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 阻止事件冒泡,避免与 CanvasEventManager 冲突
|
||||
options.e.stopPropagation();
|
||||
|
||||
// 根据选区类型执行不同的起始操作
|
||||
switch (this.selectionType) {
|
||||
case OperationType.LASSO:
|
||||
this.startFreeSelection(options);
|
||||
break;
|
||||
case OperationType.LASSO_ELLIPSE:
|
||||
this.startEllipseSelection(options);
|
||||
break;
|
||||
case OperationType.LASSO_RECTANGLE:
|
||||
this.startRectangleSelection(options);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标移动事件处理
|
||||
this._mouseMoveHandler = (options) => {
|
||||
// 如果选区功能未激活或没有正在绘制的对象,不处理事件
|
||||
if (!this.isActive || !this.drawingObject) return;
|
||||
|
||||
// 阻止事件冒泡
|
||||
options.e.stopPropagation();
|
||||
|
||||
// 根据选区类型执行不同的绘制操作
|
||||
switch (this.selectionType) {
|
||||
case OperationType.LASSO_RECTANGLE:
|
||||
this.drawRectangleSelection(options);
|
||||
break;
|
||||
case OperationType.LASSO_ELLIPSE:
|
||||
this.drawEllipseSelection(options);
|
||||
break;
|
||||
case OperationType.LASSO:
|
||||
this.drawFreeSelection(options);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标抬起事件处理
|
||||
this._mouseUpHandler = (options) => {
|
||||
// 如果选区功能未激活或没有正在绘制的对象,不处理事件
|
||||
if (!this.isActive || !this.drawingObject) return;
|
||||
|
||||
// 阻止事件冒泡
|
||||
if (options && options.e) {
|
||||
options.e.stopPropagation();
|
||||
}
|
||||
|
||||
// 根据选区类型执行不同的完成操作
|
||||
switch (this.selectionType) {
|
||||
case OperationType.LASSO_RECTANGLE:
|
||||
this.endRectangleSelection();
|
||||
break;
|
||||
case OperationType.LASSO_ELLIPSE:
|
||||
this.endEllipseSelection();
|
||||
break;
|
||||
case OperationType.LASSO:
|
||||
this.endFreeSelection();
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果有命令管理器,使用命令模式记录选区创建
|
||||
if (this.commandManager && this.selectionObject) {
|
||||
this.commandManager.execute(
|
||||
new CreateSelectionCommand({
|
||||
canvas: this.canvas,
|
||||
selectionManager: this,
|
||||
selectionObject: this.selectionObject,
|
||||
selectionType: this.selectionType,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘事件处理
|
||||
this._keyDownHandler = (event) => {
|
||||
// 只在选区功能激活时处理键盘事件
|
||||
if (!this.isActive) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
// ESC键取消当前选区操作
|
||||
if (this.drawingObject) {
|
||||
this.canvas.remove(this.drawingObject);
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
}
|
||||
// 清除已有选区
|
||||
else if (this.selectionObject) {
|
||||
if (this.commandManager) {
|
||||
this.commandManager.execute(
|
||||
new ClearSelectionCommand({
|
||||
selectionManager: this,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
this.canvas.on("mouse:down", this._mouseDownHandler);
|
||||
this.canvas.on("mouse:move", this._mouseMoveHandler);
|
||||
this.canvas.on("mouse:up", this._mouseUpHandler);
|
||||
|
||||
// 添加键盘事件监听
|
||||
document.addEventListener("keydown", this._keyDownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理事件监听
|
||||
*/
|
||||
cleanupEvents() {
|
||||
if (!this.canvas) return;
|
||||
|
||||
// 移除事件监听
|
||||
if (this._mouseDownHandler) {
|
||||
this.canvas.off("mouse:down", this._mouseDownHandler);
|
||||
this._mouseDownHandler = null;
|
||||
}
|
||||
if (this._mouseMoveHandler) {
|
||||
this.canvas.off("mouse:move", this._mouseMoveHandler);
|
||||
this._mouseMoveHandler = null;
|
||||
}
|
||||
if (this._mouseUpHandler) {
|
||||
this.canvas.off("mouse:up", this._mouseUpHandler);
|
||||
this._mouseUpHandler = null;
|
||||
}
|
||||
if (this._keyDownHandler) {
|
||||
document.removeEventListener("keydown", this._keyDownHandler);
|
||||
this._keyDownHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取选区对象
|
||||
* @returns {Object} 选区对象
|
||||
*/
|
||||
getSelectionObject() {
|
||||
return this.selectionObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取选区路径
|
||||
* @returns {Array|String} 选区路径数据
|
||||
*/
|
||||
getSelectionPath() {
|
||||
return this.selectionPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取羽化值
|
||||
* @returns {Number} 羽化值
|
||||
*/
|
||||
getFeatherAmount() {
|
||||
return this.featherAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置羽化值
|
||||
* @param {Number} amount 羽化值
|
||||
*/
|
||||
setFeatherAmount(amount) {
|
||||
this.featherAmount = amount;
|
||||
return this.updateSelectionAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置选区对象
|
||||
* @param {Object} object 选区对象
|
||||
*/
|
||||
setSelectionObject(object) {
|
||||
// 如果已存在选区,先移除
|
||||
if (this.selectionObject) {
|
||||
this.removeSelectionFromCanvas();
|
||||
}
|
||||
|
||||
// 更新选区对象
|
||||
this.selectionObject = object;
|
||||
this.selectionPath = object.path;
|
||||
this.selectionId = object.id || generateId();
|
||||
|
||||
// 更新外观
|
||||
this.updateSelectionAppearance();
|
||||
|
||||
// 添加到画布(确保在顶层)
|
||||
if (this.canvas && this.selectionObject) {
|
||||
this.canvas.add(this.selectionObject);
|
||||
this.canvas.bringToFront(this.selectionObject);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
// 触发选区变化回调
|
||||
if (
|
||||
this.onSelectionChanged &&
|
||||
typeof this.onSelectionChanged === "function"
|
||||
) {
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从路径数据设置选区
|
||||
* @param {Array|String} path 选区路径数据
|
||||
*/
|
||||
setSelectionFromPath(path) {
|
||||
if (!path) return false;
|
||||
|
||||
// 创建选区对象
|
||||
const selectionObj = new fabric.Path(path, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
});
|
||||
|
||||
// 设置选区
|
||||
return this.setSelectionObject(selectionObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新选区外观
|
||||
*/
|
||||
updateSelectionAppearance() {
|
||||
if (!this.selectionObject) return false;
|
||||
|
||||
// 应用基本样式
|
||||
Object.assign(this.selectionObject, this.selectionStyle);
|
||||
|
||||
// 应用羽化效果
|
||||
if (this.featherAmount > 0) {
|
||||
this.selectionObject.shadow = new fabric.Shadow({
|
||||
color: "rgba(0, 150, 255, 0.5)",
|
||||
blur: this.featherAmount,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
} else {
|
||||
this.selectionObject.shadow = null;
|
||||
}
|
||||
|
||||
// 更新画布
|
||||
this.canvas.renderAll();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除选区
|
||||
*/
|
||||
removeSelectionFromCanvas() {
|
||||
if (this.canvas && this.selectionObject) {
|
||||
this.canvas.remove(this.selectionObject);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除选区
|
||||
*/
|
||||
clearSelection() {
|
||||
// 移除选区对象
|
||||
this.removeSelectionFromCanvas();
|
||||
|
||||
// 重置选区状态
|
||||
this.selectionObject = null;
|
||||
this.selectionPath = null;
|
||||
this.selectionId = null;
|
||||
this.featherAmount = 0;
|
||||
|
||||
// 触发选区变化回调
|
||||
if (
|
||||
this.onSelectionChanged &&
|
||||
typeof this.onSelectionChanged === "function"
|
||||
) {
|
||||
this.onSelectionChanged();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 反转选区
|
||||
*/
|
||||
async invertSelection() {
|
||||
if (!this.canvas || !this.selectionObject) return false;
|
||||
|
||||
// 获取画布范围
|
||||
const canvasRect = new fabric.Rect({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
selectable: false,
|
||||
});
|
||||
|
||||
// 创建反选路径
|
||||
let invertedPath;
|
||||
try {
|
||||
invertedPath = canvasRect.subtractPathFromRect(this.selectionObject.path);
|
||||
} catch (error) {
|
||||
console.error("无法反转选区:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置新的选区
|
||||
const newSelection = new fabric.Path(invertedPath.path, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
});
|
||||
|
||||
return this.setSelectionObject(newSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到选区
|
||||
* @param {Object} newSelection 要添加的选区对象
|
||||
*/
|
||||
async addToSelection(newSelection) {
|
||||
if (!this.canvas) return false;
|
||||
|
||||
// 如果当前没有选区,直接使用新选区
|
||||
if (!this.selectionObject) {
|
||||
return this.setSelectionObject(newSelection);
|
||||
}
|
||||
|
||||
// 合并选区
|
||||
let combinedPath;
|
||||
try {
|
||||
combinedPath = this.selectionObject.union(newSelection);
|
||||
} catch (error) {
|
||||
console.error("无法添加到选区:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置新的选区
|
||||
const combinedSelection = new fabric.Path(combinedPath.path, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
});
|
||||
|
||||
return this.setSelectionObject(combinedSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从选区中移除
|
||||
* @param {Object} removeSelection 要移除的选区对象
|
||||
*/
|
||||
async removeFromSelection(removeSelection) {
|
||||
if (!this.canvas || !this.selectionObject) return false;
|
||||
|
||||
// 从当前选区中减去新选区
|
||||
let resultPath;
|
||||
try {
|
||||
resultPath = this.selectionObject.subtract(removeSelection);
|
||||
} catch (error) {
|
||||
console.error("无法从选区中移除:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置新的选区
|
||||
const newSelection = new fabric.Path(resultPath.path, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
});
|
||||
|
||||
return this.setSelectionObject(newSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用羽化效果
|
||||
* @param {Number} amount 羽化值
|
||||
*/
|
||||
async featherSelection(amount) {
|
||||
if (!this.selectionObject) return false;
|
||||
|
||||
// 更新羽化值
|
||||
this.featherAmount = amount;
|
||||
|
||||
// 更新选区外观
|
||||
return this.updateSelectionAppearance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查对象是否在选区内
|
||||
* @param {Object} object 要检查的对象
|
||||
* @returns {Boolean} 是否在选区内
|
||||
*/
|
||||
isObjectInSelection(object) {
|
||||
if (!this.selectionObject || !object) return false;
|
||||
|
||||
// 获取对象的边界框
|
||||
const bounds = object.getBoundingRect();
|
||||
const { left, top, width, height } = bounds;
|
||||
|
||||
// 检查对象的中心点和四个角是否在选区内
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
|
||||
// 检查中心点
|
||||
if (this.isPointInSelection(centerX, centerY)) return true;
|
||||
|
||||
// 检查四个角
|
||||
if (this.isPointInSelection(left, top)) return true;
|
||||
if (this.isPointInSelection(left + width, top)) return true;
|
||||
if (this.isPointInSelection(left, top + height)) return true;
|
||||
if (this.isPointInSelection(left + width, top + height)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点是否在选区内
|
||||
* @param {Number} x X坐标
|
||||
* @param {Number} y Y坐标
|
||||
* @returns {Boolean} 是否在选区内
|
||||
*/
|
||||
isPointInSelection(x, y) {
|
||||
if (!this.selectionObject) return false;
|
||||
|
||||
// 使用fabric.js的containsPoint方法判断点是否在选区内
|
||||
return this.selectionObject.containsPoint({ x, y });
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始自由选区
|
||||
* @param {Object} options 事件对象
|
||||
*/
|
||||
startFreeSelection(options) {
|
||||
if (!this.canvas || !this.isActive) return;
|
||||
|
||||
// 获取鼠标位置
|
||||
const pointer = this.canvas.getPointer(options.e);
|
||||
this.startPoint = pointer;
|
||||
|
||||
// 创建用于绘制轨迹的点数组
|
||||
this.drawingPoints = [pointer];
|
||||
|
||||
// 初始化SVG路径字符串
|
||||
this.currentPathString = `M ${pointer.x} ${pointer.y}`;
|
||||
|
||||
// 创建临时路径对象用于实时显示
|
||||
this.drawingObject = new fabric.Path(this.currentPathString, {
|
||||
stroke: this.selectionStyle.stroke,
|
||||
strokeWidth: this.selectionStyle.strokeWidth,
|
||||
strokeDashArray: this.selectionStyle.strokeDashArray,
|
||||
fill: "transparent",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
strokeLineCap: "round",
|
||||
strokeLineJoin: "round",
|
||||
});
|
||||
|
||||
// 添加到画布
|
||||
this.canvas.add(this.drawingObject);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制自由选区
|
||||
* @param {Object} options 事件对象
|
||||
*/
|
||||
drawFreeSelection(options) {
|
||||
if (!this.drawingObject || !this.drawingPoints || !this.isActive) return;
|
||||
|
||||
// 获取鼠标位置
|
||||
const pointer = this.canvas.getPointer(options.e);
|
||||
|
||||
// 添加新的点,但避免添加过于密集的点
|
||||
const lastPoint = this.drawingPoints[this.drawingPoints.length - 1];
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(pointer.x - lastPoint.x, 2) +
|
||||
Math.pow(pointer.y - lastPoint.y, 2)
|
||||
);
|
||||
|
||||
// 只有当距离大于2像素时才添加新点,避免路径过于复杂
|
||||
if (distance > 2) {
|
||||
this.drawingPoints.push(pointer);
|
||||
|
||||
// 更新路径字符串
|
||||
this.currentPathString += ` L ${pointer.x} ${pointer.y}`;
|
||||
|
||||
// 移除旧的绘制对象
|
||||
this.canvas.remove(this.drawingObject);
|
||||
|
||||
// 创建新的路径对象
|
||||
this.drawingObject = new fabric.Path(this.currentPathString, {
|
||||
stroke: this.selectionStyle.stroke,
|
||||
strokeWidth: this.selectionStyle.strokeWidth,
|
||||
strokeDashArray: this.selectionStyle.strokeDashArray,
|
||||
fill: "transparent",
|
||||
selectable: false,
|
||||
evented: false,
|
||||
strokeLineCap: "round",
|
||||
strokeLineJoin: "round",
|
||||
});
|
||||
|
||||
// 重新添加到画布
|
||||
this.canvas.add(this.drawingObject);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束自由选区
|
||||
*/
|
||||
endFreeSelection() {
|
||||
if (!this.drawingObject || !this.drawingPoints || !this.isActive) return;
|
||||
|
||||
// 检查是否有足够的点来形成选区
|
||||
if (this.drawingPoints.length < 3) {
|
||||
// 点太少,清除绘制对象
|
||||
this.canvas.remove(this.drawingObject);
|
||||
this.drawingObject = null;
|
||||
this.drawingPoints = null;
|
||||
this.startPoint = null;
|
||||
this.currentPathString = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 自动闭合路径 - 连接最后一点到第一点
|
||||
const firstPoint = this.drawingPoints[0];
|
||||
const lastPoint = this.drawingPoints[this.drawingPoints.length - 1];
|
||||
const closingDistance = Math.sqrt(
|
||||
Math.pow(firstPoint.x - lastPoint.x, 2) +
|
||||
Math.pow(firstPoint.y - lastPoint.y, 2)
|
||||
);
|
||||
|
||||
// 如果首尾距离较大,自动添加闭合线段
|
||||
let finalPathString = this.currentPathString;
|
||||
if (closingDistance > 10) {
|
||||
finalPathString += ` L ${firstPoint.x} ${firstPoint.y}`;
|
||||
}
|
||||
finalPathString += " Z"; // 闭合路径
|
||||
|
||||
// 创建最终选区对象
|
||||
const selectionObj = new fabric.Path(finalPathString, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
fill: this.selectionStyle.fill, // 恢复填充
|
||||
});
|
||||
|
||||
// 移除绘制中的临时对象
|
||||
this.canvas.remove(this.drawingObject);
|
||||
|
||||
// 重置绘制状态
|
||||
this.drawingObject = null;
|
||||
this.drawingPoints = null;
|
||||
this.startPoint = null;
|
||||
this.currentPathString = null;
|
||||
|
||||
// 设置选区
|
||||
this.setSelectionObject(selectionObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始矩形选区
|
||||
* @param {Object} options 事件对象
|
||||
*/
|
||||
startRectangleSelection(options) {
|
||||
if (!this.canvas || !this.isActive) return;
|
||||
|
||||
// 获取鼠标位置
|
||||
const pointer = this.canvas.getPointer(options.e);
|
||||
this.startPoint = pointer;
|
||||
|
||||
// 创建矩形对象
|
||||
this.drawingObject = new fabric.Rect({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
...this.selectionStyle,
|
||||
fill: "transparent", // 在绘制过程中不显示填充
|
||||
});
|
||||
|
||||
// 添加到画布
|
||||
this.canvas.add(this.drawingObject);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制矩形选区
|
||||
* @param {Object} options 事件对象
|
||||
*/
|
||||
drawRectangleSelection(options) {
|
||||
if (!this.drawingObject || !this.startPoint || !this.isActive) return;
|
||||
|
||||
// 获取鼠标位置
|
||||
const pointer = this.canvas.getPointer(options.e);
|
||||
|
||||
// 计算宽度和高度
|
||||
const width = Math.abs(pointer.x - this.startPoint.x);
|
||||
const height = Math.abs(pointer.y - this.startPoint.y);
|
||||
|
||||
// 确定左上角坐标
|
||||
const left = Math.min(this.startPoint.x, pointer.x);
|
||||
const top = Math.min(this.startPoint.y, pointer.y);
|
||||
|
||||
// 更新矩形
|
||||
this.drawingObject.set({
|
||||
left: left,
|
||||
top: top,
|
||||
width: width,
|
||||
height: height,
|
||||
});
|
||||
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束矩形选区
|
||||
*/
|
||||
endRectangleSelection() {
|
||||
if (!this.drawingObject || !this.startPoint || !this.isActive) return;
|
||||
|
||||
// 将矩形转换为路径
|
||||
const left = this.drawingObject.left;
|
||||
const top = this.drawingObject.top;
|
||||
const width = this.drawingObject.width;
|
||||
const height = this.drawingObject.height;
|
||||
|
||||
// 如果矩形太小,忽略
|
||||
if (width < 5 || height < 5) {
|
||||
this.canvas.remove(this.drawingObject);
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建矩形路径字符串
|
||||
const pathString = `M ${left} ${top} L ${left + width} ${top} L ${
|
||||
left + width
|
||||
} ${top + height} L ${left} ${top + height} Z`;
|
||||
|
||||
// 创建最终选区对象
|
||||
const selectionObj = new fabric.Path(pathString, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
fill: this.selectionStyle.fill, // 恢复填充
|
||||
});
|
||||
|
||||
// 移除绘制中的临时对象
|
||||
this.canvas.remove(this.drawingObject);
|
||||
|
||||
// 重置绘制状态
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
|
||||
// 设置选区
|
||||
this.setSelectionObject(selectionObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始椭圆选区
|
||||
* @param {Object} options 事件对象
|
||||
*/
|
||||
startEllipseSelection(options) {
|
||||
if (!this.canvas || !this.isActive) return;
|
||||
|
||||
// 获取鼠标位置
|
||||
const pointer = this.canvas.getPointer(options.e);
|
||||
this.startPoint = pointer;
|
||||
|
||||
// 创建椭圆对象
|
||||
this.drawingObject = new fabric.Ellipse({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
...this.selectionStyle,
|
||||
fill: "transparent", // 在绘制过程中不显示填充
|
||||
// originX: "left",
|
||||
// originY: "top",
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
});
|
||||
|
||||
// 添加到画布
|
||||
this.canvas.add(this.drawingObject);
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制椭圆选区
|
||||
* @param {Object} options 事件对象
|
||||
*/
|
||||
drawEllipseSelection(options) {
|
||||
if (!this.drawingObject || !this.startPoint || !this.isActive) return;
|
||||
|
||||
// 获取鼠标位置
|
||||
const pointer = this.canvas.getPointer(options.e);
|
||||
|
||||
// 计算半径
|
||||
const rx = Math.abs(pointer.x - this.startPoint.x) / 2;
|
||||
const ry = Math.abs(pointer.y - this.startPoint.y) / 2;
|
||||
|
||||
// 确定中心坐标
|
||||
const left = Math.min(this.startPoint.x, pointer.x);
|
||||
const top = Math.min(this.startPoint.y, pointer.y);
|
||||
|
||||
// 更新椭圆
|
||||
this.drawingObject.set({
|
||||
left: left,
|
||||
top: top,
|
||||
rx: rx,
|
||||
ry: ry,
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
});
|
||||
|
||||
this.canvas.renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束椭圆选区
|
||||
*/
|
||||
endEllipseSelection() {
|
||||
if (!this.drawingObject || !this.startPoint || !this.isActive) return;
|
||||
|
||||
// 获取椭圆参数
|
||||
const { left, top, rx, ry } = this.drawingObject;
|
||||
|
||||
// 如果椭圆太小,忽略
|
||||
if (rx < 2 || ry < 2) {
|
||||
this.canvas.remove(this.drawingObject);
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算中心点
|
||||
const cx = left + rx;
|
||||
const cy = top + ry;
|
||||
|
||||
// 将椭圆转换为路径字符串
|
||||
const pathString = this.ellipseToSVGPath(cx, cy, rx, ry);
|
||||
|
||||
// 创建最终选区对象
|
||||
const selectionObj = new fabric.Path(pathString, {
|
||||
...this.selectionStyle,
|
||||
id: `selection_${Date.now()}`,
|
||||
name: "selection",
|
||||
fill: this.selectionStyle.fill, // 恢复填充
|
||||
});
|
||||
|
||||
// 移除绘制中的临时对象
|
||||
this.canvas.remove(this.drawingObject);
|
||||
|
||||
// 重置绘制状态
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
|
||||
// 设置选区
|
||||
this.setSelectionObject(selectionObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将椭圆转换为SVG路径字符串
|
||||
* @param {Number} cx 中心点X坐标
|
||||
* @param {Number} cy 中心点Y坐标
|
||||
* @param {Number} rx X半径
|
||||
* @param {Number} ry Y半径
|
||||
* @returns {String} SVG路径字符串
|
||||
*/
|
||||
ellipseToSVGPath(cx, cy, rx, ry) {
|
||||
// 使用椭圆弧命令创建完整椭圆
|
||||
return `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${
|
||||
cx + rx
|
||||
} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置选区工具
|
||||
* @param {string} type 选区类型:OperationType.LASSO, OperationType.LASSO_RECTANGLE, OperationType.LASSO_ELLIPSE
|
||||
*/
|
||||
setSelectionType(type) {
|
||||
this.selectionType = type;
|
||||
|
||||
// 如果正在绘制,清除临时对象
|
||||
if (this.drawingObject) {
|
||||
this.canvas.remove(this.drawingObject);
|
||||
this.drawingObject = null;
|
||||
this.startPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置选区工具的鼠标事件
|
||||
*/
|
||||
setupSelectionEvents() {
|
||||
// 选区事件现在通过 setCurrentTool 方法管理
|
||||
// 这个方法现在主要用于刷新或重置事件监听
|
||||
if (!this.canvas || !this.isActive) return;
|
||||
|
||||
// 确保选区处于激活状态
|
||||
if (this.selectionTools.includes(this.currentTool)) {
|
||||
this.isActive = true;
|
||||
// 如果事件还没有初始化,初始化它们
|
||||
if (!this._mouseDownHandler) {
|
||||
this.initEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
dispose() {
|
||||
this.cleanupEvents();
|
||||
this.clearSelection();
|
||||
this.canvas = null;
|
||||
this.commandManager = null;
|
||||
this.layerManager = null;
|
||||
}
|
||||
}
|
||||
582
src/component/Canvas/CanvasEditor/store/BrushStore.js
Normal file
582
src/component/Canvas/CanvasEditor/store/BrushStore.js
Normal file
@@ -0,0 +1,582 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
import texturePresetManager from "../managers/brushes/TexturePresetManager";
|
||||
|
||||
/**
|
||||
* 笔刷数据存储
|
||||
* 使用Vue 3的响应式API实现笔刷相关数据的全局状态管理
|
||||
*/
|
||||
const state = reactive({
|
||||
// 笔刷基础属性
|
||||
size: 5, // 笔刷大小
|
||||
color: "#000000", // 笔刷颜色
|
||||
opacity: 1, // 笔刷透明度
|
||||
type: "pencil", // 当前笔刷类型
|
||||
|
||||
// 笔刷材质相关
|
||||
textureScale: 1, // 材质缩放
|
||||
textureEnabled: false, // 是否启用材质
|
||||
texturePath: "", // 材质图片路径
|
||||
textureOpacity: 1, // 材质透明度
|
||||
textureRepeat: "repeat", // 材质重复模式
|
||||
textureAngle: 0, // 材质旋转角度
|
||||
selectedTextureId: null, // 当前选中的材质ID
|
||||
|
||||
// 可用笔刷类型列表 (由BrushManager初始化)
|
||||
availableBrushes: [],
|
||||
|
||||
// 自定义笔刷列表
|
||||
customBrushes: [],
|
||||
|
||||
// 笔刷预设
|
||||
presets: [
|
||||
{ name: "细线", size: 2, opacity: 1, color: "#000000", type: "pencil" },
|
||||
{ name: "中粗", size: 5, opacity: 1, color: "#000000", type: "pencil" },
|
||||
{ name: "粗线", size: 10, opacity: 1, color: "#000000", type: "pencil" },
|
||||
{ name: "水彩", size: 15, opacity: 0.7, color: "#3366ff", type: "marker" },
|
||||
{ name: "喷枪", size: 20, opacity: 0.5, color: "#ff6633", type: "spray" },
|
||||
],
|
||||
|
||||
// 材质预设
|
||||
texturePresets: [
|
||||
{
|
||||
name: "默认纹理",
|
||||
textureId: "preset_texture_0",
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
repeat: "repeat",
|
||||
angle: 0,
|
||||
},
|
||||
{
|
||||
name: "细纹理",
|
||||
textureId: "preset_texture_1",
|
||||
scale: 0.5,
|
||||
opacity: 0.8,
|
||||
repeat: "repeat",
|
||||
angle: 0,
|
||||
},
|
||||
{
|
||||
name: "粗纹理",
|
||||
textureId: "preset_texture_2",
|
||||
scale: 2,
|
||||
opacity: 1,
|
||||
repeat: "repeat",
|
||||
angle: 45,
|
||||
},
|
||||
{
|
||||
name: "水彩纹理",
|
||||
textureId: "preset_texture_5",
|
||||
scale: 1.5,
|
||||
opacity: 0.6,
|
||||
repeat: "no-repeat",
|
||||
angle: 0,
|
||||
},
|
||||
],
|
||||
|
||||
// 最近使用的颜色
|
||||
recentColors: ["#000000", "#ffffff", "#ff0000", "#00ff00", "#0000ff"],
|
||||
|
||||
// 最近使用的材质
|
||||
recentTextures: [],
|
||||
|
||||
// 当前笔刷可配置属性(由当前选中笔刷动态设置)
|
||||
currentBrushProperties: [],
|
||||
|
||||
// 当前笔刷实例的引用
|
||||
currentBrushInstance: null,
|
||||
|
||||
// 笔刷属性值的映射,存储可由UI修改的属性值
|
||||
propertyValues: {},
|
||||
});
|
||||
|
||||
// Actions - 修改状态的函数
|
||||
const actions = {
|
||||
setBrushSize(size) {
|
||||
state.size = Math.max(0.5, Math.min(100, size));
|
||||
},
|
||||
|
||||
setBrushColor(color) {
|
||||
state.color = color;
|
||||
// 添加到最近使用的颜色
|
||||
if (!state.recentColors.includes(color)) {
|
||||
state.recentColors.unshift(color);
|
||||
if (state.recentColors.length > 10) {
|
||||
state.recentColors.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setBrushOpacity(opacity) {
|
||||
state.opacity = Math.max(0.05, Math.min(1, opacity));
|
||||
},
|
||||
|
||||
setBrushType(type) {
|
||||
if (state.availableBrushes.some((brush) => brush.id === type)) {
|
||||
state.type = type;
|
||||
}
|
||||
},
|
||||
|
||||
setTextureScale(scale) {
|
||||
state.textureScale = Math.max(0.1, Math.min(10, scale));
|
||||
},
|
||||
|
||||
setTextureEnabled(enabled) {
|
||||
state.textureEnabled = enabled;
|
||||
},
|
||||
|
||||
setTexturePath(path) {
|
||||
state.texturePath = path;
|
||||
},
|
||||
|
||||
setAvailableBrushes(brushes) {
|
||||
state.availableBrushes = brushes;
|
||||
},
|
||||
|
||||
addCustomBrush(brush) {
|
||||
if (!brush.id) {
|
||||
brush.id = `custom_${Date.now()}`;
|
||||
}
|
||||
|
||||
state.customBrushes.push(brush);
|
||||
return brush.id;
|
||||
},
|
||||
|
||||
removeCustomBrush(brushId) {
|
||||
const index = state.customBrushes.findIndex((b) => b.id === brushId);
|
||||
if (index !== -1) {
|
||||
state.customBrushes.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// 应用预设
|
||||
applyPreset(presetIndex) {
|
||||
const preset = state.presets[presetIndex];
|
||||
if (preset) {
|
||||
state.size = preset.size;
|
||||
state.opacity = preset.opacity;
|
||||
state.color = preset.color;
|
||||
state.type = preset.type;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
// 将当前设置保存为新预设
|
||||
saveCurrentAsPreset(name) {
|
||||
const newPreset = {
|
||||
name: name || `预设 ${state.presets.length + 1}`,
|
||||
size: state.size,
|
||||
opacity: state.opacity,
|
||||
color: state.color,
|
||||
type: state.type,
|
||||
textureEnabled: state.textureEnabled,
|
||||
textureScale: state.textureScale,
|
||||
texturePath: state.texturePath,
|
||||
};
|
||||
|
||||
state.presets.push(newPreset);
|
||||
return state.presets.length - 1; // 返回新预设的索引
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置当前笔刷实例
|
||||
* @param {Object} brushInstance BaseBrush实例
|
||||
*/
|
||||
setCurrentBrushInstance(brushInstance) {
|
||||
state.currentBrushInstance = brushInstance;
|
||||
|
||||
// 获取并设置当前笔刷的可配置属性
|
||||
if (brushInstance && brushInstance.getConfigurableProperties) {
|
||||
const properties = brushInstance.getConfigurableProperties();
|
||||
state.currentBrushProperties = properties;
|
||||
|
||||
// 初始化属性值
|
||||
properties.forEach((prop) => {
|
||||
// 如果是基础属性,使用已有值
|
||||
if (prop.id === "size") {
|
||||
state.propertyValues[prop.id] = state.size;
|
||||
} else if (prop.id === "color") {
|
||||
state.propertyValues[prop.id] = state.color;
|
||||
} else if (prop.id === "opacity") {
|
||||
state.propertyValues[prop.id] = state.opacity;
|
||||
} else {
|
||||
// 对于特殊属性,使用默认值
|
||||
state.propertyValues[prop.id] = prop.defaultValue;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果没有实例或方法,清空属性列表
|
||||
state.currentBrushProperties = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新笔刷属性值
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} value 属性值
|
||||
*/
|
||||
updatePropertyValue(propId, value) {
|
||||
// 更新Store中的值
|
||||
state.propertyValues[propId] = value;
|
||||
|
||||
// 同步更新基础属性
|
||||
if (propId === "size") {
|
||||
state.size = value;
|
||||
} else if (propId === "color") {
|
||||
state.color = value;
|
||||
} else if (propId === "opacity") {
|
||||
state.opacity = value;
|
||||
}
|
||||
|
||||
// 如果有当前笔刷实例且有更新方法,则调用
|
||||
if (
|
||||
state.currentBrushInstance &&
|
||||
state.currentBrushInstance.updateProperty
|
||||
) {
|
||||
state.currentBrushInstance.updateProperty(propId, value);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取属性值
|
||||
* @param {String} propId 属性ID
|
||||
* @param {any} defaultValue 默认值
|
||||
* @returns {any} 属性值
|
||||
*/
|
||||
getPropertyValue(propId, defaultValue) {
|
||||
// 检查属性值是否存在
|
||||
if (state.propertyValues.hasOwnProperty(propId)) {
|
||||
return state.propertyValues[propId];
|
||||
}
|
||||
|
||||
// 对于基础属性,返回store中的值
|
||||
if (propId === "size") {
|
||||
return state.size;
|
||||
} else if (propId === "color") {
|
||||
return state.color;
|
||||
} else if (propId === "opacity") {
|
||||
return state.opacity;
|
||||
}
|
||||
|
||||
// 否则返回默认值
|
||||
return defaultValue;
|
||||
},
|
||||
|
||||
/**
|
||||
* 按分类获取当前笔刷可配置属性
|
||||
* @returns {Object} 按分类分组的属性对象
|
||||
*/
|
||||
getPropertiesByCategory() {
|
||||
const result = {};
|
||||
|
||||
state.currentBrushProperties.forEach((prop) => {
|
||||
const category = prop.category || "默认";
|
||||
if (!result[category]) {
|
||||
result[category] = [];
|
||||
}
|
||||
result[category].push({
|
||||
...prop,
|
||||
value: this.getPropertyValue(prop.id, prop.defaultValue),
|
||||
});
|
||||
});
|
||||
|
||||
// 按order排序每个分类中的属性
|
||||
Object.keys(result).forEach((category) => {
|
||||
result[category].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* 材质相关方法
|
||||
*/
|
||||
setTextureOpacity(opacity) {
|
||||
state.textureOpacity = Math.max(0, Math.min(1, opacity));
|
||||
},
|
||||
|
||||
setTextureRepeat(repeat) {
|
||||
const validModes = ["repeat", "repeat-x", "repeat-y", "no-repeat"];
|
||||
if (validModes.includes(repeat)) {
|
||||
state.textureRepeat = repeat;
|
||||
}
|
||||
},
|
||||
|
||||
setTextureAngle(angle) {
|
||||
state.textureAngle = angle % 360;
|
||||
},
|
||||
|
||||
setSelectedTextureId(textureId) {
|
||||
state.selectedTextureId = textureId;
|
||||
|
||||
// 添加到最近使用的材质
|
||||
if (textureId && !state.recentTextures.includes(textureId)) {
|
||||
state.recentTextures.unshift(textureId);
|
||||
if (state.recentTextures.length > 8) {
|
||||
state.recentTextures.pop();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 应用材质预设
|
||||
* @param {Number} presetIndex 预设索引
|
||||
* @returns {Boolean} 是否应用成功
|
||||
*/
|
||||
applyTexturePreset(presetIndex) {
|
||||
const preset = state.texturePresets[presetIndex];
|
||||
if (preset) {
|
||||
state.selectedTextureId = preset.textureId;
|
||||
state.textureScale = preset.scale;
|
||||
state.textureOpacity = preset.opacity;
|
||||
state.textureRepeat = preset.repeat;
|
||||
state.textureAngle = preset.angle;
|
||||
|
||||
// 添加到最近使用
|
||||
this.setSelectedTextureId(preset.textureId);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 将当前材质设置保存为新预设
|
||||
* @param {String} name 预设名称
|
||||
* @returns {Number} 新预设的索引
|
||||
*/
|
||||
saveCurrentTextureAsPreset(name) {
|
||||
const newPreset = {
|
||||
name: name || `材质预设 ${state.texturePresets.length + 1}`,
|
||||
textureId: state.selectedTextureId,
|
||||
scale: state.textureScale,
|
||||
opacity: state.textureOpacity,
|
||||
repeat: state.textureRepeat,
|
||||
angle: state.textureAngle,
|
||||
};
|
||||
|
||||
state.texturePresets.push(newPreset);
|
||||
return state.texturePresets.length - 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除材质预设
|
||||
* @param {Number} presetIndex 预设索引
|
||||
* @returns {Boolean} 是否删除成功
|
||||
*/
|
||||
removeTexturePreset(presetIndex) {
|
||||
if (presetIndex >= 0 && presetIndex < state.texturePresets.length) {
|
||||
state.texturePresets.splice(presetIndex, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有可用材质(预设+自定义)
|
||||
* @returns {Array} 材质列表
|
||||
*/
|
||||
getAllTextures() {
|
||||
return texturePresetManager.getAllTextures();
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据ID获取材质信息
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Object|null} 材质对象
|
||||
*/
|
||||
getTextureById(textureId) {
|
||||
return texturePresetManager.getTextureById(textureId);
|
||||
},
|
||||
|
||||
/**
|
||||
* 按分类获取材质
|
||||
* @param {String} category 分类名称
|
||||
* @returns {Array} 材质列表
|
||||
*/
|
||||
getTexturesByCategory(category) {
|
||||
return texturePresetManager.getTexturesByCategory(category);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取材质分类列表
|
||||
* @returns {Array} 分类名称数组
|
||||
*/
|
||||
getTextureCategories() {
|
||||
return texturePresetManager.getCategories();
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索材质
|
||||
* @param {String} keyword 搜索关键词
|
||||
* @returns {Array} 匹配的材质列表
|
||||
*/
|
||||
searchTextures(keyword) {
|
||||
return texturePresetManager.searchTextures(keyword);
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加自定义材质
|
||||
* @param {Object} textureData 材质数据
|
||||
* @returns {String} 材质ID
|
||||
*/
|
||||
addCustomTexture(textureData) {
|
||||
const textureId = texturePresetManager.addCustomTexture(textureData);
|
||||
|
||||
// 保存到本地存储
|
||||
texturePresetManager.saveCustomTexturesToStorage();
|
||||
|
||||
return textureId;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除自定义材质
|
||||
* @param {String} textureId 材质ID
|
||||
* @returns {Boolean} 是否删除成功
|
||||
*/
|
||||
removeCustomTexture(textureId) {
|
||||
const success = texturePresetManager.removeCustomTexture(textureId);
|
||||
|
||||
if (success) {
|
||||
// 如果删除的是当前选中的材质,清空选择
|
||||
if (state.selectedTextureId === textureId) {
|
||||
state.selectedTextureId = null;
|
||||
}
|
||||
|
||||
// 从最近使用中移除
|
||||
const recentIndex = state.recentTextures.indexOf(textureId);
|
||||
if (recentIndex !== -1) {
|
||||
state.recentTextures.splice(recentIndex, 1);
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
texturePresetManager.saveCustomTexturesToStorage();
|
||||
}
|
||||
|
||||
return success;
|
||||
},
|
||||
|
||||
/**
|
||||
* 从文件上传自定义材质
|
||||
* @param {File} file 图片文件
|
||||
* @param {String} name 材质名称(可选)
|
||||
* @returns {Promise<String>} 材质ID
|
||||
*/
|
||||
uploadCustomTexture(file, name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 验证文件
|
||||
if (!texturePresetManager.validateTextureFile(file)) {
|
||||
reject(new Error("无效的材质文件"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const textureData = {
|
||||
name: name || file.name.replace(/\.[^/.]+$/, ""),
|
||||
path: e.target.result,
|
||||
preview: e.target.result,
|
||||
description: `用户上传的材质: ${file.name}`,
|
||||
};
|
||||
|
||||
const textureId = this.addCustomTexture(textureData);
|
||||
resolve(textureId);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error("文件读取失败"));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出材质预设配置
|
||||
* @returns {String} JSON格式的配置
|
||||
*/
|
||||
exportTexturePresets() {
|
||||
const config = {
|
||||
texturePresets: state.texturePresets,
|
||||
customTextures: texturePresetManager.exportCustomTextures(),
|
||||
};
|
||||
return JSON.stringify(config, null, 2);
|
||||
},
|
||||
|
||||
/**
|
||||
* 导入材质预设配置
|
||||
* @param {String} configJson JSON格式的配置
|
||||
* @returns {Boolean} 是否导入成功
|
||||
*/
|
||||
importTexturePresets(configJson) {
|
||||
try {
|
||||
const config = JSON.parse(configJson);
|
||||
|
||||
// 导入材质预设
|
||||
if (config.texturePresets && Array.isArray(config.texturePresets)) {
|
||||
state.texturePresets = [
|
||||
...state.texturePresets,
|
||||
...config.texturePresets,
|
||||
];
|
||||
}
|
||||
|
||||
// 导入自定义材质
|
||||
if (config.customTextures) {
|
||||
texturePresetManager.importCustomTextures(config.customTextures);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("导入材质预设失败:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化材质预设管理器
|
||||
*/
|
||||
initializeTexturePresets() {
|
||||
// 从本地存储加载自定义材质
|
||||
texturePresetManager.loadCustomTexturesFromStorage();
|
||||
|
||||
// 确保预设材质引用的是有效的材质ID
|
||||
state.texturePresets.forEach((preset, index) => {
|
||||
const texture = texturePresetManager.getTextureById(preset.textureId);
|
||||
if (!texture) {
|
||||
console.warn(
|
||||
`材质预设 "${preset.name}" 引用的材质 ${preset.textureId} 不存在`
|
||||
);
|
||||
// 可以选择使用默认材质替换或删除该预设
|
||||
if (texturePresetManager.getAllTextures().length > 0) {
|
||||
preset.textureId = texturePresetManager.getAllTextures()[0].id;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// 暴露给组件使用的Store对象
|
||||
export const BrushStore = {
|
||||
// 只读状态,防止直接修改
|
||||
state: readonly(state),
|
||||
|
||||
// 可调用的Actions
|
||||
...actions,
|
||||
|
||||
// 辅助方法
|
||||
getRGBAColor() {
|
||||
// 解析十六进制颜色并添加透明度
|
||||
const hex = state.color.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${state.opacity})`;
|
||||
},
|
||||
};
|
||||
31
src/component/Canvas/CanvasEditor/utils/canvasFactory.js
Normal file
31
src/component/Canvas/CanvasEditor/utils/canvasFactory.js
Normal file
@@ -0,0 +1,31 @@
|
||||
//import { fabric } from "fabric-with-all";
|
||||
import { canvasConfig } from "../config/canvasConfig";
|
||||
|
||||
/**
|
||||
* Factory for creating optimized fabric canvas instances
|
||||
*/
|
||||
export const createCanvas = (elementId, options = {}) => {
|
||||
// Create the canvas instance
|
||||
const canvas = new fabric.Canvas(elementId, {
|
||||
enableRetinaScaling: canvasConfig.enableRetinaScaling,
|
||||
renderOnAddRemove: false,
|
||||
enableRetinaScaling: true,
|
||||
preserveObjectStacking: true, // 保持对象堆叠顺序
|
||||
// skipOffscreen: true, // 跳过离屏渲染
|
||||
...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: canvasConfig.enableRetinaScaling,
|
||||
...options,
|
||||
});
|
||||
|
||||
return canvas;
|
||||
};
|
||||
458
src/component/Canvas/CanvasEditor/utils/helper.js
Normal file
458
src/component/Canvas/CanvasEditor/utils/helper.js
Normal file
@@ -0,0 +1,458 @@
|
||||
export function deepCompare(obj1, obj2) {
|
||||
const diff = {};
|
||||
|
||||
// 处理基础类型
|
||||
if (obj1 === obj2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
obj1 === null ||
|
||||
obj2 === null ||
|
||||
typeof obj1 !== "object" ||
|
||||
typeof obj2 !== "object"
|
||||
) {
|
||||
return { _value: obj2, _oldValue: obj1 };
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if (Array.isArray(obj1) && Array.isArray(obj2)) {
|
||||
if (obj1.length !== obj2.length) {
|
||||
return { _value: obj2, _oldValue: obj1 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < obj1.length; i++) {
|
||||
const itemDiff = deepCompare(obj1[i], obj2[i]);
|
||||
if (itemDiff !== null) {
|
||||
diff[i] = itemDiff;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(diff).length > 0 ? diff : null;
|
||||
}
|
||||
|
||||
// 处理对象
|
||||
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const val1 = obj1[key];
|
||||
const val2 = obj2[key];
|
||||
|
||||
if (!(key in obj1)) {
|
||||
diff[key] = { _value: val2, _type: "added" };
|
||||
} else if (!(key in obj2)) {
|
||||
diff[key] = { _value: undefined, _oldValue: val1, _type: "removed" };
|
||||
} else {
|
||||
const itemDiff = deepCompare(val1, val2);
|
||||
if (itemDiff !== null) {
|
||||
diff[key] = itemDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(diff).length > 0 ? diff : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度克隆对象
|
||||
* @param {any} obj 要克隆的对象
|
||||
* @returns {any} 克隆后的对象
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj);
|
||||
}
|
||||
|
||||
if (obj instanceof RegExp) {
|
||||
return new RegExp(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => deepClone(item));
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
const cloned = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
cloned[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用差异到基础对象
|
||||
* @param {Object} baseObj 基础对象
|
||||
* @param {Object} diff 差异对象
|
||||
* @returns {Object} 应用差异后的对象
|
||||
*/
|
||||
export function applyDiff(baseObj, diff) {
|
||||
if (!diff) {
|
||||
return deepClone(baseObj);
|
||||
}
|
||||
|
||||
// 如果是直接值替换
|
||||
if (diff._value !== undefined) {
|
||||
return diff._value;
|
||||
}
|
||||
|
||||
const result = deepClone(baseObj) || {};
|
||||
|
||||
for (const key in diff) {
|
||||
const change = diff[key];
|
||||
|
||||
if (change._type === "added" || change._type === "removed") {
|
||||
if (change._type === "removed") {
|
||||
delete result[key];
|
||||
} else {
|
||||
result[key] = change._value;
|
||||
}
|
||||
} else if (change._value !== undefined) {
|
||||
result[key] = change._value;
|
||||
} else {
|
||||
// 递归应用嵌套差异
|
||||
result[key] = applyDiff(result[key], change);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func 要节流的函数
|
||||
* @param {number} wait 等待时间
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export function throttle(func, wait) {
|
||||
let timeout;
|
||||
let previous = 0;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
const now = Date.now();
|
||||
const remaining = wait - (now - previous);
|
||||
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
previous = now;
|
||||
return func.apply(this, args);
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
previous = Date.now();
|
||||
timeout = null;
|
||||
func.apply(this, args);
|
||||
}, remaining);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func 要防抖的函数
|
||||
* @param {number} wait 等待时间
|
||||
* @param {boolean} immediate 是否立即执行
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export function debounce(func, wait, immediate = false) {
|
||||
let timeout;
|
||||
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func.apply(this, args);
|
||||
};
|
||||
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
|
||||
if (callNow) func.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
* @param {string} prefix 前缀
|
||||
* @returns {string} 唯一ID
|
||||
*/
|
||||
export function generateId(prefix = "id") {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes 字节数
|
||||
* @returns {string} 格式化后的大小
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间差
|
||||
* @param {number} milliseconds 毫秒数
|
||||
* @returns {string} 格式化后的时间
|
||||
*/
|
||||
export function formatDuration(milliseconds) {
|
||||
if (milliseconds < 1000) {
|
||||
return `${milliseconds.toFixed(2)}ms`;
|
||||
} else if (milliseconds < 60000) {
|
||||
return `${(milliseconds / 1000).toFixed(2)}s`;
|
||||
} else {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const seconds = ((milliseconds % 60000) / 1000).toFixed(2);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是有效的命令对象
|
||||
* @param {*} command 命令对象
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
export function isValidCommand(command) {
|
||||
return (
|
||||
command &&
|
||||
typeof command === "object" &&
|
||||
typeof command.execute === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是Promise
|
||||
* @param {*} obj 对象
|
||||
* @returns {boolean} 是否是Promise
|
||||
*/
|
||||
export function isPromise(obj) {
|
||||
return obj && typeof obj.then === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的JSON解析
|
||||
* @param {string} jsonString JSON字符串
|
||||
* @param {*} defaultValue 默认值
|
||||
* @returns {*} 解析结果
|
||||
*/
|
||||
export function safeJSONParse(jsonString, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (error) {
|
||||
console.warn("JSON解析失败:", error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全的JSON序列化
|
||||
* @param {*} obj 要序列化的对象
|
||||
* @param {*} defaultValue 默认值
|
||||
* @returns {string} JSON字符串
|
||||
*/
|
||||
export function safeJSONStringify(obj, defaultValue = "{}") {
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch (error) {
|
||||
console.warn("JSON序列化失败:", error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算对象深度
|
||||
* @param {*} obj 对象
|
||||
* @returns {number} 深度
|
||||
*/
|
||||
export function getObjectDepth(obj) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxDepth = 0;
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const depth = getObjectDepth(obj[key]);
|
||||
maxDepth = Math.max(maxDepth, depth);
|
||||
}
|
||||
}
|
||||
|
||||
return maxDepth + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算对象大小(字节)
|
||||
* @param {*} obj 对象
|
||||
* @returns {number} 大小(字节)
|
||||
*/
|
||||
export function getObjectSize(obj) {
|
||||
const jsonString = safeJSONStringify(obj, "{}");
|
||||
return new Blob([jsonString]).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查浏览器支持
|
||||
* @returns {Object} 支持信息
|
||||
*/
|
||||
export function checkBrowserSupport() {
|
||||
return {
|
||||
WeakRef: typeof WeakRef !== "undefined",
|
||||
FinalizationRegistry: typeof FinalizationRegistry !== "undefined",
|
||||
PerformanceMemory:
|
||||
typeof performance !== "undefined" && !!performance.memory,
|
||||
RequestIdleCallback: typeof requestIdleCallback !== "undefined",
|
||||
IntersectionObserver: typeof IntersectionObserver !== "undefined",
|
||||
ResizeObserver: typeof ResizeObserver !== "undefined",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟执行
|
||||
* @param {number} ms 延迟毫秒数
|
||||
* @returns {Promise} Promise对象
|
||||
*/
|
||||
export function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试执行函数
|
||||
* @param {Function} fn 要执行的函数
|
||||
* @param {Object} options 重试选项
|
||||
* @returns {Promise} 执行结果
|
||||
*/
|
||||
export async function retry(fn, options = {}) {
|
||||
const {
|
||||
retries = 3,
|
||||
delay: delayMs = 1000,
|
||||
backoff = 1.5,
|
||||
shouldRetry = () => true,
|
||||
} = options;
|
||||
|
||||
let attempt = 0;
|
||||
let currentDelay = delayMs;
|
||||
|
||||
while (attempt <= retries) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
attempt++;
|
||||
|
||||
if (attempt > retries || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await delay(currentDelay);
|
||||
currentDelay *= backoff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批处理执行
|
||||
* @param {Array} items 要处理的项目
|
||||
* @param {Function} processor 处理函数
|
||||
* @param {Object} options 批处理选项
|
||||
* @returns {Promise<Array>} 处理结果
|
||||
*/
|
||||
export async function batchProcess(items, processor, options = {}) {
|
||||
const { batchSize = 10, delay: delayMs = 0, onProgress = () => {} } = options;
|
||||
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < items.length; i += batchSize) {
|
||||
const batch = items.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((item) => processor(item))
|
||||
);
|
||||
|
||||
results.push(...batchResults);
|
||||
|
||||
onProgress({
|
||||
completed: Math.min(i + batchSize, items.length),
|
||||
total: items.length,
|
||||
percentage: Math.min(((i + batchSize) / items.length) * 100, 100),
|
||||
});
|
||||
|
||||
if (delayMs > 0 && i + batchSize < items.length) {
|
||||
await delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建可取消的Promise
|
||||
* @param {Function} executor Promise执行器
|
||||
* @returns {Object} 包含promise和cancel方法的对象
|
||||
*/
|
||||
export function createCancellablePromise(executor) {
|
||||
let isCancelled = false;
|
||||
let cancelCallback = null;
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const wrappedResolve = (value) => {
|
||||
if (!isCancelled) resolve(value);
|
||||
};
|
||||
|
||||
const wrappedReject = (error) => {
|
||||
if (!isCancelled) reject(error);
|
||||
};
|
||||
|
||||
cancelCallback = () => {
|
||||
isCancelled = true;
|
||||
reject(new Error("Promise was cancelled"));
|
||||
};
|
||||
|
||||
executor(wrappedResolve, wrappedReject);
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (cancelCallback) {
|
||||
cancelCallback();
|
||||
}
|
||||
},
|
||||
isCancelled: () => isCancelled,
|
||||
};
|
||||
}
|
||||
|
||||
// 导出所有工具函数
|
||||
export default {
|
||||
deepCompare,
|
||||
deepClone,
|
||||
applyDiff,
|
||||
throttle,
|
||||
debounce,
|
||||
generateId,
|
||||
formatFileSize,
|
||||
formatDuration,
|
||||
isValidCommand,
|
||||
isPromise,
|
||||
safeJSONParse,
|
||||
safeJSONStringify,
|
||||
getObjectDepth,
|
||||
getObjectSize,
|
||||
checkBrowserSupport,
|
||||
delay,
|
||||
retry,
|
||||
batchProcess,
|
||||
createCancellablePromise,
|
||||
};
|
||||
532
src/component/Canvas/CanvasEditor/utils/helperLine.js
Normal file
532
src/component/Canvas/CanvasEditor/utils/helperLine.js
Normal file
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* Should objects be aligned by a bounding box?
|
||||
* [Bug] Scaled objects sometimes can not be aligned by edges
|
||||
*
|
||||
*/
|
||||
function initAligningGuidelines(canvas) {
|
||||
var ctx = canvas.getSelectionContext(),
|
||||
aligningLineOffset = 5,
|
||||
aligningLineMargin = 4,
|
||||
aligningLineWidth = 1,
|
||||
aligningLineColor = "rgb(0,255,0)",
|
||||
viewportTransform,
|
||||
zoom = 1;
|
||||
|
||||
function drawVerticalLine(coords) {
|
||||
drawLine(
|
||||
coords.x + 0.5,
|
||||
coords.y1 > coords.y2 ? coords.y2 : coords.y1,
|
||||
coords.x + 0.5,
|
||||
coords.y2 > coords.y1 ? coords.y2 : coords.y1
|
||||
);
|
||||
}
|
||||
|
||||
function drawHorizontalLine(coords) {
|
||||
drawLine(
|
||||
coords.x1 > coords.x2 ? coords.x2 : coords.x1,
|
||||
coords.y + 0.5,
|
||||
coords.x2 > coords.x1 ? coords.x2 : coords.x1,
|
||||
coords.y + 0.5
|
||||
);
|
||||
}
|
||||
|
||||
function drawLine(x1, y1, x2, y2) {
|
||||
ctx.save();
|
||||
ctx.lineWidth = aligningLineWidth;
|
||||
ctx.strokeStyle = aligningLineColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(
|
||||
x1 * zoom + viewportTransform[4],
|
||||
y1 * zoom + viewportTransform[5]
|
||||
);
|
||||
ctx.lineTo(
|
||||
x2 * zoom + viewportTransform[4],
|
||||
y2 * zoom + viewportTransform[5]
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function isInRange(value1, value2) {
|
||||
value1 = Math.round(value1);
|
||||
value2 = Math.round(value2);
|
||||
for (
|
||||
var i = value1 - aligningLineMargin, len = value1 + aligningLineMargin;
|
||||
i <= len;
|
||||
i++
|
||||
) {
|
||||
if (i === value2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var verticalLines = [],
|
||||
horizontalLines = [];
|
||||
|
||||
canvas.on("mouse:down", function () {
|
||||
viewportTransform = canvas.viewportTransform;
|
||||
zoom = canvas.getZoom();
|
||||
});
|
||||
|
||||
canvas.on("object:moving", function (e) {
|
||||
var activeObject = e.target,
|
||||
canvasObjects = canvas.getObjects(),
|
||||
activeObjectCenter = activeObject.getCenterPoint(),
|
||||
activeObjectLeft = activeObjectCenter.x,
|
||||
activeObjectTop = activeObjectCenter.y,
|
||||
activeObjectBoundingRect = activeObject.getBoundingRect(),
|
||||
activeObjectHeight =
|
||||
activeObjectBoundingRect.height / viewportTransform[3],
|
||||
activeObjectWidth = activeObjectBoundingRect.width / viewportTransform[0],
|
||||
horizontalInTheRange = false,
|
||||
verticalInTheRange = false,
|
||||
transform = canvas._currentTransform;
|
||||
|
||||
if (!transform) return;
|
||||
|
||||
// It should be trivial to DRY this up by encapsulating (repeating) creation of x1, x2, y1, and y2 into functions,
|
||||
// but we're not doing it here for perf. reasons -- as this a function that's invoked on every mouse move
|
||||
|
||||
for (var i = canvasObjects.length; i--; ) {
|
||||
if (canvasObjects[i] === activeObject) continue;
|
||||
|
||||
var objectCenter = canvasObjects[i].getCenterPoint(),
|
||||
objectLeft = objectCenter.x,
|
||||
objectTop = objectCenter.y,
|
||||
objectBoundingRect = canvasObjects[i].getBoundingRect(),
|
||||
objectHeight = objectBoundingRect.height / viewportTransform[3],
|
||||
objectWidth = objectBoundingRect.width / viewportTransform[0];
|
||||
|
||||
// snaps if the right side of the active object touches the left side of the object
|
||||
if (
|
||||
isInRange(
|
||||
activeObjectLeft + activeObjectWidth / 2,
|
||||
objectLeft - objectWidth / 2
|
||||
)
|
||||
) {
|
||||
verticalInTheRange = true;
|
||||
verticalLines.push({
|
||||
x: objectLeft - objectWidth / 2,
|
||||
y1:
|
||||
objectTop < activeObjectTop
|
||||
? objectTop - objectHeight / 2 - aligningLineOffset
|
||||
: objectTop + objectHeight / 2 + aligningLineOffset,
|
||||
y2:
|
||||
activeObjectTop > objectTop
|
||||
? activeObjectTop + activeObjectHeight / 2 + aligningLineOffset
|
||||
: activeObjectTop - activeObjectHeight / 2 - aligningLineOffset,
|
||||
});
|
||||
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
objectLeft - objectWidth / 2 - activeObjectWidth / 2,
|
||||
activeObjectTop
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snaps if the left side of the active object touches the right side of the object
|
||||
if (
|
||||
isInRange(
|
||||
activeObjectLeft - activeObjectWidth / 2,
|
||||
objectLeft + objectWidth / 2
|
||||
)
|
||||
) {
|
||||
verticalInTheRange = true;
|
||||
verticalLines.push({
|
||||
x: objectLeft + objectWidth / 2,
|
||||
y1:
|
||||
objectTop < activeObjectTop
|
||||
? objectTop - objectHeight / 2 - aligningLineOffset
|
||||
: objectTop + objectHeight / 2 + aligningLineOffset,
|
||||
y2:
|
||||
activeObjectTop > objectTop
|
||||
? activeObjectTop + activeObjectHeight / 2 + aligningLineOffset
|
||||
: activeObjectTop - activeObjectHeight / 2 - aligningLineOffset,
|
||||
});
|
||||
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
objectLeft + objectWidth / 2 + activeObjectWidth / 2,
|
||||
activeObjectTop
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snaps if the bottom of the object touches the top of the active object
|
||||
if (
|
||||
isInRange(
|
||||
objectTop + objectHeight / 2,
|
||||
activeObjectTop - activeObjectHeight / 2
|
||||
)
|
||||
) {
|
||||
horizontalInTheRange = true;
|
||||
horizontalLines.push({
|
||||
y: objectTop + objectHeight / 2,
|
||||
x1:
|
||||
objectLeft < activeObjectLeft
|
||||
? objectLeft - objectWidth / 2 - aligningLineOffset
|
||||
: objectLeft + objectWidth / 2 + aligningLineOffset,
|
||||
x2:
|
||||
activeObjectLeft > objectLeft
|
||||
? activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset
|
||||
: activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset,
|
||||
});
|
||||
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
activeObjectLeft,
|
||||
objectTop + objectHeight / 2 + activeObjectHeight / 2
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snaps if the top of the object touches the bottom of the active object
|
||||
if (
|
||||
isInRange(
|
||||
objectTop - objectHeight / 2,
|
||||
activeObjectTop + activeObjectHeight / 2
|
||||
)
|
||||
) {
|
||||
horizontalInTheRange = true;
|
||||
horizontalLines.push({
|
||||
y: objectTop - objectHeight / 2,
|
||||
x1:
|
||||
objectLeft < activeObjectLeft
|
||||
? objectLeft - objectWidth / 2 - aligningLineOffset
|
||||
: objectLeft + objectWidth / 2 + aligningLineOffset,
|
||||
x2:
|
||||
activeObjectLeft > objectLeft
|
||||
? activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset
|
||||
: activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset,
|
||||
});
|
||||
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
activeObjectLeft,
|
||||
objectTop - objectHeight / 2 - activeObjectHeight / 2
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snap by the horizontal center line
|
||||
if (isInRange(objectLeft, activeObjectLeft)) {
|
||||
verticalInTheRange = true;
|
||||
verticalLines.push({
|
||||
x: objectLeft,
|
||||
y1:
|
||||
objectTop < activeObjectTop
|
||||
? objectTop - objectHeight / 2 - aligningLineOffset
|
||||
: objectTop + objectHeight / 2 + aligningLineOffset,
|
||||
y2:
|
||||
activeObjectTop > objectTop
|
||||
? activeObjectTop + activeObjectHeight / 2 + aligningLineOffset
|
||||
: activeObjectTop - activeObjectHeight / 2 - aligningLineOffset,
|
||||
});
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(objectLeft, activeObjectTop),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snap by the left edge
|
||||
if (
|
||||
isInRange(
|
||||
objectLeft - objectWidth / 2,
|
||||
activeObjectLeft - activeObjectWidth / 2
|
||||
)
|
||||
) {
|
||||
verticalInTheRange = true;
|
||||
verticalLines.push({
|
||||
x: objectLeft - objectWidth / 2,
|
||||
y1:
|
||||
objectTop < activeObjectTop
|
||||
? objectTop - objectHeight / 2 - aligningLineOffset
|
||||
: objectTop + objectHeight / 2 + aligningLineOffset,
|
||||
y2:
|
||||
activeObjectTop > objectTop
|
||||
? activeObjectTop + activeObjectHeight / 2 + aligningLineOffset
|
||||
: activeObjectTop - activeObjectHeight / 2 - aligningLineOffset,
|
||||
});
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
objectLeft - objectWidth / 2 + activeObjectWidth / 2,
|
||||
activeObjectTop
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snap by the right edge
|
||||
if (
|
||||
isInRange(
|
||||
objectLeft + objectWidth / 2,
|
||||
activeObjectLeft + activeObjectWidth / 2
|
||||
)
|
||||
) {
|
||||
verticalInTheRange = true;
|
||||
verticalLines.push({
|
||||
x: objectLeft + objectWidth / 2,
|
||||
y1:
|
||||
objectTop < activeObjectTop
|
||||
? objectTop - objectHeight / 2 - aligningLineOffset
|
||||
: objectTop + objectHeight / 2 + aligningLineOffset,
|
||||
y2:
|
||||
activeObjectTop > objectTop
|
||||
? activeObjectTop + activeObjectHeight / 2 + aligningLineOffset
|
||||
: activeObjectTop - activeObjectHeight / 2 - aligningLineOffset,
|
||||
});
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
objectLeft + objectWidth / 2 - activeObjectWidth / 2,
|
||||
activeObjectTop
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snap by the vertical center line
|
||||
if (isInRange(objectTop, activeObjectTop)) {
|
||||
horizontalInTheRange = true;
|
||||
horizontalLines.push({
|
||||
y: objectTop,
|
||||
x1:
|
||||
objectLeft < activeObjectLeft
|
||||
? objectLeft - objectWidth / 2 - aligningLineOffset
|
||||
: objectLeft + objectWidth / 2 + aligningLineOffset,
|
||||
x2:
|
||||
activeObjectLeft > objectLeft
|
||||
? activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset
|
||||
: activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset,
|
||||
});
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(activeObjectLeft, objectTop),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snap by the top edge
|
||||
if (
|
||||
isInRange(
|
||||
objectTop - objectHeight / 2,
|
||||
activeObjectTop - activeObjectHeight / 2
|
||||
)
|
||||
) {
|
||||
horizontalInTheRange = true;
|
||||
horizontalLines.push({
|
||||
y: objectTop - objectHeight / 2,
|
||||
x1:
|
||||
objectLeft < activeObjectLeft
|
||||
? objectLeft - objectWidth / 2 - aligningLineOffset
|
||||
: objectLeft + objectWidth / 2 + aligningLineOffset,
|
||||
x2:
|
||||
activeObjectLeft > objectLeft
|
||||
? activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset
|
||||
: activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset,
|
||||
});
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
activeObjectLeft,
|
||||
objectTop - objectHeight / 2 + activeObjectHeight / 2
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
|
||||
// snap by the bottom edge
|
||||
if (
|
||||
isInRange(
|
||||
objectTop + objectHeight / 2,
|
||||
activeObjectTop + activeObjectHeight / 2
|
||||
)
|
||||
) {
|
||||
horizontalInTheRange = true;
|
||||
horizontalLines.push({
|
||||
y: objectTop + objectHeight / 2,
|
||||
x1:
|
||||
objectLeft < activeObjectLeft
|
||||
? objectLeft - objectWidth / 2 - aligningLineOffset
|
||||
: objectLeft + objectWidth / 2 + aligningLineOffset,
|
||||
x2:
|
||||
activeObjectLeft > objectLeft
|
||||
? activeObjectLeft + activeObjectWidth / 2 + aligningLineOffset
|
||||
: activeObjectLeft - activeObjectWidth / 2 - aligningLineOffset,
|
||||
});
|
||||
activeObject.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
activeObjectLeft,
|
||||
objectTop + objectHeight / 2 - activeObjectHeight / 2
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!horizontalInTheRange) {
|
||||
horizontalLines.length = 0;
|
||||
}
|
||||
|
||||
if (!verticalInTheRange) {
|
||||
verticalLines.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on("before:render", function () {
|
||||
if (canvas.contextTop) {
|
||||
canvas.clearContext(canvas.contextTop);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on("after:render", function () {
|
||||
for (var i = verticalLines.length; i--; ) {
|
||||
drawVerticalLine(verticalLines[i]);
|
||||
}
|
||||
for (var i = horizontalLines.length; i--; ) {
|
||||
drawHorizontalLine(horizontalLines[i]);
|
||||
}
|
||||
|
||||
verticalLines.length = horizontalLines.length = 0;
|
||||
});
|
||||
|
||||
canvas.on("mouse:up", function () {
|
||||
verticalLines.length = horizontalLines.length = 0;
|
||||
canvas.renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
export default initAligningGuidelines;
|
||||
|
||||
/**
|
||||
* Augments canvas by assigning to `onObjectMove` and `onAfterRender`.
|
||||
* This kind of sucks because other code using those methods will stop functioning.
|
||||
* Need to fix it by replacing callbacks with pub/sub kind of subscription model.
|
||||
* (or maybe use existing fabric.util.fire/observe (if it won't be too slow))
|
||||
*/
|
||||
|
||||
export function initCenteringGuidelines(canvas) {
|
||||
var canvasWidth = canvas.getWidth(),
|
||||
canvasHeight = canvas.getHeight(),
|
||||
canvasWidthCenter = canvasWidth / 2,
|
||||
canvasHeightCenter = canvasHeight / 2,
|
||||
canvasWidthCenterMap = {},
|
||||
canvasHeightCenterMap = {},
|
||||
centerLineMargin = 4,
|
||||
centerLineColor = "rgba(255,0,241,0.5)",
|
||||
centerLineWidth = 1,
|
||||
ctx = canvas.getSelectionContext(),
|
||||
viewportTransform;
|
||||
|
||||
for (
|
||||
var i = canvasWidthCenter - centerLineMargin,
|
||||
len = canvasWidthCenter + centerLineMargin;
|
||||
i <= len;
|
||||
i++
|
||||
) {
|
||||
canvasWidthCenterMap[Math.round(i)] = true;
|
||||
}
|
||||
for (
|
||||
var i = canvasHeightCenter - centerLineMargin,
|
||||
len = canvasHeightCenter + centerLineMargin;
|
||||
i <= len;
|
||||
i++
|
||||
) {
|
||||
canvasHeightCenterMap[Math.round(i)] = true;
|
||||
}
|
||||
|
||||
function showVerticalCenterLine() {
|
||||
showCenterLine(
|
||||
canvasWidthCenter + 0.5,
|
||||
0,
|
||||
canvasWidthCenter + 0.5,
|
||||
canvasHeight
|
||||
);
|
||||
}
|
||||
|
||||
function showHorizontalCenterLine() {
|
||||
showCenterLine(
|
||||
0,
|
||||
canvasHeightCenter + 0.5,
|
||||
canvasWidth,
|
||||
canvasHeightCenter + 0.5
|
||||
);
|
||||
}
|
||||
|
||||
function showCenterLine(x1, y1, x2, y2) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = centerLineColor;
|
||||
ctx.lineWidth = centerLineWidth;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 * viewportTransform[0], y1 * viewportTransform[3]);
|
||||
ctx.lineTo(x2 * viewportTransform[0], y2 * viewportTransform[3]);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
var afterRenderActions = [],
|
||||
isInVerticalCenter,
|
||||
isInHorizontalCenter;
|
||||
|
||||
canvas.on("mouse:down", function () {
|
||||
viewportTransform = canvas.viewportTransform;
|
||||
});
|
||||
|
||||
canvas.on("object:moving", function (e) {
|
||||
var object = e.target,
|
||||
objectCenter = object.getCenterPoint(),
|
||||
transform = canvas._currentTransform;
|
||||
|
||||
if (!transform) return;
|
||||
|
||||
(isInVerticalCenter = Math.round(objectCenter.x) in canvasWidthCenterMap),
|
||||
(isInHorizontalCenter =
|
||||
Math.round(objectCenter.y) in canvasHeightCenterMap);
|
||||
|
||||
if (isInHorizontalCenter || isInVerticalCenter) {
|
||||
object.setPositionByOrigin(
|
||||
new fabric.Point(
|
||||
isInVerticalCenter ? canvasWidthCenter : objectCenter.x,
|
||||
isInHorizontalCenter ? canvasHeightCenter : objectCenter.y
|
||||
),
|
||||
"center",
|
||||
"center"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on("before:render", function () {
|
||||
if (canvas.contextTop) {
|
||||
canvas.clearContext(canvas.contextTop);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on("after:render", function () {
|
||||
if (isInVerticalCenter) {
|
||||
showVerticalCenterLine();
|
||||
}
|
||||
if (isInHorizontalCenter) {
|
||||
showHorizontalCenterLine();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.on("mouse:up", function () {
|
||||
// clear these values, to stop drawing guidelines once mouse is up
|
||||
isInVerticalCenter = isInHorizontalCenter = null;
|
||||
canvas.renderAll();
|
||||
});
|
||||
}
|
||||
1238
src/component/Canvas/CanvasEditor/utils/imageHelper.js
Normal file
1238
src/component/Canvas/CanvasEditor/utils/imageHelper.js
Normal file
File diff suppressed because it is too large
Load Diff
463
src/component/Canvas/CanvasEditor/utils/layerHelper.js
Normal file
463
src/component/Canvas/CanvasEditor/utils/layerHelper.js
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 图层类型枚举
|
||||
*/
|
||||
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", // 固定图层 - 位于背景图层之上,普通图层之下
|
||||
};
|
||||
|
||||
/**
|
||||
* 画布操作模式枚举:draw(绘画)、select(选择)、pan(拖拽)....
|
||||
*/
|
||||
export const OperationType = {
|
||||
// 编辑器模式
|
||||
DRAW: "draw", // 绘画模式
|
||||
ERASER: "eraser", // 橡皮擦模式
|
||||
SELECT: "select", // 选择模式
|
||||
PAN: "pan", // 拖拽模式
|
||||
EYEDROPPER: "eyedropper", // 吸色器模式
|
||||
|
||||
// 套索工具
|
||||
LASSO: "lasso", // 套索工具模式 - 自由套索模式
|
||||
LASSO_RECTANGLE: "lasso_rectangle", // 套索工具模式 - 矩形模式
|
||||
LASSO_ELLIPSE: "lasso_ellipse", // 套索工具模式 - 椭圆
|
||||
|
||||
// 创建临时选区工具模式 - 类似于临时图层 在这个区域的操作不会影响其他图层
|
||||
AREA_RECTANGLE: "area_rectangle", // 矩形选区模式
|
||||
|
||||
// 材质笔刷工具模式
|
||||
TEXTURE: "texture", // 选择材质笔刷工具模式 - // 选择材质笔刷后会切换到绘画模式 笔刷固定到材质笔刷
|
||||
|
||||
// 液化工具
|
||||
LIQUIFY: "liquify", // 液化工具模式
|
||||
|
||||
// 矢量工具
|
||||
// VECTOR: "vector", // 矢量工具模式
|
||||
// 矢量工具模式 - 自由绘制
|
||||
// VECTOR_FREE: "vector_free",
|
||||
|
||||
TEXT: "text", // 文字工具模式
|
||||
|
||||
// 红绿图模式
|
||||
RED_GREEN: "red_green", // 红绿图模式 - 只有红色和绿色笔刷还有橡皮擦 不支持添加其他图片 特殊模式
|
||||
RED_BRUSH: "red_brush", // 红色笔刷
|
||||
GREEN_BRUSH: "green_brush", // 绿色笔刷
|
||||
|
||||
// SHAPE: "shape", // 形状模式
|
||||
// 可以根据需要添加更多工具
|
||||
};
|
||||
|
||||
// 所有操作模式类型列表
|
||||
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", // 目标外
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断图层是否为组图层
|
||||
* @param {Object} layer 要检查的图层
|
||||
* @returns {boolean} 是否为组图层
|
||||
*/
|
||||
export function isGroupLayer(layer) {
|
||||
if (!layer) return false;
|
||||
return (
|
||||
layer.type === LayerType.GROUP ||
|
||||
(Array.isArray(layer.children) && layer.children.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从fabric对象创建图层
|
||||
* @param {Object} fabricObject fabric对象
|
||||
* @param {String} layerType 图层类型
|
||||
* @param {Object} options 其他选项
|
||||
* @returns {Object} 创建的图层对象
|
||||
*/
|
||||
export function createLayerFromFabricObject(
|
||||
fabricObject,
|
||||
layerType = "bitmap",
|
||||
options = {}
|
||||
) {
|
||||
if (!fabricObject) return null;
|
||||
|
||||
// 确定图层类型
|
||||
let type = layerType;
|
||||
if (fabricObject.type === "textbox" || fabricObject.type === "text") {
|
||||
type = LayerType.TEXT;
|
||||
} else if (
|
||||
fabricObject.type === "rect" ||
|
||||
fabricObject.type === "circle" ||
|
||||
fabricObject.type === "polygon" ||
|
||||
fabricObject.type === "polyline"
|
||||
) {
|
||||
type = LayerType.SHAPE;
|
||||
} else if (fabricObject.type === "path" || fabricObject.type === "line") {
|
||||
type = LayerType.VECTOR;
|
||||
} else if (fabricObject.type === "image") {
|
||||
type = LayerType.BITMAP;
|
||||
}
|
||||
|
||||
// 创建基础图层
|
||||
let layer = createLayer({
|
||||
...options,
|
||||
type: type,
|
||||
name:
|
||||
options.name ||
|
||||
`${
|
||||
fabricObject.type.charAt(0).toUpperCase() + fabricObject.type.slice(1)
|
||||
} 图层`,
|
||||
parentId: options.parentId || null,
|
||||
});
|
||||
|
||||
// 添加对象到图层
|
||||
if (Array.isArray(layer.fabricObjects)) {
|
||||
layer.fabricObjects.push(fabricObject);
|
||||
} else {
|
||||
layer.fabricObjects = [fabricObject];
|
||||
}
|
||||
|
||||
// 如果对象有自己的ID,将其与图层关联
|
||||
if (fabricObject.id) {
|
||||
fabricObject.layerId = layer.id;
|
||||
fabricObject.layerName = layer.name;
|
||||
}
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准图层对象
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 图层对象
|
||||
*/
|
||||
export function createLayer(options = {}) {
|
||||
const id =
|
||||
options.id || `layer_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
return {
|
||||
id: id,
|
||||
// 图层基本属性
|
||||
name: options.name || `图层 ${id.substring(id.lastIndexOf("_") + 1)}`,
|
||||
type: options.type || LayerType.EMPTY,
|
||||
visible: options.visible !== undefined ? options.visible : true,
|
||||
locked: options.locked !== undefined ? options.locked : false,
|
||||
opacity: options.opacity !== undefined ? options.opacity : 1.0,
|
||||
blendMode: options.blendMode || BlendMode.NORMAL,
|
||||
|
||||
// 确保不是背景图层
|
||||
isBackground: false,
|
||||
|
||||
// Fabric.js 对象列表
|
||||
fabricObjects: options.fabricObjects || [],
|
||||
|
||||
// 嵌套结构 - 适用于组图层
|
||||
children: options.children || [],
|
||||
|
||||
// 剪切蒙版
|
||||
clippingMask: options.clippingMask || null,
|
||||
|
||||
// 位置和大小信息(可选)
|
||||
bounds: options.bounds || null,
|
||||
|
||||
// 图层特定属性
|
||||
layerProperties: options.layerProperties || {},
|
||||
|
||||
// 元数据 - 可用于存储任意数据
|
||||
metadata: options.metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建背景图层
|
||||
* @param {Object} options 背景图层选项
|
||||
* @returns {Object} 背景图层对象
|
||||
*/
|
||||
export function createBackgroundLayer(options = {}) {
|
||||
const id =
|
||||
options.id || `bg_layer_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
return {
|
||||
id: id,
|
||||
// 图层基本属性
|
||||
name: options.name || "背景",
|
||||
type: LayerType.BITMAP,
|
||||
visible: true,
|
||||
locked: true, // 背景图层默认锁定
|
||||
opacity: 1.0, // 背景图层始终不透明
|
||||
blendMode: BlendMode.NORMAL, // 背景图层始终使用正常混合模式
|
||||
|
||||
// 标记为背景图层
|
||||
isBackground: true,
|
||||
|
||||
// 画布尺寸
|
||||
canvasWidth: options.canvasWidth || 800,
|
||||
canvasHeight: options.canvasHeight || 600,
|
||||
backgroundColor: options.backgroundColor || "#ffffff",
|
||||
|
||||
// Fabric.js 背景对象 (单个矩形对象)
|
||||
fabricObject: null, // 创建后设置
|
||||
// Fabric.js 对象列表
|
||||
fabricObjects: [], // 创建后设置
|
||||
|
||||
// 无子图层
|
||||
children: [],
|
||||
|
||||
// 元数据 - 可用于存储任意数据
|
||||
metadata: options.metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建位图图层
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 位图图层对象
|
||||
*/
|
||||
export function createBitmapLayer(options = {}) {
|
||||
const baseLayer = createLayer({
|
||||
...options,
|
||||
type: LayerType.BITMAP,
|
||||
});
|
||||
|
||||
// 添加位图特定属性
|
||||
baseLayer.layerProperties = {
|
||||
...baseLayer.layerProperties,
|
||||
filters: options.filters || [], // 滤镜数组
|
||||
imageUrl: options.imageUrl || null, // 图片URL
|
||||
imageElement: options.imageElement || null, // 图片元素
|
||||
};
|
||||
|
||||
return baseLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本图层
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 文本图层对象
|
||||
*/
|
||||
export function createTextLayer(options = {}) {
|
||||
const baseLayer = createLayer({
|
||||
...options,
|
||||
type: LayerType.TEXT,
|
||||
});
|
||||
|
||||
// 添加文字特定属性
|
||||
baseLayer.layerProperties = {
|
||||
...baseLayer.layerProperties,
|
||||
text: options.text || "新文本",
|
||||
fontFamily: options.fontFamily || "Arial",
|
||||
fontSize: options.fontSize || 24,
|
||||
fontWeight: options.fontWeight || "normal",
|
||||
fontStyle: options.fontStyle || "normal",
|
||||
textAlign: options.textAlign || "left",
|
||||
underline: options.underline || false,
|
||||
overline: options.overline || false,
|
||||
linethrough: options.linethrough || false,
|
||||
textBackgroundColor: options.textBackgroundColor || "transparent",
|
||||
lineHeight: options.lineHeight || 1.16,
|
||||
charSpacing: options.charSpacing || 0,
|
||||
};
|
||||
|
||||
return baseLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建矢量图层
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 矢量图层对象
|
||||
*/
|
||||
export function createVectorLayer(options = {}) {
|
||||
const baseLayer = createLayer({
|
||||
...options,
|
||||
type: LayerType.VECTOR,
|
||||
});
|
||||
|
||||
// 添加矢量特定属性
|
||||
baseLayer.layerProperties = {
|
||||
...baseLayer.layerProperties,
|
||||
vectorType: options.vectorType || "path", // path, polygon, polyline等
|
||||
strokeWidth: options.strokeWidth !== undefined ? options.strokeWidth : 1,
|
||||
strokeColor: options.strokeColor || "#000000",
|
||||
fillColor: options.fillColor || "transparent",
|
||||
fillRule: options.fillRule || "nonzero",
|
||||
strokeLineCap: options.strokeLineCap || "butt",
|
||||
strokeLineJoin: options.strokeLineJoin || "miter",
|
||||
strokeDashArray: options.strokeDashArray || null,
|
||||
strokeDashOffset: options.strokeDashOffset || 0,
|
||||
};
|
||||
|
||||
return baseLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建形状图层
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 形状图层对象
|
||||
*/
|
||||
export function createShapeLayer(options = {}) {
|
||||
const baseLayer = createLayer({
|
||||
...options,
|
||||
type: LayerType.SHAPE,
|
||||
});
|
||||
|
||||
// 添加形状特定属性
|
||||
baseLayer.layerProperties = {
|
||||
...baseLayer.layerProperties,
|
||||
shapeType: options.shapeType || "rect", // rect, circle, ellipse等
|
||||
strokeWidth: options.strokeWidth !== undefined ? options.strokeWidth : 1,
|
||||
strokeColor: options.strokeColor || "#000000",
|
||||
fillColor: options.fillColor || "#ffffff",
|
||||
rx: options.rx || 0, // 矩形圆角
|
||||
ry: options.ry || 0, // 矩形圆角
|
||||
};
|
||||
|
||||
return baseLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建调整图层
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 调整图层对象
|
||||
*/
|
||||
export function createAdjustmentLayer(options = {}) {
|
||||
const baseLayer = createLayer({
|
||||
...options,
|
||||
type: LayerType.ADJUSTMENT,
|
||||
});
|
||||
|
||||
// 添加调整图层特定属性
|
||||
baseLayer.layerProperties = {
|
||||
...baseLayer.layerProperties,
|
||||
adjustmentType: options.adjustmentType || "brightness", // brightness, contrast, hue, saturation等
|
||||
value: options.value !== undefined ? options.value : 0,
|
||||
affectedLayerIds: options.affectedLayerIds || [], // 受影响的图层ID列表
|
||||
};
|
||||
|
||||
return baseLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建智能对象图层
|
||||
* @param {Object} options 图层选项
|
||||
* @returns {Object} 智能对象图层
|
||||
*/
|
||||
export function createSmartObjectLayer(options = {}) {
|
||||
const baseLayer = createLayer({
|
||||
...options,
|
||||
type: LayerType.SMART_OBJECT,
|
||||
});
|
||||
|
||||
// 添加智能对象特定属性
|
||||
baseLayer.layerProperties = {
|
||||
...baseLayer.layerProperties,
|
||||
sourceType: options.sourceType || "image", // image, vector, embedded等
|
||||
sourceUrl: options.sourceUrl || null,
|
||||
sourceData: options.sourceData || null,
|
||||
originalWidth: options.originalWidth || 0,
|
||||
originalHeight: options.originalHeight || 0,
|
||||
embedded: options.embedded !== undefined ? options.embedded : true,
|
||||
};
|
||||
|
||||
return baseLayer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建固定图层 - 位于背景图层之上,普通图层之下
|
||||
* @param {Object} options 固定图层选项
|
||||
* @returns {Object} 固定图层对象
|
||||
*/
|
||||
export function createFixedLayer(options = {}) {
|
||||
const id =
|
||||
options.id ||
|
||||
`fixed_layer_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
return {
|
||||
id: id,
|
||||
// 图层基本属性
|
||||
name: options.name || "固定图层",
|
||||
type: LayerType.FIXED,
|
||||
visible: true, // 固定图层始终可见
|
||||
locked: true, // 固定图层默认锁定
|
||||
opacity: options.opacity !== undefined ? options.opacity : 1.0,
|
||||
blendMode: options.blendMode || BlendMode.NORMAL,
|
||||
|
||||
// 标记为固定图层
|
||||
isFixed: true,
|
||||
isBackground: false,
|
||||
|
||||
// Fabric.js 对象列表
|
||||
fabricObjects: options.fabricObjects || [],
|
||||
|
||||
// 无子图层
|
||||
children: [],
|
||||
|
||||
// 元数据 - 可用于存储任意数据
|
||||
metadata: options.metadata || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝图层对象
|
||||
* @param {Object} layer 要拷贝的图层
|
||||
* @returns {Object} 拷贝后的图层
|
||||
*/
|
||||
export function cloneLayer(layer) {
|
||||
if (!layer) return null;
|
||||
|
||||
// 基本属性深拷贝
|
||||
const clonedLayer = {
|
||||
...JSON.parse(JSON.stringify(layer)), // 深拷贝基本属性
|
||||
fabricObjects: [], // 重置,后面处理
|
||||
};
|
||||
|
||||
// 复制 fabric 对象 (如果存在)
|
||||
if (Array.isArray(layer.fabricObjects)) {
|
||||
clonedLayer.fabricObjects = layer.fabricObjects.map((obj) => {
|
||||
return obj && typeof obj.clone === "function"
|
||||
? obj.clone()
|
||||
: JSON.parse(JSON.stringify(obj));
|
||||
});
|
||||
}
|
||||
|
||||
// 复制背景对象 (如果存在)
|
||||
if (layer.isBackground && layer.fabricObject) {
|
||||
clonedLayer.fabricObject =
|
||||
typeof layer.fabricObject.clone === "function"
|
||||
? layer.fabricObject.clone()
|
||||
: JSON.parse(JSON.stringify(layer.fabricObject));
|
||||
}
|
||||
|
||||
// 递归复制子图层
|
||||
if (Array.isArray(layer.children) && layer.children.length > 0) {
|
||||
clonedLayer.children = layer.children.map((child) => cloneLayer(child));
|
||||
}
|
||||
|
||||
return clonedLayer;
|
||||
}
|
||||
49
src/component/Canvas/SvgIcon/index.vue
Normal file
49
src/component/Canvas/SvgIcon/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="c-svg">
|
||||
<svg
|
||||
:class="svgClass"
|
||||
v-bind="$attrs"
|
||||
:style="{ color: color, fontSize: size + 'px' }"
|
||||
>
|
||||
<use :href="iconName"></use>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 16,
|
||||
},
|
||||
});
|
||||
const iconName = computed(() => `#icon-${props.name}`);
|
||||
const svgClass = computed(() => {
|
||||
if (props.name) return `svg-icon icon-${props.name}`;
|
||||
return "svg-icon";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.svg-icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
.c-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,360 +1,83 @@
|
||||
<template>
|
||||
<div class="generalCanvas">
|
||||
<div class="argument-box">
|
||||
<argument ref="argument" v-if="canvasObj.canvas" :elementList="elementList"></argument>
|
||||
</div>
|
||||
<div class="canvasBox">
|
||||
<tool ref="tool" v-if="canvasObj.canvas" @toolLiquefaction="toolLiquefaction"></tool>
|
||||
<div class="canvas">
|
||||
<canvasContent ref="canvasContent"></canvasContent>
|
||||
</div>
|
||||
<div class="detail-box">
|
||||
<detail ref="detail" v-if="canvasObj.canvas"></detail>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mark_loading" v-show="isShowMark">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; margin-top: 2rem;">
|
||||
<div class="gallery_btn" style="margin: 0 2rem;" @click="setShare">{{ $t('exportModel.Share') }}</div>
|
||||
<div class="gallery_btn" style="margin: 0 2rem;" @click="setExport">{{ $t('exportModel.Export') }}</div>
|
||||
<div class="gallery_btn" style="margin: 0 2rem;" @click="setExport">{{ $t('exportModel.Export') }}</div>
|
||||
</div>
|
||||
<liquefaction ref="liquefaction" @submitLiquefaction="submitLiquefaction"></liquefaction>
|
||||
<publish ref="publish" @clearPublish="()=>{}"></publish>
|
||||
</div>
|
||||
<div class="red-green-mode-example">
|
||||
<!-- 画布编辑器 - 永久启用红绿图模式 -->
|
||||
<div class="canvas-wrapper">
|
||||
<div class="canvas-wrapper-btns">
|
||||
<div @click="getJSON">获取JSON</div>
|
||||
<div @click="loadJSON">读取JSON</div>
|
||||
</div>
|
||||
<CanvasEditor
|
||||
ref="canvasEditor"
|
||||
:enabledRedGreenMode="false"
|
||||
:clothingImageUrl="imageUrls.baseImage"
|
||||
:redGreenImageUrl="imageUrls.maskImage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import {defineComponent, computed, provide, h, ref, watch, onBeforeUnmount, reactive, onMounted, toRefs,
|
||||
} from "vue";
|
||||
import {message} from 'ant-design-vue'
|
||||
import { Https } from "@/tool/https";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import canvasGeneral from "@/tool/canvasGeneralCopy";
|
||||
import tool from "./tool.vue"
|
||||
import argument from "./argument.vue"
|
||||
import detail from "./detail.vue"
|
||||
import canvasContent from "./canvasContent.vue"
|
||||
import liquefaction from "@/component/modules/liquefaction.vue";
|
||||
|
||||
import JSZip, { forEach } from "jszip";
|
||||
import publish from "@/component/WorksPage/publish.vue";
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import CanvasEditor from "./CanvasEditor/index.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
tool,
|
||||
argument,
|
||||
detail,
|
||||
canvasContent,
|
||||
liquefaction,publish
|
||||
},
|
||||
props: {
|
||||
isState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props,{emit}) {
|
||||
watch(()=>props.isState,(newVal)=>{
|
||||
if(!newVal && canvasObj)canvasObj.clearEvent()
|
||||
})
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const isShowMark = ref(false)
|
||||
let liquefaction = ref(null)
|
||||
let liquefactionData = ref()
|
||||
let groupDashed = ref(null)//用来判断是否需要对group添加img
|
||||
let canvasType = 'export'
|
||||
let canvasObj = reactive(new canvasGeneral('export'))
|
||||
const dataDom = reactive({
|
||||
argument:null,
|
||||
canvasContent:null,
|
||||
tool:null,
|
||||
detail:null,
|
||||
publish:null,
|
||||
})
|
||||
let data = reactive({
|
||||
elementList:null,
|
||||
showCanvas:false,
|
||||
// 画布编辑器引用
|
||||
const canvasEditor = ref(null);
|
||||
|
||||
})
|
||||
provide('canvasType',canvasType)
|
||||
provide('canvasObj',canvasObj)
|
||||
provide('isShowMark',isShowMark)
|
||||
const close = ()=>{
|
||||
dataDom.forEach((item)=>{
|
||||
if(item.closeModal)item.closeModal()
|
||||
})
|
||||
}
|
||||
|
||||
let expoet = ()=>{
|
||||
console.log( canvasObj.selectExport());
|
||||
console.log( canvasObj.allExport());
|
||||
// 图像URL配置
|
||||
const imageUrls = {
|
||||
baseImage: "@/assets/redGreenPic/clothing_base_image.png",
|
||||
maskImage: "@/assets/redGreenPic/clothing_mask_image.png",
|
||||
};
|
||||
const getJSON = () => {
|
||||
if (canvasEditor.value) {
|
||||
const json = canvasEditor.value.getJSON();
|
||||
console.log("获取的JSON数据:", json);
|
||||
localStorage.setItem("redGreenModeJSON", json);
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
const setLiquefaction = async ()=>{//进入液化页面
|
||||
canvasObj.getLiquefactionImgObj().then((data)=>{
|
||||
if(data?.img){
|
||||
liquefactionData.value = data
|
||||
liquefaction.value.init(data.img)
|
||||
}else {
|
||||
message.info(t('exportModel.jsContent6'))
|
||||
return null;
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
const toolLiquefaction = ()=>{//工具点击按钮
|
||||
setLiquefaction()
|
||||
}
|
||||
const submitLiquefaction = (rv)=>{//液化回参
|
||||
canvasObj.setLiquefactionImgObj(liquefactionData.value,rv)
|
||||
// liquefactionData.value.setSrc(rv, (value)=>{
|
||||
// // liquefactionData.value.scaleToWidth(originalWidth);
|
||||
// // liquefactionData.value.scaleToHeight(originalHeight);
|
||||
// delete liquefactionData.value.minioUrl
|
||||
// if(groupDashed.value && groupDashed.value._objects.length == 1){
|
||||
// value.set({
|
||||
// left:-groupDashed.value.width/2,
|
||||
// top:-groupDashed.value.height/2,
|
||||
// })
|
||||
// groupDashed.value.insertAt(value)
|
||||
// // canvasObj.addDashedImg(value)
|
||||
// }
|
||||
// canvasObj.canvas.renderAll();
|
||||
// canvasObj.updateCanvasState()
|
||||
// });
|
||||
}
|
||||
const getCanvasData = ()=>{
|
||||
if(!canvasObj.canvas)return
|
||||
var json = canvasObj.canvas.toJSON(['src','minioUrl','custom','perPixelTargetFind','hasBorders','selectable','hasControls','erasable']);
|
||||
json.objects.forEach(item=>{
|
||||
if(item.type == 'image'){
|
||||
delete item.src
|
||||
}
|
||||
})
|
||||
let canvasExport = {
|
||||
canvas:json,
|
||||
groupList: canvasObj.layer.list
|
||||
}
|
||||
return canvasExport
|
||||
}
|
||||
const openSetData = async ()=>{
|
||||
//获取所有所选元素
|
||||
let arr = store.state.Workspace.projectList
|
||||
let obj = {}
|
||||
for (let index = 0; index < arr.length; index++) {
|
||||
const item = arr[index];
|
||||
await new Promise((resolve, reject) => {
|
||||
store.dispatch('getProjectCanvasData',item.value).then((value)=>{
|
||||
const keys = Object.keys(value)[0];
|
||||
if(!value[keys] || value[keys].length == 0){
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
if(keys == 'design'){
|
||||
value[keys].forEach((designItem)=>{
|
||||
let minioUrl = designItem.url
|
||||
designItem.url = designItem.designOutfitUrl
|
||||
designItem.minioUrl = designItem.minioUrl
|
||||
})
|
||||
}
|
||||
let rv = {
|
||||
list:value[keys],
|
||||
name:item.name,
|
||||
}
|
||||
obj[keys] = rv
|
||||
resolve('')
|
||||
})
|
||||
})
|
||||
}
|
||||
data.elementList = obj
|
||||
//获取所有所选元素 END
|
||||
const loadJSON = () => {
|
||||
if (canvasEditor.value) {
|
||||
const currentJSON = localStorage.getItem("redGreenModeJSON");
|
||||
canvasEditor.value.loadJSON(currentJSON);
|
||||
console.log("加载的JSON数据:", currentJSON);
|
||||
}
|
||||
};
|
||||
|
||||
if(data.showCanvas)return
|
||||
data.showCanvas = true
|
||||
dataDom.canvasContent.openSetData()
|
||||
}
|
||||
let setShare = async ()=>{
|
||||
dataDom.publish.publishMask = true
|
||||
canvasObj.detailSubmit().then((img)=>{
|
||||
let data = {
|
||||
"imgUrl":img,
|
||||
userlikeGroupId:store.state.Workspace.probjects.id,
|
||||
}
|
||||
dataDom.publish.init(data)
|
||||
})
|
||||
|
||||
}
|
||||
//设置导出
|
||||
let setExport = async () => {
|
||||
var imageDataURL = await canvasObj.detailSubmit()
|
||||
let a = document.createElement("a");
|
||||
let img = [];
|
||||
let index = 0;
|
||||
img.push({
|
||||
imgUrl: imageDataURL,
|
||||
name: "collection.png",
|
||||
});
|
||||
let num = 0;
|
||||
for (let key in data.elementList) {
|
||||
for (let index = 0; index < data.elementList[key].list.length; index++) {
|
||||
const item = data.elementList[key].list[index];
|
||||
let url = item.imgUrl || item.url || item.designOutfitUrl || item.minioUrl
|
||||
let name = url?.split(".").pop().split("?").shift();
|
||||
img.push({imgUrl:url,name:`element${index}.${name}`})
|
||||
}
|
||||
}
|
||||
downImg(img);
|
||||
};
|
||||
let getImgArrayBuffer = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
//通过请求获取文件blob格式
|
||||
let xmlhttp = new XMLHttpRequest();
|
||||
xmlhttp.open("GET", url, true);
|
||||
xmlhttp.responseType = "blob";
|
||||
xmlhttp.withCredentials = false;
|
||||
xmlhttp.onload = function () {
|
||||
if (this.status == 200) {
|
||||
resolve(this.response);
|
||||
} else {
|
||||
reject(this.status);
|
||||
}
|
||||
};
|
||||
xmlhttp.send();
|
||||
});
|
||||
};
|
||||
let downImg = (imagesParams) => {
|
||||
let zip = new JSZip();
|
||||
let cache = {};
|
||||
let promises = [];
|
||||
for (let item of imagesParams) {
|
||||
const promise = getImgArrayBuffer(item.imgUrl).then((data) => {
|
||||
// 下载文件, 并存成ArrayBuffer对象(blob)
|
||||
zip.file(item.name, data, { binary: true }); // 逐个添加文件
|
||||
cache[item.title] = data;
|
||||
})
|
||||
promises.push(promise);
|
||||
}
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
function downloadBlob(blob, filename) {
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
zip.generateAsync({ type: "blob" }).then((content) => {
|
||||
// 生成二进制流
|
||||
downloadBlob(content,'DesignFiles')
|
||||
// FileSaver.saveAs(content, "DesignFiles"); // 利用file-saver保存文件 自定义文件名
|
||||
isShowMark.value = false;
|
||||
// 组件挂载时绑定键盘事件
|
||||
onMounted(() => {});
|
||||
|
||||
});
|
||||
setSubmit()//导出的时候保存
|
||||
initAligningGuidelines(canvas,true)
|
||||
|
||||
})
|
||||
.catch((res) => {
|
||||
// message.warning(t('HomeView.jsContent3'));
|
||||
isShowMark.value = false;
|
||||
initAligningGuidelines(canvas,true)
|
||||
});
|
||||
};
|
||||
onMounted(() => {
|
||||
});
|
||||
onBeforeUnmount(()=>{
|
||||
canvasObj.canvasClear()
|
||||
})
|
||||
return {
|
||||
isShowMark,
|
||||
liquefaction,
|
||||
...toRefs(dataDom),
|
||||
...toRefs(data),
|
||||
canvasObj,
|
||||
close,
|
||||
expoet,
|
||||
toolLiquefaction,
|
||||
submitLiquefaction,
|
||||
getCanvasData,
|
||||
openSetData,
|
||||
setShare,
|
||||
setExport,
|
||||
};
|
||||
},
|
||||
data(prop) {
|
||||
return {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
watch: {
|
||||
|
||||
},
|
||||
mounted() {},
|
||||
|
||||
methods: {
|
||||
|
||||
},
|
||||
});
|
||||
// 组件卸载时移除键盘事件
|
||||
onUnmounted(() => {});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.generalCanvas{
|
||||
width: 100%;
|
||||
|
||||
<style scoped lang="less">
|
||||
.red-green-mode-example {
|
||||
width: 100%;
|
||||
// height: 100vh;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.argument-box,
|
||||
.canvasBox,
|
||||
.detail-box{
|
||||
:deep(i){
|
||||
font-size: 2.5rem;
|
||||
cursor: pointer;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all .3s;
|
||||
margin-bottom: .5rem;
|
||||
padding: 1rem;
|
||||
&.active{
|
||||
border: 1px solid;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
&.icon-xiala{
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
&.icon-xialaActive{
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.argument-box{
|
||||
margin-left: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.canvasBox{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
.canvas{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.detail-box{
|
||||
width: 20%;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas-wrapper-btns {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 150px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
& > div {
|
||||
cursor: pointer;
|
||||
padding: 10px 20px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 5px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
360
src/component/Canvas/index1.vue
Normal file
360
src/component/Canvas/index1.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="generalCanvas">
|
||||
<div class="argument-box">
|
||||
<argument ref="argument" v-if="canvasObj.canvas" :elementList="elementList"></argument>
|
||||
</div>
|
||||
<div class="canvasBox">
|
||||
<tool ref="tool" v-if="canvasObj.canvas" @toolLiquefaction="toolLiquefaction"></tool>
|
||||
<div class="canvas">
|
||||
<canvasContent ref="canvasContent"></canvasContent>
|
||||
</div>
|
||||
<div class="detail-box">
|
||||
<detail ref="detail" v-if="canvasObj.canvas"></detail>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mark_loading" v-show="isShowMark">
|
||||
<a-spin size="large" />
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; margin-top: 2rem;">
|
||||
<div class="gallery_btn" style="margin: 0 2rem;" @click="setShare">{{ $t('exportModel.Share') }}</div>
|
||||
<div class="gallery_btn" style="margin: 0 2rem;" @click="setExport">{{ $t('exportModel.Export') }}</div>
|
||||
<div class="gallery_btn" style="margin: 0 2rem;" @click="setExport">{{ $t('exportModel.Export') }}</div>
|
||||
</div>
|
||||
<liquefaction ref="liquefaction" @submitLiquefaction="submitLiquefaction"></liquefaction>
|
||||
<publish ref="publish" @clearPublish="()=>{}"></publish>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import {defineComponent, computed, provide, h, ref, watch, onBeforeUnmount, reactive, onMounted, toRefs,
|
||||
} from "vue";
|
||||
import {message} from 'ant-design-vue'
|
||||
import { Https } from "@/tool/https";
|
||||
import { useStore } from "vuex";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import canvasGeneral from "@/tool/canvasGeneralCopy";
|
||||
import tool from "./tool.vue"
|
||||
import argument from "./argument.vue"
|
||||
import detail from "./detail.vue"
|
||||
import canvasContent from "./canvasContent.vue"
|
||||
import liquefaction from "@/component/modules/liquefaction.vue";
|
||||
|
||||
import JSZip, { forEach } from "jszip";
|
||||
import publish from "@/component/WorksPage/publish.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
tool,
|
||||
argument,
|
||||
detail,
|
||||
canvasContent,
|
||||
liquefaction,publish
|
||||
},
|
||||
props: {
|
||||
isState: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props,{emit}) {
|
||||
watch(()=>props.isState,(newVal)=>{
|
||||
if(!newVal && canvasObj)canvasObj.clearEvent()
|
||||
})
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const isShowMark = ref(false)
|
||||
let liquefaction = ref(null)
|
||||
let liquefactionData = ref()
|
||||
let groupDashed = ref(null)//用来判断是否需要对group添加img
|
||||
let canvasType = 'export'
|
||||
let canvasObj = reactive(new canvasGeneral('export'))
|
||||
const dataDom = reactive({
|
||||
argument:null,
|
||||
canvasContent:null,
|
||||
tool:null,
|
||||
detail:null,
|
||||
publish:null,
|
||||
})
|
||||
let data = reactive({
|
||||
elementList:null,
|
||||
showCanvas:false,
|
||||
|
||||
})
|
||||
provide('canvasType',canvasType)
|
||||
provide('canvasObj',canvasObj)
|
||||
provide('isShowMark',isShowMark)
|
||||
const close = ()=>{
|
||||
dataDom.forEach((item)=>{
|
||||
if(item.closeModal)item.closeModal()
|
||||
})
|
||||
}
|
||||
|
||||
let expoet = ()=>{
|
||||
console.log( canvasObj.selectExport());
|
||||
console.log( canvasObj.allExport());
|
||||
|
||||
}
|
||||
const setLiquefaction = async ()=>{//进入液化页面
|
||||
canvasObj.getLiquefactionImgObj().then((data)=>{
|
||||
if(data?.img){
|
||||
liquefactionData.value = data
|
||||
liquefaction.value.init(data.img)
|
||||
}else {
|
||||
message.info(t('exportModel.jsContent6'))
|
||||
return null;
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
const toolLiquefaction = ()=>{//工具点击按钮
|
||||
setLiquefaction()
|
||||
}
|
||||
const submitLiquefaction = (rv)=>{//液化回参
|
||||
canvasObj.setLiquefactionImgObj(liquefactionData.value,rv)
|
||||
// liquefactionData.value.setSrc(rv, (value)=>{
|
||||
// // liquefactionData.value.scaleToWidth(originalWidth);
|
||||
// // liquefactionData.value.scaleToHeight(originalHeight);
|
||||
// delete liquefactionData.value.minioUrl
|
||||
// if(groupDashed.value && groupDashed.value._objects.length == 1){
|
||||
// value.set({
|
||||
// left:-groupDashed.value.width/2,
|
||||
// top:-groupDashed.value.height/2,
|
||||
// })
|
||||
// groupDashed.value.insertAt(value)
|
||||
// // canvasObj.addDashedImg(value)
|
||||
// }
|
||||
// canvasObj.canvas.renderAll();
|
||||
// canvasObj.updateCanvasState()
|
||||
// });
|
||||
}
|
||||
const getCanvasData = ()=>{
|
||||
if(!canvasObj.canvas)return
|
||||
var json = canvasObj.canvas.toJSON(['src','minioUrl','custom','perPixelTargetFind','hasBorders','selectable','hasControls','erasable']);
|
||||
json.objects.forEach(item=>{
|
||||
if(item.type == 'image'){
|
||||
delete item.src
|
||||
}
|
||||
})
|
||||
let canvasExport = {
|
||||
canvas:json,
|
||||
groupList: canvasObj.layer.list
|
||||
}
|
||||
return canvasExport
|
||||
}
|
||||
const openSetData = async ()=>{
|
||||
//获取所有所选元素
|
||||
let arr = store.state.Workspace.projectList
|
||||
let obj = {}
|
||||
for (let index = 0; index < arr.length; index++) {
|
||||
const item = arr[index];
|
||||
await new Promise((resolve, reject) => {
|
||||
store.dispatch('getProjectCanvasData',item.value).then((value)=>{
|
||||
const keys = Object.keys(value)[0];
|
||||
if(!value[keys] || value[keys].length == 0){
|
||||
resolve('')
|
||||
return
|
||||
}
|
||||
if(keys == 'design'){
|
||||
value[keys].forEach((designItem)=>{
|
||||
let minioUrl = designItem.url
|
||||
designItem.url = designItem.designOutfitUrl
|
||||
designItem.minioUrl = designItem.minioUrl
|
||||
})
|
||||
}
|
||||
let rv = {
|
||||
list:value[keys],
|
||||
name:item.name,
|
||||
}
|
||||
obj[keys] = rv
|
||||
resolve('')
|
||||
})
|
||||
})
|
||||
}
|
||||
data.elementList = obj
|
||||
//获取所有所选元素 END
|
||||
|
||||
if(data.showCanvas)return
|
||||
data.showCanvas = true
|
||||
dataDom.canvasContent.openSetData()
|
||||
}
|
||||
let setShare = async ()=>{
|
||||
dataDom.publish.publishMask = true
|
||||
canvasObj.detailSubmit().then((img)=>{
|
||||
let data = {
|
||||
"imgUrl":img,
|
||||
userlikeGroupId:store.state.Workspace.probjects.id,
|
||||
}
|
||||
dataDom.publish.init(data)
|
||||
})
|
||||
|
||||
}
|
||||
//设置导出
|
||||
let setExport = async () => {
|
||||
var imageDataURL = await canvasObj.detailSubmit()
|
||||
let a = document.createElement("a");
|
||||
let img = [];
|
||||
let index = 0;
|
||||
img.push({
|
||||
imgUrl: imageDataURL,
|
||||
name: "collection.png",
|
||||
});
|
||||
let num = 0;
|
||||
for (let key in data.elementList) {
|
||||
for (let index = 0; index < data.elementList[key].list.length; index++) {
|
||||
const item = data.elementList[key].list[index];
|
||||
let url = item.imgUrl || item.url || item.designOutfitUrl || item.minioUrl
|
||||
let name = url?.split(".").pop().split("?").shift();
|
||||
img.push({imgUrl:url,name:`element${index}.${name}`})
|
||||
}
|
||||
}
|
||||
downImg(img);
|
||||
};
|
||||
let getImgArrayBuffer = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
//通过请求获取文件blob格式
|
||||
let xmlhttp = new XMLHttpRequest();
|
||||
xmlhttp.open("GET", url, true);
|
||||
xmlhttp.responseType = "blob";
|
||||
xmlhttp.withCredentials = false;
|
||||
xmlhttp.onload = function () {
|
||||
if (this.status == 200) {
|
||||
resolve(this.response);
|
||||
} else {
|
||||
reject(this.status);
|
||||
}
|
||||
};
|
||||
xmlhttp.send();
|
||||
});
|
||||
};
|
||||
let downImg = (imagesParams) => {
|
||||
let zip = new JSZip();
|
||||
let cache = {};
|
||||
let promises = [];
|
||||
for (let item of imagesParams) {
|
||||
const promise = getImgArrayBuffer(item.imgUrl).then((data) => {
|
||||
// 下载文件, 并存成ArrayBuffer对象(blob)
|
||||
zip.file(item.name, data, { binary: true }); // 逐个添加文件
|
||||
cache[item.title] = data;
|
||||
})
|
||||
promises.push(promise);
|
||||
}
|
||||
Promise.all(promises)
|
||||
.then(() => {
|
||||
function downloadBlob(blob, filename) {
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
zip.generateAsync({ type: "blob" }).then((content) => {
|
||||
// 生成二进制流
|
||||
downloadBlob(content,'DesignFiles')
|
||||
// FileSaver.saveAs(content, "DesignFiles"); // 利用file-saver保存文件 自定义文件名
|
||||
isShowMark.value = false;
|
||||
|
||||
});
|
||||
setSubmit()//导出的时候保存
|
||||
initAligningGuidelines(canvas,true)
|
||||
|
||||
})
|
||||
.catch((res) => {
|
||||
// message.warning(t('HomeView.jsContent3'));
|
||||
isShowMark.value = false;
|
||||
initAligningGuidelines(canvas,true)
|
||||
});
|
||||
};
|
||||
onMounted(() => {
|
||||
});
|
||||
onBeforeUnmount(()=>{
|
||||
canvasObj.canvasClear()
|
||||
})
|
||||
return {
|
||||
isShowMark,
|
||||
liquefaction,
|
||||
...toRefs(dataDom),
|
||||
...toRefs(data),
|
||||
canvasObj,
|
||||
close,
|
||||
expoet,
|
||||
toolLiquefaction,
|
||||
submitLiquefaction,
|
||||
getCanvasData,
|
||||
openSetData,
|
||||
setShare,
|
||||
setExport,
|
||||
};
|
||||
},
|
||||
data(prop) {
|
||||
return {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
watch: {
|
||||
|
||||
},
|
||||
mounted() {},
|
||||
|
||||
methods: {
|
||||
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.generalCanvas{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
.argument-box,
|
||||
.canvasBox,
|
||||
.detail-box{
|
||||
:deep(i){
|
||||
font-size: 2.5rem;
|
||||
cursor: pointer;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all .3s;
|
||||
margin-bottom: .5rem;
|
||||
padding: 1rem;
|
||||
&.active{
|
||||
border: 1px solid;
|
||||
border-radius: .4rem;
|
||||
}
|
||||
&.icon-xiala{
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
&.icon-xialaActive{
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
.argument-box{
|
||||
margin-left: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.canvasBox{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
.canvas{
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
.detail-box{
|
||||
width: 20%;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user