From 5d0511e4053cc9e5ff6e5559019c6a2e07bd712f Mon Sep 17 00:00:00 2001 From: bighuixiang <472705331@qq.com> Date: Mon, 23 Jun 2025 15:56:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=9B=BE=E5=B1=82=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=B0=86=E5=9B=BE=E5=B1=82=E8=BD=AC=E6=8D=A2=E4=B8=BA=E4=BD=8D?= =?UTF-8?q?=E5=9B=BE=E5=9B=BE=E5=83=8F=E5=B9=B6=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/icons/CExport.svg | 1 + .../commands/ObjectLayerCommands.js | 50 +---- .../commands/RasterizeLayerCommand.js | 194 ++++++++++++++++++ .../CanvasEditor/commands/RedGreenCommands.js | 31 ++- .../components/LayersPanel/LayersPanel.vue | 33 ++- .../CanvasEditor/components/ToolsSidebar.vue | 31 +-- src/component/Canvas/CanvasEditor/index.vue | 18 +- .../CanvasEditor/managers/LayerManager.js | 56 ++++- .../managers/RedGreenModeManager.js | 2 + .../Canvas/CanvasEditor/utils/imageHelper.js | 57 +++++ 10 files changed, 405 insertions(+), 68 deletions(-) create mode 100644 src/assets/icons/CExport.svg diff --git a/src/assets/icons/CExport.svg b/src/assets/icons/CExport.svg new file mode 100644 index 00000000..392fac43 --- /dev/null +++ b/src/assets/icons/CExport.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/component/Canvas/CanvasEditor/commands/ObjectLayerCommands.js b/src/component/Canvas/CanvasEditor/commands/ObjectLayerCommands.js index da61fba7..4a111c25 100644 --- a/src/component/Canvas/CanvasEditor/commands/ObjectLayerCommands.js +++ b/src/component/Canvas/CanvasEditor/commands/ObjectLayerCommands.js @@ -10,6 +10,7 @@ import { insertObjectAtZIndex, } from "../utils/helper"; import { fabric } from "fabric-with-all"; +import { imageModeHandler } from "../utils/imageHelper"; /** * 设置活动图层命令 @@ -725,48 +726,13 @@ export class ChangeFixedImageCommand extends Command { }); } - // 如果是包含 则需要根据图像模式调整大小 - switch (this.imageMode) { - case "stretch": - // 拉伸模式 - 填充整个画布 - newImage.scaleToWidth(this.canvasWidth); - newImage.scaleToHeight(this.canvasHeight); - break; - case "tile": - // 平铺模式 - 保持原始大小 - newImage.scaleX = 1; - newImage.scaleY = 1; - break; - case "stretchTile": - // 拉伸平铺模式 - 填充整个画布,但保持宽高比 - newImage.scaleToWidth(this.canvasWidth); - newImage.scaleToHeight(this.canvasHeight); - break; - case "stretchTileCrop": - // 拉伸平铺并裁剪模式 - 填充整个画布,可能 - // 会裁剪图像以适应画布 - newImage.scaleToWidth(this.canvasWidth); - newImage.scaleToHeight(this.canvasHeight); - // 这里可以添加裁剪逻辑,如果需要的话 - // 例如使用fabric.Image.clipPath来裁剪图像 - break; - case "contains": - // 包含模式 - 保证图像在画布内完整显示 - // 既要考虑画布的宽高比,也要考虑图像的宽高比 - // 图片缩放后要保证最长边能完全显示在画布内 - const canvasAspect = this.canvasWidth / this.canvasHeight; - const imageAspect = newImage.width / newImage.height; - // 保证图像在画布内完整显示 - 既要考虑画布的宽高比,也要考虑图像的宽高比 - // 图片缩放后要保证最长边能完全显示在画布内 - if (imageAspect > canvasAspect) { - // 图像更宽 - newImage.scaleToWidth(this.canvasWidth); - } else { - // 图像更高 - newImage.scaleToHeight(this.canvasHeight); - } - break; - } + // 通用处理图片模式 + imageModeHandler({ + imageMode: this.imageMode, + newImage, + canvasWidth: this.canvasWidth, + canvasHeight: this.canvasHeight, + }); // 使用帮助函数在指定z-index位置插入新图像 if (this.previousZIndex !== undefined && this.previousZIndex >= 0) { diff --git a/src/component/Canvas/CanvasEditor/commands/RasterizeLayerCommand.js b/src/component/Canvas/CanvasEditor/commands/RasterizeLayerCommand.js index 86568254..b437e662 100644 --- a/src/component/Canvas/CanvasEditor/commands/RasterizeLayerCommand.js +++ b/src/component/Canvas/CanvasEditor/commands/RasterizeLayerCommand.js @@ -13,6 +13,7 @@ import { removeCanvasObjectByObject, } from "../utils/helper"; import { createRasterizedImage } from "../utils/rasterizedImage"; +import { message } from "ant-design-vue"; /** * 栅格化图层命令 @@ -376,3 +377,196 @@ export class RasterizeLayerCommand extends Command { }; } } + +/** + * 导出图层命令 + * 将图层中的所有矢量对象转换为位图图像 + * 支持普通图层和组图层的栅格化 + */ +export class ExportLayerToImageCommand extends Command { + constructor(options) { + super({ + name: "导出图层", + saveState: true, + }); + this.canvas = options.canvas; + this.layers = options.layers; + this.layerId = options.layerId; // 指定要栅格化的图层ID + // 是否包含锁定对象 + this.hasLocked = options.hasLocked || true; + // 是否包含隐藏对象 + this.hasHidden = options.hasHidden || false; + + this.activeLayerId = options.activeLayerId; + this.layerManager = options.layerManager; + + // 查找目标图层 + const { layer, parent } = findLayerRecursively( + this.layers.value, + this.layerId + ); + this.layer = layer; + this.parentLayer = parent; + this.isGroupLayer = this.layer?.children && this.layer.children.length > 0; + + // 保存原始状态用于撤销 + this.originalLayers = [...this.layers.value]; + this.originalCanvasObjects = [...this.canvas.getObjects()]; + this.originalObjectStates = new Map(); + + // 栅格化结果 + this.rasterizedImage = null; + this.rasterizedImageId = null; + // 生成新图层ID + this.rasterizedLayerId = generateId("rasterized_layer_"); + this.resterizedId = generateId("rasterized_"); + + this.rasterizedLayer = null; + + // 要栅格化的图层和对象 + this.layersToRasterize = []; + this.objectsToRasterize = []; + } + + async execute() { + // 查找目标图层 + const { layer, parent } = findLayerRecursively( + this.layers.value, + this.layerId + ); + this.layer = layer; + this.parentLayer = parent; + this.isGroupLayer = this.layer?.children && this.layer.children.length > 0; + + if (!this.layer) { + throw new Error(`图层 ${this.layerId} 不存在`); + } + + try { + // 收集要栅格化的图层和对象 + this._collectLayersAndObjects(); + + if (this.objectsToRasterize.length === 0) { + message.error("图层没有内容可导出"); + throw new Error("图层没有内容可导出"); + } + + // 保存原始对象状态 + this.canvas.discardActiveObject(); + this.canvas.renderAll(); + + // 创建图像 + const imageBase64 = await createRasterizedImage({ + canvas: this.canvas, + fabricObjects: this.objectsToRasterize, + isReturenDataURL: true, + }); + + // 模拟浏览器下载 + const link = document.createElement("a"); + link.href = imageBase64; + link.download = `${this.layer.name}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + console.log(`✅ 图层 ${this.layer.name} 导出完成`); + return true; + } catch (error) { + console.error("导出图层失败:", error); + throw error; + } + } + + async undo() { + // 导出图片不需要撤销操作 + } + + /** + * 收集要栅格化的图层和对象 + * @private + */ + _collectLayersAndObjects() { + if (this.isGroupLayer) { + // 组图层:收集自身和所有子图层 + this.layersToRasterize = this._collectLayersToRasterize(this.layer); + } else { + // 普通图层:只收集自身 + this.layersToRasterize = [this.layer]; + } + + // 收集所有图层的fabricObjects并按画布z-index顺序排序 + const allCanvasObjects = this.canvas.getObjects(); + const objectsWithZIndex = []; + + this.layersToRasterize.forEach((layer) => { + if (layer.fabricObjects && layer.fabricObjects.length > 0) { + layer.fabricObjects.forEach((layerObj) => { + if (layerObj && layerObj.id) { + const { object } = findObjectById(this.canvas, layerObj.id); + if (object) { + // 获取对象在画布中的z-index(数组索引) + const zIndex = allCanvasObjects.indexOf(object); + objectsWithZIndex.push({ + object: object, + zIndex: zIndex, + layerObj: layerObj, + }); + } + } + }); + } + }); + + // 按z-index排序,确保保持原有的渲染顺序 + objectsWithZIndex.sort((a, b) => a.zIndex - b.zIndex); + + // 提取排序后的对象 + this.objectsToRasterize = objectsWithZIndex.map((item) => item.object); + + console.log( + `📊 收集到 ${this.layersToRasterize.length} 个图层,${this.objectsToRasterize.length} 个对象进行栅格化` + ); + console.log( + "🔢 对象z-index顺序:", + objectsWithZIndex.map((item) => ({ + id: item.object.id, + type: item.object.type, + zIndex: item.zIndex, + })) + ); + } + + /** + * 收集要栅格化的图层(递归收集子图层) + * @param {Object} sourceLayer 源图层 + * @returns {Array} 图层数组 + * @private + */ + _collectLayersToRasterize(sourceLayer) { + const result = [sourceLayer]; + + // 如果是组图层,收集所有子图层 + if (sourceLayer.children && sourceLayer.children.length > 0) { + sourceLayer.children.forEach((childLayer) => { + if (childLayer) { + result.push(...this._collectLayersToRasterize(childLayer)); + } + }); + } + + return result; + } + + getInfo() { + return { + name: this.name, + originalLayerId: this.layerId, + originalLayerName: this.layer?.name, + rasterizedLayerId: this.rasterizedLayerId, + rasterizedLayerName: this.rasterizedLayer?.name, + isGroupLayer: this.isGroupLayer, + objectCount: this.objectsToRasterize?.length || 0, + }; + } +} diff --git a/src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js b/src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js index b2262b78..b060c0cc 100644 --- a/src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js +++ b/src/component/Canvas/CanvasEditor/commands/RedGreenCommands.js @@ -3,6 +3,7 @@ import { generateId, optimizeCanvasRendering, } from "../utils/helper.js"; +import { imageModeHandler } from "../utils/imageHelper.js"; import { LayerType, OperationType } from "../utils/layerHelper.js"; import { Command, CompositeCommand } from "./Command.js"; import { fabric } from "fabric-with-all"; @@ -26,6 +27,7 @@ export class BatchInitializeRedGreenModeCommand extends Command { this.redGreenImageUrl = options.redGreenImageUrl; this.onImageGenerated = options.onImageGenerated; this.normalLayerOpacity = options.normalLayerOpacity || 0.4; + this.clothingImageOpts = options.clothingImageOpts || null; // 衣服底图选项 - 用于设置图片加载时的选项 // 存储原始状态以便撤销 this.originalCanvasBackground = null; @@ -398,15 +400,30 @@ export class BatchInitializeRedGreenModeCommand extends Command { * 设置衣服底图 */ 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); + if (this.clothingImageOpts?.imageMode) { + // 如果有衣服底图选项,应用这些选项 + // 底图加载方式 1.平铺 2.拉伸 3.拉伸平铺 4.拉伸平铺并裁剪 5.包含 + // this.clothingImageOpts?.imageMode // 默认不处理 可选 contains, stretch,tile, stretchTile, stretchTileCrop + // 通用处理图片模式 + imageModeHandler({ + imageMode: this.clothingImageOpts?.imageMode, + newImage: img, + canvasWidth: this.canvas.width, + canvasHeight: this.canvas.height, + }); + } else { + // 计算图片缩放,保持上下留边距 + 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, + }); + } img.set({ - scaleX: scale, - scaleY: scale, left: this.canvas.width / 2, top: this.canvas.height / 2, originX: "center", diff --git a/src/component/Canvas/CanvasEditor/components/LayersPanel/LayersPanel.vue b/src/component/Canvas/CanvasEditor/components/LayersPanel/LayersPanel.vue index 93c19162..605b0079 100644 --- a/src/component/Canvas/CanvasEditor/components/LayersPanel/LayersPanel.vue +++ b/src/component/Canvas/CanvasEditor/components/LayersPanel/LayersPanel.vue @@ -665,7 +665,19 @@ function buildContextMenuItems(layer) { hideContextMenu(); }, }, - + // 栅格化图层 + { + label: "导出图层", + icon: "CExport", + disabled: + layer.isBackground || + layer.isFixed || + !layerManager?.canRasterizeLayer?.(layer.id), + action: () => { + exportLayerToImage(layer.id); + hideContextMenu(); + }, + }, { type: "divider" }, // 分组操作 - 带子菜单 { @@ -1107,6 +1119,25 @@ async function rasterizeLayer(layerId) { } } +// 导出图层 +async function exportLayerToImage(layerId) { + if (!layerManager?.rasterizeLayer) { + console.warn("导出图层功能不可用"); + return; + } + + try { + const success = await layerManager.exportLayerToImage(layerId); + if (success) { + console.log(`✅ 成功导出图层: ${layerId}`); + } else { + console.warn("导出图层失败"); + } + } catch (error) { + console.error("导出图层时发生错误:", error); + } +} + // 合并组图层 async function mergeGroupLayer(groupId) { if (!layerManager?.mergeGroupLayers) { diff --git a/src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue b/src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue index a18e806c..aa96dcb9 100644 --- a/src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue +++ b/src/component/Canvas/CanvasEditor/components/ToolsSidebar.vue @@ -2,6 +2,19 @@ import { ref, inject, computed, onMounted, onUnmounted } from "vue"; import { OperationType } from "../utils/layerHelper"; +const emit = defineEmits([ + "tool-selected", + "trigger-image-upload", + "add-text", + "undo", + "redo", + "toggle-minimap", + "zoom-in", + "zoom-out", + "toggle-red-green-mode", + "undo-redo-status-changed", +]); + const props = defineProps({ activeTool: String, minimapEnabled: { @@ -24,24 +37,18 @@ const canRedo = ref(false); commandManager.setChangeCallback((info) => { canUndo.value = info.canUndo; canRedo.value = info.canRedo; + + emit("undo-redo-status-changed", { + canUndo: canUndo.value, + canRedo: canRedo.value, + commandManager, + }); }); // 撤销/重做操作 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([ { diff --git a/src/component/Canvas/CanvasEditor/index.vue b/src/component/Canvas/CanvasEditor/index.vue index 4b5cd34b..54284477 100644 --- a/src/component/Canvas/CanvasEditor/index.vue +++ b/src/component/Canvas/CanvasEditor/index.vue @@ -40,7 +40,7 @@ const KeyboardShortcutHelp = defineAsyncComponent(() => import("./components/KeyboardShortcutHelp.vue") ); -const emit = defineEmits(["trigger-red-green-mouseup"]); +const emit = defineEmits(["trigger-red-green-mouseup", "changeCanvas"]); const props = defineProps({ canvasJSON: { @@ -296,6 +296,7 @@ onMounted(async () => { layerManager, toolManager, commandManager, + clothingImageOpts: props.clothingImageOpts, }); canvasManager.setRedGreenModeManager(redGreenModeManager); @@ -628,6 +629,15 @@ function handleChildLayersReorder(reorderData) { } } +// 处理画布变更事件 +const changeCanvas = (command) => { + const commandData = { + isChange: command.canUndo || command.canRedo, // 是否有可撤销或可重做的操作 + ...command, // 传递完整的命令数据 + }; + emit("changeCanvas", commandData); +}; + // 提供外部ref实例方法 defineExpose({ getCanvasManager: () => canvasManager, // 获取画布管理器实例 @@ -653,6 +663,7 @@ defineExpose({ //图片url或者base64 addImageToLayer: async (url) => { if (!url) return Promise.reject(new Error("图片URL不能为空")); + return await loadImageUrlToLayer({ imageUrl: url, layerManager, @@ -660,10 +671,6 @@ defineExpose({ toolManager, }); }, - //图片url或者base64数组 可选图层ID 不传默认新建图层 - addMultipleImagesToLayer: (urls, layerId) => { - return canvasManager?.addMultipleImages(urls, layerId); - }, // 导出图片 exportImage: ({ isContainBg = false, // 是否包含背景图层 @@ -806,6 +813,7 @@ defineExpose({ @add-text="handleAddText" @zoom-in="zoomIn" @zoom-out="zoomOut" + @undo-redo-status-changed="changeCanvas" /> diff --git a/src/component/Canvas/CanvasEditor/managers/LayerManager.js b/src/component/Canvas/CanvasEditor/managers/LayerManager.js index f5049683..4be2c749 100644 --- a/src/component/Canvas/CanvasEditor/managers/LayerManager.js +++ b/src/component/Canvas/CanvasEditor/managers/LayerManager.js @@ -45,7 +45,10 @@ import { BackgroundSizeWithScaleCommand, } from "../commands/BackgroundCommands"; import { MergeGroupLayerCommand } from "../commands/GroupCommands"; -import { RasterizeLayerCommand } from "../commands/RasterizeLayerCommand"; +import { + ExportLayerToImageCommand, + RasterizeLayerCommand, +} from "../commands/RasterizeLayerCommand"; // 导入图层排序相关类和混入 import { @@ -2865,6 +2868,57 @@ export class LayerManager { } } + /** + * 导出图层 -- 下载图层图片 + * @param {string} layerId 图层ID,默认使用当前活动图层 + */ + async exportLayerToImage(layerId = null) { + const targetLayerId = layerId || this.activeLayerId.value; + + if (!targetLayerId) { + console.warn($t("没有指定要栅格化的图层")); + return false; + } + + // 查找目标图层 + // const targetLayer = this.getLayerById(targetLayerId); + const { layer: targetLayer } = findLayerRecursively( + this.layers.value, + targetLayerId + ); + + if (!targetLayer) { + console.error($t("图层不存在", { layerId: targetLayerId })); + return false; + } + + // 直接创建和执行导出命令 + const command = new ExportLayerToImageCommand({ + canvas: this.canvas, + layers: this.layers, + layerId: targetLayerId, + activeLayerId: this.activeLayerId, + layerManager: this, + }); + + command.undoable = false; // 导出操作通常不需要撤销 + + // 执行命令 + if (this.commandManager) { + const result = await this.commandManager.execute(command); + if (result) { + console.log(`✅ 成功导出图层: ${targetLayer.name}`); + } + return result; + } else { + const result = await command.execute(); + if (result) { + console.log(`✅ 成功导出图层: ${targetLayer.name}`); + } + return result; + } + } + /** * 更新图层缩略图 * @param {string} layerId 图层ID diff --git a/src/component/Canvas/CanvasEditor/managers/RedGreenModeManager.js b/src/component/Canvas/CanvasEditor/managers/RedGreenModeManager.js index bd5dcd93..0c268471 100644 --- a/src/component/Canvas/CanvasEditor/managers/RedGreenModeManager.js +++ b/src/component/Canvas/CanvasEditor/managers/RedGreenModeManager.js @@ -22,6 +22,7 @@ export class RedGreenModeManager { // 图片URL this.clothingImageUrl = null; this.redGreenImageUrl = null; + this.clothingImageOpts = options.clothingImageOpts || null; // 回调函数 this.onImageGenerated = null; @@ -77,6 +78,7 @@ export class RedGreenModeManager { toolManager: this.toolManager, clothingImageUrl: this.clothingImageUrl, redGreenImageUrl: this.redGreenImageUrl, + clothingImageOpts: this.clothingImageOpts, normalLayerOpacity: this.normalLayerOpacity, onImageGenerated: this.onImageGenerated, }); diff --git a/src/component/Canvas/CanvasEditor/utils/imageHelper.js b/src/component/Canvas/CanvasEditor/utils/imageHelper.js index 1a6a97cd..e74ca7c4 100644 --- a/src/component/Canvas/CanvasEditor/utils/imageHelper.js +++ b/src/component/Canvas/CanvasEditor/utils/imageHelper.js @@ -2192,3 +2192,60 @@ function _getAlternativeMethods(analysis) { return alternatives; } + +/** * 图像模式处理函数 + * 根据不同的图像模式调整图像大小和位置 + * @param {Object} params - 参数对象 + * @param {string} params.imageMode - 图像模式 + * @param {fabric.Image} params.newImage - 新图像对象 + * @param {number} params.canvasWidth - 画布宽度 + * @param {number} params.canvasHeight - 画布高度 + */ +export const imageModeHandler = ({ + imageMode, + newImage, + canvasWidth, + canvasHeight, +}) => { + switch (imageMode) { + case "stretch": + // 拉伸模式 - 填充整个画布 + newImage.scaleToWidth(canvasWidth); + newImage.scaleToHeight(canvasHeight); + break; + case "tile": + // 平铺模式 - 保持原始大小 + newImage.scaleX = 1; + newImage.scaleY = 1; + break; + case "stretchTile": + // 拉伸平铺模式 - 填充整个画布,但保持宽高比 + newImage.scaleToWidth(canvasWidth); + newImage.scaleToHeight(canvasHeight); + break; + case "stretchTileCrop": + // 拉伸平铺并裁剪模式 - 填充整个画布,可能 + // 会裁剪图像以适应画布 + newImage.scaleToWidth(canvasWidth); + newImage.scaleToHeight(canvasHeight); + // 这里可以添加裁剪逻辑,如果需要的话 + // 例如使用fabric.Image.clipPath来裁剪图像 + break; + case "contains": + // 包含模式 - 保证图像在画布内完整显示 + // 既要考虑画布的宽高比,也要考虑图像的宽高比 + // 图片缩放后要保证最长边能完全显示在画布内 + const canvasAspect = canvasWidth / canvasHeight; + const imageAspect = newImage.width / newImage.height; + // 保证图像在画布内完整显示 - 既要考虑画布的宽高比,也要考虑图像的宽高比 + // 图片缩放后要保证最长边能完全显示在画布内 + if (imageAspect > canvasAspect) { + // 图像更宽 + newImage.scaleToWidth(canvasWidth); + } else { + // 图像更高 + newImage.scaleToHeight(canvasHeight); + } + break; + } +};