feat: 1.固定图层缩略图(完成)
2.工具栏新增插槽(完成) 3.loadJSON的元素顺序回发生错误
This commit is contained in:
@@ -33,7 +33,11 @@ export class CreateBackgroundLayerCommand extends Command {
|
||||
const bgObject = this._createBackgroundObject();
|
||||
|
||||
// 将背景对象添加到图层中
|
||||
this.backgroundLayer.fabricObject = bgObject;
|
||||
this.backgroundLayer.fabricObject = bgObject.toObject([
|
||||
"id",
|
||||
"layerId",
|
||||
"type",
|
||||
]);
|
||||
|
||||
// 添加图层到最底部
|
||||
this.layers.value.push(this.backgroundLayer);
|
||||
|
||||
@@ -3581,7 +3581,11 @@ export class ChangeFixedImageCommand extends Command {
|
||||
restoredImage.setCoords();
|
||||
|
||||
// 更新引用
|
||||
this.targetLayer.fabricObject = restoredImage;
|
||||
this.targetLayer.fabricObject = restoredImage.toObject([
|
||||
"id",
|
||||
"layerId",
|
||||
"type",
|
||||
]);
|
||||
this.layerManager.updateLayerObject(
|
||||
this.targetLayer.id,
|
||||
restoredImage
|
||||
|
||||
@@ -358,7 +358,7 @@ export class BatchInitializeRedGreenModeCommand extends Command {
|
||||
|
||||
this.canvas.add(object);
|
||||
this.canvas.sendToBack(object);
|
||||
backgroundLayer.fabricObject = object;
|
||||
backgroundLayer.fabricObject = object.toObject(["id", "layerId", "type"]);
|
||||
} else {
|
||||
// 更新现有背景对象大小
|
||||
object.set({
|
||||
|
||||
@@ -18,6 +18,10 @@ const props = defineProps({
|
||||
activeLayerId: String,
|
||||
activeElementId: String,
|
||||
thumbnailManager: Object, // 添加缩略图管理器属性
|
||||
showFixedLayer: {
|
||||
type: Boolean,
|
||||
default: false, // 默认不显示固定层
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -74,10 +78,11 @@ const sortableRootLayers = computed(() => {
|
||||
// 计算属性:不可排序的固定图层(背景层和固定层)
|
||||
const fixedLayers = computed(() => {
|
||||
if (!layers) return [];
|
||||
return layers.value.filter(
|
||||
// (layer) => !layer.parentId && (layer.isFixed || layer.isBackground)
|
||||
(layer) => !layer.parentId && layer.isBackground // 只显示背景层,不显示固定层 - 固定层用来做红绿图模式 和 放模特
|
||||
);
|
||||
return layers.value.filter((layer) => {
|
||||
if (props.showFixedLayer)
|
||||
return !layer.parentId && (layer.isFixed || layer.isBackground);
|
||||
return !layer.parentId && layer.isBackground; // 只显示背景层,不显示固定层 - 固定层用来做红绿图模式 和 放模特
|
||||
});
|
||||
});
|
||||
|
||||
// 计算属性:获取当前选中的图层
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, inject, computed, onMounted, onUnmounted } from "vue";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import ToolButton from "../../ExistsImageList/ToolButton.vue";
|
||||
|
||||
const emit = defineEmits([
|
||||
"tool-selected",
|
||||
@@ -291,32 +292,30 @@ onUnmounted(() => {
|
||||
// 移除键盘事件监听
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 处理工具按钮点击
|
||||
const handleToolClick = (tool) => {
|
||||
tool.action();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tools-sidebar">
|
||||
<div
|
||||
<ToolButton
|
||||
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>
|
||||
:tool="tool"
|
||||
:active-tool="activeTool"
|
||||
:can-undo="canUndo"
|
||||
:can-redo="canRedo"
|
||||
@click="handleToolClick"
|
||||
/>
|
||||
|
||||
<!-- 自定义工具栏按钮插槽 -->
|
||||
<slot
|
||||
name="customTools"
|
||||
:tool-button-props="{ activeTool, canUndo, canRedo }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -333,67 +332,6 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false, // 是否允许擦除背景图层
|
||||
},
|
||||
|
||||
showFixedLayer: {
|
||||
type: Boolean,
|
||||
default: false, // 是否显示固定图层
|
||||
},
|
||||
});
|
||||
|
||||
// 引用和状态
|
||||
@@ -676,6 +681,7 @@ const changeCanvas = (command) => {
|
||||
// 提供外部ref实例方法
|
||||
defineExpose({
|
||||
layers, // 图层数据
|
||||
activeTool, // 当前选中的工具
|
||||
getCanvasManager: () => canvasManager, // 获取画布管理器实例
|
||||
// type : isBackground isFixed flag: 是否可擦除图层
|
||||
setFixedLayerErasable: ({ type = "isFixed", flag = false }) => {
|
||||
@@ -862,7 +868,12 @@ defineExpose({
|
||||
@zoom-in="zoomIn"
|
||||
@zoom-out="zoomOut"
|
||||
@undo-redo-status-changed="changeCanvas"
|
||||
/>
|
||||
>
|
||||
<!-- 扩展插槽 -->
|
||||
<template #customTools="{ toolButtonProps }">
|
||||
<slot name="customTools" :tool-button-props="toolButtonProps" />
|
||||
</template>
|
||||
</ToolsSidebar>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -934,6 +945,7 @@ defineExpose({
|
||||
:activeLayerId="activeLayerId"
|
||||
:activeElementId="activeElementId"
|
||||
:thumbnailManager="canvasManager.thumbnailManager"
|
||||
:showFixedLayer="showFixedLayer"
|
||||
@add-layer="addLayer"
|
||||
@set-active-layer="setActiveLayer"
|
||||
@toggle-layer-visibility="toggleLayerVisibility"
|
||||
|
||||
@@ -348,14 +348,18 @@ export class CanvasManager {
|
||||
}
|
||||
}
|
||||
|
||||
setCanvasSize(width, height) {
|
||||
async setCanvasSize(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.canvas.setWidth(width);
|
||||
this.canvas.setHeight(height);
|
||||
|
||||
// 重置视图变换以确保元素位置正确
|
||||
this._resetViewportTransform();
|
||||
// this._resetViewportTransform();
|
||||
if (this.canvas.getZoom() !== 1 || this.canvas.viewportTransform[0] !== 1) {
|
||||
this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
await this.resetZoom();
|
||||
}
|
||||
|
||||
// 居中所有画布元素,包括背景层和其他元素
|
||||
this.centerAllObjects();
|
||||
@@ -964,7 +968,6 @@ export class CanvasManager {
|
||||
this.layers.value,
|
||||
this.canvas.getObjects()
|
||||
);
|
||||
|
||||
// 验证图层关联关系 - 稳定后可以注释
|
||||
const isValidate = validateLayerAssociations(
|
||||
this.layers.value,
|
||||
@@ -972,6 +975,9 @@ export class CanvasManager {
|
||||
);
|
||||
|
||||
console.log("图层关联验证结果:", isValidate);
|
||||
// 排序
|
||||
// 使用LayerSort工具重新排列画布对象(如果可用)
|
||||
await this?.layerManager?.layerSort?.rearrangeObjects();
|
||||
|
||||
this.layerManager.activeLayerId.value =
|
||||
this.layers.value[0]?.id || parsedJson?.activeLayerId || null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fabric } from "fabric-with-all";
|
||||
import { findObjectById } from "../utils/helper";
|
||||
import { createRasterizedImage } from "../utils/SelectionToImage";
|
||||
import { createRasterizedImage } from "../utils/selectionToImage";
|
||||
|
||||
/**
|
||||
* 图片导出管理器
|
||||
|
||||
@@ -1390,136 +1390,6 @@ export class LayerManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入图层数据
|
||||
* @param {Object} data 图层数据对象
|
||||
* @returns {boolean} 是否导入成功
|
||||
*/
|
||||
importLayersData(data) {
|
||||
if (!data || !data.layers || !Array.isArray(data.layers)) {
|
||||
console.error("无效的图层数据");
|
||||
return false;
|
||||
}
|
||||
|
||||
const fabric = window.fabric;
|
||||
if (!fabric) {
|
||||
console.error("未找到fabric库");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 清除画布
|
||||
this.canvas.clear();
|
||||
|
||||
// 清空图层列表
|
||||
this.layers.value = [];
|
||||
|
||||
// 设置画布尺寸
|
||||
if (data.canvasWidth && data.canvasHeight) {
|
||||
this.canvasWidth = data.canvasWidth;
|
||||
this.canvasHeight = data.canvasHeight;
|
||||
this.canvas.setWidth(data.canvasWidth);
|
||||
this.canvas.setHeight(data.canvasHeight);
|
||||
}
|
||||
|
||||
// 设置背景颜色
|
||||
if (data.backgroundColor) {
|
||||
this.backgroundColor = data.backgroundColor;
|
||||
}
|
||||
|
||||
// 设置编辑模式
|
||||
if (data.editorMode) {
|
||||
this.editorMode = data.editorMode;
|
||||
}
|
||||
|
||||
// 导入图层
|
||||
const promises = data.layers.map((layerData) => {
|
||||
return new Promise((resolve) => {
|
||||
const newLayer = {
|
||||
...layerData,
|
||||
fabricObjects: [],
|
||||
children: layerData.children || [],
|
||||
};
|
||||
|
||||
// 如果有序列化的对象,恢复它们
|
||||
if (
|
||||
layerData.serializedObjects &&
|
||||
Array.isArray(layerData.serializedObjects)
|
||||
) {
|
||||
fabric.util.enlivenObjects(layerData.serializedObjects, (objects) => {
|
||||
objects.forEach((obj) => {
|
||||
// 关联到图层
|
||||
obj.layerId = newLayer.id;
|
||||
obj.layerName = newLayer.name;
|
||||
|
||||
// 添加到画布
|
||||
this.canvas.add(obj);
|
||||
|
||||
// 添加到图层
|
||||
newLayer.fabricObjects.push(obj);
|
||||
});
|
||||
|
||||
resolve(newLayer);
|
||||
});
|
||||
} else if (
|
||||
layerData.isBackground &&
|
||||
layerData.serializedBackgroundObject
|
||||
) {
|
||||
// 恢复背景对象
|
||||
fabric.util.enlivenObjects(
|
||||
[layerData.serializedBackgroundObject],
|
||||
([bgObject]) => {
|
||||
if (bgObject) {
|
||||
bgObject.layerId = newLayer.id;
|
||||
bgObject.layerName = newLayer.name;
|
||||
this.canvas.add(bgObject);
|
||||
newLayer.fabricObject = bgObject;
|
||||
}
|
||||
|
||||
resolve(newLayer);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
resolve(newLayer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 等待所有图层加载完成
|
||||
Promise.all(promises).then((layers) => {
|
||||
// 更新图层列表
|
||||
this.layers.value = layers;
|
||||
|
||||
// 设置活动图层
|
||||
if (data.activeLayerId) {
|
||||
const activeLayer = layers.find(
|
||||
(layer) => layer.id === data.activeLayerId
|
||||
);
|
||||
if (activeLayer && !activeLayer.isBackground && !activeLayer.locked) {
|
||||
this.activeLayerId.value = data.activeLayerId;
|
||||
} else {
|
||||
// 查找第一个非背景、非锁定的图层
|
||||
const firstNormalLayer = layers.find(
|
||||
(layer) => !layer.isBackground && !layer.locked
|
||||
);
|
||||
if (firstNormalLayer) {
|
||||
this.activeLayerId.value = firstNormalLayer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保至少有一个背景图层和一个普通图层
|
||||
this.initializeLayers();
|
||||
|
||||
// 更新对象交互性
|
||||
this.updateLayersObjectsInteractivity();
|
||||
|
||||
// 重新排列对象
|
||||
this._rearrangeObjects();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制图层数据到剪贴板
|
||||
* @param {string} layerId 要复制的图层ID
|
||||
|
||||
@@ -12,6 +12,8 @@ export const createCanvas = (elementId, options = {}) => {
|
||||
enableRetinaScaling: true,
|
||||
preserveObjectStacking: true, // 保持对象堆叠顺序
|
||||
// skipOffscreen: true, // 跳过离屏渲染
|
||||
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
|
||||
imageSmoothingQuality: "high", // 设置高质量图像平滑
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -24,6 +26,9 @@ export const createCanvas = (elementId, options = {}) => {
|
||||
export const createStaticCanvas = (elementId, options = {}) => {
|
||||
const canvas = new fabric.StaticCanvas(elementId, {
|
||||
enableRetinaScaling: canvasConfig.enableRetinaScaling,
|
||||
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
|
||||
imageSmoothingQuality: "high", // 设置高质量图像平滑
|
||||
skipOffscreen: false, // 不跳过离屏渲染
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ export function buildLayerAssociations(layer, canvasObjects) {
|
||||
*/
|
||||
export function restoreObjectLayerAssociations(layers, canvasObjects) {
|
||||
if (!layers || !canvasObjects || !isArray(canvasObjects)) return;
|
||||
|
||||
layers.forEach((layer) => {
|
||||
buildLayerAssociations(layer, canvasObjects);
|
||||
// 处理子图层
|
||||
@@ -242,7 +241,11 @@ export function restoreLayers(simplifiedLayers, canvasObjects) {
|
||||
if (fabricObj) {
|
||||
fabricObj.layerId = layer.id;
|
||||
fabricObj.layerName = layer.name;
|
||||
restoredLayer.fabricObject = fabricObj;
|
||||
restoredLayer.fabricObject = fabricObj.toObject([
|
||||
"id",
|
||||
"layerId",
|
||||
"type",
|
||||
]);
|
||||
} else {
|
||||
restoredLayer.fabricObject = null;
|
||||
}
|
||||
@@ -258,7 +261,7 @@ export function restoreLayers(simplifiedLayers, canvasObjects) {
|
||||
if (fabricObj) {
|
||||
fabricObj.layerId = layer.id;
|
||||
fabricObj.layerName = layer.name;
|
||||
return fabricObj;
|
||||
return fabricObj.toObject(["id", "layerId", "type"]);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 栅格化帮助
|
||||
import { fabric } from "fabric-with-all";
|
||||
import { createStaticCanvas } from "./canvasFactory";
|
||||
/**
|
||||
* 创建栅格化图像
|
||||
* 使用增强版栅格化方法,不受原始画布变换影响
|
||||
* 使用组对象方式,避免边界计算误差
|
||||
* @returns {Promise<fabric.Image>} 栅格化后的图像对象
|
||||
* @private
|
||||
*/
|
||||
@@ -11,7 +12,7 @@ export const createRasterizedImage = async ({
|
||||
fabricObjects = [], // 要栅格化的对象列表 - 按顺序 必填
|
||||
maskObject = null, // 用于裁剪的对象 - 可选
|
||||
trimWhitespace = true, // 是否裁剪空白区域
|
||||
trimPadding = 1, // 裁剪边距
|
||||
trimPadding = 0, // 裁剪边距
|
||||
quality = 1.0, // 图像质量
|
||||
format = "png", // 图像格式
|
||||
scaleFactor = 2, // 高清倍数 - 默认是画布的高清倍数
|
||||
@@ -33,22 +34,12 @@ export const createRasterizedImage = async ({
|
||||
currentZoom
|
||||
);
|
||||
|
||||
scaleFactor = Math.min(scaleFactor, 3); // 最大不能大于3
|
||||
|
||||
console.log(`高清倍数: ${scaleFactor}, 当前缩放: ${currentZoom}`);
|
||||
|
||||
// 计算绝对边界框(原始尺寸)和相对边界框(当前缩放后的尺寸)
|
||||
const { absoluteBounds, relativeBounds } = calculateBounds(fabricObjects);
|
||||
|
||||
console.log("📏 绝对边界框:", absoluteBounds);
|
||||
console.log("📏 相对边界框:", relativeBounds);
|
||||
|
||||
// 使用绝对边界框创建高质量的离屏渲染
|
||||
const rasterizedImage = await createOffscreenRasterization({
|
||||
// 使用组对象方式创建栅格化图像
|
||||
const rasterizedImage = await createRasterizedImageWithGroup({
|
||||
canvas,
|
||||
objects: fabricObjects,
|
||||
absoluteBounds,
|
||||
relativeBounds,
|
||||
scaleFactor,
|
||||
maskObject,
|
||||
trimWhitespace,
|
||||
@@ -80,8 +71,6 @@ export const createRasterizedImage = async ({
|
||||
type: "rasterized",
|
||||
rasterizedAt: new Date().toISOString(),
|
||||
objectCount: fabricObjects.length,
|
||||
absoluteBounds,
|
||||
relativeBounds,
|
||||
originalZoom: currentZoom,
|
||||
},
|
||||
});
|
||||
@@ -97,84 +86,13 @@ export const createRasterizedImage = async ({
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算对象的绝对边界框和相对边界框
|
||||
* @param {Array} fabricObjects fabric对象数组
|
||||
* @returns {Object} 包含绝对边界框和相对边界框的对象
|
||||
*/
|
||||
const calculateBounds = (fabricObjects) => {
|
||||
if (fabricObjects.length === 0) {
|
||||
console.warn("⚠️ 没有对象,无法计算边界框");
|
||||
return { absoluteBounds: null, relativeBounds: null };
|
||||
}
|
||||
|
||||
let absoluteBounds = null;
|
||||
let relativeBounds = null;
|
||||
|
||||
fabricObjects.forEach((obj, index) => {
|
||||
// 获取相对边界框(考虑画布缩放和平移)
|
||||
const relativeBound = obj.getBoundingRect();
|
||||
// 获取绝对边界框(原始大小和位置)
|
||||
const absoluteBound = obj.getBoundingRect(true, true);
|
||||
|
||||
console.log(`对象 ${obj.id || index} 边界框比较:`, {
|
||||
relative: relativeBound,
|
||||
absolute: absoluteBound,
|
||||
scaleX: obj.scaleX,
|
||||
scaleY: obj.scaleY,
|
||||
});
|
||||
|
||||
// 计算绝对边界框的累积范围
|
||||
if (!absoluteBounds) {
|
||||
absoluteBounds = { ...absoluteBound };
|
||||
} else {
|
||||
const right = Math.max(
|
||||
absoluteBounds.left + absoluteBounds.width,
|
||||
absoluteBound.left + absoluteBound.width
|
||||
);
|
||||
const bottom = Math.max(
|
||||
absoluteBounds.top + absoluteBounds.height,
|
||||
absoluteBound.top + absoluteBound.height
|
||||
);
|
||||
|
||||
absoluteBounds.left = Math.min(absoluteBounds.left, absoluteBound.left);
|
||||
absoluteBounds.top = Math.min(absoluteBounds.top, absoluteBound.top);
|
||||
absoluteBounds.width = right - absoluteBounds.left;
|
||||
absoluteBounds.height = bottom - absoluteBounds.top;
|
||||
}
|
||||
|
||||
// 计算相对边界框的累积范围
|
||||
if (!relativeBounds) {
|
||||
relativeBounds = { ...relativeBound };
|
||||
} else {
|
||||
const right = Math.max(
|
||||
relativeBounds.left + relativeBounds.width,
|
||||
relativeBound.left + relativeBound.width
|
||||
);
|
||||
const bottom = Math.max(
|
||||
relativeBounds.top + relativeBounds.height,
|
||||
relativeBound.top + relativeBound.height
|
||||
);
|
||||
|
||||
relativeBounds.left = Math.min(relativeBounds.left, relativeBound.left);
|
||||
relativeBounds.top = Math.min(relativeBounds.top, relativeBound.top);
|
||||
relativeBounds.width = right - relativeBounds.left;
|
||||
relativeBounds.height = bottom - relativeBounds.top;
|
||||
}
|
||||
});
|
||||
|
||||
return { absoluteBounds, relativeBounds };
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建离屏栅格化渲染
|
||||
* 使用组对象方式创建栅格化图像
|
||||
* @param {Object} options 渲染选项
|
||||
* @returns {Promise<fabric.Image>} 栅格化后的图像对象
|
||||
*/
|
||||
const createOffscreenRasterization = async ({
|
||||
const createRasterizedImageWithGroup = async ({
|
||||
canvas,
|
||||
objects,
|
||||
absoluteBounds,
|
||||
relativeBounds,
|
||||
scaleFactor,
|
||||
maskObject,
|
||||
trimWhitespace,
|
||||
@@ -185,49 +103,71 @@ const createOffscreenRasterization = async ({
|
||||
isReturenDataURL,
|
||||
}) => {
|
||||
try {
|
||||
// 创建离屏画布,使用绝对尺寸以保证高质量
|
||||
const offscreenCanvas = new fabric.Canvas();
|
||||
// 创建离屏画布
|
||||
const offscreenCanvas = createStaticCanvas();
|
||||
|
||||
// 设置离屏画布尺寸为绝对边界框大小,并应用高清倍数
|
||||
const canvasWidth = Math.ceil(absoluteBounds.width * scaleFactor);
|
||||
const canvasHeight = Math.ceil(absoluteBounds.height * scaleFactor);
|
||||
// 克隆所有对象
|
||||
const clonedObjects = [];
|
||||
for (const obj of objects) {
|
||||
const clonedObj = await cloneObjectAsync(obj);
|
||||
clonedObj.set({
|
||||
select: false,
|
||||
evented: false,
|
||||
hasControls: false,
|
||||
});
|
||||
clonedObjects.push(clonedObj);
|
||||
}
|
||||
|
||||
// 创建组对象
|
||||
const group = new fabric.Group(clonedObjects, {
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
});
|
||||
|
||||
// 获取组的绝对边界框
|
||||
const groupBounds = group.getBoundingRect(true, true);
|
||||
console.log("📏 组边界框:", groupBounds);
|
||||
|
||||
// 设置离屏画布尺寸,使用组的边界大小
|
||||
const canvasWidth = Math.ceil(groupBounds.width * scaleFactor);
|
||||
const canvasHeight = Math.ceil(groupBounds.height * scaleFactor);
|
||||
|
||||
offscreenCanvas.setDimensions({
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
hasControls: false,
|
||||
});
|
||||
|
||||
// 设置离屏画布的缩放,确保对象以原始尺寸渲染
|
||||
// offscreenCanvas.setZoom(scaleFactor);
|
||||
|
||||
console.log(
|
||||
`🎨 离屏画布尺寸: ${canvasWidth}x${canvasHeight}, 缩放: ${scaleFactor}`
|
||||
);
|
||||
|
||||
// 克隆对象到离屏画布
|
||||
const clonedObjects = [];
|
||||
for (const obj of objects) {
|
||||
const clonedObj = await cloneObjectAsync(obj);
|
||||
// 调整组的位置,让它位于画布的左上角
|
||||
group.set({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
|
||||
// 调整对象位置,相对于绝对边界框的左上角
|
||||
// const absoluteObjBounds = obj.getBoundingRect(true, true);
|
||||
clonedObj.set({
|
||||
left: clonedObj.left - absoluteBounds.left,
|
||||
top: clonedObj.top - absoluteBounds.top,
|
||||
});
|
||||
// 取消对象激活
|
||||
group.set({
|
||||
selectable: false, // 禁用组的选择
|
||||
evented: false, // 禁用组的事件
|
||||
});
|
||||
|
||||
clonedObjects.push(clonedObj);
|
||||
offscreenCanvas.add(clonedObj);
|
||||
// 将组添加到离屏画布
|
||||
offscreenCanvas.add(group);
|
||||
|
||||
// 设置离屏画布的缩放
|
||||
offscreenCanvas.setZoom(scaleFactor);
|
||||
|
||||
// 如果有遮罩对象,应用遮罩
|
||||
if (maskObject) {
|
||||
await applyMaskToCanvas(offscreenCanvas, maskObject, groupBounds);
|
||||
}
|
||||
|
||||
// 渲染离屏画布
|
||||
offscreenCanvas.renderAll();
|
||||
|
||||
// 如果有遮罩对象,应用遮罩
|
||||
if (maskObject) {
|
||||
await applyMaskToCanvas(offscreenCanvas, maskObject, absoluteBounds);
|
||||
}
|
||||
|
||||
// 生成图像数据
|
||||
const dataURL = offscreenCanvas.toDataURL({
|
||||
format,
|
||||
@@ -236,22 +176,30 @@ const createOffscreenRasterization = async ({
|
||||
});
|
||||
|
||||
if (isReturenDataURL) {
|
||||
// 清理离屏画布
|
||||
offscreenCanvas.dispose();
|
||||
return dataURL; // 如果需要返回DataURL
|
||||
}
|
||||
|
||||
// 清理离屏画布
|
||||
offscreenCanvas.dispose();
|
||||
|
||||
// 创建fabric.Image对象
|
||||
const fabricImage = await createFabricImageFromDataURL(dataURL);
|
||||
|
||||
// // 应用变换到fabric图像
|
||||
// 设置图像的位置和缩放,使其与原始组的位置和大小匹配
|
||||
fabricImage.set({
|
||||
...absoluteBounds,
|
||||
scaleX: 1 / scaleFactor, // 由于我们生成的图像是高清版本,需要缩放回原始大小
|
||||
scaleY: 1 / scaleFactor,
|
||||
left: groupBounds.left + groupBounds.width / 2, // 设置为组中心点
|
||||
top: groupBounds.top + groupBounds.height / 2, // 设置为组中心点
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
});
|
||||
|
||||
return fabricImage;
|
||||
} catch (error) {
|
||||
console.error("离屏栅格化失败:", error);
|
||||
console.error("组对象栅格化失败:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -290,37 +238,6 @@ const createFabricImageFromDataURL = (dataURL) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算栅格化图像在当前画布上的正确变换
|
||||
* @param {Object} params 计算参数
|
||||
* @returns {Object} 变换属性对象
|
||||
*/
|
||||
const calculateImageTransform = ({
|
||||
absoluteBounds,
|
||||
relativeBounds,
|
||||
currentZoom,
|
||||
scaleFactor,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
}) => {
|
||||
// 计算缩放比例:相对尺寸 / 绝对尺寸
|
||||
const scaleX = relativeBounds.width / absoluteBounds.width;
|
||||
const scaleY = relativeBounds.height / absoluteBounds.height;
|
||||
|
||||
// 由于我们生成的图像是基于绝对尺寸的高清版本,需要考虑scaleFactor
|
||||
const finalScaleX = scaleX / scaleFactor;
|
||||
const finalScaleY = scaleY / scaleFactor;
|
||||
|
||||
return {
|
||||
left: relativeBounds.left + relativeBounds.width / 2, // 设置为中心点
|
||||
top: relativeBounds.top + relativeBounds.height / 2, // 设置为中心点
|
||||
// scaleX: finalScaleX,
|
||||
// scaleY: finalScaleY,
|
||||
originX: "center",
|
||||
originY: "center",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用遮罩到画布(如果需要)
|
||||
* @param {fabric.Canvas} canvas 目标画布
|
||||
@@ -333,7 +250,26 @@ const applyMaskToCanvas = async (canvas, maskObject, bounds) => {
|
||||
console.log("应用遮罩功能待实现");
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取对象组的边界框
|
||||
* @param {Array} fabricObjects fabric对象数组
|
||||
* @returns {Object} 边界框信息
|
||||
*/
|
||||
export const getObjectsBounds = (fabricObjects) => {
|
||||
const { absoluteBounds } = calculateBounds(fabricObjects);
|
||||
return absoluteBounds;
|
||||
if (fabricObjects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建临时组来获取准确的边界框
|
||||
const tempGroup = new fabric.Group([...fabricObjects], {
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
});
|
||||
|
||||
const bounds = tempGroup.getBoundingRect(true, true);
|
||||
|
||||
// 清理临时组
|
||||
tempGroup.destroy();
|
||||
|
||||
return bounds;
|
||||
};
|
||||
|
||||
131
src/component/Canvas/ExistsImageList/ToolButton.vue
Normal file
131
src/component/Canvas/ExistsImageList/ToolButton.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
tool: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeTool: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
canUndo: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
canRedo: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
// 计算按钮是否激活
|
||||
const isActive = computed(() => {
|
||||
return (
|
||||
props.tool.id === props.activeTool ||
|
||||
props.tool.id === props.activeTool.toLowerCase() ||
|
||||
props.tool?.activeList?.includes(props.activeTool)
|
||||
);
|
||||
});
|
||||
|
||||
// 计算按钮是否禁用
|
||||
const isDisabled = computed(() => {
|
||||
return (
|
||||
(props.tool.id === "undo" && !props.canUndo) ||
|
||||
(props.tool.id === "redo" && !props.canRedo)
|
||||
);
|
||||
});
|
||||
|
||||
// 处理按钮点击
|
||||
const handleClick = () => {
|
||||
if (isDisabled.value) return;
|
||||
emit("click", props.tool);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'tool-btn',
|
||||
tool.class,
|
||||
{
|
||||
active: isActive,
|
||||
disabled: isDisabled,
|
||||
},
|
||||
]"
|
||||
:style="tool.style"
|
||||
@click="handleClick"
|
||||
>
|
||||
<SvgIcon :name="tool.icon.name" :size="tool.icon.size"></SvgIcon>
|
||||
<div class="tool-tooltip">{{ t(tool.title) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -3,25 +3,14 @@ import { ref } from "vue";
|
||||
import CanvasEditor from "./CanvasEditor/index.vue";
|
||||
import RedGreenModeExample from "./RedGreenModeExample.vue";
|
||||
import ExistsImageList from "@/component/Canvas/ExistsImageList/index.vue";
|
||||
import ToolButton from "@/component/Canvas/ExistsImageList/ToolButton.vue";
|
||||
|
||||
// 当前显示的组件
|
||||
const canvasEditor = ref();
|
||||
const currentView = ref("canvasEditor"); // 默认显示红绿图示例
|
||||
const currentView = ref("canvasEditor"); // 默认显示红绿图示例 canvasEditor redGreenExample
|
||||
|
||||
const clothingImageUrl = "/src/assets/work/3.PNG";
|
||||
|
||||
// 切换视图
|
||||
function switchView(view) {
|
||||
currentView.value = view;
|
||||
}
|
||||
|
||||
// 定义编辑器配置
|
||||
const editorConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
backgroundColor: "#f8f8f8",
|
||||
};
|
||||
|
||||
const imageData = [
|
||||
{
|
||||
name: "风景照片",
|
||||
@@ -62,70 +51,118 @@ const handleImageSelect = (selectedImage) => {
|
||||
// selectedImage 包含:url, name, categoryName, categoryType, originalItem
|
||||
};
|
||||
|
||||
const getJSON = () => {
|
||||
// 切换视图
|
||||
function switchView(view) {
|
||||
currentView.value = view;
|
||||
}
|
||||
|
||||
// 定义编辑器配置
|
||||
const editorConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
backgroundColor: "#ffffff", // 画布背景色
|
||||
};
|
||||
|
||||
const exportImage = () => {
|
||||
if (canvasEditor.value) {
|
||||
const json = canvasEditor.value.getJSON();
|
||||
console.log("获取的JSON数据:", json);
|
||||
localStorage.setItem("redGreenModeJSON", json);
|
||||
canvasEditor.value.exportImage({
|
||||
isContainFixed: true, // 是否导出底图
|
||||
isContainBg: false, // 是否导出背景
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadJSON = () => {
|
||||
if (canvasEditor.value) {
|
||||
const currentJSON = localStorage.getItem("redGreenModeJSON");
|
||||
canvasEditor.value.loadJSON(currentJSON);
|
||||
console.log("加载的JSON数据:", currentJSON);
|
||||
const changeCanvas = (command) => {
|
||||
console.log(command);
|
||||
};
|
||||
|
||||
const loadImageUrlToLayer = async () => {
|
||||
try {
|
||||
const imageUrl =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABrQAAAZNCAYAAACENbGaAAAAAXNSR0IArs4c6QAAIABJREFUeF7s3Yt208YaBtCflkuhhXLv+z/e4X6HQiFnfbYmTIwDsRM7srS91ixJY8WW9kjjVh8jXS";
|
||||
const layerId = await canvasEditor?.value?.addImageToLayer?.(imageUrl);
|
||||
console.log("新图层ID:", layerId);
|
||||
} catch (error) {
|
||||
console.error("加载图片到图层失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义工具配置相关
|
||||
const customToolsList = ref([
|
||||
{
|
||||
id: "exportPNG",
|
||||
title: "导出PNG", //导出画布图片
|
||||
action: exportAsPNG,
|
||||
icon: { name: "CExport", size: "24" },
|
||||
class: "export-btn",
|
||||
},
|
||||
{
|
||||
id: "saveCanvas",
|
||||
title: "保存画布",
|
||||
action: saveCanvas,
|
||||
icon: { name: "CBottom", size: "24" },
|
||||
class: "save-btn",
|
||||
},
|
||||
|
||||
{
|
||||
id: "readCanvas",
|
||||
title: "读取画布",
|
||||
action: canvasProject,
|
||||
icon: { name: "CMiniMap", size: "24" },
|
||||
class: "clear-btn",
|
||||
},
|
||||
]);
|
||||
|
||||
// 自定义工具方法
|
||||
function exportAsPNG() {
|
||||
console.log("导出PNG");
|
||||
// 实现导出PNG逻辑
|
||||
exportImage();
|
||||
}
|
||||
|
||||
function saveCanvas() {
|
||||
console.log("保存项目");
|
||||
// 实现保存画布逻辑
|
||||
const json = canvasEditor.value.getJSON();
|
||||
localStorage.setItem("canvasProject", json);
|
||||
}
|
||||
|
||||
function canvasProject() {
|
||||
console.log("读取项目");
|
||||
// 实现读取画布逻辑
|
||||
const json = localStorage.getItem("canvasProject");
|
||||
if (json) {
|
||||
console.log("读取的项目JSON:", JSON.parse(json)?.layers);
|
||||
canvasEditor.value.loadJSON(json);
|
||||
} else {
|
||||
console.warn("没有找到保存的画布项目");
|
||||
}
|
||||
}
|
||||
|
||||
// 处理自定义工具点击
|
||||
const handleCustomToolClick = (tool) => {
|
||||
tool.action();
|
||||
};
|
||||
|
||||
const changeImageUrl = "/src/assets/work/1.PNG";
|
||||
|
||||
const changeFixedImage = () => {
|
||||
canvasEditor.value.changeFixedImage(clothingImageUrl);
|
||||
canvasEditor.value.changeFixedImage(changeImageUrl, {
|
||||
imageMode: "contains", // 设置底图包含在画布内
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- <div class="canvas-wrapper-btns">
|
||||
<div @click="changeFixedImage">更换底图</div>
|
||||
<div @click="getJSON">获取JSON</div>
|
||||
<div @click="loadJSON">读取JSON</div>
|
||||
</div> -->
|
||||
<!-- 内容区域 -->
|
||||
<div class="app-content">
|
||||
<div
|
||||
style="
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
"
|
||||
>
|
||||
<div class="view-switcher">
|
||||
<div
|
||||
class="switch-btn"
|
||||
:class="{ active: currentView === 'redGreenExample' }"
|
||||
@click="switchView('redGreenExample')"
|
||||
>
|
||||
红绿图模式示例
|
||||
</div>
|
||||
<div
|
||||
class="switch-btn"
|
||||
:class="{ active: currentView === 'canvasEditor' }"
|
||||
@click="switchView('canvasEditor')"
|
||||
>
|
||||
普通画布编辑器
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 红绿图模式示例 -->
|
||||
<RedGreenModeExample
|
||||
v-if="currentView === 'redGreenExample'"
|
||||
key="redGreenExample"
|
||||
/>
|
||||
>
|
||||
</RedGreenModeExample>
|
||||
|
||||
<!-- 普通画布编辑器 -->
|
||||
<CanvasEditor
|
||||
@@ -137,10 +174,47 @@ const changeFixedImage = () => {
|
||||
:clothing-image-opts="{
|
||||
imageMode: 'contains', // 设置底图包含在画布内
|
||||
}"
|
||||
@change-canvas="changeCanvas"
|
||||
isFixedErasable
|
||||
showFixedLayer
|
||||
>
|
||||
<template #existsImageList>
|
||||
<ExistsImageList :list="imageData" @select="handleImageSelect" />
|
||||
</template>
|
||||
|
||||
<!-- 使用插槽添加自定义工具栏按钮 -->
|
||||
<template #customTools="{ toolButtonProps }">
|
||||
<!-- 分隔线 -->
|
||||
<div class="tool-separator"></div>
|
||||
|
||||
<!-- 自定义工具按钮 -->
|
||||
<ToolButton
|
||||
v-for="tool in customToolsList"
|
||||
:key="tool.id"
|
||||
:tool="tool"
|
||||
:active-tool="toolButtonProps.activeTool"
|
||||
@click="handleCustomToolClick"
|
||||
/>
|
||||
|
||||
<!-- 也可以直接使用普通的按钮 -->
|
||||
<div class="custom-tool-btn" @click="loadImageUrlToLayer">
|
||||
<span>🎨</span>
|
||||
<div class="tool-tooltip">添加画布图片</div>
|
||||
</div>
|
||||
|
||||
<div class="custom-tool-btn" @click="switchView('redGreenExample')">
|
||||
<span>红</span>
|
||||
<div class="tool-tooltip">红绿图模式</div>
|
||||
</div>
|
||||
<div class="custom-tool-btn" @click="switchView('canvasEditor')">
|
||||
<span>普</span>
|
||||
<div class="tool-tooltip">普通模式</div>
|
||||
</div>
|
||||
<div class="custom-tool-btn" @click="changeFixedImage">
|
||||
<span>更</span>
|
||||
<div class="tool-tooltip">更换底图</div>
|
||||
</div>
|
||||
</template>
|
||||
</CanvasEditor>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,6 +290,65 @@ body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 自定义工具按钮样式 */
|
||||
.tool-separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.custom-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;
|
||||
}
|
||||
|
||||
.custom-tool-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.custom-tool-btn:hover .tool-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 深色模式适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.app-header {
|
||||
|
||||
Reference in New Issue
Block a user