画布增加的新功能

This commit is contained in:
李志鹏
2026-01-02 11:24:11 +08:00
parent 1ae365b1f3
commit f8e4ab8cdb
59 changed files with 4401 additions and 1213 deletions

View File

@@ -54,6 +54,24 @@
<div class="content unicode" style="display: block;">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont">&#xe7a4;</span>
<div class="name">混合模式</div>
<div class="code-name">&amp;#xe7a4;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60f;</span>
<div class="name">更多</div>
<div class="code-name">&amp;#xe60f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe8d7;</span>
<div class="name">平铺</div>
<div class="code-name">&amp;#xe8d7;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe650;</span>
<div class="name">裁剪</div>
@@ -276,9 +294,9 @@
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
src: url('iconfont.woff2?t=1762934152017') format('woff2'),
url('iconfont.woff?t=1762934152017') format('woff'),
url('iconfont.ttf?t=1762934152017') format('truetype');
src: url('iconfont.woff2?t=1766460927921') format('woff2'),
url('iconfont.woff?t=1766460927921') format('woff'),
url('iconfont.ttf?t=1766460927921') format('truetype');
}
</code></pre>
<h3 id="-iconfont-">第二步:定义使用 iconfont 的样式</h3>
@@ -304,6 +322,33 @@
<div class="content font-class">
<ul class="icon_lists dib-box">
<li class="dib">
<span class="icon iconfont icon-hunhemoshi"></span>
<div class="name">
混合模式
</div>
<div class="code-name">.icon-hunhemoshi
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-gengduo"></span>
<div class="name">
更多
</div>
<div class="code-name">.icon-gengduo
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-repeat"></span>
<div class="name">
平铺
</div>
<div class="code-name">.icon-repeat
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-caijian"></span>
<div class="name">
@@ -637,6 +682,30 @@
<div class="content symbol">
<ul class="icon_lists dib-box">
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-hunhemoshi"></use>
</svg>
<div class="name">混合模式</div>
<div class="code-name">#icon-hunhemoshi</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-gengduo"></use>
</svg>
<div class="name">更多</div>
<div class="code-name">#icon-gengduo</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-repeat"></use>
</svg>
<div class="name">平铺</div>
<div class="code-name">#icon-repeat</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-caijian"></use>

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4292253 */
src: url('iconfont.woff2?t=1762934152017') format('woff2'),
url('iconfont.woff?t=1762934152017') format('woff'),
url('iconfont.ttf?t=1762934152017') format('truetype');
src: url('iconfont.woff2?t=1766460927921') format('woff2'),
url('iconfont.woff?t=1766460927921') format('woff'),
url('iconfont.ttf?t=1766460927921') format('truetype');
}
.iconfont {
@@ -13,6 +13,18 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-hunhemoshi:before {
content: "\e7a4";
}
.icon-gengduo:before {
content: "\e60f";
}
.icon-repeat:before {
content: "\e8d7";
}
.icon-caijian:before {
content: "\e650";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,27 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "42604348",
"name": "混合模式",
"font_class": "hunhemoshi",
"unicode": "e7a4",
"unicode_decimal": 59300
},
{
"icon_id": "45981931",
"name": "更多",
"font_class": "gengduo",
"unicode": "e60f",
"unicode_decimal": 58895
},
{
"icon_id": "17005660",
"name": "平铺",
"font_class": "repeat",
"unicode": "e8d7",
"unicode_decimal": 59607
},
{
"icon_id": "22138606",
"name": "裁剪",

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -69,7 +69,7 @@ export class FillGroupLayerBackgroundCommand extends Command {
layer.clippingMask,
this.canvas
);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({ absolutePositioned: true });
this.newFill = new fabric.Rect({
width: clippingMaskFabricObject.width,
@@ -117,7 +117,7 @@ export class FillGroupLayerBackgroundCommand extends Command {
this.parent.clippingMask,
this.canvas
);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({ absolutePositioned: true });
this.newFill = new fabric.Rect({
width: clippingMaskFabricObject.width,
@@ -222,7 +222,7 @@ export class FillGroupLayerBackgroundCommand extends Command {
this.parent?.clippingMask,
this.canvas
);
clipPath.clipPath = null;
// clipPath.clipPath = null;
clipPath.set({ absolutePositioned: true });
this.group.clipPath = clipPath;
}

View File

@@ -56,7 +56,7 @@ export class FillLayerBackgroundCommand extends Command {
layer.clippingMask,
this.canvas
);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
// 设置绝对定位

View File

@@ -0,0 +1,301 @@
import { Command } from "./Command";
import { findLayerRecursively } from "../utils/layerHelper";
import { fabric } from "fabric-with-all";
import {
findObjectById,
generateId,
insertObjectAtZIndex,
removeCanvasObjectByObject,
createPatternTransform,
} from "../utils/helper";
import { restoreFabricObject } from "../utils/objectHelper";
const scale = 0.3;// 默认缩放比例
export const FillSourceToBase64 = (source) => {
if (source?.toDataURL) {
return source.toDataURL?.();
} else if (source?.src) {
return source.src;
}
return source;
}
/**
* 填充图案平铺命令
* 填充重复属性repeat | repeat-x | repeat-y | no-repeat
* 默认缩放比例0.3
* 默认偏移量50%
*/
export class FillRepeatCommand extends Command {
constructor(options) {
super({ name: "填充图案平铺", saveState: true });
this.canvas = options.canvas;
this.layers = options.layers;
this.canvasManager = options.canvasManager;
this.layerManager = options.layerManager;
this.layerId = options.layerId;
this.fillRepeat = options.fillRepeat;
this.oldObjects = null;
this.oldLocked = null;
this.oldIsDisableUnlock = null;
}
async execute() {
const { layer } = findLayerRecursively(this.layers.value, this.layerId);
if (!layer || !layer.fabricObjects || layer.fabricObjects.length === 0) {
console.warn("图层不存在或没有 fabric 对象");
return false;
}
const { object } = findObjectById(this.canvas, layer?.fabricObjects?.[0]?.id);
if (!object || (object.type !== "rect" && object.type !== "image")) {
console.warn("当前对象不能平铺", object.type);
return false;
}
this.oldObjects = object;
const img = await new Promise((resolve, reject) => {
if (object.type === "rect") {
let source = object.fill.source;
resolve(source);
} else if (object.type === "image") {
// resolve(object.getElement());
// fabric.Image.fromURL(
// object.src,
// v => resolve(v),
// { crossOrigin: "anonymous" }
// );
const imgElement = object.getElement();
// 创建透明 Canvas
const tcanvas = document.createElement('canvas');
tcanvas.width = imgElement.width;
tcanvas.height = imgElement.height;
const ctx = tcanvas.getContext('2d');
ctx.clearRect(0, 0, tcanvas.width, tcanvas.height);
ctx.drawImage(imgElement, 0, 0);
resolve(tcanvas);
}
});
const fill_ = {
source: FillSourceToBase64(img),
gapX: 0,
gapY: 0,
};
const bgObject = this.canvasManager.getBackgroundLayerObject();
const pattern = new fabric.Pattern({
source: img,
repeat: this.fillRepeat,
patternTransform: object.fill?.hasOwnProperty("patternTransform") ? object.fill.patternTransform : createPatternTransform(scale, 0),
offsetX: object.fill?.hasOwnProperty("offsetX") ? object.fill.offsetX : bgObject.width / 2, // 水平偏移
offsetY: object.fill?.hasOwnProperty("offsetY") ? object.fill.offsetY : bgObject.height / 2, // 垂直偏移
});
const rect = new fabric.Rect({
id: object.id,
layerId: object.layerId,
layerName: object.layerName,
fill_,
});
layer.fabricObjects = [rect.toObject(["id", "layerId", "layerName"])];
this.oldLocked = layer.locked;
// this.oldIsDisableUnlock = layer.isDisableUnlock;
// layer.isDisableUnlock = true;
if (this.oldObjects.type === "rect") {
rect.set({
width: object.width,
height: object.height,
top: object.top,
left: object.left,
originX: object.originX,
originY: object.originY,
angle: object.angle,
scaleX: object.scaleX,
scaleY: object.scaleY,
flipX: object.flipX,
flipY: object.flipY,
});
} else {
rect.set({
width: bgObject.width,
height: bgObject.height,
top: bgObject.top,
left: bgObject.left,
originX: bgObject.originX,
originY: bgObject.originY,
});
layer.locked = true;
}
rect.set("fill", pattern);
this.canvas.add(rect);
this.canvas.remove(object);
await this.layerManager?.updateLayersObjectsInteractivity();
await this.layerManager?.sortLayersWithTool?.();
await this.canvasManager.thumbnailManager?.generateLayerThumbnail(
this.layerId
);
await this.layerManager.selectLayerObjects(this.layerId);
return true;
}
async undo() {
if (!this.oldObjects) {
console.warn("没有旧对象可恢复");
return false;
}
const { layer } = findLayerRecursively(this.layers.value, this.oldObjects.layerId);
if (!layer || !layer.fabricObjects || layer.fabricObjects.length === 0) {
console.warn("图层不存在或没有 fabric 对象");
return false;
}
const { object } = findObjectById(this.canvas, layer?.fabricObjects?.[0]?.id);
this.canvas.remove(object);
this.canvas.add(this.oldObjects);
layer.fabricObjects = [this.oldObjects.toObject(["id", "layerId", "layerName"])];
layer.locked = this.oldLocked;
// layer.isDisableUnlock = this.oldIsDisableUnlock;
await this.layerManager?.updateLayersObjectsInteractivity();
await this.layerManager?.sortLayersWithTool?.();
this.canvas.renderAll();
this.canvasManager.thumbnailManager?.generateLayerThumbnail(this.layerId);
return true;
}
}
/**
* 填充图案更改参数
*/
export class FillRepeatChangeCommand extends Command {
constructor(options) {
super({ name: "填充图案更改参数", saveState: true });
this.canvas = options.canvas;
this.layers = options.layers;
this.canvasManager = options.canvasManager;
this.layerManager = options.layerManager;
this.layerId = options.layerId;
this.newPattern = options.newPattern;
this.oldPattern = null;
}
async execute() {
const { layer } = findLayerRecursively(this.layers.value, this.layerId);
if (!layer || !layer.fabricObjects || layer.fabricObjects.length === 0) {
console.warn("图层不存在或没有 fabric 对象");
return false;
}
const { object } = findObjectById(this.canvas, layer?.fabricObjects?.[0]?.id);
if (!object || object.type !== "rect") {
console.warn("当前对象不是矩形", object);
return false;
}
this.oldPattern = object.oldPattern || object.get("fill");
delete object.oldPattern;
const oldPattern = { ...this.oldPattern };
delete oldPattern.id;
const pattern = new fabric.Pattern({
...oldPattern,
...this.newPattern,
});
object.set("fill", pattern);
this.canvas.renderAll();
return true;
}
async undo() {
if (!this.oldPattern) {
console.warn("没有旧图案可恢复");
return false;
}
const { layer } = findLayerRecursively(this.layers.value, this.layerId);
if (!layer || !layer.fabricObjects || layer.fabricObjects.length === 0) {
console.warn("图层不存在或没有 fabric 对象");
return false;
}
const { object } = findObjectById(this.canvas, layer?.fabricObjects?.[0]?.id);
if (!object || object.type !== "rect") {
console.warn("当前对象不是矩形", object);
return false;
}
const pattern = new fabric.Pattern({
...this.oldPattern
});
object.set("fill", pattern);
this.canvas.renderAll();
return true;
}
}
/**
* 填充图案更改间隙
*/
export class FillRepeatGapChangeCommand extends Command {
constructor(options) {
super({ name: "填充图案更改间隙", saveState: true });
this.canvas = options.canvas;
this.layers = options.layers;
this.canvasManager = options.canvasManager;
this.layerManager = options.layerManager;
this.layerId = options.layerId;
this.newGapX = options.newGapX;
this.newGapY = options.newGapY;
this.record = !!options.record;
this.oldGapX = null;
this.oldGapY = null;
}
async execute(isUndo = false) {
const { layer } = findLayerRecursively(this.layers.value, this.layerId);
if (!layer || !layer.fabricObjects || layer.fabricObjects.length === 0) {
console.warn("图层不存在或没有 fabric 对象");
return false;
}
const { object } = findObjectById(this.canvas, layer?.fabricObjects?.[0]?.id);
if (!object || object.type !== "rect") {
console.warn("当前对象不是矩形", object);
return false;
}
if (!object.fill_) {
object.fill_ = {
source: FillSourceToBase64(object.fill.source),
gapX: 0,
gapY: 0,
}
}
if (isUndo) {
object.fill_.gapX = this.oldGapX;
object.fill_.gapY = this.oldGapY;
} else {
if (!object.oldFill_ && this.record) {
object.oldFill_ = { ...object.fill_ };
}
this.oldGapX = object.fill_.gapX;
this.oldGapY = object.fill_.gapY;
object.fill_.gapX = this.newGapX;
object.fill_.gapY = this.newGapY;
}
const image = new Image();
image.src = object.fill_.source;
await image.decode();
// 创建透明 Canvas
const tcanvas = document.createElement('canvas');
tcanvas.width = image.width + object.fill_.gapX;
tcanvas.height = image.height + object.fill_.gapY;
const ctx = tcanvas.getContext('2d');
ctx.clearRect(0, 0, tcanvas.width, tcanvas.height);
ctx.drawImage(image, 0, 0);
const fill = object.get("fill");
fill.source = tcanvas;
object.set("fill", new fabric.Pattern(fill));
this.canvas.renderAll();
return true;
}
async undo() {
if (this.oldGapX === null || this.oldGapY === null) {
console.warn("没有旧间隙可恢复");
return false;
}
await this.execute(true);
return true;
}
}

View File

@@ -10,6 +10,7 @@ import { AddObjectToLayerCommand } from "./ObjectLayerCommands";
import { ToolCommand } from "./ToolCommands";
import {
findObjectById,
findObjectByLayerId,
generateId,
getObjectZIndex,
insertObjectAtZIndex,
@@ -19,7 +20,7 @@ import {
} from "../utils/helper";
import { fabric } from "fabric-with-all";
import { restoreFabricObject } from "../utils/objectHelper";
import EventManager from "../utils/event.js";
/**
* 添加图层命令
*/
@@ -36,7 +37,7 @@ export class AddLayerCommand extends Command {
this.insertIndex = options.insertIndex;
this.oldActiveLayerId = null;
this.beforeLayers = [...this.layers.value]; // 备份原图层列表
this.beforeLayers = JSON.stringify(this.layers.value); // 备份原图层列表
this.options = options.options || {};
}
@@ -70,7 +71,7 @@ export class AddLayerCommand extends Command {
undo() {
// 从图层列表删除该图层
this.layers.value = [...this.beforeLayers];
this.layers.value = JSON.parse(this.beforeLayers);
// 恢复原活动图层
this.activeLayerId.value = this.oldActiveLayerId;
@@ -251,12 +252,12 @@ export class PasteLayerCommand extends Command {
(await restoreFabricObject(groupLayer?.clippingMask, this.canvas)) ||
null;
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
absolutePositioned: true,
});
clippingMaskFabricObject.dirty = true;
// clippingMaskFabricObject.dirty = true;
clippingMaskFabricObject.setCoords();
// 添加所有对象到画布
allObjects.forEach((obj) => {
@@ -802,15 +803,23 @@ export class ToggleLayerVisibilityCommand extends Command {
// 切换可见性
this.layer.visible = !this.layer.visible;
const ids = [this.layerId];
const childLayers = this.layer?.children || [];
childLayers.forEach((childLayer) => {
childLayer.visible = this.layer.visible;
ids.push(childLayer.id);
});
// 更新画布上图层对象的可见性
if (this.canvas) {
const layerObjects = this.canvas
.getObjects()
.filter((obj) => obj.layerId === this.layerId);
layerObjects.forEach((obj) => {
obj.visible = this.layer.visible;
});
this.canvas.getObjects().forEach((obj) => {
if (ids.includes(obj.layerId)) {
obj.getObjects?.()?.forEach((item) => {
item.visible = this.layer.visible;
});
obj.visible = this.layer.visible;
}
});
}
// 更新画布上对象的可选择状态
await this.layerManager?.updateLayersObjectsInteractivity();
@@ -868,13 +877,14 @@ export class ToggleChildLayerVisibilityCommand extends Command {
// 更新画布上图层对象的可见性
if (this.canvas) {
const layerObjects = this.canvas
.getObjects()
.filter((obj) => obj.layerId === this.layerId);
layerObjects.forEach((obj) => {
obj.visible = this.childLayer.visible;
});
this.canvas.getObjects().forEach((obj) => {
if (obj.layerId === this.layerId) {
obj.getObjects?.()?.forEach((item) => {
item.visible = this.childLayer.visible;
});
obj.visible = this.childLayer.visible;
}
});
}
// 更新画布上对象的可选择状态
@@ -1007,9 +1017,8 @@ export class LayerLockCommand extends Command {
// 如果是组图层,递归更新所有子图层
if (
layer.type === "group" &&
layer.children &&
Array.isArray(layer.children)
Array.isArray(layer.children) && layer.children.length > 0
) {
layer.children.forEach((child) => {
this._updateLayerLockState(child, locked);
@@ -1108,7 +1117,7 @@ export class SetLayerOpacityCommand extends Command {
this.canvas.renderAll();
}
EventManager.emit("object:opacity:execute", this.layerId, this.opacity);
return true;
}
@@ -1130,6 +1139,7 @@ export class SetLayerOpacityCommand extends Command {
this.canvas.renderAll();
}
}
EventManager.emit("object:opacity:undo", this.layerId, this.opacity);
}
getInfo() {
@@ -1371,7 +1381,7 @@ export class GroupLayersCommand extends Command {
// 备份原图层
this.originalLayers = [...this.layers.value];
// 新组ID
this.groupId =
this.groupId = options.id ||
generateId("group_layer_") ||
`group_layer_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
@@ -4434,3 +4444,87 @@ export class ChildLayerLockCommand extends Command {
};
}
}
/**
* 设置图层混合模式
*/
export class SetLayerCompositeCommand extends Command {
constructor(options) {
super({
name: "设置图层混合模式",
saveState: false,
});
this.canvas = options.canvas;
this.layers = options.layers;
this.layerManager = options.layerManager;
this.layerId = options.layerId;
this.newValue = options.newValue;
this.oldValue = options.oldValue;
}
execute(isUndo = false) {
const { layer } = findLayerRecursively(this.layers.value, this.layerId);
const { object } = findObjectByLayerId(this.canvas, this.layerId);
if (!layer || !object) {
console.error(`图层${this.layerId}不存在`);
return false;
}
// console.log("==========", this.newValue, this.oldValue);
const value = isUndo ? this.oldValue : this.newValue;
layer.blendMode = value;
object.set("globalCompositeOperation", value);
this.canvas.renderAll();
const event = isUndo ? "object:composite:undo" : "object:composite:execute";
EventManager.emit(event, object);
return true;
}
undo() {
return this.execute(true);
}
}
/**
* 设置颜色图层颜色
*/
export class SetColorLayerFillCommand extends Command {
constructor(options) {
super({
name: "设置颜色图层颜色",
saveState: false,
});
this.canvas = options.canvas;
this.layerManager = options.layerManager;
this.object = options.object;
this.layer = this.layerManager?.getLayerById(this.object.layerId);
this.newFill = options.newFill;
this.oldFill = JSON.parse(JSON.stringify(this.object.fill));
}
async execute(isUndo = false) {
if (!this.object) {
console.error(`颜色图层不存在`);
return false;
}
const isVisible = this.layer?.visible;
if(!isVisible && this.layer) this.layer.visible = true;
const gradient = new fabric.Gradient({
type: "linear",
gradientUnits: "percentage",
...(isUndo ? this.oldFill : this.newFill),
});
this.object.setFill(gradient);
this.canvas.renderAll();
await this.canvas?.thumbnailManager?.generateLayerThumbnail?.(
this.object.id
);
if(!isVisible && this.layer) this.layer.visible = false;
this.layerManager?.updateLayersObjectsInteractivity();
return true;
}
undo() {
this.execute(true);
return true;
}
}

View File

@@ -0,0 +1,50 @@
import { Command } from "./Command.js";
/**
* 对象移动命令
* 轻量级命令,只记录对象的移动属性变化(位置)
*/
export class ObjectMoveCommand extends Command {
constructor(options) {
super({
name: options.name || "对象移动",
description: options.description || "移动对象",
saveState: false, // 自己管理状态,避免递归
});
this.canvas = options.canvas;
this.initPos = options.initPos;
this.finalPos = options.finalPos;
}
/**
* 执行命令
*/
async execute() {
this.setObjectsPos(this.finalPos);
return true;
}
/**
* 撤销命令
* 应用初始状态
*/
async undo() {
this.setObjectsPos(this.initPos);
return true;
}
async setObjectsPos(pos) {
const objects = this.canvas.getObjects();
const arr = typeof pos === "object" ? [pos] : pos;
arr.forEach((item) => {
const obj = objects.find((o) => o.id === item.id);
if(obj) {
obj.set({
left: item.left,
top: item.top,
});
}
});
this.canvas.renderAll();
}
}

View File

@@ -234,7 +234,7 @@ export class AddObjectToLayerCommand extends Command {
parent.clippingMask,
this.canvas
);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({ absolutePositioned: true });
this.fabricObject.clipPath = clippingMaskFabricObject;
// 标记为脏对象

View File

@@ -46,13 +46,13 @@ export class RasterizeLayerCommand extends Command {
this.layerId
);
this.layer = layer;
this.parentLayer = parent;
// this.parentLayer = parent;
// 新增:如果有父图层,则栅格化父图层及其所有子图层
if (this.parentLayer) {
this.layer = this.parentLayer;
this.layerId = this.parentLayer.id;
}
// // 新增:如果有父图层,则栅格化父图层及其所有子图层
// if (this.parentLayer) {
// this.layer = this.parentLayer;
// this.layerId = this.parentLayer.id;
// }
this.isGroupLayer = this.layer?.children && this.layer.children.length > 0;
@@ -153,7 +153,7 @@ export class RasterizeLayerCommand extends Command {
});
// 恢复原始图层结构
this.layers.value = [...this.originalLayerStructure];
this.layers.value = JSON.parse(this.originalLayerStructure);
// 恢复原活动图层
this.activeLayerId.value = this.layerId;
@@ -191,7 +191,7 @@ export class RasterizeLayerCommand extends Command {
*/
_saveOriginalLayerStructure() {
// 只保存相关的图层结构,而不是整个图层数组
this.originalLayerStructure = JSON.parse(JSON.stringify(this.layers.value));
this.originalLayerStructure = JSON.stringify(this.layers.value);
}
/**
@@ -517,12 +517,12 @@ export class ExportLayerToImageCommand extends Command {
this.canvas
);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
absolutePositioned: true,
});
clippingMaskFabricObject.dirty = true;
// clippingMaskFabricObject.dirty = true;
clippingMaskFabricObject.setCoords();
}

View File

@@ -2,6 +2,7 @@ import { findObjectById } from "../utils/helper";
import { findLayerRecursively } from "../utils/layerHelper";
import { restoreFabricObject } from "../utils/objectHelper";
import { Command } from "./Command";
import EventManager from "../utils/event.js";
/**
* 对象变换命令
@@ -75,7 +76,7 @@ export class TransformCommand extends Command {
// 触发画布更新
this.canvas.renderAll();
EventManager.emit("object:modified:execute", targetObject);
return true;
}
@@ -113,7 +114,7 @@ export class TransformCommand extends Command {
}, 300);
// 触发画布更新
this.canvas.renderAll();
EventManager.emit("object:modified:undo", targetObject);
return true;
}
@@ -167,7 +168,7 @@ export class TransformCommand extends Command {
);
if (clippingMaskFabricObject) {
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
absolutePositioned: true,
});

View File

@@ -233,7 +233,7 @@ export class UpdateGroupMaskPositionCommand extends Command {
return;
}
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
absolutePositioned: true,
});

View File

@@ -1,5 +1,4 @@
<template>
<!-- 图片列表面板 -->
<div v-if="showPanel" class="crop-image-overlay" @click.self="close">
<div class="crop-image-modal">
<div class="modal-header">
@@ -392,7 +391,7 @@
<style scoped lang="less">
/* 弹窗遮罩层 */
.crop-image-overlay {
position: fixed;
position: absolute;
top: 0;
left: 0;
right: 0;
@@ -420,8 +419,8 @@
.crop-image-modal {
background-color: #fff;
border-radius: 12px;
width: 80%;
height: 80%;
width: 90%;
height: 90%;
overflow: hidden;
display: flex;
flex-direction: column;

View File

@@ -2,7 +2,9 @@
import { ref, nextTick, computed, inject } from "vue";
import { Checkbox } from "ant-design-vue";
import { VueDraggable } from "vue-draggable-plus";
import { isGroupLayer } from "../../utils/layerHelper";
import { isGroupLayer, SpecialLayerId } from "../../utils/layerHelper";
import { fillToCssStyle, palletToFill, fillToPallet } from "../../utils/helper";
import { SetColorLayerFillCommand } from "../../commands/LayerCommands";
import { useI18n } from 'vue-i18n'
const {t} = useI18n()
// 设置组件名称,用于递归渲染
@@ -183,6 +185,9 @@ function handleToggleVisibility() {
}
function handleToggleLock() {
// 禁用解锁的图层不能操作
if (props.layer.isDisableUnlock) return;
if (props.isChild) {
// 子图层需要传递父图层ID - 从父级组件获取
const parentId = props.layer.parentId || findParentLayerId();
@@ -348,6 +353,30 @@ function findParentLayerId() {
console.warn("无法找到图层的父图层:", props.layer.id);
return null;
}
const canvasManager = inject('canvasManager');
const layerObject = computed(() => {
const layer = props.layer;
const id = layer.fabricObject?.id || layer.fabricObjects?.[0]?.id || layer.id;
return canvasManager.getLayerObjectById(id);
});
const palletPanel = inject("palletPanel");
const clickColor = () => {
const fill = layerObject.value.fill;
if (fill) {
const obj = fillToPallet(fill);
console.log("===========:", obj);
palletPanel(obj).then((res) => {
console.log("===========:", res);
const cmd = new SetColorLayerFillCommand({
canvas: canvasManager.canvas,
layerManager: layerManager,
object: layerObject.value,
newFill: palletToFill(res),
});
layerManager.commandManager.execute(cmd);
});
}
}
</script>
<template>
@@ -377,8 +406,8 @@ function findParentLayerId() {
@contextmenu.prevent="handleContextMenu"
>
<!-- 拖拽手柄 -->
<div class="layer-drag-handle" :title="$t('拖拽排序')">
<SvgIcon v-if="!isHidenDragHandle" :name="isChild ? 'CSort' : 'CSort'" :size="32"></SvgIcon>
<div class="layer-drag-handle" :title="$t('拖拽排序')" v-if="!isHidenDragHandle">
<SvgIcon :name="isChild ? 'CSort' : 'CSort'" :size="32"></SvgIcon>
</div>
<!-- 图层头部 -->
@@ -417,9 +446,18 @@ function findParentLayerId() {
/>
</div>
</div>
<!-- 颜色图层按钮 -->
<div
class="layer-color-btn"
v-if="layer.id === SpecialLayerId.COLOR"
@click.stop="clickColor"
:style="{
background: fillToCssStyle(layerObject.fill),
}"
></div>
<!-- 图层操作按钮 -->
<div class="layer-actions" v-if="!(isGroupLayerType && !isChild)">
<div class="layer-actions" >
<!-- 可见性切换 -->
<div
class="visibility-btn"
@@ -434,7 +472,7 @@ function findParentLayerId() {
<span
v-if="layer.locked"
class="status-icon locked"
:class="{ disabled: layer.isBackground || layer.isFixed }"
:class="{ disabled: layer.isBackground || layer.isFixed || layer.isDisableUnlock || layer.isFixedOther }"
:title="$t('锁定')"
@click.stop="handleToggleLock"
>

View File

@@ -81,14 +81,14 @@ const fillColorRef = ref(null);
// 计算属性:可排序的根级图层(排除背景层和固定层)
const sortableRootLayers = computed(() => {
if (!layers) return [];
return layers.value.filter((layer) => !layer.parentId && !layer.isFixed && !layer.isBackground);
return layers.value.filter((layer) => !layer.parentId && !layer.isFixed && !layer.isBackground && !layer.isFixedOther);
});
// 计算属性:不可排序的固定图层(背景层和固定层)
const fixedLayers = computed(() => {
if (!layers) return [];
return layers.value.filter((layer) => {
if (props.showFixedLayer) return !layer.parentId && (layer.isFixed || layer.isBackground);
if (props.showFixedLayer) return !layer.parentId && (layer.isFixed || layer.isBackground || layer.isFixedOther);
return !layer.parentId && layer.isBackground; // 只显示背景层,不显示固定层 - 固定层用来做红绿图模式 和 放模特
});
});
@@ -576,7 +576,7 @@ function handleLayerClick(layer, event) {
if (event.ctrlKey || event.metaKey || event.shiftKey || isMultiSelectMode.value) {
toggleLayerSelection(layer, event);
} else {
lastSelectLayerId.value = layer.id; // 更新最后选中的图层ID
if(!layer.isFixedClipMask) lastSelectLayerId.value = layer.id; // 更新最后选中的图层ID
// 普通点击:进入单选模式
// selectedLayerIds.value = [layer.id];
// isMultiSelectMode.value = false;
@@ -596,7 +596,7 @@ function handleLayerClick(layer, event) {
layerManager?.updateLayersObjectsInteractivity();
}
}
lastSelectedIndex.value = sortableRootLayers.value.findIndex((l) => l.id === layer.id);
if(!layer.isFixedClipMask) lastSelectedIndex.value = sortableRootLayers.value.findIndex((l) => l.id === layer.id);
}
}
@@ -999,7 +999,7 @@ function buildChildLayerContextMenuItems(childLayer) {
{
label: childLayer.locked ? "解锁图层" : "锁定图层",
icon: childLayer.locked ? "CUnLock" : "CLock",
disabled: childLayer.isBackground || childLayer.isFixed,
disabled: childLayer.isBackground || childLayer.isFixed || childLayer.isDisableUnlock,
action: () => toggleChildLayerLock(childLayer.id),
},
// 显示/隐藏
@@ -1633,7 +1633,6 @@ async function moveGroupToGroup(draggedLayer, fromParentId, toParentId, newIndex
@delete-child="deleteChildLayer"
@rename-child="renameChildLayer"
/>
<!-- 固定层背景层和固定层 -->
<div v-if="fixedLayers.length > 0" class="fixed-layers">
<!-- 遍历固定层 -->

View File

@@ -340,6 +340,14 @@
}
}
.layer-color-btn{
width: 30px;
height: 20px;
margin-right: 5px;
border-radius: 2px;
border: 1px solid #000;
}
// 图层操作
.layer-actions {
display: flex;

View File

@@ -384,7 +384,7 @@ async function prepareForLiquify(targetObj) {
}
updateAllParams();
console.log("液化环境准备完成");
console.log("液化环境准备完成",compositeCommand);
}
} catch (error) {
console.error("准备液化环境失败:", error);
@@ -1614,6 +1614,7 @@ function close() {
*/
function startPressTimer() {
if (pressTimer.value) return;
if (currentMode.value === compositeCommand.value.liquifyManager.enhancedManager.modes.PUSH) return;
pressTimer.value = setInterval(() => {
// 计算按压持续时间

View File

@@ -0,0 +1,199 @@
<template>
<!-- 颜色选择器模板 -->
<div v-show="showPanel" class="pallet-overlay" @click.self="close">
<div class="pallet-modal">
<!-- <div class="modal-header">
<h3></h3>
<button class="close-btn" @click="close">&times;</button>
</div> -->
<div class="modal-content">
<pallet
v-if="showPanel"
:selectColor="selectColor"
@selectUplpadColor="selectUplpadColor"
/>
</div>
<div class="modal-footer">
<div class="image-count" @click="close">
{{ $t("Canvas.close") }}
</div>
<div class="image-submit gallery_btn" @click="confirm">
{{ $t("Canvas.confirm") }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
watch,
computed,
defineProps,
onDeactivated,
reactive,
onMounted,
defineExpose,
nextTick,
onUnmounted,
} from "vue";
import pallet from "./pallet.vue";
import { useI18n } from "vue-i18n";
// Props
const props = defineProps([]);
const { t } = useI18n();
var resolveFn: (value: any) => void;
const showPanel = ref(false);
const open = (obj = {}) => {
selectColor.value = JSON.parse(JSON.stringify(obj));
showPanel.value = true;
return new Promise((resolve) => (resolveFn = resolve));
};
const close = () => {
showPanel.value = false;
};
//提交选中的T图片
const confirm = () => {
close();
resolveFn && resolveFn(JSON.parse(JSON.stringify(selectColor.value)));
};
const selectColor = ref({});
const selectUplpadColor = (item: any) => {
selectColor.value = JSON.parse(JSON.stringify(item));
};
defineExpose({
open,
close,
});
</script>
<style scoped lang="less">
/* 弹窗遮罩层 */
.pallet-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 弹窗主体 */
.pallet-modal {
background-color: #fff;
border-radius: 12px;
max-width: 90%;
max-height: 95%;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
animation: modalSlideUp 0.3s ease;
overflow-y: auto;
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 弹窗头部 */
.modal-header {
padding: 16px 20px;
background-color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
h3 {
margin: 0;
font-size: 18px;
color: #333;
font-weight: 600;
}
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
opacity: 0.7;
transition: all 0.2s;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
opacity: 1;
color: #333;
transform: scale(1.1);
}
}
/* 弹窗内容 */
.modal-content {
width: 35rem;
// max-width: 240px;
margin: 10px 20px;
background-color: #fff;
-webkit-overflow-scrolling: touch;
display: flex;
}
/* 弹窗底部 */
.modal-footer {
padding: 10px 20px;
background-color: rgba(255, 255, 255, 0.8);
// border-top: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
> .image-submit {
font-size: 1.2rem;
line-height: 3.5rem;
}
}
.image-count {
font-size: 14px;
color: #666;
font-weight: 500;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,666 @@
<template>
<div class="pallet" ref="palletRef">
<div class="palletColo" @click="openPallet">
<div v-show="!selectColor.gradient" class="palletBackColor" :title="selectColor.name" :style="{'background-color':selectColor.hex}">
{{ selectColor.hex }}
</div>
<div v-show="selectColor.gradient" class="palletBackColor" :style="{'background-image':`linear-gradient(${selectColor.gradient?.angle}deg,${setGradient(selectColor.gradient)})`}">
</div>
</div>
<div class="palletBox">
<div class="color_setting_block" @click.stop>
<Chrome class="chrome_color" v-model="color_"></Chrome>
<div class="color_setting_operateSingle">
<div class="color_setting_btn" :class="{active:!color?.gradient?.gradientShow}">{{ $t('ColorboardUpload.Single') }}</div>
<a-switch :checked="color?.gradient?.gradientShow" @click="setOperate"/>
<div class="color_setting_btn" :class="{active:color?.gradient?.gradientShow}">{{ $t('ColorboardUpload.Gradual') }}</div>
</div>
<div class="color_setting_operate" v-if="color?.gradient?.gradientShow">
<div class="color_setting_operate_item color_setting_operate_control">
<div class="operate_item_box">
<div>{{ $t('ColorboardUpload.Alignment') }}</div>
</div>
<div class="operate_item_box operate_item_angle">
<div class="operate_item_angle_box" @mousedown="mousedownGradientAngle(getMousePosition($event,false))" @touchstart="mousedownGradientAngle(getMousePosition($event,true))">
<div :style="{'transform':`rotate(${color.gradient.angle}deg)`}"></div>
</div>
</div>
<div class="operate_item_box operate_item_delete">
<i class="fi fi-rr-trash" @click="deleteGradientItem"></i>
</div>
</div>
<div class="color_setting_operate_item color_setting_operate_input">
<div class="color_setting_operate_bg" @click="addGradient($event)" :style="{'background-image':color?.gradient?`linear-gradient(90deg,${setGradient(color.gradient)})`:'none'}">
</div>
<div v-for="item,index in color.gradient.gradientList" :key="item" class="color_setting_operate_btn" :class="{'active':index == color.gradient.selectIndex}" :style="{'left':item.left,'background-color':`rgba(${item.rgba.r},${item.rgba.g},${item.rgba.b},${item.rgba.a})`}" @mousedown="mousedownGradient(getMousePosition($event,false),item,index,)" @touchstart="mousedownGradient(getMousePosition($event,true),item,index,)"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent,computed,ref,watch,nextTick,onMounted,onUnmounted,toRefs, reactive} from 'vue'
import { useStore } from "vuex";
import { useI18n } from 'vue-i18n'
import { message,Upload} from 'ant-design-vue';
import { Sketch, Chrome} from '@ans1998/vue3-color'
import { getMousePosition } from "@/tool/mdEvent";
import { rgbaToHex } from "@/tool/util"
import { color } from 'echarts/core';
export default defineComponent({
components:{
Chrome,
},
props:{
selectColor:{
type:Object,
default:()=>{}
},
},
emits:['selectUplpadColor'],
setup(props,{emit}) {
const {t} = useI18n()
const store = useStore();
const palletData = reactive({
palletShow: true,
palletList:[],
color_:{} as any,
color:{} as any,
updataSelectColorTime:null as any,
gradient:{
gradientList:[
{
rgba:{
r:117,
g:119,
b:255,
a:1,
},
left:'0%'
},{
rgba:{
r:0,
g:222,
b:152,
a:1,
},
left:'100%'
},
],
angle:45,
selectIndex:-1,
gradientShow:false,
},
setGradient:computed(()=>{
return (gradient:any)=>{
let gradientStr = ''
if(!gradient?.gradientList)return
gradient.gradientList.sort((a:any, b:any) => {
let aArr = a.left.split('%')[0]
let bArr = b.left.split('%')[0]
return aArr - bArr;
});
gradient.gradientList.forEach((item:any,index:any)=>{
let str = ','
if(gradient.gradientList.length == index+1)str = ''
let rgba = item.rgba?item.rgba:{r:255,g:255,b:255}
gradientStr += `rgba(${rgba.r},${rgba.g},${rgba.b},${rgba.a}) ${item.left}${str}`
})
return `${gradientStr}`
}
})
})
const getpalletListDom = reactive({
})
const palletRef = ref(null)
watch(()=>palletData.color_,(newVal:any)=>{
if(!newVal?.rgba?.r)return
if(palletData.color?.gradient?.gradientShow){
palletData.color.gradient.gradientList[palletData.color.gradient.selectIndex].rgba = {
r:newVal.rgba.r,
g:newVal.rgba.g,
b:newVal.rgba.b,
a:newVal.rgba.a,
}
}else{
palletData.color = newVal
}
})
watch(()=>palletData.color,(newVal:any)=>{
if(JSON.stringify(props.selectColor) != JSON.stringify(newVal)){
newVal.name = ''
newVal.tcx = ''
let rgba = [newVal.rgba.r,newVal.rgba.g,newVal.rgba.b]
let hex = rgbaToHex(rgba)
newVal.hex = hex
emit('selectUplpadColor',newVal)
}
},{deep: true })
const setOperate = ()=>{
if(!palletData.color.rgba)return message.info(t('DesignDetailAlter.jsContent7'))
palletData.color.rgba = palletData.color?.rgba?.r?palletData.color.rgba:{r:0,g:0,b:0,a:1}
palletData.gradient.selectIndex = 0
palletData.gradient.gradientShow = true
if(!palletData.color.gradient){
if(palletData.color.rgba.r){
palletData.gradient.gradientList[palletData.gradient.selectIndex].rgba = {
r:palletData.color.rgba.r,
g:palletData.color.rgba.g,
b:palletData.color.rgba.b,
a:1,
}
}
palletData.color.gradient = JSON.parse(JSON.stringify(palletData.gradient))
}else{
palletData.color.rgba = palletData.color.gradient.gradientList[0].rgba
palletData.color.gradient = null
}
}
const deleteGradientItem = ()=>{
if(palletData.color.gradient.gradientList.length <= 2)return
palletData.color.gradient.gradientList.splice(palletData.color.gradient.selectIndex,1)
}
const addGradient = (event:any)=>{
let gradientWidth = event.target.clientWidth
let left:any = event.offsetX/gradientWidth
palletData.color.gradient.gradientList.push({
rgba:palletData.color_.rgba,
left:left.toFixed(2)*100+'%'
})
}
const mousedownGradientAngle = (event:any)=>{
// isMoible() true为移动端
let domPosition = event.target.getBoundingClientRect()
let position = {
x:domPosition.x+domPosition.width/2,
y:domPosition.y+domPosition.height/2,
}
let angle
let mousedown = function(event:any){
let e = getMousePosition(event,false)
mouseDownOperation(e)
}
let touchstart = function(event:any){
let e = getMousePosition(event,true)
mouseDownOperation(e)
}
let mouseDownOperation = (e:any)=>{
let X = position.x
let Y = position.y
let x = (e.clientX) - X
let y = Y -( e.clientY)
angle = Math.atan2(x,y)*(180 / Math.PI)
// this.colorList[this.selectIndex].gradient = JSON.parse(JSON.stringify(this.gradient))
palletData.color.gradient.angle = angle
}
let mouseupGradientAngle = ()=>{
window.removeEventListener('touchmove',touchstart)
window.removeEventListener('touchend',mouseupGradientAngle)
window.removeEventListener('mousemove',mousedown)
window.removeEventListener('mouseup',mouseupGradientAngle)
}
window.addEventListener('touchmove',touchstart)
window.addEventListener('touchend',mouseupGradientAngle)
window.addEventListener('mousemove',mousedown)
window.addEventListener('mouseup',mouseupGradientAngle)
}
const mousedownGradient = (event:any,item:any,index:number)=>{
palletData.color.gradient.selectIndex = index
// this.selectColor = {rgba:gradientRgba,hex:hex} //顔色选择器默认颜色
let gradientWidth = (palletRef.value.querySelector('.color_setting_operate_bg') as any).clientWidth
let position = {
x:event.clientX,
left:event.target.style.left?event.target.style.left.split('%')[0]:0
}
let mousedown = function(event:any){
let e = getMousePosition(event,false)
mousedownGradient(e)
}
let touchstart = function(event:any){
let e = getMousePosition(event,true)
mousedownGradient(e)
}
let mousedownGradient = (e:any)=>{
let left = ((e.clientX) - position.x)/gradientWidth*100+Number(position.left)
left = (left<0?0:left>100?100:left)
item.left = left+'%'
}
let mouseupGradientAngle = ()=>{
window.removeEventListener('touchmove',touchstart)
window.removeEventListener('touchend',mouseupGradientAngle)
window.removeEventListener('mousemove',mousedown)
window.removeEventListener('mouseup',mouseupGradientAngle)
}
window.addEventListener('touchmove',touchstart)
window.addEventListener('touchend',mouseupGradientAngle)
window.addEventListener('mousemove',mousedown)
window.addEventListener('mouseup',mouseupGradientAngle)
}
const selectImgItem = ()=>{
}
const openPallet = ()=>{
if(palletData.palletShow && props.selectColor?.rgba?.r){
if(props.selectColor.gradient){
palletData.color_.rgba = props.selectColor.gradient.gradientList[0].rgba
}else{
palletData.color_ = JSON.parse(JSON.stringify(props.selectColor))
palletData.gradient.gradientShow = false
}
palletData.color = JSON.parse(JSON.stringify(props.selectColor))
}else{
}
}
// 点击外部区域关闭颜色选择器
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
const colorSettingBlock = palletRef.value.querySelector('.color_setting_block');
const palletColo = palletRef.value.querySelector('.palletColo');
// 如果点击的是 .palletColo 或 .color_setting_block 内部,则不关闭
if (palletData.palletShow && colorSettingBlock &&
!colorSettingBlock.contains(target) &&
!palletColo?.contains(target)) {
palletData.palletShow = false;
}
}
onMounted(()=>{
// 添加点击外部区域监听器
// document.addEventListener('click', handleClickOutside);
nextTick().then(()=>{
const backIcon = document.createElement('div');
backIcon.classList.add('vc-sketch-color-wrap')
let dropperDom = palletRef.value.getElementsByClassName('vc-chrome-fields-wrap')[0]
dropperDom.appendChild(backIcon);
backIcon.addEventListener('click',async ()=>{
try {
const dropper = new EyeDropper();
const result = await dropper.open();
let hex = result.sRGBHex.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);
palletData.color = {rgba:{r:r,g:g,b:b,a:1},hex:result.sRGBHex}
// return `rgb(${r}, ${g}, ${b})`;
// box.style.backgroundColor = label.textContent = result.sRGBHex;
} catch (e) {
message.info(t('DesignDetailAlter.jsContent1'))
}
})
openPallet();
})
})
onUnmounted(()=>{
// 清理事件监听器
// document.removeEventListener('click', handleClickOutside);
})
return{
...toRefs(palletData),
...toRefs(getpalletListDom),
palletRef,
openPallet,
selectImgItem,
setOperate,
deleteGradientItem,
addGradient,
mousedownGradientAngle,
mousedownGradient,
getMousePosition,
}
},
provide() {
return {
}
},
})
</script>
<style lang="less" scoped>
.pallet{
// position: absolute;
width: 100%;
user-select: none;
> .palletColo{
width: 100%;
height: 7rem;
border-radius: .5rem;
border: 1px solid #000;
padding: .5rem .6rem;
cursor: pointer;
> .palletBackColor{
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
> .palletBox{
margin-top: 1.5rem;
width: 100%;
z-index: 2;
> .color_setting_block{
margin: auto;
background: linear-gradient(70deg, #eee4f3, #f3f4e6);
width: 100%;
// border-radius: calc(1rem*1.2);
overflow: hidden;
box-shadow: 2px 2px 8px rgba(0,0,0,.3);
.vc-chrome{
background: rgba(0,0,0,0);
box-shadow:none;
}
:deep(.chrome_color){
width: 100%;
overflow: hidden;
.vc-chrome-saturation-wrap{
width: 30rem;
height: 30rem;
margin: 2rem auto;
padding-bottom: 0;
}
.vc-saturation-pointer{
pointer-events: none;
}
.vc-chrome-body{
padding: 0;
width: 90%;
margin: 2 auto;
margin: 0 auto;
background: rgba(0,0,0,0);
margin-bottom: 3rem;
// display: none;
.vc-chrome-fields-wrap{
margin-top: 5%;
padding: 0;
position: relative;
.vc-chrome-toggle-btn{
width: calc(3.2rem*1.2);
.vc-chrome-toggle-icon{
height: auto;
margin-right: calc(-0.4rem*1.2);
margin-top: calc(0rem*1.2);
display: flex;
flex-direction: column;
align-items: center;
svg{
width: calc(2.4rem*1.2) !important;
height: calc(2.4rem*1.2) !important;
}
}
}
.vc-chrome-fields{
.vc-chrome-field{
padding-left: calc(.6rem*1.2);
}
.vc-input__label{
font-size: calc(1.6rem*1.2);
}
.vc-input__input{
font-size: 2rem;
height: 4rem;
}
}
.ant-upload-list{
}
.vc-sketch-color-wrap{
background-image: url(@/assets/images/homePage/dropper.png);
background-size: 3rem;
background-repeat: no-repeat;
background-position: 50%;
cursor: pointer;
margin: 0;
width: 4rem;
height: 4rem;
padding: calc(.7rem*1.2);
border: 1px solid;
position: absolute;
bottom: -2rem;
right: 0rem;
border-radius: calc(.5rem*1.2);
}
.vc-chrome-fields{
.vc-input__label{
margin-top: calc(1rem*1.2);
}
}
.vc-chrome-fields:nth-child(2){
>:last-of-type {
display: none;
}
}
.vc-chrome-fields:nth-child(3){
>:last-of-type {
display: none;
}
}
}
.vc-chrome-controls{
align-items: center;
flex-direction: row-reverse;
.vc-chrome-color-wrap{
// width: 3.6rem*1.2);
margin-left: calc(2rem*1.2);
width: auto;
.vc-chrome-active-color{
border-radius: 50%;
}
.vc-chrome-active-color,.vc-checkerboard{
width: calc(3rem*1.2);
height: calc(3rem*1.2);
}
}
.vc-chrome-hue-wrap,.vc-chrome-alpha-wrap{
.vc-hue{
border-radius: 15px;
}
.vc-alpha{
border-radius: 15px;
overflow: hidden;
}
height: 2rem;
margin: 0;
.vc-hue-pointer{
transform: translateX(-1.25rem);
}
.vc-hue-picker{
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
transform: translate(0px,-3px);
}
}
.vc-chrome-alpha-wrap{
display: none;
}
}
}
.vc-chrome-saturation-wrap .vc-saturation-circle{
width: calc(1rem*1.2);
height: calc(1rem*1.2);
}
}
.color_block{
// margin-top: calc(1rem;
// display: flex;
// justify-content: space-between;
// font-size: calc(1.6rem;
width: 100%;
padding: 0 5%;
padding-bottom: 5%;
margin: calc(0.5rem*1.2) auto;
display: flex;
justify-content: space-between;
align-items: center;
.color_right{
width: 13rem;
font-size: calc(1.2rem*1.2);
color: #666666;
.color_rgb_block{
display: flex;
.rgb_item{
margin-left: calc(.2rem*1.2);
}
}
}
.color_left{
cursor: pointer;
color: rgb(153, 153, 153);
}
.color_right,.color_left{
>div{
display: flex;
align-items: center;
}
.color_HEX_block,.color_rgb_block{
padding: .25rem .6rem;
box-shadow: inset 0 0 0 1px #ccc;
border-radius: .5rem;
justify-content: space-around;
text-transform:uppercase;
.color_block_bg{
width: 1.8rem;
height: 2.5rem;
// margin-right: .5rem;
display: flex;
justify-content: space-between;
}
}
.color_block_bg{
}
}
}
.color_setting_operateSingle{
text-align: center;
margin: 1rem 0;
display: flex;
justify-content: center;
.color_setting_btn{
margin: 0 1rem;
color: rgba(0, 0, 0, 0.5);
&.active{
color: rgba(0, 0, 0, 0.7);
font-weight: 900;
}
}
}
.color_setting_operate{
*{
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none;
}
.color_setting_operate_item{
display: flex;
justify-content: space-around;
align-items: center;
.operate_item_box{
}
}
.color_setting_operate_control{
.operate_item_delete,.operate_item_angle{
cursor: pointer;
}
.operate_item_delete{
i{
display: flex;
font-size: 3rem;
}
}
.operate_item_angle{
.operate_item_angle_box{
border-radius: 50%;
width: 4rem;
height: 4rem;
border: solid 2px #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
>div{
height: 100%;
width: 1rem;
position: relative;
pointer-events:none;
}
>div::before{
position: absolute;
content: "";
top: 0.2rem;
left: 0;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: #000;
}
}
}
}
.color_setting_operate_input{
width: 80%;
// padding: 0 10%;
margin: 1.2rem 10%;
border-radius: 10%;
position: relative;
height: 2.5rem;
.color_setting_operate_bg{
border-radius: .5rem;
width: 100%;
height: 2.5rem;
background: #fff;
position: absolute;
}
}
.color_setting_operate_btn{
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
left: 0;
width: 1rem;
height: 110%;
border: .2rem solid;
border-radius: .5rem;
cursor: pointer;
box-sizing: content-box;
z-index: 2;
&.active{
border: .3rem solid;
}
}
.color_setting_operate_btn:hover{
border: .3rem solid;
}
}
}
}
}
</style>

View File

@@ -1,744 +0,0 @@
<template>
<transition name="fade">
<div
class="select-menu-panel"
v-if="visible"
:class="{ active: !closePanel }"
>
<div class="btn" @click="setClosePanel">
<i class="fi fi-br-angle-left"></i>
</div>
<!-- 变换工具顶部 -->
<div class="panel-select">
<!-- <div class="panel-header">
<div class="header-title">变换工具</div>
</div> -->
<!-- 分割线 -->
<!-- <div class="panel-divider"></div> -->
<!-- 变换工具内容 -->
<div class="tool-content">
<div
class="object-item"
v-for="v in activeObjects"
:key="v.id"
>
<div class="title">{{ v.layer?.name }}</div>
<div class="list">
<div>
<span class="label">W</span>
<input
type="number"
:value="v.width"
disabled
/>
</div>
<div>
<span class="label">H</span>
<input
type="number"
:value="v.height"
disabled
/>
</div>
<!-- <div>
<span class="label">X</span>
<input type="number" :value="v.left" disabled />
</div>
<div>
<span class="label">Y</span>
<input type="number" :value="v.top" disabled />
</div> -->
<div>
<span class="label iconfont icon-angle"></span>
<input
type="number"
:value="Number(Number(v.angle).toFixed(3))"
@change="(e) => changeAngle(e, v)"
/>
</div>
<div class="btn" @click="clickflipHorizontal(v)">
<i class="iconfont icon-flip-horizontal"></i>
<p class="tip">
{{ t("Canvas.flipHorizontal") }}
</p>
</div>
<div class="btn" @click="clickflipVertical(v)">
<i class="iconfont icon-flip-vertical"></i>
<p class="tip">
{{ t("Canvas.flipVertical") }}
</p>
</div>
<div class="btn" @click="clickCropImage(v)">
<i class="iconfont icon-caijian"></i>
<p class="tip">
{{ t("Canvas.cropAndAdd") }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import showViewVideo from "@/tool/mount";
import { ref, onMounted, watch, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { ToolCommand } from "../commands/ToolCommands";
import { OperationType } from "../utils/layerHelper";
import { loadImageUrlToLayer } from "../utils/imageHelper";
import { TransformCommand } from "../commands/StateCommands";
const props = defineProps({
canvas: {
type: Object,
required: true,
},
commandManager: {
type: Object,
required: true,
},
selectManager: {
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 closePanel = ref(false);
const setClosePanel = () => {
closePanel.value = !closePanel.value;
};
// 国际化
const { t } = useI18n();
onMounted(() => {
setupCanvasListeners();
});
onUnmounted(() => {
removeCanvasListeners();
});
// 监听 activeTool 变化
watch(
() => props.activeTool,
(newTool) => {
if (newTool === OperationType.SELECT) {
show();
} else {
close();
}
},
{ immediate: true }
);
/**
* 显示面板
*/
function show() {
if (activeObjects.value.length === 0) return;
visible.value = true;
closePanel.value = true;
}
/**
* 关闭面板
*/
function close() {
visible.value = false;
}
// 获取当前选中的对象
const activeObjects = ref([]);
const getActiveObject = (e) => {
console.log("==========切换激活对象", e);
activeObjects.value = e.selected.map((v) => v);
activeObjects.value.forEach((v) => {
v.layer = props.layerManager.getLayerById(v.layerId);
});
if (activeObjects.value.length === 0) {
close();
} else {
show(false);
}
};
const lastSelectLayerId = inject("lastSelectLayerId");
const layers = inject("layers");
const transformObject = (activeObj, initialState, finalState) => {
const transformCmd = new TransformCommand({
canvas: props.canvas,
objectId: activeObj.id,
initialState,
finalState,
objectType: activeObj.type,
name: `变换 ${activeObj.type || "对象"}`,
layerManager: props.layerManager,
layers: layers,
lastSelectLayerId: lastSelectLayerId,
});
props.layerManager.commandManager.execute(transformCmd, {
name: "对象修改",
});
};
/**
* 根据左上角坐标计算旋转后的新坐标
* @param {number} W - 宽度
* @param {number} H - 高度
* @param {number} currentX - 当前左上角x坐标
* @param {number} currentY - 当前左上角y坐标
* @param {number} currentAngleDeg - 当前角度(度)
* @param {number} newAngleDeg - 新角度(度)
* @returns {Object} 旋转后的左上角坐标 {x, y}
*/
function calculateRotatedTopLeftDeg(
W,
H,
currentX,
currentY,
currentAngleDeg,
newAngleDeg
) {
const currentAngle = (currentAngleDeg * Math.PI) / 180;
const newAngle = (newAngleDeg * Math.PI) / 180;
// 1. 用当前角度计算中心点位置
const cosCurrent = Math.cos(currentAngle);
const sinCurrent = Math.sin(currentAngle);
const Cx = currentX + (W / 2) * cosCurrent - (H / 2) * sinCurrent;
const Cy = currentY + (W / 2) * sinCurrent + (H / 2) * cosCurrent;
// 2. 用新角度计算旋转后的左上角位置
const cosNew = Math.cos(newAngle);
const sinNew = Math.sin(newAngle);
const newX = Cx + (-W / 2) * cosNew - (-H / 2) * sinNew;
const newY = Cy + (-W / 2) * sinNew + (-H / 2) * cosNew;
return { x: newX, y: newY };
}
// 改变角度
const changeAngle = (e, obj) => {
const initialState = TransformCommand.captureTransformState(obj);
const finalState = { ...initialState };
const angle = e.target.value;
if (obj.originX === "left" && obj.originY === "top") {
const width = obj.width * obj.scaleX;
const height = obj.height * obj.scaleY;
const left = obj.left;
const top = obj.top;
const { x, y } = calculateRotatedTopLeftDeg(
width,
height,
left,
top,
obj.angle,
angle
);
finalState.left = x;
finalState.top = y;
}
finalState.angle = angle;
transformObject(obj, initialState, finalState);
};
// 水平翻转
const clickflipHorizontal = (obj) => {
const initialState = TransformCommand.captureTransformState(obj);
const finalState = { ...initialState };
finalState.flipX = !finalState.flipX;
transformObject(obj, initialState, finalState);
};
// 垂直翻转
const clickflipVertical = (obj) => {
const initialState = TransformCommand.captureTransformState(obj);
const finalState = { ...initialState };
finalState.flipY = !finalState.flipY;
transformObject(obj, initialState, finalState);
};
// 裁剪图片
const cropImage = inject("cropImage");
const clickCropImage = async (obj) => {
const base64 = await props.layerManager.getLayerToBase64(obj.layerId);
if(base64) cropImage(base64).then((res) => {
loadImageUrlToLayer({
imageUrl: res,
layerManager: props.layerManager,
canvas: props.canvas,
toolManager: props.toolManager,
})
});
};
const updateActiveObjects = (arrs, keys) => {
arrs.forEach((v) => {
activeObjects.value.forEach((item) => {
if (item.id === v.id) {
keys.forEach((key) => (item[key] = v[key]));
}
});
activeObjects.value = [...activeObjects.value];
});
};
const objectRotatingChange = (e) => {
const arrs = [];
if (e.target._objects) {
e.target._objects.forEach((v) => arrs.push(v));
} else {
arrs.push(e.target);
}
updateActiveObjects(arrs, ["angle"]);
};
/**
* 设置画布事件监听
*/
function setupCanvasListeners() {
if (!props.canvas) return;
// 鼠标事件
props.canvas.on("selection:created", getActiveObject);
props.canvas.on("selection:updated", getActiveObject);
props.canvas.on("selection:cleared", close);
props.canvas.on("object:rotating", objectRotatingChange);
}
/**
* 移除画布事件监听
*/
function removeCanvasListeners() {
if (!props.canvas) return;
// 移除鼠标事件
props.canvas.off("selection:created", getActiveObject);
props.canvas.off("selection:updated", getActiveObject);
props.canvas.off("selection:cleared", close);
props.canvas.off("object:rotating", objectRotatingChange);
}
</script>
<style scoped lang="less">
.select-menu-panel {
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);
user-select: none;
&.active {
transform: translateY(100%);
> .btn {
> i {
transform: rotate(90deg);
}
}
}
> .btn {
width: 100%;
height: 22px;
text-align: center;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
> i {
font-size: 1.4rem;
transform: rotate(270deg);
}
}
}
/* 平板和手机适配 */
@media screen and (max-width: 768px) {
.select-menu-panel {
bottom: 15px;
left: 15px;
right: 15px;
max-width: calc(100vw - 30px);
border-radius: 6px;
}
}
@media screen and (max-width: 480px) {
.select-menu-panel {
bottom: 10px;
left: 10px;
right: 10px;
max-width: calc(100vw - 20px);
}
}
.select-menu-panel.is-active {
transform: translateY(0);
}
.panel-header {
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;
}
.panel-select {
// padding: 0 0 10px;
}
/* 平板适配 */
@media screen and (max-width: 768px) {
.panel-header {
padding: 6px 12px;
border-radius: 6px 6px 0 0;
}
}
/* 手机适配 */
@media screen and (max-width: 480px) {
.panel-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: 6px;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.tool-btn span {
margin-top: 0;
font-size: 12px;
}
.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;
}
.panel-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.05);
margin: 0 10px 5px 10px;
}
.tool-content {
overflow-y: auto;
max-height: 220px;
margin-top: 10px;
padding: 0 10px;
> .object-item {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 10px 0;
&:last-child {
border-bottom: none;
}
> .title {
text-align: left;
margin-bottom: 5px;
}
> .list {
display: flex;
> div {
margin-right: 15px;
font-size: 14px;
color: #474747;
> .label {
margin-right: 5px;
}
> input {
width: 65px;
}
.iconfont {
font-size: 14px;
}
}
> div.btn {
position: relative;
min-width: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: background-color 0.2s;
background-color: rgba(0, 0, 0, 0);
> .tip {
position: absolute;
top: -5px;
left: 50%;
transform: translate(-50%, -100%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 0.4rem;
margin-left: 0.8rem;
font-size: 1.2rem;
white-space: nowrap;
pointer-events: none;
display: none;
&::after {
content: "";
position: absolute;
top: 97%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid rgba(0, 0, 0, 0.8);
}
}
&:hover {
background-color: rgba(0, 0, 0, 0.08);
> .tip {
display: block;
}
}
}
}
}
}
/* 平板适配 - 每行4个按钮 */
@media screen and (max-width: 768px) {
.tool-content {
grid-template-columns: repeat(3, 1fr);
gap: 8px 6px;
padding: 0 8px;
}
}
/* 手机适配 - 每行3个按钮 */
@media screen and (max-width: 480px) {
.tool-content {
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;
flex-direction: row;
align-items: center;
justify-content: center;
background: none;
border: none;
color: #333;
cursor: pointer;
padding: 0;
gap: 4px;
.c-svg {
width: auto;
}
}
.action-btn svg {
width: 22px;
height: 22px;
margin-bottom: 8px;
}
.btn-text {
display: block;
font-size: 12px;
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>

View File

@@ -0,0 +1,133 @@
<template>
<div class="repeat-setting">
<div class="repeat-setting-item">
<span class="label">{{ t("Canvas.angle") }}</span>
<angle-tool
:angle="angle"
@input="(e) => emit('inputFillAngle', e)"
@change="(e) => emit('changeFillAngle', e)"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">{{ t("Canvas.scale") }}</span>
<slider
:min="1"
:max="500"
:step="1"
is-input
:tipFormatter="(v) => `${scale}%`"
:value="scale"
@input="inputFillScale"
@change="changeFillScale"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">Gap X</span>
<slider
:min="0"
:max="1000"
:step="1"
is-input
:tipFormatter="(v) => `${v}px`"
:value="gapX"
@input="(e) => emit('inputFill_Gap', e, gapY)"
@change="(e) => emit('changeFill_Gap', e, gapY)"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">Gap Y</span>
<slider
:min="0"
:max="1000"
:step="1"
is-input
:tipFormatter="(v) => `${v}px`"
:value="gapY"
@input="(e) => emit('inputFill_Gap', gapX, e)"
@change="(e) => emit('changeFill_Gap', gapX, e)"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">{{ t("Canvas.offset") }}</span>
<offset-tool
:top="(props.object.fill?.offsetY / props.object.height) * 100"
:left="(props.object.fill?.offsetX / props.object.width) * 100"
@input="(e) => emit('inputFillOffset', e)"
@change="(e) => emit('changeFillOffset', e)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, computed } from "vue";
import { getTransformScaleAngle } from "../../utils/helper";
import AngleTool from "../tools/AngleTool.vue";
import OffsetTool from "../tools/OffsetTool.vue";
import Slider from "../tools/Slider.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = defineProps({
object: {
required: true,
type: Object,
},
});
const angle = computed(
() => getTransformScaleAngle(props.object.fill?.patternTransform).angle
);
const scale = computed(() => {
const patternTransform = props.object.fill?.patternTransform;
const scaleValue = getTransformScaleAngle(patternTransform).scale * 100;
return Number(Number(scaleValue).toFixed(2));
});
const gapX = computed(() => props.object.fill_?.gapX || 0);
const gapY = computed(() => props.object.fill_?.gapY || 0);
const emit = defineEmits([
"inputFillAngle",
"changeFillAngle",
"inputFillOffset",
"changeFillOffset",
"inputFillScale",
"changeFillScale",
"inputFill_Gap",
"changeFill_Gap",
]);
const inputFillScale = (e) => {
const scale = e / 100;
emit("inputFillScale", scale);
};
const changeFillScale = (e) => {
const scale = e / 100;
emit("changeFillScale", scale);
};
</script>
<style scoped lang="less">
.repeat-setting {
user-select: none;
> .repeat-setting-item {
display: flex;
align-items: center;
//虚线
> .label {
min-width: 50px;
font-size: 14px;
}
> .angle-tool {
width: 120px;
}
}
> p {
margin: 10px 0;
width: 100%;
height: 0;
border-bottom: 1px dashed #e5e5e5;
}
}
</style>

View File

@@ -0,0 +1,47 @@
import { ref } from "vue";
import i18n from "@/lang/index.ts";
const { t } = i18n.global;
/** 填充重复模式 */
export const getSelectOptions = () => ref([
{ value: "no-repeat", label: t("Canvas.noRepeat") },
{ value: "repeat", label: t("Canvas.repeat") },
{ value: "repeat-x", label: t("Canvas.repeatX") },
{ value: "repeat-y", label: t("Canvas.repeatY") },
]);
/** 图层混合模式 */
export const getLayerCompositeOptions = () => ref([
{ value: "source-over", label: t("Canvas.CompositeNormal"), tip: t("Canvas.CompositeNormalTip") },// 正常
{ value: "darken", label: t("Canvas.CompositeDarken"), tip: t("Canvas.CompositeDarkenTip") },// 变暗
{ value: "multiply", label: t("Canvas.CompositeMultiply"), tip: t("Canvas.CompositeMultiplyTip") },// 正片叠底
{ value: "color-burn", label: t("Canvas.CompositeColorBurn"), tip: t("Canvas.CompositeColorBurnTip") },// 颜色加深
{ value: "lighten", label: t("Canvas.CompositeLighten"), tip: t("Canvas.CompositeLightenTip") },// 颜色减淡
{ value: "screen", label: t("Canvas.CompositeScreen"), tip: t("Canvas.CompositeScreenTip") },// 滤色
{ value: "color-dodge", label: t("Canvas.CompositeColorDodge"), tip: t("Canvas.CompositeColorDodgeTip") },// 颜色减淡
{ value: "lighter", label: t("Canvas.CompositeLighter"), tip: t("Canvas.CompositeLighterTip") },// 颜色减淡
{ value: "overlay", label: t("Canvas.CompositeOverlay"), tip: t("Canvas.CompositeOverlayTip") },// 叠加
{ value: "soft-light", label: t("Canvas.CompositeSoftLight"), tip: t("Canvas.CompositeSoftLightTip") },// 柔光
{ value: "hard-light", label: t("Canvas.CompositeHardLight"), tip: t("Canvas.CompositeHardLightTip") },// 强光
{ value: "difference", label: t("Canvas.CompositeDifference"), tip: t("Canvas.CompositeDifferenceTip") },// 差值
{ value: "exclusion", label: t("Canvas.CompositeExclusion"), tip: t("Canvas.CompositeExclusionTip") },// 排除
{ value: "hue", label: t("Canvas.CompositeHue"), tip: t("Canvas.CompositeHueTip") },// 色相
{ value: "saturation", label: t("Canvas.CompositeSaturation"), tip: t("Canvas.CompositeSaturationTip") },// 饱和度
{ value: "color", label: t("Canvas.CompositeColor"), tip: t("Canvas.CompositeColorTip") },// 颜色
{ value: "luminosity", label: t("Canvas.CompositeLuminosity"), tip: t("Canvas.CompositeLuminosityTip") },// 亮度
// { value: "destination-over", label: "背后", tip:"背后:新图形绘制到原内容下方" },
// { value: "source-in", label: "颜色加深", tip:"颜色加深:只显示重叠部分,其他透明" },
// { value: "destination-in", label: "颜色减淡", tip:"颜色减淡:只显示原内容与新图形重叠部分" },
// { value: "source-out", label: "排除", tip:"排除:只显示新图形中不重叠部分" },
// { value: "destination-out", label: "差值", tip:"差值:只清除原内容中与新图形重叠部分" },
// { value: "xor", label: "排除", tip:"排除:重叠部分透明" },
// { value: "copy", label: "正常", tip:"正常:完全忽略原内容,只显示新图形" },
// { value: "source-atop", label: "叠加", tip:"叠加:只在与现有内容重叠处绘制新图形" },
// { value: "destination-atop", label: "柔光", tip:"柔光:仅保留重叠部分,新图形在原内容后绘制" },
// { value: "darker", label: "变暗", tip:"变暗:重叠部分颜色减淡" },
]);

View File

@@ -0,0 +1,899 @@
<template>
<transition name="fade">
<div
class="select-menu-panel"
v-if="visible"
:class="{ active: !closePanel }"
>
<div class="btn" @click="setClosePanel">
<i class="fi fi-br-angle-left"></i>
</div>
<!-- 变换工具顶部 -->
<div class="panel-select">
<!-- <div class="panel-header">
<div class="header-title">变换工具</div>
</div> -->
<!-- 分割线 -->
<!-- <div class="panel-divider"></div> -->
<!-- 变换工具内容 -->
<div class="tool-content">
<div
class="object-item"
v-for="v in activeObjects"
:key="v.id"
>
<div class="title">{{ v.layer?.name }}</div>
<div class="list">
<div
class="input"
v-if="v.layerId !== SpecialLayerId.COLOR"
>
<angle-tool
:angle="Number(Number(v.angle).toFixed(3))"
@input="(e) => inputAngle(e, v)"
@change="(e) => changeAngle(e, v)"
/>
</div>
<div class="input">
<span class="label"
>{{ t("Canvas.opacity") }}:</span
>
<slider
:tipFormatter="
(v) => `${Math.round(v * 100)}%`
"
:value="v.opacity"
:min="0"
:max="1"
:step="0.01"
@change="(e) => changeOpacity(e, v)"
@input="(e) => inputOpacity(e, v)"
/>
</div>
<div
class="btn"
@click="clickflipHorizontal(v)"
v-if="v.layerId !== SpecialLayerId.COLOR"
>
<i class="iconfont icon-flip-horizontal"></i>
<p class="tip">
{{ t("Canvas.flipHorizontal") }}
</p>
</div>
<div
class="btn"
@click="clickflipVertical(v)"
v-if="v.layerId !== SpecialLayerId.COLOR"
>
<i class="iconfont icon-flip-vertical"></i>
<p class="tip">
{{ t("Canvas.flipVertical") }}
</p>
</div>
<!-- <div
class="btn"
@click="clickCropImage(v)"
v-if="v.layerId !== SpecialLayerId.COLOR"
>
<i class="iconfont icon-caijian"></i>
<p class="tip">
{{ t("Canvas.cropAndAdd") }}
</p>
</div> -->
<!-- <div
class="btn"
@click="clickRasterizeLayer(v)"
v-if="v.type !== 'image'"
>
<span class="label">{{ t("Canvas.RasterizedLayer") }}</span>
</div> -->
<div class="select">
<!-- 混合模式 -->
<i class="iconfont icon-hunhemoshi"></i>
<my-select
:defaultValue="
v.layer?.blendMode ||
v.globalCompositeOperation
"
:list="layerCompositeOptions"
@change="
(n, o) => setLayerComposite(n, o, v, 1)
"
@active="
(n, o) => setLayerComposite(n, o, v, 0)
"
/>
</div>
<!-- <div
class="btn"
@click="clickTest(v)"
>
<span class="label">测试</span>
</div> -->
<div
class="select"
v-if="v.type === 'rect' || v.type === 'image'"
>
<!-- 平铺 -->
<i class="iconfont icon-repeat"></i>
<a-select
size="small"
:defaultValue="
typeof v.fill === 'object'
? v.fill?.repeat || 'no-repeat'
: 'no-repeat'
"
:options="selectOptions"
@change="(e) => changeFillRepeat(e, v)"
/>
</div>
<!-- 平铺设置 -->
<a-popover
v-if="v.type === 'rect'"
trigger="click"
destroyTooltipOnHide
:title="t('Canvas.repeatSetting')"
>
<template #content>
<repeat-setting
:object="v"
@inputFillAngle="
(e) => inputFillAngle(e, v)
"
@changeFillAngle="
(e) => changeFillAngle(e, v)
"
@inputFillOffset="
(e) => inputFillOffset(e, v)
"
@changeFillOffset="
(e) => changeFillOffset(e, v)
"
@inputFillScale="
(e) => inputFillScale(e, v)
"
@changeFillScale="
(e) => changeFillScale(e, v)
"
@inputFill_Gap="
(x, y) => inputFill_Gap(x, y, v)
"
@changeFill_Gap="
(x, y) => changeFill_Gap(x, y, v)
"
/>
</template>
<div class="btn">
<i class="iconfont icon-gengduo"></i>
</div>
</a-popover>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
import { OperationType, SpecialLayerId } from "../../utils/layerHelper";
import { loadImageUrlToLayer } from "../../utils/imageHelper";
import {
calculateRotatedTopLeftDeg,
createPatternTransform,
getTransformScaleAngle,
} from "../../utils/helper";
import { TransformCommand } from "../../commands/StateCommands";
import {
FillRepeatCommand,
FillRepeatChangeCommand,
FillRepeatGapChangeCommand,
} from "../../commands/FillRepeatCommand";
import { SetLayerCompositeCommand } from "../../commands/LayerCommands.js";
import RepeatSetting from "./RepeatSetting.vue";
import Slider from "../tools/Slider.vue";
import AngleTool from "../tools/AngleTool.vue";
import MySelect from "../tools/MySelect.vue";
import EventManager from "../../utils/event.js";
import { getSelectOptions, getLayerCompositeOptions } from "./data.js";
const selectOptions = getSelectOptions();
const layerCompositeOptions = getLayerCompositeOptions();
const props = defineProps({
canvas: {
type: Object,
required: true,
},
commandManager: {
type: Object,
required: true,
},
selectManager: {
type: Object,
required: true,
},
layerManager: {
type: Object,
required: true,
},
canvasManager: {
type: Object,
required: true,
},
toolManager: {
type: Object,
required: true,
},
activeTool: {
type: String,
required: false,
default: null,
},
});
// 响应式数据
const visible = ref(false);
//打开隐藏操作面板
const closePanel = ref(false);
const setClosePanel = () => {
closePanel.value = !closePanel.value;
};
onMounted(() => {
setupCanvasListeners();
});
onUnmounted(() => {
removeCanvasListeners();
});
// 监听 activeTool 变化
watch(
() => props.activeTool,
(newTool) => {
if (newTool === OperationType.SELECT) {
show();
} else {
close();
}
},
{ immediate: true }
);
/**
* 显示面板
*/
function show() {
if (activeObjects.length === 0) return;
visible.value = true;
closePanel.value = true;
}
/**
* 关闭面板
*/
function close() {
visible.value = false;
}
// 获取当前选中的对象
const activeObjects = reactive([]);
const getActiveObject = (e) => {
console.log("==========切换激活对象", e, activeObjects);
activeObjects.splice(0, activeObjects.length, ...e.selected);
activeObjects.forEach((v) => {
v.layer = props.layerManager.getLayerById(v.layerId);
});
if (activeObjects.length === 0) {
close();
} else {
show();
}
};
//取消当前选中
const cancelSelect = () => {
activeObjects.splice(0, activeObjects.length);
close();
};
const lastSelectLayerId = inject("lastSelectLayerId");
const layers = inject("layers");
const transformObject = (
activeObj,
initialState,
finalState,
isCommand = true
) => {
const cmd = new TransformCommand({
canvas: props.canvas,
objectId: activeObj.id,
initialState,
finalState,
objectType: activeObj.type,
name: `变换 ${activeObj.type || "对象"}`,
layerManager: props.layerManager,
layers: layers,
lastSelectLayerId: lastSelectLayerId,
});
if (isCommand) {
props.commandManager.execute(cmd);
} else {
cmd.execute();
}
};
// 改变不透明度
const changeOpacity = (opacity, obj) => {
props.layerManager?.setLayerOpacity(obj.layerId, opacity);
};
const inputOpacity = (opacity, obj) => {
obj.opacity = opacity;
props.canvas.renderAll();
};
// 改变角度
const inputAngle = (angle, obj) => {
const initialState = TransformCommand.captureTransformState(obj);
const finalState = computeAngleState(angle, obj, initialState);
transformObject(obj, initialState, finalState, false);
if (!obj.hasOwnProperty("oldState")) obj.oldState = initialState;
};
const changeAngle = (angle, obj) => {
var initialState;
if (obj.hasOwnProperty("oldState")) {
initialState = obj.oldState;
delete obj.oldState;
} else {
initialState = TransformCommand.captureTransformState(obj);
}
const finalState = computeAngleState(angle, obj, initialState);
transformObject(obj, initialState, finalState);
};
const computeAngleState = (angle, obj, initialState) => {
const finalState = { ...initialState };
if (obj.originX === "left" && obj.originY === "top") {
const width = obj.width * obj.scaleX;
const height = obj.height * obj.scaleY;
const left = obj.left;
const top = obj.top;
const { x, y } = calculateRotatedTopLeftDeg(
width,
height,
left,
top,
obj.angle,
angle
);
finalState.left = x;
finalState.top = y;
}
finalState.angle = angle;
return finalState;
};
// 水平翻转
const clickflipHorizontal = (obj) => {
const initialState = TransformCommand.captureTransformState(obj);
const finalState = { ...initialState };
finalState.flipX = !finalState.flipX;
transformObject(obj, initialState, finalState);
};
// 垂直翻转
const clickflipVertical = (obj) => {
const initialState = TransformCommand.captureTransformState(obj);
const finalState = { ...initialState };
finalState.flipY = !finalState.flipY;
transformObject(obj, initialState, finalState);
};
// 裁剪图片
const cropImage = inject("cropImage");
const clickCropImage = async (obj) => {
const base64 = await props.layerManager.getLayerToBase64(obj.layerId);
if (base64)
cropImage(base64).then((res) => {
loadImageUrlToLayer({
imageUrl: res,
layerManager: props.layerManager,
canvas: props.canvas,
toolManager: props.toolManager,
});
});
};
// 栅格化图层
const clickRasterizeLayer = (obj) => {
props.layerManager.rasterizeLayer(obj.layerId);
};
// 改变填充重复
const changeFillRepeat = async (value, obj) => {
console.log("==========改变填充重复", obj.type);
const cmd = new FillRepeatCommand({
canvas: props.canvas,
layers: layers,
canvasManager: props.canvasManager,
layerManager: props.layerManager,
layerId: obj.layerId,
fillRepeat: value,
});
props.commandManager.execute(cmd);
};
// 改变填充角度
const inputFillAngle = (angle, obj) => {
if (!obj.oldPattern) obj.oldPattern = obj.get("fill");
const fill = obj.get("fill");
const scale = getTransformScaleAngle(fill?.patternTransform).scale;
const pattern = new fabric.Pattern({
...fill,
patternTransform: createPatternTransform(scale, angle),
});
obj.set("fill", pattern);
props.canvas.renderAll();
};
const changeFillAngle = (angle, obj) => {
const fill = obj.get("fill");
const scale = getTransformScaleAngle(fill?.patternTransform).scale;
const pattern = {
patternTransform: createPatternTransform(scale, angle),
};
changeFill(obj, pattern);
};
// 改变填充便宜
const inputFillOffset = (value, obj) => {
if (!obj.oldPattern) obj.oldPattern = obj.get("fill");
const pattern = new fabric.Pattern({
...obj.get("fill"),
offsetX: (value.left / 100) * obj.width,
offsetY: (value.top / 100) * obj.height,
});
obj.set("fill", pattern);
props.canvas.renderAll();
};
const changeFillOffset = (value, obj) => {
const pattern = new fabric.Pattern({
offsetX: (value.left / 100) * obj.width,
offsetY: (value.top / 100) * obj.height,
});
changeFill(obj, pattern);
};
// 改变填充缩放
const inputFillScale = (scale, obj) => {
if (!obj.oldPattern) obj.oldPattern = obj.get("fill");
const fill = obj.get("fill");
const angle = getTransformScaleAngle(fill?.patternTransform).angle;
const pattern = new fabric.Pattern({
...fill,
patternTransform: createPatternTransform(scale, angle),
});
obj.set("fill", pattern);
props.canvas.renderAll();
};
const changeFillScale = (scale, obj) => {
const fill = obj.get("fill");
const angle = getTransformScaleAngle(fill?.patternTransform).angle;
const pattern = {
patternTransform: createPatternTransform(scale, angle),
};
changeFill(obj, pattern);
};
const changeFill = (obj, pattern) => {
const cmd = new FillRepeatChangeCommand({
canvas: props.canvas,
layers: layers,
canvasManager: props.canvasManager,
layerManager: props.layerManager,
layerId: obj.layerId,
newPattern: pattern,
});
props.commandManager.execute(cmd);
};
// 改变填充间隙
const inputFill_Gap = (gapX, gapY, obj) => {
const cmd = new FillRepeatGapChangeCommand({
canvas: props.canvas,
layers: layers,
canvasManager: props.canvasManager,
layerManager: props.layerManager,
layerId: obj.layerId,
newGapX: gapX,
newGapY: gapY,
record: true,
});
cmd.execute();
};
const changeFill_Gap = (gapX, gapY, obj) => {
if (obj.oldFill_) {
obj.fill_ = { ...obj.oldFill_ };
delete obj.oldFill_;
}
const cmd = new FillRepeatGapChangeCommand({
canvas: props.canvas,
layers: layers,
canvasManager: props.canvasManager,
layerManager: props.layerManager,
layerId: obj.layerId,
newGapX: gapX,
newGapY: gapY,
});
props.commandManager.execute(cmd);
};
const setLayerComposite = (newValue, oldValue, obj, isCmd) => {
const cmd = new SetLayerCompositeCommand({
canvas: props.canvas,
layers: layers,
layerManager: props.layerManager,
layerId: obj.layerId,
newValue: newValue,
oldValue: oldValue,
});
if (isCmd) {
props.commandManager.execute(cmd);
} else {
cmd.execute();
}
};
const clickTest = (obj) => {
console.log("==========点击测试", obj);
};
// 更新选中对象属性
const updateActiveObjects = (arrs, keys, isNumber = true) => {
arrs.forEach((v) => {
activeObjects.forEach((item) => {
if (item.id === v.id) {
keys.forEach(
(key) => (item[key] = isNumber ? Number(v[key]) : v[key])
);
}
});
});
};
// 旋转对象时更新角度
const objectRotatingChange = (e) => {
const arrs = [];
if (e.target._objects) {
e.target._objects.forEach((v) => arrs.push(v));
} else {
arrs.push(e.target);
}
updateActiveObjects(arrs, ["angle"]);
};
// 对象属性修改后触发
const objectModifiedChange = (e) => {
console.log("==========object:modified", e.target);
};
// 不透明度撤销时触发
const objectOpacityUndo = (layerId, opacity) => {
const layerObjects = props.canvas
.getObjects()
.filter((obj) => obj.layerId === layerId);
updateActiveObjects(layerObjects, ["opacity"]);
};
// 对象属性修改撤销时触发
const objectModifiedUndo = (object) => {
updateActiveObjects([object], ["angle"]);
};
// 组合操作撤销时触发
const objectCompositeChange = (object) => {
updateActiveObjects([object], ["globalCompositeOperation"], false);
};
/**
* 设置画布事件监听
*/
function setupCanvasListeners() {
if (!props.canvas) return;
// 注册事件
props.canvas.on("selection:created", getActiveObject);
props.canvas.on("selection:updated", getActiveObject);
props.canvas.on("selection:cleared", cancelSelect);
props.canvas.on("object:rotating", objectRotatingChange);
props.canvas.on("object:modified", objectModifiedChange);
EventManager.on("object:opacity:execute", objectOpacityUndo);
EventManager.on("object:opacity:undo", objectOpacityUndo);
EventManager.on("object:modified:execute", objectModifiedUndo);
EventManager.on("object:modified:undo", objectModifiedUndo);
EventManager.on("object:composite:execute", objectCompositeChange);
EventManager.on("object:composite:undo", objectCompositeChange);
}
/**
* 移除画布事件监听
*/
function removeCanvasListeners() {
if (!props.canvas) return;
// 移除事件
props.canvas.off("selection:created", getActiveObject);
props.canvas.off("selection:updated", getActiveObject);
props.canvas.off("selection:cleared", cancelSelect);
props.canvas.off("object:rotating", objectRotatingChange);
props.canvas.off("object:modified", objectModifiedChange);
EventManager.off("object:opacity:execute", objectOpacityUndo);
EventManager.off("object:opacity:undo", objectOpacityUndo);
EventManager.off("object:modified:execute", objectModifiedUndo);
EventManager.off("object:modified:undo", objectModifiedUndo);
EventManager.off("object:composite:execute", objectCompositeChange);
EventManager.off("object:composite:undo", objectCompositeChange);
}
</script>
<style scoped lang="less">
.select-menu-panel {
position: absolute;
bottom: 22px;
left: 0;
right: 0;
// max-width: min(90vw, 640px);
max-width: 95%;
width: 80rem;
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);
user-select: none;
&.active {
transform: translateY(100%);
> .btn {
> i {
transform: rotate(90deg);
}
}
}
> .btn {
width: 100%;
height: 22px;
text-align: center;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
> i {
font-size: 1.4rem;
transform: rotate(270deg);
}
}
}
/* 平板和手机适配 */
@media screen and (max-width: 768px) {
.select-menu-panel {
bottom: 15px;
left: 15px;
right: 15px;
max-width: calc(100vw - 30px);
border-radius: 6px;
}
}
@media screen and (max-width: 480px) {
.select-menu-panel {
bottom: 10px;
left: 10px;
right: 10px;
max-width: calc(100vw - 20px);
}
}
.select-menu-panel.is-active {
transform: translateY(0);
}
.panel-header {
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;
}
.panel-select {
// padding: 0 0 10px;
}
/* 平板适配 */
@media screen and (max-width: 768px) {
.panel-header {
padding: 6px 12px;
border-radius: 6px 6px 0 0;
}
}
/* 手机适配 */
@media screen and (max-width: 480px) {
.panel-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: 6px;
color: #333;
cursor: pointer;
transition: all 0.2s;
}
.tool-btn span {
margin-top: 0;
font-size: 12px;
}
.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;
}
.panel-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.05);
margin: 0 10px 5px 10px;
}
.tool-content {
overflow-y: auto;
max-height: 20rem;
margin-top: 1rem;
padding: 0 1.5rem;
> .object-item {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
padding: 1rem 0;
&:last-child {
border-bottom: none;
}
> .title {
text-align: left;
margin-bottom: 0.5rem;
}
> .list {
display: flex;
> div {
display: flex;
align-items: center;
justify-content: center;
margin-right: 1.5rem;
position: relative;
&:last-child {
margin-right: 0;
}
> .iconfont {
font-size: 1.8rem;
}
> .label {
font-size: 1.3rem;
margin: 0 0.5rem;
}
> .angle-tool {
width: 9rem;
}
> .tip {
position: absolute;
top: -5px;
left: 50%;
transform: translate(-50%, -100%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 0.4rem;
// margin-left: 0.8rem;
font-size: 1.2rem;
white-space: nowrap;
pointer-events: none;
display: none;
&::after {
content: "";
position: absolute;
top: 97%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid rgba(0, 0, 0, 0.8);
}
}
&:hover {
> .tip {
display: block;
}
}
}
> div.input {
font-size: 1.4rem;
color: #474747;
> .label {
margin-right: 0.5rem;
font-size: 1.4rem;
}
> .iconfont {
margin-right: 0.4rem;
}
> .slider {
width: 8rem;
}
}
> div.select {
> .iconfont {
margin-right: 4px;
}
> .my-select,
> .ant-select {
width: 12rem;
text-align: left;
font-size: 1.4rem;
}
}
> div.btn {
min-width: 2.8rem;
cursor: pointer;
border-radius: 2px;
transition: background-color 0.2s;
background-color: rgba(0, 0, 0, 0);
&:hover {
background-color: rgba(0, 0, 0, 0.08);
}
}
> div.color {
width: 4rem;
height: 2.5rem;
cursor: pointer;
background-image: linear-gradient(to bottom, #ff0000, #ffff00);
}
}
}
}
/* 平板适配 - 每行4个按钮 */
@media screen and (max-width: 768px) {
.tool-content {
grid-template-columns: repeat(3, 1fr);
gap: 8px 6px;
padding: 0 8px;
}
}
/* 手机适配 - 每行3个按钮 */
@media screen and (max-width: 480px) {
.tool-content {
grid-template-columns: repeat(3, 1fr);
gap: 6px 4px;
padding: 0 6px;
}
.header-btn {
font-size: 11px;
padding: 2px 4px;
min-width: 28px;
}
}
</style>

View File

@@ -412,8 +412,12 @@ const handleToolClick = (tool) => {
overflow-y: auto;
overflow-x: hidden;
}
.tools-list::-webkit-scrollbar {
display: none;
}
.red-green-mode {
background-color: #fff4f4;
background-color: #060505;
}
.mode-indicator {

View File

@@ -270,6 +270,13 @@
color: #ccc;
cursor: not-allowed;
}
.layer-color-btn {
width: 30px;
height: 20px;
margin-right: 5px;
border-radius: 2px;
border: 1px solid #000;
}
.layer-actions {
display: flex;
gap: 0.6rem;

View File

@@ -0,0 +1,121 @@
<template>
<div class="angle-tool">
<div
ref="dishRef"
class="dish"
@mousedown.stop="mousedown"
@touchmove.stop="mousedown"
>
<div class="pointer" :style="{ transform: `rotate(${angle}deg)` }">
<span></span>
</div>
</div>
<div class="input">
<input type="number" v-model="angle" @input="onInput" @change="onChange" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
import { calculateAngle } from "../../utils/helper";
// Props
const props = defineProps({
angle: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["change", "input"]);
const angle = ref(props.angle);
watch(() => props.angle, (value) => {
angle.value = value;
});
const dishRef = ref<HTMLDivElement>();
const mousedown = (e: MouseEvent | TouchEvent) => {
const mousemove = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return;
const { left, top, width, height } =
dishRef.value.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const { clientX, clientY } = e?.touches?.[0] || e;
angle.value = calculateAngle(centerX, centerY, clientX, clientY, true);
onInput();
};
mousemove(e);
const mouseup = () => {
onChange();
document.removeEventListener("mousemove", mousemove);
document.removeEventListener("touchmove", mousemove);
document.removeEventListener("mouseup", mouseup);
document.removeEventListener("touchend", mouseup);
};
document.addEventListener("mousemove", mousemove);
document.addEventListener("touchmove", mousemove);
document.addEventListener("mouseup", mouseup);
document.addEventListener("touchend", mouseup);
};
const onInput = () => emit("input", angle.value);
var changeTime: any = null;
const onChange = () => {
clearTimeout(changeTime);
changeTime = setTimeout(() => emit("change", angle.value), 500);
};
// var angleTime = null;
// watch(angle, (value) => {
// emit("input", value);
// clearTimeout(angleTime);
// angleTime = setTimeout(() => emit("change", value), 50);
// });
// defineExpose({
// open,
// close,
// });
</script>
<style scoped lang="less">
.angle-tool {
display: flex;
align-items: center;
width: 100%;
> .dish {
width: 24px;
height: 24px;
border: 1px solid #000;
border-radius: 50%;
cursor: pointer;
> .pointer {
pointer-events: none;
user-select: none;
position: relative;
width: 100%;
height: 100%;
> span {
position: absolute;
top: 10%;
left: 50%;
transform: translate(-50%, 0);
width: 35%;
height: 35%;
background-color: #000;
border-radius: 50%;
}
}
}
> .input {
margin-left: 5px;
font-size: 14px;
color: #000;
flex: 1;
// min-width: 45px;
// max-width: 80px;
// width: 50px;
> input {
width: 100%;
border-radius: 3px;
outline: none;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<a-select
class="my-select"
:size="size"
@change="change"
:defaultValue="defaultValue"
@dropdownVisibleChange="dropdownVisibleChange"
>
<a-select-option
v-for="v in list"
:key="v.value"
:value="v.value"
:title="v.tip"
@mouseover.stop.prevent="mouseover(v)"
@mouseleave="mouseleave(v)"
>{{ v.label }}</a-select-option
>
</a-select>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
const props = defineProps({
defaultValue: {
default: "",
},
list: {
type: Array,
default: () => [],
},
size: {
type: String,
default: "small",
},
});
const emit = defineEmits(["change", "active"]);
const isChange = ref(false);
const initValue = ref(props.defaultValue);
const activeValue = ref(props.defaultValue);
const timeout = ref(null);
const mouseover = (v) => {
clearTimeout(timeout.value);
if (v.value === activeValue.value) return;
emit("active", v.value, activeValue.value);
activeValue.value = v.value;
};
const mouseleave = () => {
clearTimeout(timeout.value);
timeout.value = setTimeout(() => {
dropdownVisibleChange(false);
}, 100);
};
const change = (v) => {
isChange.value = true;
emit("change", v, initValue.value);
};
const dropdownVisibleChange = (v) => {
if (v) {
isChange.value = false;
initValue.value = props.defaultValue;
} else if (!isChange.value) {
emit("active", initValue.value, activeValue.value);
activeValue.value = initValue.value;
}
};
</script>

View File

@@ -0,0 +1,190 @@
<template>
<div class="offset-tool">
<div
class="dish"
@mousedown="mousedown"
@touchstart="mousedown"
ref="dishRef"
>
<span
:style="{ top: data.top + '%', left: data.left + '%' }"
></span>
</div>
<input
class="top"
type="range"
:min="0"
:max="100"
:step="0.1"
v-model="data.top"
@input="onInput"
@change="onChange"
/>
<input
class="left"
type="range"
:min="0"
:max="100"
:step="0.1"
v-model="data.left"
@input="onInput"
@change="onChange"
/>
<span class="tip"
>x:{{ tofix(data.left) }}% y:{{ tofix(data.top) }}%</span
>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
const props = defineProps({
top: {
type: Number,
default: 50,
},
left: {
type: Number,
default: 50,
},
});
const tofix = (v: number | string) => Number(Number(v).toFixed(1));
const emit = defineEmits(["change", "input"]);
const data = reactive({
top: tofix(props.top),
left: tofix(props.left),
});
watch(
() => props.top,
(v) => (data.top = tofix(v))
);
watch(
() => props.left,
(v) => (data.left = tofix(v))
);
const dishRef = ref<HTMLDivElement>();
const mousedown = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return;
const mousemove = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return;
const { left, top, width, height } =
dishRef.value.getBoundingClientRect();
const X = e.clientX || (e as TouchEvent).touches[0].clientX;
const Y = e.clientY || (e as TouchEvent).touches[0].clientY;
var x = ((X - left) / width) * 100;
var y = ((Y - top) / height) * 100;
if (x < 0) x = 0;
if (x > 100) x = 100;
if (y < 0) y = 0;
if (y > 100) y = 100;
data.left = tofix(x);
data.top = tofix(y);
onInput();
};
mousemove(e);
const mouseup = () => {
onChange();
document.removeEventListener("mousemove", mousemove);
document.removeEventListener("touchmove", mousemove);
document.removeEventListener("mouseup", mouseup);
document.removeEventListener("touchend", mouseup);
};
document.addEventListener("mousemove", mousemove);
document.addEventListener("touchmove", mousemove);
document.addEventListener("mouseup", mouseup);
document.addEventListener("touchend", mouseup);
};
const onInput = () => emit("input", { ...data });
var changeTime: any = null;
const onChange = () => {
clearTimeout(changeTime);
changeTime = setTimeout(() => emit("change", { ...data }), 500);
};
// var offsetTime = null;
// watch(data, (v) => {
// const obj = { ...v };
// emit("input", obj);
// clearTimeout(offsetTime);
// offsetTime = setTimeout(() => emit("change", obj), 50);
// });
// defineExpose({
// open,
// close,
// });
</script>
<style scoped lang="less">
.offset-tool {
width: 125px;
height: 125px;
display: flex;
position: relative;
overflow: hidden;
--gap: 15px;
> .dish {
margin: var(--gap) 0 0 var(--gap);
flex: 1;
border: 1px solid #000;
border-radius: 5px;
cursor: pointer;
position: relative;
background-color: #fff;
> span {
pointer-events: none;
user-select: none;
position: absolute;
top: 0%;
left: 0%;
transform: translate(-50%, -50%);
width: 8px;
height: 8px;
background-color: #000;
border-radius: 50%;
}
}
> .tip {
position: absolute;
right: 4px;
bottom: 0;
font-size: 10px;
pointer-events: none;
user-select: none;
color: #666;
}
> input.left {
right: 0;
}
> input.top {
bottom: 0;
left: 0;
transform-origin: left bottom;
transform: rotate(90deg) translateX(-100%);
}
> input {
position: absolute;
width: calc(100% - var(--gap));
-webkit-appearance: none;
appearance: none;
height: 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
// outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4285f4; /* 蓝色滑块 */
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
&::-webkit-slider-thumb:hover {
background: #3b77db;
transform: scale(1.1);
}
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="slider">
<div class="input-range">
<span
class="tip"
:style="{
'--progress': (value - props.min) / (props.max - props.min),
}"
>{{ props.tipFormatter(value) }}</span
>
<input
type="range"
v-model="value"
:min="props.min"
:max="props.max"
:step="props.step"
@input="onInput"
@change="onChange"
/>
</div>
<div class="input" v-show="isInput">
<input
type="number"
v-model="value"
:min="props.min"
:max="props.max"
:step="props.step"
@input="onInput"
@change="onChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
const props = defineProps({
value: {
type: Number,
default: 0,
},
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 100,
},
step: {
type: Number,
default: 1,
},
tipFormatter: {
type: Function,
default: (v) => v,
},
isInput: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["change", "input"]);
const value = ref(props.value);
watch(
() => props.value,
(v) => (value.value = v)
);
const onInput = () => emit("input", Number(value.value));
var changeTime: any = null;
const onChange = () => {
clearTimeout(changeTime);
changeTime = setTimeout(() => emit("change", Number(value.value)), 500);
};
</script>
<style scoped lang="less">
.slider {
position: relative;
display: flex;
align-items: center;
--input-thumb-size: 12px;
width: 150px;
// &:focus-within,
&:hover {
> .input-range > .tip {
display: block;
}
}
> .input-range {
position: relative;
flex: 2;
> input {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 5px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
outline: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--input-thumb-size);
height: var(--input-thumb-size);
border-radius: 50%;
background: #4285f4; /* 蓝色滑块 */
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
&::-webkit-slider-thumb:hover {
background: #3b77db;
transform: scale(1.1);
}
}
> .tip {
position: absolute;
font-size: 10px;
pointer-events: none;
user-select: none;
color: #666;
top: 0;
left: calc(
(100% - var(--input-thumb-size)) * var(--progress) +
var(--input-thumb-size) / 2
);
transform: translate(-50%, -100%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 0.4rem;
font-size: 1.2rem;
white-space: nowrap;
pointer-events: none;
display: none;
&::after {
content: "";
position: absolute;
top: 97%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid rgba(0, 0, 0, 0.8);
}
}
}
> .input {
flex: 1;
margin-left: 10px;
> input {
border-radius: 3px;
width: 100%;
}
}
}
</style>

View File

@@ -35,7 +35,8 @@ import LayersPanel from "./components/LayersPanel/LayersPanel.vue";
import BrushControlPanel from "./components/BrushControlPanel.vue";
import TextEditorPanel from "./components/TextEditorPanel.vue"; // 引入文本编辑面板
import LiquifyPanel from "./components/LiquifyPanel.vue"; // 引入液化编辑面板
import SelectMenuPanel from "./components/SelectMenuPanel.vue"; // 引入选择工具菜单组件
import PalletPanel from "./components/PalletPanel/index.vue";
import SelectMenuPanel from "./components/SelectMenuPanel/index.vue"; // 引入选择工具菜单组件
import SelectionPanel from "./components/SelectionPanel.vue"; // 引入选区面板
import { LayerType, OperationType } from "./utils/layerHelper.js";
import { ToolManager } from "./managers/ToolManager.js";
@@ -64,6 +65,10 @@ const props = defineProps({
type: [Object, String],
default: "", // 默认空
},
otherData: {
type: [Object, null],
default: null, // 默认空对象
},
config: {
type: Object,
default: () => CanvasConfig, // 默认配置
@@ -78,7 +83,11 @@ const props = defineProps({
},
clothingImageUrl: {
type: String,
default: "", // 衣服底图URL
default: "", // 衣服底图URL-线稿
},
clothingImageUrl2: {
type: String,
default: "", // 衣服底图URL-上色
},
redGreenImageUrl: {
type: String,
@@ -250,6 +259,7 @@ onMounted(async () => {
canvasColor,
enabledRedGreenMode: props.enabledRedGreenMode,
isFixedErasable: props.isFixedErasable,
props,
});
canvasManager.canvas.activeLayerId = activeLayerId;
canvasManager.activeLayerId = activeLayerId;
@@ -307,6 +317,7 @@ onMounted(async () => {
canvas: canvasManager.canvas,
commandManager,
layerManager,
canvasManager,
toolManager,
isRedGreenMode,
pasteText: (text) => {
@@ -435,6 +446,12 @@ onMounted(async () => {
canvasManager.canvas.width,
canvasManager.canvas.height
);
if(props.otherData && !props.otherData.canvasId) {
await canvasManager?.createOtherLayers(props.otherData);
await layerManager?.layerSort?.rearrangeObjects();
}
}
// // 设置固定图层是否可擦除
@@ -720,42 +737,7 @@ function deleteFun() {
}
function removeLayer(layerId) {
// Check if this is the last layer - prevent deletion
var isChild = false;
var parentLength = 0;
layers.value.forEach((layer) => {
if(layer.children.some(v => v.id == layerId)){
isChild = true;
parentLength = layer.children.length;
}
})
if(isChild && parentLength == 1 || layers.value.length <= 3){
console.warn(
"Cannot delete the last layer. At least one layer must remain."
);
return;
}
layerManager.removeLayer(layerId);
// 此处删除画布上内容导致撤回操作无效(多余)
// if (canvasManager && canvasManager.canvas) {
// const layerToRemove = layers.value.find((l) => l.id === layerId);
// if (layerToRemove) {
// const elementIds = layerToRemove?.fabricObjects?.map((e) => e.id);
// elementIds.forEach((elementId) => {
// const objectToRemove = canvasManager.canvas
// .getObjects()
// .find((obj) => obj.id === elementId);
// if (objectToRemove) {
// canvasManager.canvas.remove(objectToRemove);
// }
// });
// if (activeLayerId.value === layerId) {
// activeElementId.value = null;
// }
// canvasManager.canvas.renderAll();
// }
// }
}
function triggerImageUpload() {
@@ -902,13 +884,17 @@ const changeCanvas = async (command) => {
...command, // 传递完整的命令数据
};
emit("changeCanvas", commandData);
if (command.canUndo || command.canRedo) {
if ((command.canUndo || command.canRedo) && props.enabledRedGreenMode) {
setTimeout(async () => {
const imageData = await canvasManager.exportImage({
restoreOpacityInRedGreen: true, // 恢复红绿图模式下的透明度
isCropByBg: true,
});
emit("trigger-red-green-mouseup", imageData);
try {
const imageData = await canvasManager.exportImage({
restoreOpacityInRedGreen: true, // 恢复红绿图模式下的透明度
isCropByBg: true,
});
emit("trigger-red-green-mouseup", imageData);
} catch (error) {
}
}, 100);
}
};
@@ -918,6 +904,14 @@ const cropImage = (url) => {
return cropImageRef.value.open(url)
};
provide("cropImage", cropImage); // 提供给子组件使用
// 颜色选择器组件
const palletPanelRef = ref(null);
const palletPanel = (url) => {
return palletPanelRef.value.open(url)
};
provide("palletPanel", palletPanel); // 提供给子组件使用
// 处理画布容器的拖放事件
const isDragOver = ref(false);
@@ -1030,6 +1024,10 @@ defineExpose({
isEnhanceImg,
});
},
// 导出颜色图层
exportColorLayer: () => {
return canvasManager.exportColorLayer();
},
/**
* 移动图层位置
* @param {string} layerId 图层ID
@@ -1245,6 +1243,7 @@ defineExpose({
:commandManager="commandManager"
:selectionManager="selectionManager"
:layerManager="layerManager"
:canvasManager="canvasManager"
:toolManager="toolManager"
:activeTool="activeTool"
/>
@@ -1269,6 +1268,7 @@ defineExpose({
?
</button>
</div>
</div>
<!-- 图层面板组件 -->
@@ -1298,9 +1298,11 @@ defineExpose({
</div>
</transition>
<!-- 裁剪图片组件 -->
<CropImage ref="cropImageRef" />
</div>
<!-- 裁剪图片组件 -->
<CropImage ref="cropImageRef" />
<!-- 颜色选择器组件 -->
<PalletPanel ref="palletPanelRef" />
<!-- <div class="footer-actions">
<button class="share-btn">Share</button>

View File

@@ -9,7 +9,12 @@ import {
isGroupLayer,
OperationType,
OperationTypes,
findLayer,
createLayer,
LayerType,
SpecialLayerId,
} from "../utils/layerHelper";
import { ObjectMoveCommand } from "../commands/ObjectCommands";
import { AnimationManager } from "./animation/AnimationManager";
import { createCanvas } from "../utils/canvasFactory";
import { CanvasEventManager } from "./events/CanvasEventManager";
@@ -21,6 +26,10 @@ import {
findObjectById,
generateId,
optimizeCanvasRendering,
palletToFill,
fillToCssStyle,
calculateRotatedTopLeftDeg,
createPatternTransform,
} from "../utils/helper";
import { ChangeFixedImageCommand } from "../commands/ObjectLayerCommands";
import { isFunction } from "lodash-es";
@@ -30,6 +39,11 @@ import {
validateLayerAssociations,
} from "../utils/layerUtils";
import { imageModeHandler } from "../utils/imageHelper";
import { getObjectAlphaToCanvas } from "../utils/objectHelper";
import { AddLayerCommand } from "../commands/LayerCommands";
import { fa, id } from "element-plus/es/locales.mjs";
import i18n from "@/lang/index.ts";
const {t} = i18n.global;
export class CanvasManager {
constructor(canvasElement, options) {
@@ -50,6 +64,7 @@ export class CanvasManager {
this.isFixedErasable = options.isFixedErasable || false; // 是否允许擦除固定图层
this.eraserStateManager = null; // 橡皮擦状态管理器引用
this.handleCanvasInit = null; // 画布初始化回调函数
this.props = options.props || {};
// 初始化画布
this.initializeCanvas();
}
@@ -83,10 +98,10 @@ export class CanvasManager {
this.canvas.thumbnailManager = this.thumbnailManager; // 将缩略图管理器绑定到画布
// // 设置画布辅助线
// initAligningGuidelines(this.canvas);
// 设置画布辅助线
initAligningGuidelines(this.canvas);
// // 设置画布中心线
// 设置画布中心线
// initCenteringGuidelines(this.canvas);
// 初始化画布事件监听器
@@ -431,7 +446,7 @@ export class CanvasManager {
* 以背景层为参照,计算背景层的偏移量并应用到所有对象上
* 这样可以保持对象间的相对位置关系不变
*/
centerAllObjects() {
async centerAllObjects() {
if (!this.canvas) return;
// 获取所有可见对象(不是背景元素的对象)
@@ -448,8 +463,8 @@ export class CanvasManager {
// 获取背景对象
const backgroundObject = visibleObjects.find((obj) => obj.isBackground);
!this.canvas?.clipPath &&
this.centerBackgroundLayer(this.canvas.width, this.canvas.height);
// !this.canvas?.clipPath &&
// this.centerBackgroundLayer(this.canvas.width, this.canvas.height);
this.canvas?.clipPath?.set?.({
left: this.width / 2,
@@ -496,7 +511,6 @@ export class CanvasManager {
// 计算背景层的偏移量
const deltaX = backgroundObject.left - backgroundOldLeft;
const deltaY = backgroundObject.top - backgroundOldTop;
// 将相同的偏移量应用到所有其他对象上
const otherObjects = visibleObjects.filter(
(obj) => obj !== backgroundObject
@@ -549,8 +563,21 @@ export class CanvasManager {
this.updateMaskPosition(backgroundObject);
}
// 更新颜色层信息
const fixedLayerObj = this.getFixedLayerObject();
const colorObject = this.getLayerObjectById(SpecialLayerId.COLOR);
if(colorObject && fixedLayerObj){
await this.setColorObjectInfo(colorObject, fixedLayerObj);
}
const groupLayer = this.layerManager.getLayerById(SpecialLayerId.SPECIAL_GROUP);
if(groupLayer && fixedLayerObj){
const groupRect = new fabric.Rect({});
await this.setColorObjectInfo(groupRect, fixedLayerObj);
groupLayer.clippingMask = groupRect.toObject();
}
// 重新渲染画布
// this.canvas.renderAll();
this.canvas.renderAll();
}
/**
@@ -600,7 +627,7 @@ export class CanvasManager {
* @param {Number} canvasWidth 画布宽度
* @param {Number} canvasHeight 画布高度
*/
centerBackgroundLayer(canvasWidth, canvasHeight) {
async centerBackgroundLayer(canvasWidth, canvasHeight) {
const backgroundLayerObject = this.getBackgroundLayer();
if (!backgroundLayerObject) return false;
@@ -646,6 +673,11 @@ export class CanvasManager {
if (this.maskLayer) {
this.canvas.remove(this.maskLayer);
}
this.canvas.getObjects().forEach((obj) => {
if (obj.id === "canvasMaskLayer") {
this.canvas.remove(obj);
}
})
// 创建蒙层 - 使用透明矩形作为裁剪区域
this.maskLayer = new fabric.Rect({
@@ -706,6 +738,75 @@ export class CanvasManager {
return backgroundLayerByBgLayer;
}
getFixedLayerObject() {
if (!this.canvas) return null;
const fixedLayer = this.canvas.getObjects().find((obj) => {
return obj.isFixed;
});
if (fixedLayer) return fixedLayer;
// 如果没有找到固定层则根据图层ID查找
const fixedLayerId = this.layers.value.find((layer) => {
return layer.isFixed;
})?.id;
const fixedLayerByFixedLayer = this.canvas.getObjects().find((obj) => {
return obj.isFixed || obj.id === fixedLayerId;
});
if (!fixedLayerByFixedLayer) {
console.warn(
"CanvasManager.js = >getFixedLayerObject 方法没有找到固定层"
);
}
return fixedLayerByFixedLayer;
}
getBackgroundLayerObject() {
if (!this.canvas) return null;
const backgroundLayer = this.canvas.getObjects().find((obj) => {
return obj.isBackground;
});
if (backgroundLayer) return backgroundLayer;
// 如果没有找到背景层则根据图层ID查找
const backgroundLayerId = this.layers.value.find((layer) => {
return layer.isBackground;
})?.id;
const backgroundLayerByBgLayer = this.canvas.getObjects().find((obj) => {
return obj.isBackground || obj.id === backgroundLayerId;
});
if (!backgroundLayerByBgLayer) {
console.warn(
"CanvasManager.js = >getBackgroundLayerObject 方法没有找到背景层"
);
}
return backgroundLayerByBgLayer;
}
getLayerObjectById(layerId) {
if (!this.canvas) return null;
const layerObject = this.canvas.getObjects().find((obj) => {
return obj.id === layerId;
});
if (layerObject) return layerObject;
// 如果没有找到图层对象则根据图层ID查找
const layerObjectByLayerId = this.canvas.getObjects().find((obj) => {
return obj.id === layerId;
});
if (!layerObjectByLayerId) {
console.warn(
"CanvasManager.js = >getLayerObjectById 方法没有找到图层对象"
);
}
return layerObjectByLayerId;
}
/**
* 更新蒙层位置
* @param {Object} backgroundLayerObject 背景层对象
@@ -798,7 +899,7 @@ export class CanvasManager {
// 如果找到了图层,则生成缩略图
findLayer && this.thumbnailManager?.generateLayerThumbnail(findLayer.id);
this.layerManager?.sortLayers?.();
return result;
}
@@ -812,6 +913,7 @@ export class CanvasManager {
* @param {String} options.expPicType 导出图片类型 (png/jpg/svg)
* @param {Boolean} options.restoreOpacityInRedGreen 红绿图模式下是否恢复透明度为1
* @param {Boolean} options.isEnhanceImg 是否是增强图片
* @param {Boolean} options.isCropByBg 是否使用背景大小裁剪
* @returns {String} 导出的图片数据URL
*/
async exportImage(options = {}) {
@@ -857,11 +959,55 @@ export class CanvasManager {
}
return await this.exportManager.exportImage(enhancedOptions);
} catch (error) {
console.error("CanvasManager导出图片失败:", error);
console.warn("CanvasManager导出图片失败:", error);
throw error;
}
}
/**
* 导出颜色图层
* @returns {Object} 导出的颜色图层数据URL
*/
async exportColorLayer() {
if (!this.exportManager) {
console.warn("导出管理器未初始化,请确保已设置图层管理器");
return Promise.reject("颜色图层不存在");
}
const object = this.getLayerObjectById(SpecialLayerId.COLOR);
if(!object){
console.warn("颜色图层不存在,请确保已添加颜色图层");
return Promise.reject("颜色图层不存在");
}
const color = fillToCssStyle(object.fill)
const canvas = new fabric.StaticCanvas();
canvas.setDimensions({
width: object.width,
height: object.height,
backgroundColor: null,
// enableRetinaScaling: true,
imageSmoothingEnabled: true,
});
const cloneObject = await new Promise((resolve, reject) => {
object.clone(resolve);
});
cloneObject.set({
left: canvas.width / 2,
top: canvas.height / 2,
scaleX: 1,
scaleY: 1,
visible: true,
clipPath: null,
});
canvas.add(cloneObject);
canvas.renderAll();
const base64 = canvas.toDataURL({
format: "png",
quality: 1,
});
canvas.clear();
return {color, base64};
}
dispose() {
// 释放导出管理器资源
if (this.exportManager) {
@@ -956,14 +1102,13 @@ export class CanvasManager {
// };
try {
// 清除画布中选中状态
this.canvas.discardActiveObject();
// this.canvas.discardActiveObject();
this.canvas.renderAll();
const simplifyLayersData = simplifyLayers(
JSON.parse(JSON.stringify(this.layers.value))
);
console.log("获取画布JSON数据...", simplifyLayersData);
return JSON.stringify({
const data = JSON.stringify({
canvas: this.canvas.toJSON([
"id",
"type",
@@ -978,6 +1123,7 @@ export class CanvasManager {
"eraserable",
"erasable",
"customType",
"fill_",
]),
layers: simplifyLayersData, // 简化图层数据
// layers: JSON.stringify(JSON.parse(JSON.stringify(this.layers.value))), // 全数据
@@ -988,6 +1134,8 @@ export class CanvasManager {
canvasColor: this.canvasColor.value,
activeLayerId: this.layerManager?.activeLayerId?.value,
});
console.log("获取画布JSON数据...", data);
return data;
} catch (error) {
console.error("获取画布JSON失败:", error);
throw new Error("获取画布JSON失败");
@@ -1070,8 +1218,10 @@ export class CanvasManager {
// }
try {
// 重置画布数据
this.setCanvasSize(this.canvas.width, this.canvas.height);
this.centerBackgroundLayer(this.canvas.width, this.canvas.height);
await this.setCanvasSize(this.canvas.width, this.canvas.height);
await this.centerBackgroundLayer(this.canvas.width, this.canvas.height);
await this.createOtherLayers(this.props.otherData);
// 重新构建对象关系
// restoreObjectLayerAssociations(this.layers.value, this.canvas.getObjects());
// 验证图层关联关系 - 稳定后可以注释
@@ -1099,9 +1249,7 @@ export class CanvasManager {
await calllBack?.();
// 确保所有对象的交互性正确设置
await this.layerManager?.updateLayersObjectsInteractivity?.(
false
);
await this.layerManager?.updateLayersObjectsInteractivity?.();
console.log(this.layerManager.layers.value);
// 更新所有缩略图
@@ -1126,6 +1274,253 @@ export class CanvasManager {
}
}
/**
* 创建其他图层:印花、颜色、元素...
* @param {Object} otherData - 其他图层数据
*/
async createOtherLayers(otherData) {
if (!otherData) return console.warn("otherData 为空不需要添加");
const otherData_ = JSON.parse(JSON.stringify(otherData));
console.log("==========创建其他图层", otherData_);
// 创建颜色图层
await this.createColorLayer(otherData_.color);
if(findLayer(this.layers.value, SpecialLayerId.SPECIAL_GROUP)){
console.warn("画布中已存在印花和元素组图层");
}else{
const printTrimsLayers = [];// 印花和元素图层
const singleLayers = [];// 平铺图层
otherData_?.printObject?.prints?.forEach((print, index) => {
print.name = t("Canvas.Print") + (index + 1);
if(print.ifSingle){
printTrimsLayers.unshift({...print});
}else{
singleLayers.unshift({...print});
}
})
otherData_?.trims?.prints?.forEach((print, index) => {
print.name = t("Canvas.Elements") + (index + 1);
printTrimsLayers.unshift({...print});
})
await this.createPrintTrimsLayers(printTrimsLayers, singleLayers);
}
}
async setColorObjectInfo(colorRect, fixedLayerObj){
colorRect.set({
top: fixedLayerObj.top,
left: fixedLayerObj.left,
width: fixedLayerObj.width,
height: fixedLayerObj.height,
originX: fixedLayerObj.originX,
originY: fixedLayerObj.originY,
scaleX: fixedLayerObj.scaleX,
scaleY: fixedLayerObj.scaleY,
});
var object = fixedLayerObj;
const imageUrl = this.props.clothingImageUrl2;
if(imageUrl){
object = await new Promise((resolve, reject) => {
fabric.Image.fromURL(imageUrl, (imgObject) => {
colorRect.set({
width: imgObject.width,
height: imgObject.height,
});
resolve(imgObject);
}, { crossOrigin: "anonymous" });
});
}
const canvas = getObjectAlphaToCanvas(object);
const transparentMask = new fabric.Image(canvas, {
top: 0,
left: 0,
originX: fixedLayerObj.originX,
originY: fixedLayerObj.originY,
});
colorRect.set('clipPath', transparentMask);
}
async createColorLayer(color){
if(!color) return console.warn("颜色为空不需要添加");
if(findLayer(this.layers.value, SpecialLayerId.COLOR)) return console.warn("画布中已存在颜色图层");
console.log("==========添加颜色图层", color, this.layers.value.length)
const fixedLayerObj = this.getFixedLayerObject();
// 创建颜色图层对象
const colorRect = new fabric.Rect({
id: SpecialLayerId.COLOR,
layerId: SpecialLayerId.COLOR,
layerName: t("Canvas.color"),
isVisible: true,
isLocked: true,
});
await this.setColorObjectInfo(colorRect, fixedLayerObj);
const gradientObj = palletToFill(color);
const gradient = new fabric.Gradient({
type: 'linear',
gradientUnits: 'percentage',
...gradientObj,
})
colorRect.set('fill', gradient);
this.canvas.add(colorRect);
// 创建颜色图层
const colorLayer = createLayer({
id: colorRect.layerId,
name: colorRect.layerName,
type: LayerType.SHAPE,
visible: colorRect.isVisible,
locked: colorRect.isLocked,
opacity: 1.0,
isFixedOther: true,
fabricObjects: [colorRect.toObject(["id", "layerId", "layerName"])],
})
const groupIndex = this.layers.value.findIndex(layer => layer.isFixed || layer.isBackground);
this.layers.value.splice(groupIndex, 0, colorLayer);
}
// 创建印花和元素图层
async createPrintTrimsLayers(printTrimsLayers, singleLayers){
console.log("==========添加印花和元素图层组", printTrimsLayers, singleLayers)
const fixedLayerObj = this.getFixedLayerObject();
const flWidth = fixedLayerObj.width
const flHeight = fixedLayerObj.height
const flTop = fixedLayerObj.top
const flLeft = fixedLayerObj.left
const flScaleX = fixedLayerObj.scaleX
const flScaleY = fixedLayerObj.scaleY
const children = [];
// 添加印花和元素图层
for(let index = 0; index < printTrimsLayers.length; index++){
let print = printTrimsLayers[index];
let id = generateId("layer_image_");
let name = print.name;
let image = await new Promise(resolve => {
fabric.Image.fromURL(print.path, (fabricImage)=>{
const left = flLeft - flWidth * flScaleX / 2 + (print.location?.[0] || 0) * flScaleX
const top = flTop - flHeight * flScaleY / 2 + (print.location?.[1] || 0) * flScaleY
const scaleX = flWidth * (print.scale?.[0] || 1) / fabricImage.width * flScaleX
const scaleY = flHeight * (print.scale?.[1] || 1) / fabricImage.height * flScaleY
const {x, y} = calculateRotatedTopLeftDeg(
fabricImage.width * scaleX,
fabricImage.height * scaleY,
left,
top,
0,
print.angle || 0
)
const angle = print.angle || 0
fabricImage.set({
left: x,
top: y,
scaleX: scaleX,
scaleY: scaleY,
angle: angle,
id: id,
layerId: id,
layerName: name,
selectable: true,
hasControls: true,
hasBorders: true,
});
resolve(fabricImage);
}, { crossOrigin: "anonymous" });
})
this.canvas.add(image);
let layer = createLayer({
id: id,
name: name,
type: LayerType.BITMAP,
visible: true,
locked: false,
opacity: 1.0,
fabricObjects: [image.toObject(["id", "layerId", "layerName"])],
})
children.push(layer);
};
// 添加平铺图层
for(let index = 0; index < singleLayers.length; index++){
let print = singleLayers[index];
let id = generateId("layer_image_");
let name = print.name;
let image = await new Promise(resolve => {
fabric.Image.fromURL(print.path, (fabricImage)=>{
const imgElement = fabricImage.getElement();
const tcanvas = document.createElement('canvas');
tcanvas.width = imgElement.width;
tcanvas.height = imgElement.height;
const ctx = tcanvas.getContext('2d');
ctx.clearRect(0, 0, tcanvas.width, tcanvas.height);
ctx.drawImage(imgElement, 0, 0);
resolve(tcanvas);
}, { crossOrigin: "anonymous" });
})
console.log("==========添加平铺图层", fixedLayerObj.width,image.width)
let scaleX = fixedLayerObj.width / image.width * (print.scale?.[0] || 1) / 5;
let scaleY = fixedLayerObj.height / image.height * (print.scale?.[1] || 1) / 5;
let scale = fixedLayerObj.width > fixedLayerObj.height ? scaleX : scaleY;
let left = (print.location?.[0] || 0) - image.width * scale / 2
let top = (print.location?.[1] || 0) - image.height * scale / 2
let rect = new fabric.Rect({
id: id,
layerId: id,
layerName: name,
width: fixedLayerObj.width,
height: fixedLayerObj.height,
top: fixedLayerObj.top,
left: fixedLayerObj.left,
scaleX: fixedLayerObj.scaleX,
scaleY: fixedLayerObj.scaleY,
originX: fixedLayerObj.originX,
originY: fixedLayerObj.originY,
fill: new fabric.Pattern({
source: image,
repeat: "repeat",
patternTransform: createPatternTransform(scale, print.angle || 0),
offsetX: left, // 水平偏移
offsetY: top, // 垂直偏移
}),
});
this.canvas.add(rect);
let layer = createLayer({
id: id,
name: name,
type: LayerType.BITMAP,
visible: true,
locked: true,
opacity: 1,
fabricObjects: [rect.toObject(["id", "layerId", "layerName"])],
})
children.push(layer);
};
if(children.length === 0){
let layer = createLayer({
id: generateId("layer_image_"),
name: t("Canvas.EmptyLayer"),
type: LayerType.BITMAP,
visible: true,
locked: false,
opacity: 1.0,
fabricObjects: [],
})
children.push(layer);
}
const groupRect = new fabric.Rect({});
await this.setColorObjectInfo(groupRect, fixedLayerObj);
// 插入组图层
const groupIndex = this.layers.value.findIndex(layer => layer.isFixedOther || layer.isFixed || layer.isBackground);
const groupLayer = createLayer({
id: SpecialLayerId.SPECIAL_GROUP,
name: t("Canvas.PrintAndElementsGroup"),
type: LayerType.GROUP,
visible: true,
locked: false,
opacity: 1.0,
fabricObjects: [],
children: children,
clippingMask: groupRect.toObject(),
isFixedClipMask: true,
});
this.layers.value.splice(groupIndex, 0, groupLayer);
}
/**
* 缩放红绿图模式内容以适应当前画布大小
* 确保衣服底图和红绿图永远在画布内可见
@@ -1249,6 +1644,7 @@ export class CanvasManager {
return fixedLayer.fabricObject || null;
}
/**
* 获取所有普通图层对象(包括红绿图)
* @returns {Array} 普通图层对象数组
@@ -1315,4 +1711,46 @@ export class CanvasManager {
return sizeMatch && positionMatch;
}
/**
* 键盘移动激活对象
* @param {String} direction 移动方向up, down, left, right
* @param {<Number>} step 移动步长
* @private
*/
moveActiveObject(direction, step = 1) {
const objects = [];
const activeObject = this.canvas.getActiveObject();
if(!activeObject) return;
const initPos = {
id: activeObject.id,
left: activeObject.left,
top: activeObject.top,
};
switch(direction) {
case "up":
activeObject.top -= step;
break;
case "down":
activeObject.top += step;
break;
case "left":
activeObject.left -= step;
break;
case "right":
activeObject.left += step;
break;
}
if(!activeObject.id) return this.canvas.renderAll();
const cmd = new ObjectMoveCommand({
canvas: this.canvas,
initPos,
finalPos: {
id: activeObject.id,
left: activeObject.left,
top: activeObject.top,
},
});
this.commandManager.executeCommand(cmd);
}
}

View File

@@ -1,6 +1,7 @@
import { fabric } from "fabric-with-all";
import { findObjectById } from "../utils/helper";
import { createRasterizedImage } from "../utils/selectionToImage";
import { OperationType, SpecialLayerId } from "../utils/layerHelper";
/**
* 图片导出管理器
@@ -18,7 +19,7 @@ export class ExportManager {
* @param {Object} options 导出选项
* @param {Boolean} options.isContainBg 是否包含背景图层
* @param {Boolean} options.isContainFixed 是否包含固定图层
* @param {Boolean} options.isCropByBg 是否使用背景大小裁剪
* @param {Boolean} options.isCropByBg 是否使用背景大小裁剪
* @param {String} options.layerId 导出具体图层ID
* @param {Array} options.layerIdArray 导出多个图层ID数组
* @param {String} options.expPicType 导出图片类型 (png/jpg/svg)
@@ -26,7 +27,7 @@ export class ExportManager {
* @param {Boolean} options.isEnhanceImg 是否是增强图片
* @returns {String} 导出的图片数据URL
*/
exportImage(options = {}) {
async exportImage(options = {}) {
const {
isContainBg = false,
isContainFixed = false,
@@ -35,9 +36,16 @@ export class ExportManager {
layerIdArray = [],
expPicType = "png",
restoreOpacityInRedGreen = true,
isEnhanceImg, // 是否是增强图片
isEnhanceImg, // 是否是增强图片
} = options;
try {
// 查找颜色图层并隐藏
const colorLayer = this.layerManager.getLayerById(SpecialLayerId.COLOR);
if (colorLayer && colorLayer.visible) {
colorLayer.visible = false;
await this.layerManager?.updateLayersObjectsInteractivity();
}
// 检查是否为红绿图模式
const isRedGreenMode = this.layerManager?.isInRedGreenMode?.() || false;
// 如果指定了具体图层ID导出指定图层
@@ -48,7 +56,7 @@ export class ExportManager {
isRedGreenMode,
restoreOpacityInRedGreen,
isCropByBg,
isEnhanceImg, // 是否是增强图片
isEnhanceImg, // 是否是增强图片
);
}
@@ -62,7 +70,7 @@ export class ExportManager {
isRedGreenMode,
restoreOpacityInRedGreen,
isCropByBg,
isEnhanceImg, // 是否是增强图片
isEnhanceImg, // 是否是增强图片
);
}
@@ -74,7 +82,7 @@ export class ExportManager {
isRedGreenMode,
restoreOpacityInRedGreen,
isCropByBg,
isEnhanceImg, // 是否是增强图片
isEnhanceImg, // 是否是增强图片
);
} catch (error) {
console.error("导出图片失败:", error);
@@ -128,8 +136,6 @@ export class ExportManager {
objectsToExport,
expPicType,
restoreOpacityInRedGreen,
isCropByBg, // 是否使用背景大小裁剪
isEnhanceImg, // 是否是增强图片
);
}
@@ -555,37 +561,22 @@ export class ExportManager {
);
}
// 获取固定图层对象的边界矩形(包含位置、尺寸、缩放等信息)
const fixedBounds = fixedLayerObject?.getBoundingRect?.();
// 使用固定图层的实际显示尺寸作为导出画布尺寸
const canvasWidth = Math.round(fixedBounds.width);
const canvasHeight = Math.round(fixedBounds.height);
const canvasWidth = (fixedLayerObject.width);
const canvasHeight = (fixedLayerObject.height);
console.log(`红绿图模式导出,画布尺寸: ${canvasWidth}x${canvasHeight}`);
console.log("固定图层边界:", fixedBounds);
// 创建固定尺寸的临时画布
const scaleFactor = 2; // 高清导出
const tempCanvas = document.createElement("canvas");
tempCanvas.width = canvasWidth * scaleFactor;
tempCanvas.height = canvasHeight * scaleFactor;
tempCanvas.style.width = canvasWidth + "px";
tempCanvas.style.height = canvasHeight + "px";
const tempFabricCanvas = new fabric.StaticCanvas(tempCanvas, {
const tempFabricCanvas = new fabric.StaticCanvas()
tempFabricCanvas.setDimensions({
width: canvasWidth,
height: canvasHeight,
backgroundColor: null,
enableRetinaScaling: true,
// enableRetinaScaling: true,
imageSmoothingEnabled: true,
});
tempFabricCanvas.setZoom(1);
// tempFabricCanvas.setZoom(1);
console.log("==========", fixedLayerObject)
try {
// 获取裁剪路径对象(如果存在)
const clipPathObject = await this._getClipPathObject(fixedBounds);
// 克隆并添加所有对象到临时画布,需要调整位置相对于固定图层
for (let i = 0; i < objectsToExport.length; i++) {
const obj = objectsToExport[i];
@@ -596,18 +587,16 @@ export class ExportManager {
if (cloned) {
// 调整对象位置:将原画布坐标转换为以固定图层为原点的相对坐标
cloned.set({
left: cloned.left - fixedBounds.left,
top: cloned.top - fixedBounds.top,
left: canvasWidth / 2,
top: canvasHeight / 2,
scaleX: cloned.scaleX / fixedLayerObject.scaleX,
scaleY: cloned.scaleY / fixedLayerObject.scaleY,
originX: "center",
originY: "center",
});
console.log("==========", {...cloned})
// 更新对象坐标
cloned.setCoords();
// 设置裁剪路径到对象
if (clipPathObject) {
cloned.clipPath = clipPathObject;
}
tempFabricCanvas.add(cloned);
}
}
@@ -616,7 +605,7 @@ export class ExportManager {
tempFabricCanvas.renderAll();
// 生成图片
return this._generateHighQualityDataURL(tempCanvas, expPicType);
return this._generateHighQualityDataURL(tempFabricCanvas, expPicType);
} finally {
this._cleanupTempCanvas(tempFabricCanvas);
}
@@ -736,7 +725,7 @@ export class ExportManager {
*/
_cloneObjectAsync(
obj,
propertiesToInclude = ["id", "layerId", "layerName", "name"]
propertiesToInclude = ["id", "layerId", "layerName", "name", "scaleX", "scaleY"]
) {
return new Promise((resolve, reject) => {
if (!obj) {

View File

@@ -31,6 +31,7 @@ import {
} from "../commands/ObjectLayerCommands";
import {
LayerType,
SpecialLayerId,
BlendMode,
createLayer,
createBackgroundLayer,
@@ -343,35 +344,36 @@ export class LayerManager {
});
// 批量更新对象
objects.forEach(async (obj) => {
const layer = layerMap[obj.layerId];
for(let obj of objects){
let layer = layerMap[obj.layerId];
if (!obj.layerId) {
// 没有关联图层的对象使用默认设置
obj.selectable = false;
obj.evented = false;
obj.erasable = false; // 未关联图层的对象不可擦除
return;
break;
}
if (!layer) return;
if (!layer) break;
// 设置一级图层对象的交互性
await this._setObjectInteractivity(obj, layer, editorMode);
// 设置子图层对象的交互性
layer?.children?.forEach(async (childLayer) => {
const childObj = this.canvas
for(let childLayer of layer.children){
let childObj = this.canvas
.getObjects()
.find((o) => o.layerId === childLayer.id);
if (childObj) {
await this._setObjectInteractivity(childObj, childLayer, editorMode);
}
});
});
};
};
// 设置裁剪对象
layers.forEach(async (layer) => {
for(let layer of layers){
if(layer.id === SpecialLayerId.COLOR) break;
let clippingMaskFabricObject = null;
if (layer.clippingMask) {
// 反序列化 clippingMask
@@ -379,7 +381,7 @@ export class LayerManager {
layer.clippingMask,
this.canvas
);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
// 设置绝对定位
@@ -403,7 +405,7 @@ export class LayerManager {
.find((o) => o.layerId === childLayer.id);
if (childObj) {
childObj.clipPath = clippingMaskFabricObject;
childObj.dirty = true; // 标记为脏对象
// childObj.dirty = true; // 标记为脏对象
childObj.setCoords();
}
@@ -499,7 +501,7 @@ export class LayerManager {
isOldSelectObject
);
}
});
};
}
/**
@@ -952,18 +954,28 @@ export class LayerManager {
// 查找要删除的图层
const { layer, parent } = findLayerRecursively(this.layers.value, layerId);
// 如果是背景层或固定层,不允许删除
if (layer && (layer.isBackground || layer.isFixed)) {
if (layer && (layer.isBackground || layer.isFixed || layer.isFixedOther)) {
console.warn(layer.isBackground ? "背景层不可删除" : "固定层不可删除");
message.warning(layer.isBackground ? "背景层不可删除" : "固定层不可删除");
message.warning(layer.isBackground ? this.t("Canvas.backLayerCannotDelete") : this.t("Canvas.fixedLayerCannotDelete"));
return false;
}
// 检查是否是唯一的普通图层
const normalLayers = this.layers.value.filter((l) => !l.isBackground && !l.isFixed);
var isChild = false;
var parentLength = 0;
const normalLayers = this.layers.value.filter((layer) => {
if(layer.children.some(v => v.id == layerId)){
isChild = true;
parentLength = layer.children.length;
}
return !layer.isFixed && !layer.isFixedOther && !layer.isBackground
})
// const normalLayers = this.layers.value.filter((l) => !l.isBackground && !l.isFixed && !l.isFixedOther);
console.log("普通图层:", normalLayers)
if (normalLayers.length === 1) {
if (isChild ? parentLength <= 1 : normalLayers.length <= 1) {
console.warn("不能删除唯一的普通图层");
message.warning("不能删除唯一的普通图层");
message.warning(this.t("Canvas.cannotDeleteOnlyLayer"));
return false;
}
// // 如果图层有子图层,提示确认
@@ -1132,7 +1144,7 @@ export class LayerManager {
return acc;
}, []);
console.log("==========", allObjects)
// if (layer.fill) {
// // 如果图层有填充颜色,设置所有对象的填充颜色
// const { object } = findObjectById(this.canvas, layer.fill.id);
@@ -1578,6 +1590,12 @@ export class LayerManager {
// 如果b是固定图层而a不是固定图层b应该排在后面固定图层在普通图层下方
if (b.isFixed && !a.isFixed) return -1;
// 如果a是固定图层而b不是固定图层a应该排在后面固定图层在普通图层下方
if (a.isFixedOther && !b.isFixedOther) return 1;
// 如果b是固定图层而a不是固定图层b应该排在后面固定图层在普通图层下方
if (b.isFixedOther && !a.isFixedOther) return -1;
// 其他情况保持原有顺序
return 0;
});
@@ -1848,9 +1866,9 @@ export class LayerManager {
}
// 检查是否是唯一的普通图层
const normalLayers = this.layers.value.filter((l) => !l.isBackground && !l.isFixed);
const normalLayers = this.layers.value.filter((l) => !l.isBackground && !l.isFixed && !l.isFixedOther);
console.log("普通图层:", normalLayers)
if (normalLayers.length === 1) {
if (normalLayers.length <= 1) {
console.warn("不能剪切唯一的普通图层");
return null;
}
@@ -3250,7 +3268,7 @@ export class LayerManager {
* @private
*/
_setupGroupMaskMovementSync(activeSelection, layer) {
if (!activeSelection || !layer || !layer.clippingMask) {
if (!activeSelection || !layer || !layer.clippingMask || layer.isFixedClipMask) {
return;
}
@@ -3314,7 +3332,6 @@ export class LayerManager {
// 计算移动距离
const deltaX = target.left - initialLeft;
const deltaY = target.top - initialTop;
// 创建更新遮罩位置的命令
const command = new UpdateGroupMaskPositionCommand({
canvas: this.canvas,

View File

@@ -91,12 +91,12 @@ export class ThumbnailManager {
// 重新创建遮罩对象
clippingMaskFabricObject = await restoreFabricObject(layer?.clippingMask, this.canvas);
clippingMaskFabricObject.clipPath = null;
// clippingMaskFabricObject.clipPath = null;
clippingMaskFabricObject.set({
absolutePositioned: true,
});
clippingMaskFabricObject.dirty = true;
// clippingMaskFabricObject.dirty = true;
clippingMaskFabricObject.setCoords();
}
@@ -128,8 +128,13 @@ export class ThumbnailManager {
}
const { layer } = findLayerRecursively(this.layers.value, layerId);
let layersToRasterize = [];
if (!layer) {
console.warn("⚠️ 无效的图层,无法收集对象");
return [];
}
let layersToRasterize = [];
if (layer.children && layer.children.length > 0) {
// 组图层:收集自身和所有子图层
layersToRasterize = this._collectLayersToRasterize(layer);

View File

@@ -69,7 +69,7 @@ export class AnimationManager {
// 如果变化太小,直接应用缩放
if (Math.abs(targetZoom - currentZoom) < 0.01) {
// this._applyZoom(point, targetZoom);
this._applyZoom(point, targetZoom);
return;
}
@@ -121,7 +121,7 @@ export class AnimationManager {
this._zoomAnimation = null;
// 确保最终状态准确
// this._applyZoom(point, targetZoom, true);
this._applyZoom(point, targetZoom, true);
},
};
@@ -173,7 +173,7 @@ export class AnimationManager {
this._zoomAnimation = null;
// 确保最终状态准确
// this._applyZoom(point, targetZoom, true);
this._applyZoom(point, targetZoom, true);
},
};
@@ -817,7 +817,7 @@ export class AnimationManager {
this._wasZooming = false;
// 确保最终状态准确
// this._applyZoom(point, targetZoom, true);
this._applyZoom(point, targetZoom, true);
},
});
}

View File

@@ -8,6 +8,7 @@ import { PerformanceManager } from "./PerformanceManager.js";
*/
export class CommandManager {
constructor(options = {}) {
this.canvas = options.canvas;
this.undoStack = [];
this.redoStack = [];
this.maxHistorySize = options.maxHistorySize || 50;
@@ -205,6 +206,7 @@ export class CommandManager {
const startTime = performance.now();
try {
this.canvas?.discardActiveObject();
const command = this.undoStack.pop();
console.log(`↩️ 撤销命令: ${command.constructor.name}`);
@@ -243,6 +245,7 @@ export class CommandManager {
const startTime = performance.now();
try {
this.canvas?.discardActiveObject();
const command = this.redoStack.pop();
console.log(`↪️ 重做命令: ${command.constructor.name}`);

View File

@@ -688,7 +688,6 @@ export class CanvasEventManager {
this.layerManager.commandManager.execute(transformCmd, {
name: "对象修改",
});
// 清除临时状态记录
delete activeObj._initialTransformState;
}

View File

@@ -10,6 +10,7 @@ export class KeyboardManager {
* @param {Object} options.toolManager 工具管理器实例
* @param {Object} options.commandManager 命令管理器实例
* @param {Object} options.layerManager 图层管理器实例
* @param {Object} options.canvasManager 画布管理器实例
* @param {Function} options.pasteText 粘贴文本回调函数
* @param {Function} options.pasteImage 粘贴图片回调函数
* @param {Ref<Boolean>} options.isRedGreenMode 是否为红绿模式
@@ -19,6 +20,7 @@ export class KeyboardManager {
this.toolManager = options.toolManager;
this.commandManager = options.commandManager;
this.layerManager = options.layerManager;
this.canvasManager = options.canvasManager;
this.container = options.container || document;
this.pasteText = options.pasteText || (() => {});
this.pasteImage = options.pasteImage || (() => {});
@@ -125,6 +127,10 @@ export class KeyboardManager {
// 删除
delete: { action: "delete", description: "删除" },
backspace: { action: "delete", description: "删除" },
up: { action: "up", description: "上" },
down: { action: "down", description: "下" },
left: { action: "left", description: "左" },
right: { action: "right", description: "右" },
// 选择
[`${cmdOrCtrl}+a`]: { action: "selectAll", description: "全选" },
@@ -488,6 +494,14 @@ export class KeyboardManager {
}
break;
case "up":
case "down":
case "left":
case "right":
// 方向键逻辑
this.canvasManager.moveActiveObject(action);
break;
case "increaseBrushSize":
// 增大画笔尺寸
if (this.toolManager && this.toolManager.brushManager) {
@@ -639,7 +653,6 @@ export class KeyboardManager {
if (event.altKey) shortcutKey += "alt+";
const key = event.key.toLowerCase();
// 特殊键处理
switch (key) {
case " ":

View File

@@ -12,7 +12,7 @@ export class LiquifyCPUManager {
sharpenAmount: 0.3, // 添加锐化强度参数
...options,
};
console.log("CPU版本的液化管理器config",this.config);
console.log("CPU版本的液化管理器config", this.config);
this.params = {
size: 60, // 增大默认尺寸
@@ -63,7 +63,8 @@ export class LiquifyCPUManager {
// 新增:持续按压相关状态
this.pressStartTime = 0; // 按压开始时间
this.pressDuration = 0; // 按压持续时间
this.accumulatedRotation = 0; // 累积旋转角度(用于顺时针/逆时针)
this.accumulatedRotation = 0; // 累积旋转角度(用于顺时针/逆时针)--废除使用固定角度
this.fixedRotationAngle = 0.32; // 固定旋转角度
this.accumulatedScale = 0; // 累积缩放量(用于捏合/展开)
this.lastApplyTime = 0; // 上次应用时间
this.continuousApplyInterval = 50; // 持续应用间隔(毫秒)
@@ -189,7 +190,7 @@ export class LiquifyCPUManager {
this.isHolding = true;
// 启动持续效果定时器(对于所有模式都支持持续按压)
this.startContinuousEffect();
// this.startContinuousEffect();
console.log(`开始液化操作,初始点: (${x}, ${y})`);
}
@@ -220,7 +221,6 @@ export class LiquifyCPUManager {
// 新增:启动持续效果
startContinuousEffect() {
this.stopContinuousEffect(); // 先停止已有的定时器
this.continuousTimer = setInterval(() => {
if (this.isHolding && this.initialized) {
// 更新持续时间
@@ -273,7 +273,6 @@ export class LiquifyCPUManager {
*/
_applyEnhancedRotationDeformation(centerX, centerY, radius, strength, isClockwise) {
if (!this.currentImageData) return;
const data = this.currentImageData.data;
const width = this.currentImageData.width;
const height = this.currentImageData.height;
@@ -286,6 +285,7 @@ export class LiquifyCPUManager {
const rotationAngle =
(isClockwise ? 1 : -1) * baseRotationSpeed * pressure * power * (1.0 + timeFactor * 0.5);
console.log("持续应用旋转效果");
// 累积旋转角度 - 关键:这确保了持续旋转效果
this.accumulatedRotation += rotationAngle;
@@ -309,13 +309,14 @@ export class LiquifyCPUManager {
// 计算旋转后的源位置 - 关键算法
const angle = Math.atan2(dy, dx);
const newAngle = angle + this.accumulatedRotation * falloff;
// const newAngle = angle + this.accumulatedRotation * falloff;
const newAngle = angle + (isClockwise ? this.fixedRotationAngle : -this.fixedRotationAngle) * falloff;
const sourceX = centerX + Math.cos(newAngle) * distance;
const sourceY = centerY + Math.sin(newAngle) * distance;
// 双线性插值采样 - 确保像素连续性
const color = this._bilinearSample(tempData, width, height, sourceX, sourceY);
const color = this._bicubicInterpolate(tempData, width, height, sourceX, sourceY);
if (color) {
const targetIdx = (y * width + x) * 4;
@@ -376,7 +377,7 @@ export class LiquifyCPUManager {
const sourceY = centerY + dy * scale;
// 双线性插值采样
const color = this._bilinearSample(tempData, width, height, sourceX, sourceY);
const color = this._bicubicInterpolate(tempData, width, height, sourceX, sourceY);
if (color) {
const targetIdx = (y * width + x) * 4;
@@ -401,16 +402,17 @@ export class LiquifyCPUManager {
*/
_applyEnhancedPushDeformation(centerX, centerY, radius, strength) {
if (!this.currentImageData) return;
const data = this.currentImageData.data;
const width = this.currentImageData.width;
const height = this.currentImageData.height;
const tempData = new Uint8ClampedArray(data);
// 计算推拉方向
const deltaX = this.currentMouseX - this.initialMouseX;
const deltaY = this.currentMouseY - this.initialMouseY;
const deltaX = this.currentMouseX - this.lastMouseX;
const deltaY = this.currentMouseY - this.lastMouseY;
const dragLength = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
this.lastMouseX = this.currentMouseX;
this.lastMouseY = this.currentMouseY;
const processRadius = Math.min(radius, Math.min(width, height) / 2);
const minX = Math.max(0, Math.floor(centerX - processRadius));
@@ -426,6 +428,7 @@ export class LiquifyCPUManager {
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
// 此处循环4万次
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
@@ -442,7 +445,7 @@ export class LiquifyCPUManager {
const sourceX = x - pushX;
const sourceY = y - pushY;
const color = this._bilinearSample(tempData, width, height, sourceX, sourceY);
const color = this._bicubicInterpolate(tempData, width, height, sourceX, sourceY);
if (color) {
const targetIdx = (y * width + x) * 4;
@@ -461,9 +464,9 @@ export class LiquifyCPUManager {
// 有拖拽时的推拉效果
const dirX = deltaX / dragLength;
const dirY = deltaY / dragLength;
for (let y = minY; y < maxY; y++) {
for (let x = minX; x < maxX; x++) {
// 此处循环4万次
const dx = x - centerX;
const dy = y - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
@@ -473,13 +476,13 @@ export class LiquifyCPUManager {
const falloff = 1 - normalizedDistance * normalizedDistance;
const factor = falloff * strength;
const offsetX = dirX * factor * Math.min(dragLength * 0.3, 15);
const offsetY = dirY * factor * Math.min(dragLength * 0.3, 15);
const offsetX = dirX * factor * Math.min(dragLength * 2, 30);
const offsetY = dirY * factor * Math.min(dragLength * 2, 30);
const sourceX = x - offsetX;
const sourceY = y - offsetY;
const color = this._bilinearSample(tempData, width, height, sourceX, sourceY);
const color = this._bicubicInterpolate(tempData, width, height, sourceX, sourceY);
if (color) {
const targetIdx = (y * width + x) * 4;
@@ -527,7 +530,7 @@ export class LiquifyCPUManager {
break;
case this.modes.PUSH:
this._applyEnhancedPushDeformation(x, y, radius, strength);
// this._applyEnhancedPushDeformation(x, y, radius, strength);
break;
default: {
@@ -553,101 +556,7 @@ export class LiquifyCPUManager {
}
/**
* 应用液化变形 - 主要入口,集成增强算法
*/
// applyDeformation(x, y) {
// if (!this.initialized || !this.originalImageData) {
// console.warn("液化管理器未初始化或缺少必要数据");
// return this.currentImageData;
// }
// // 更新鼠标位置
// this.currentMouseX = x;
// this.currentMouseY = y;
// // 计算拖拽参数
// const deltaX = this.currentMouseX - this.initialMouseX;
// const deltaY = this.currentMouseY - this.initialMouseY;
// this.dragDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// this.dragAngle = Math.atan2(deltaY, deltaX);
// // 获取当前参数
// const { size, pressure, power } = this.params;
// const mode = this.currentMode;
// const radius = size;
// const strength = pressure * power;
// // 根据模式选择算法
// const pixelModes = [
// this.modes.CLOCKWISE,
// this.modes.COUNTERCLOCKWISE,
// this.modes.PINCH,
// this.modes.EXPAND,
// this.modes.PUSH,
// ];
// if (pixelModes.includes(mode)) {
// // 使用增强的像素算法
// switch (mode) {
// case this.modes.CLOCKWISE:
// this._applyEnhancedRotationDeformation(x, y, radius, strength, false);
// break;
// case this.modes.COUNTERCLOCKWISE:
// this._applyEnhancedRotationDeformation(x, y, radius, strength, true);
// break;
// case this.modes.PINCH:
// this._applyEnhancedPinchDeformation(x, y, radius, strength, true);
// break;
// case this.modes.EXPAND:
// this._applyEnhancedPinchDeformation(x, y, radius, strength, false);
// break;
// case this.modes.PUSH:
// this._applyEnhancedPushDeformation(x, y, radius, strength);
// break;
// }
// // 更新最后应用时间
// this.lastApplyTime = Date.now();
// this.isFirstApply = false;
// return this.currentImageData;
// } else {
// // 使用原有的网格算法处理其他模式
// if (!this.mesh) {
// console.warn("网格未初始化");
// return this.currentImageData;
// }
// const finalStrength = (strength * this.config.maxStrength) / 100;
// // 应用变形
// this._applyDeformation(
// x,
// y,
// radius,
// finalStrength,
// mode,
// this.params.distortion,
// );
// // 平滑处理
// if (this.config.smoothingIterations > 0) {
// this._smoothMesh();
// }
// // 更新图像数据
// const result = this._applyMeshToImage();
// // 更新最后应用时间
// this.lastApplyTime = Date.now();
// this.isFirstApply = false;
// return result;
// }
// }
/**
* 双线性插值采样 - 用于像素级算法
* 双线性插值函数
* @param {Uint8ClampedArray} data 图像数据
* @param {number} width 图像宽度
* @param {number} height 图像高度
@@ -655,19 +564,55 @@ export class LiquifyCPUManager {
* @param {number} y Y坐标
* @returns {Array|null} RGBA颜色值数组或null
*/
_bilinearSample(data, width, height, x, y) {
return this._bicubicInterpolate(data, width, height, x, y);
_bilinearInterpolate(data, width, height, x, y) {
const x1 = Math.floor(x);
const y1 = Math.floor(y);
const x2 = Math.min(width - 1, x1 + 1);
const y2 = Math.min(height - 1, y1 + 1);
const dx = x - x1;
const dy = y - y1;
const dx1 = 1 - dx;
const dy1 = 1 - dy;
const index1 = (y1 * width + x1) * 4;
const index2 = (y1 * width + x2) * 4;
const index3 = (y2 * width + x1) * 4;
const index4 = (y2 * width + x2) * 4;
const r =
data[index1] * dx1 * dy1 +
data[index2] * dx * dy1 +
data[index3] * dx1 * dy +
data[index4] * dx * dy;
const g =
data[index1 + 1] * dx1 * dy1 +
data[index2 + 1] * dx * dy1 +
data[index3 + 1] * dx1 * dy +
data[index4 + 1] * dx * dy;
const b =
data[index1 + 2] * dx1 * dy1 +
data[index2 + 2] * dx * dy1 +
data[index3 + 2] * dx1 * dy +
data[index4 + 2] * dx * dy;
const a =
data[index1 + 3] * dx1 * dy1 +
data[index2 + 3] * dx * dy1 +
data[index3 + 3] * dx1 * dy +
data[index4 + 3] * dx * dy;
return [Math.round(r), Math.round(g), Math.round(b), Math.round(a)];
}
/**
* 三次插值实现 - 确保正确处理Alpha通道
* @param {Uint8ClampedArray} data 图像数据
* @param {number} width 图像宽度
* @param {number} height 图像高度
* @param {number} x X坐标
* @param {number} y Y坐标
* @returns {Array|null} RGBA颜色值数组或null
*/
* 三次插值实现 - 确保正确处理Alpha通道
* @param {Uint8ClampedArray} data 图像数据
* @param {number} width 图像宽度
* @param {number} height 图像高度
* @param {number} x X坐标
* @param {number} y Y坐标
* @returns {Array|null} RGBA颜色值数组或null
*/
_bicubicInterpolate(data, width, height, x, y) {
// return this._bilinearInterpolate(data, width, height, x, y);
// 获取周围16个像素点
const x1 = Math.floor(x) - 1;
const y1 = Math.floor(y) - 1;

View File

@@ -310,7 +310,7 @@ export class LiquifyWebGLManager {
this.isHolding = true;
// 启动持续效果定时器
this.startContinuousEffect();
// this.startContinuousEffect();
console.log(`WebGL液化开始初始点: (${x}, ${y})`);
}

View File

@@ -79,6 +79,9 @@ export class LayerSort {
} else if (layer.isFixed && layer.fabricObject) {
// 固定图层对象
zIndexMap.set(layer.fabricObject.id, currentZIndex++);
} else if (layer.isFixedOther && layer.fabricObject) {
// 其他固定图层对象
zIndexMap.set(layer.fabricObject.id, currentZIndex++);
} else if (!layer.isBackground && !layer.isFixed) {
// 普通图层
currentZIndex = this.processLayerObjects(

View File

@@ -0,0 +1,37 @@
class EventManager {
constructor() {
this.eventMap = {};
}
/**
* 注册事件
* @param {string} eventName - 事件名称
* @param {function} callback - 事件回调函数
*/
on(eventName, callback) {
if (!this.eventMap[eventName]) {
this.eventMap[eventName] = [];
}
this.eventMap[eventName].push(callback);
}
/**
* 触发事件
* @param {string} eventName - 事件名称
* @param {...any} args - 事件参数
*/
emit(eventName, ...args) {
if (this.eventMap[eventName]) {
this.eventMap[eventName].forEach(callback => callback(...args));
}
}
/**
* 移除事件
* @param {string} eventName - 事件名称
* @param {function} callback - 事件回调函数
*/
off(eventName, callback) {
if (this.eventMap[eventName]) {
this.eventMap[eventName] = this.eventMap[eventName].filter(cb => cb !== callback);
}
}
}
export default new EventManager();

View File

@@ -429,7 +429,8 @@ export function objectIsInCanvas(canvas, targetObj) {
}
const targetId = targetObj.id;
if (!targetId) {
const targetLayerId = targetObj.layerId;
if (!targetId && !targetLayerId) {
return { flag: false, object: null, parent: null };
}
@@ -437,7 +438,11 @@ export function objectIsInCanvas(canvas, targetObj) {
const topLevelObjects = canvas.getObjects();
// 直接在顶层查找
const directMatch = topLevelObjects.find((obj) => obj.id === targetId);
const directMatch = topLevelObjects.find((obj) => {
const isId = !targetId ? true : obj.id === targetId;
const isLayerId = !targetLayerId ? true : obj.layerId === targetLayerId;
return isId && isLayerId;
});
if (directMatch) {
return { flag: true, object: directMatch, parent: null };
}
@@ -500,6 +505,22 @@ export function findObjectById(canvas, objectId) {
return { object: result.object, parent: result.parent };
}
/**
* 通过layerID查找对象增强版
* @param {fabric.Canvas} canvas 画布实例
* @param {string} layerId 图层ID
* @returns {Object} { object: fabric.Object|null, parent: fabric.Group|null }
*/
export function findObjectByLayerId(canvas, layerId) {
if (!canvas || !layerId) {
return { object: null, parent: null };
}
const result = objectIsInCanvas(canvas, { layerId: layerId });
return { object: result.object, parent: result.parent };
}
/**
* 安全移除画布对象(包括组内对象)
* @param {fabric.Canvas} canvas 画布实例
@@ -738,3 +759,203 @@ export function getLayerObjectsZIndex(canvas, layerId) {
const allInfo = getAllObjectsZIndex(canvas);
return allInfo.filter((info) => info.layerId === layerId);
}
/**
* 计算两点之间的角度
* @param {number} x1 第一个点的x坐标
* @param {number} y1 第一个点的y坐标
* @param {number} x2 第二个点的x坐标
* @param {number} y2 第二个点的y坐标
* @returns {number} 角度值(-90 - 270度
*/
export function calculateAngle(x1, y1, x2, y2, int = false) {
// 计算两点之间的差值
const deltaX = x2 - x1;
const deltaY = y2 - y1;
// 使用Math.atan2计算弧度然后转换为角度
let angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI + 90;
return int ? Math.round(angle) : angle;
}
/**
* 通过角度计算直线上的两点坐标返回0-1范围的坐标
* @param {number} angle 角度值0-360度
* @returns {{x1: number, y1: number, x2: number, y2: number}} 包含两个点坐标
*/
export function calculateLinePoints(angle) {
// 将角度转换为弧度
const radian = (angle - 90) * Math.PI / 180;
// 计算直线上的两点坐标
const x1 = 0.5 - 0.5 * Math.cos(radian);
const y1 = 0.5 - 0.5 * Math.sin(radian);
const x2 = 0.5 + 0.5 * Math.cos(radian);
const y2 = 0.5 + 0.5 * Math.sin(radian);
return {x1, y1, x2, y2};
}
export function rgbaToHex(rgba){
const r = rgba.r.toString(16).padStart(2, "0");
const g = rgba.g.toString(16).padStart(2, "0");
const b = rgba.b.toString(16).padStart(2, "0");
return `#${r}${g}${b}`;
}
export function fillToPallet(fill) {
if(!fill.coords || !fill.colorStops) return {};
const angle = calculateAngle(fill.coords.x1, fill.coords.y1, fill.coords.x2, fill.coords.y2);
const colors = new Set();
// console.log("==========fill", fill);
const gradientList = fill.colorStops.map((stop) => {
colors.add(stop.color);
const rgbas = stop.color.replace("rgb(", "").replace("rgba(", "").replace(")", "").split(", ");
const rgba = {
r: parseInt(rgbas[0]),
g: parseInt(rgbas[1]),
b: parseInt(rgbas[2]),
a: parseFloat(rgbas[3]),
};
if(isNaN(rgba.r)) rgba.r = 0;
if(isNaN(rgba.g)) rgba.g = 0;
if(isNaN(rgba.b)) rgba.b = 0;
if(isNaN(rgba.a)) rgba.a = 1;
return {
rgba: rgba,
left: parseInt(stop.offset * 100) + "%",
};
});
const isGradient = colors.size > 1;
if(isGradient) {
return {
// hex: rgbaToHex(gradientList[0].rgba),
rgba: gradientList[0].rgba,
gradient:{ angle, selectIndex: 0, gradientShow: true, gradientList },
};
} else {
return {
hex: rgbaToHex(gradientList[0].rgba),
rgba: gradientList[0].rgba,
};
}
}
export function palletToFill(pallet) {
const fill = {
coords: calculateLinePoints(0),
colorStops: [
{ offset: 0, color: "rgba(0, 0, 0, 0)" },
{ offset: 1, color: "rgba(0, 0, 0, 0)" }
]
}
if(pallet?.gradient){
let obj = pallet.gradient;
fill.coords = calculateLinePoints(obj.angle);
if(obj.gradientList.length >= 2){
fill.colorStops = obj.gradientList.map(item => ({
offset: parseInt(item.left) / 100,
color: `rgba(${item.rgba.r}, ${item.rgba.g}, ${item.rgba.b}, ${item.rgba.a})`,
}));
}
}else if(pallet?.rgba?.hasOwnProperty("r") && pallet?.rgba?.hasOwnProperty("g") && pallet?.rgba?.hasOwnProperty("b")){
let rgba = pallet.rgba;
fill.colorStops = [
{ offset: 0, color: `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})` },
{ offset: 1, color: `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})` }
]
}
return fill;
}
export function fillToCssStyle(fill) {
if(!fill.coords || !fill.colorStops) return "";
const angle = calculateAngle(fill.coords.x1, fill.coords.y1, fill.coords.x2, fill.coords.y2);
if(fill.colorStops.every(v => v.color === fill.colorStops[0].color)){
return fill.colorStops[0].color;
}else{
var str = "linear-gradient(" + angle + "deg, ";
fill.colorStops.forEach((v) => {
str += `${v.color} ${v.offset * 100}%, `
})
return str.slice(0, -2) + ")";
}
}
/**
* 根据左上角坐标计算旋转后的新坐标
* @param {number} W - 宽度
* @param {number} H - 高度
* @param {number} currentX - 当前左上角x坐标
* @param {number} currentY - 当前左上角y坐标
* @param {number} currentAngleDeg - 当前角度(度)
* @param {number} newAngleDeg - 新角度(度)
* @returns {Object} 旋转后的左上角坐标 {x, y}
*/
export function calculateRotatedTopLeftDeg(
W,
H,
currentX,
currentY,
currentAngleDeg,
newAngleDeg
) {
const currentAngle = (currentAngleDeg * Math.PI) / 180;
const newAngle = (newAngleDeg * Math.PI) / 180;
// 1. 用当前角度计算中心点位置
const cosCurrent = Math.cos(currentAngle);
const sinCurrent = Math.sin(currentAngle);
const Cx = currentX + (W / 2) * cosCurrent - (H / 2) * sinCurrent;
const Cy = currentY + (W / 2) * sinCurrent + (H / 2) * cosCurrent;
// 2. 用新角度计算旋转后的左上角位置
const cosNew = Math.cos(newAngle);
const sinNew = Math.sin(newAngle);
const newX = Cx + (-W / 2) * cosNew - (-H / 2) * sinNew;
const newY = Cy + (-W / 2) * sinNew + (-H / 2) * cosNew;
return { x: newX, y: newY };
}
/**
* 创建缩放+旋转的变换矩阵
* @param {number} scale - 缩放比例
* @param {number} angle - 旋转角度(度)
* @returns {Array} 变换矩阵 [a, b, c, d, e, f]
*/
export function createPatternTransform(scale, angle) {
// return fabric.util.composeMatrix({
// scaleX: scale,
// scaleY: scale,
// angle: angle,
// });
const angle_ = angle * Math.PI / 180;
const cos = Math.cos(angle_);
const sin = Math.sin(angle_);
// 先缩放,后旋转
return [
scale * cos, // a
scale * sin, // b
-scale * sin, // c
scale * cos, // d
0, // e (水平位移)
0 // f (垂直位移)
];
}
/**
* 获取变换矩阵的缩放、旋转
* @param {Array} Transform - 变换矩阵、
* @returns {Object} 缩放、旋转角度 {scale, angle}
*/
export function getTransformScaleAngle(Transform) {
const a = Transform[0];
const b = Transform[1];
const c = Transform[2];
const d = Transform[3];
const scale = Math.sqrt(a * a + b * b);
const angle = Math.round(Math.atan2(b, a) * 180 / Math.PI);
return { scale, angle };
}

View File

@@ -5,8 +5,8 @@
*/
function initAligningGuidelines(canvas) {
var ctx = canvas.getSelectionContext(),
aligningLineOffset = 5,
aligningLineMargin = 4,
aligningLineOffset = 1,
aligningLineMargin = 1,
aligningLineWidth = 1,
aligningLineColor = "rgb(0,255,0)",
viewportTransform,
@@ -14,9 +14,9 @@ function initAligningGuidelines(canvas) {
function drawVerticalLine(coords) {
drawLine(
coords.x + 0.5,
coords.x,
coords.y1 > coords.y2 ? coords.y2 : coords.y1,
coords.x + 0.5,
coords.x,
coords.y2 > coords.y1 ? coords.y2 : coords.y1
);
}
@@ -24,9 +24,9 @@ function initAligningGuidelines(canvas) {
function drawHorizontalLine(coords) {
drawLine(
coords.x1 > coords.x2 ? coords.x2 : coords.x1,
coords.y + 0.5,
coords.y,
coords.x2 > coords.x1 ? coords.x2 : coords.x1,
coords.y + 0.5
coords.y
);
}
@@ -351,7 +351,7 @@ export function initCenteringGuidelines(canvas) {
canvasHeightCenter = canvasHeight / 2,
canvasWidthCenterMap = {},
canvasHeightCenterMap = {},
centerLineMargin = 4,
centerLineMargin = 1,
centerLineColor = "rgba(255,0,241,0.5)",
centerLineWidth = 1,
ctx = canvas.getSelectionContext(),

View File

@@ -18,6 +18,16 @@ export const LayerType = {
BACKGROUND: "background", // 背景图层 - 位于固定图层之、普通图层之下
};
/**
* 特殊图层ID
*/
export const SpecialLayerId = {
SPECIAL_GROUP: "group_special", // 特殊组
COLOR: "special_color", // 颜色图层
}
/**
* 画布操作模式枚举draw(绘画)、select(选择)、pan(拖拽)....
*/
@@ -178,12 +188,17 @@ export function createLayer(options = {}) {
locked: options.locked !== undefined ? options.locked : false,
opacity: options.opacity !== undefined ? options.opacity : 1.0,
blendMode: options.blendMode || BlendMode.NORMAL,
isHidenDragHandle: options.isHidenDragHandle || false,
isDisableUnlock: options.isDisableUnlock || false,
isFixedOther: options.isFixedOther || false,
isFixedClipMask: options.isFixedClipMask || false,
// 确保不是背景图层
isBackground: false,
// Fabric.js 对象列表
fabricObjects: options.fabricObjects || [],
fabricObject: options.fabricObject || null,
// 嵌套结构 - 适用于组图层
children: options.children || [],

View File

@@ -172,6 +172,10 @@ export function simplifyLayers(layers) {
opacity: layer.opacity,
isBackground: layer.isBackground || false,
isFixed: layer.isFixed || false,
isFixedOther: layer.isFixedOther || false,
isFixedClipMask: layer.isFixedClipMask || false,
isHidenDragHandle: layer.isHidenDragHandle || false,
isDisableUnlock: layer.isDisableUnlock || false,
clippingMask:
layer.clippingMask?.toObject?.(["id", "layerId"]) ||
layer.clippingMask ||

View File

@@ -7,55 +7,92 @@ import { fabric } from "fabric-with-all";
* @returns {Promise<fabric.Object>} 恢复的 fabric 对象
*/
export async function restoreFabricObject(serializedObject, canvas) {
return new Promise((resolve, reject) => {
const objectType = serializedObject.type;
// 定义恢复后的处理函数
const handleRestoredObject = (fabricObject) => {
if (!fabricObject) {
reject(new Error(`无法恢复 ${objectType} 类型的对象`));
return;
}
return new Promise((resolve, reject) => {
const objectType = serializedObject.type;
// 定义恢复后的处理函数
const handleRestoredObject = (fabricObject) => {
if (!fabricObject) {
reject(new Error(`无法恢复 ${objectType} 类型的对象`));
return;
}
// 恢复自定义属性
if (serializedObject.id) fabricObject.id = serializedObject.id;
if (serializedObject.layerId) fabricObject.layerId = serializedObject.layerId;
if (serializedObject.layerName) fabricObject.layerName = serializedObject.layerName;
// 恢复自定义属性
if (serializedObject.id) fabricObject.id = serializedObject.id;
if (serializedObject.layerId) fabricObject.layerId = serializedObject.layerId;
if (serializedObject.layerName) fabricObject.layerName = serializedObject.layerName;
// 更新坐标
fabricObject.setCoords();
// 更新坐标
fabricObject.setCoords();
// 添加到画布
// canvas.add(fabricObject);
// 添加到画布
// canvas.add(fabricObject);
resolve(fabricObject);
};
resolve(fabricObject);
};
// 根据类型选择恢复方法
switch (objectType) {
case "rect":
fabric.Rect.fromObject(serializedObject, handleRestoredObject);
break;
case "circle":
fabric.Circle.fromObject(serializedObject, handleRestoredObject);
break;
case "path":
fabric.Path.fromObject(serializedObject, handleRestoredObject);
break;
case "image":
fabric.Image.fromObject(serializedObject, handleRestoredObject);
break;
case "group":
fabric.Group.fromObject(serializedObject, handleRestoredObject);
break;
default:
// 使用通用方法
fabric.util.enlivenObjects([serializedObject], (objects) => {
if (objects && objects[0]) {
handleRestoredObject(objects[0]);
} else {
reject(new Error("对象恢复失败"));
}
});
}
});
// 根据类型选择恢复方法
switch (objectType) {
case "rect":
fabric.Rect.fromObject(serializedObject, handleRestoredObject);
break;
case "circle":
fabric.Circle.fromObject(serializedObject, handleRestoredObject);
break;
case "path":
fabric.Path.fromObject(serializedObject, handleRestoredObject);
break;
case "image":
fabric.Image.fromObject(serializedObject, handleRestoredObject);
break;
case "group":
fabric.Group.fromObject(serializedObject, handleRestoredObject);
break;
default:
// 使用通用方法
fabric.util.enlivenObjects([serializedObject], (objects) => {
if (objects && objects[0]) {
handleRestoredObject(objects[0]);
} else {
reject(new Error("对象恢复失败"));
}
});
}
});
}
/**
* 获取对象黑白通道画布
*/
export function getObjectAlphaToCanvas(object) {
const image = object.getElement();
const { width, height } = image;
if(!width || !height){
console.warn("对象没有元素");
return null;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height);
for (let i = 0; i < data.data.length; i += 4) {
const r = data.data[i + 0];
const g = data.data[i + 1];
const b = data.data[i + 2];
const a = data.data[i + 3];
if (r || g || b || a) {
data.data[i + 0] = 255;
data.data[i + 1] = 255;
data.data[i + 2] = 255;
data.data[i + 3] = 255;
}else{
data.data[i + 0] = 0;
data.data[i + 1] = 0;
data.data[i + 2] = 0;
data.data[i + 3] = 0;
}
}
ctx.clearRect(0, 0, width, height);
ctx.putImageData(data, 0, 0);
return canvas;
}

View File

@@ -68,7 +68,7 @@ export const createRasterizedImage = async ({
isReturenDataURL,
});
} catch (error) {
console.error("创建栅格化图像失败:", error);
console.warn("创建栅格化图像失败:", error);
throw new Error(`栅格化失败: ${error.message}`);
}
};
@@ -163,7 +163,7 @@ const createClippedObjects = async ({
console.log("✅ 返回裁剪后的fabric对象已恢复到优化后的原始大小和位置");
return fabricImage;
} catch (error) {
console.error("创建裁剪对象失败:", error);
console.warn("创建裁剪对象失败:", error);
throw error;
}
};
@@ -1239,7 +1239,7 @@ const calculateOptimizedBounds = (clippingObject, fabricObjects) => {
return optimizedBounds;
} catch (error) {
console.error("计算优化边界框失败:", error);
console.warn("计算优化边界框失败:", error);
// 返回原始计算方式作为备选
return clippingObject.getBoundingRect(true, true);
}

View File

@@ -101,7 +101,8 @@ onUnmounted(() => {
:style="tool.style"
@click="handleClick"
>
<SvgIcon :name="tool.icon.name" :size="tool.icon.size"></SvgIcon>
<SvgIcon v-if="tool.icon" :name="tool.icon.name" :size="tool.icon.size"></SvgIcon>
<span v-else>{{ tool.label }}</span>
<teleport to="body" v-if="tipBody">
<div class="tool-tooltip" :id="tipId">{{ t(tool.title) }}</div>
</teleport>

View File

@@ -53,7 +53,7 @@ const changeFixedImage = () => {
canvasEditor.value.changeFixedImage(changeImageUrl);
};
const frontBackChange = (value) =>{
console.log(value)
console.log("==========红绿图导出url", value)
}
// 组件挂载时绑定键盘事件

View File

@@ -9,7 +9,7 @@ import ToolButton from "@/component/Canvas/ExistsImageList/ToolButton.vue";
const canvasEditor = ref();
const currentView = ref("canvasEditor"); // 默认显示红绿图示例 canvasEditor redGreenExample
const clothingImageUrl = "/src/assets/work/3.PNG";
const clothingImageUrl = "https://www.minio-api.aida.com.hk/aida-collection-element/24299/Printboard/4eba03bd-4367-4c69-b1a3-3f3177a1be1f.jpg?response-content-type=image%2Fjpeg&response-content-disposition=inline&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=admin%2F20251217%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20251217T081126Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=b8223ba37625370c9716024536a08ce1ee5c5a7aaefc47d9baf8bd1e0e4d2d91";
const clothingImageUrlInit = "/src/assets/work/5.PNG";
const imageData = [
@@ -84,6 +84,20 @@ const exportImage = async () => {
document.body.removeChild(link); // 下载后移除链接元素
}
};
// 导出颜色图层
const exportColorLayer = async () => {
if (canvasEditor.value) {
const colorLayer = await canvasEditor.value.exportColorLayer();
console.log("导出颜色图层:",colorLayer);
// 模拟下载图片
const link = document.createElement("a");
link.href = colorLayer.base64;
link.download = "canvas_image.png"; // 设置下载文件名
document.body.appendChild(link);
link.click(); // 触发下载
document.body.removeChild(link); // 下载后移除链接元素
}
};
const changeCanvas = (command) => {
console.log(command);
@@ -106,32 +120,6 @@ const loadImageUrlToLayer = async () => {
}
};
// 自定义工具配置相关
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");
@@ -201,10 +189,89 @@ const canvasInit = () => {
};
const frontBackChange =(value)=>{
console.log(value)
}
const isShowLeft = ref(true);
// 自定义工具配置相关
const customToolsList = ref([
{
id: "exportPNG",
title: "导出颜色图层",
action: exportColorLayer,
label: "导颜",
class: "export-btn",
},
{
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",
},
{
id: "loadImageUrlToLayer",
title: "添加画布图片",
action: loadImageUrlToLayer,
label: "🎨",
class: "export-btn",
},
{
id: "redGreenExample",
title: "红绿图模式",
action: () => switchView('redGreenExample'),
label: "红",
class: "export-btn",
},
{
id: "canvasEditor",
title: "普通模式",
action: () => switchView('canvasEditor'),
label: "普",
class: "export-btn",
},
{
id: "changeFixedImage",
title: "更换底图",
action: changeFixedImage,
label: "更",
class: "export-btn",
},
{
id: "exportJSON",
title: "导出JSON",
action: exportJSON,
label: "导J",
class: "export-btn",
},
{
id: "copyJSON",
title: "复制JSON",
action: copyJSON,
label: "复J",
class: "export-btn",
},
{
id: "getLayers",
title: "查询图层",
action: getLayers,
label: "查L",
class: "export-btn",
},
]);
</script>
<template>
@@ -250,46 +317,15 @@ const isShowLeft = ref(true);
<template #customToolsBottom="{ toolButtonProps }">
<!-- 分隔线 -->
<div class="tool-separator"></div>
<!-- 自定义工具按钮 -->
<!-- 自定义工具按钮 -->
<ToolButton
v-for="tool in customToolsList"
:key="tool.id"
:tool="tool"
:active-tool="toolButtonProps.activeTool"
@click="handleCustomToolClick"
tipBody
/>
<!-- 也可以直接使用普通的按钮 -->
<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>
<div class="custom-tool-btn" @click="exportJSON">
<span></span>
<div class="tool-tooltip">导出JSON</div>
</div>
<div class="custom-tool-btn" @click="copyJSON">
<span></span>
<div class="tool-tooltip">复制JSON</div>
</div>
<div class="custom-tool-btn" @click="getLayers">
<span></span>
<div class="tool-tooltip">查询图层</div>
</div>
</template>
</CanvasEditor>
</div>
@@ -424,15 +460,17 @@ body {
.tool-tooltip {
display: none;
pointer-events: none;
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
background-color: rgba(0, 0, 0, 0.7);
writing-mode: vertical-rl; /* 竖直排列 */
text-orientation: upright; /* 保持文字正常显示 */// left: 100%;
left: 50%;
top: -0.8rem;
transform: translate(-50%, -100%);
background-color: rgba(0, 0, 0, 0.9);
color: white;
padding: 0.4rem 0.8rem;
padding: 0.8rem 0.4rem;
border-radius: 0.4rem;
margin-left: 0.8rem;
white-space: nowrap;
font-size: 1.2rem;
z-index: 10;
@@ -441,12 +479,12 @@ body {
.tool-tooltip:before {
content: "";
position: absolute;
top: 50%;
right: 100%;
margin-top: -0.5rem;
left: 50%;
bottom: 0;
transform: translate(-50%, 100%);
border-width: 0.5rem;
border-style: solid;
border-color: transparent rgba(0, 0, 0, 0.7) transparent transparent;
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
}
/* 深色模式适配 */

View File

@@ -5,14 +5,17 @@
<div class="canvasContent" ref="canvasContent">
<div class="content-bottom" ref="canvasContent">
<div class="contet">
<!-- :clothingImageUrl="selectDetail?.undividedLayerWithSinglePrint || selectDetail.undividedLayer || selectDetail.path" -->
<div class="canvas" v-if="currentView === 'canvasEditor'" @click.stop>
<editCanvas v-if="canvasLoad" :config="canvasConfig"
@canvasInit="canvasInit"
@changeCanvas="changeCanvas"
is-edit
:clothingImageUrl="selectDetail?.undividedLayerWithSinglePrint || selectDetail.undividedLayer || selectDetail.path"
:clothingImageUrl="selectDetail.path"
:clothingImageUrl2="selectDetail.undividedLayer"
showFixedLayer
:canvasJSON="canvasJSON"
:otherData="otherData"
:clothing-image-opts="{
imageMode:'contains',
}"
@@ -108,6 +111,12 @@ export default defineComponent({
canvasInstance:null as any,
canvasJSON:'',
hideCanvas: computed(()=>store.state.Workspace.projectPath !== route.fullPath),
otherData:computed(()=>({
canvasId: store.state.DesignDetail.selectDetail.canvasId,
color: store.state.DesignDetail.selectDetail.color,
printObject: store.state.DesignDetail.selectDetail.printObject,
trims: store.state.DesignDetail.selectDetail.trims,
})),
})
watch(()=>detailData.selectDetail,(newValue,oldValue)=>{
detailData.imgDomIndex = detailData.frontBack.front.findIndex((item:any)=>item.id == newValue.id)
@@ -117,7 +126,6 @@ export default defineComponent({
provide('canvasType',detailData.canvasType)
const editFront = (str:any)=>{//编辑前后片
let canvasJSON = '' as any
if(detailData.currentView === 'canvasEditor'){
sessionStorage.setItem('sketchEdit',detailDom.editCanvas.getJSON())
@@ -309,7 +317,7 @@ export default defineComponent({
sessionStorage.removeItem('frontBackEdit');
sessionStorage.removeItem('sketchEdit');
detailData.canvasLoad = false
privewDetail()
// privewDetail()
})
onMounted(()=>{
nextTick(async ()=>{

View File

@@ -1,5 +1,5 @@
<template>
<div class="pallet">
<div class="pallet" ref="palletRef">
<div class="palletColo" @click="openPallet">
<div v-show="!selectColor.gradient" class="palletBackColor" :title="selectColor.name" :style="{'background-color':selectColor.hex}">
{{ selectColor.hex }}
@@ -117,6 +117,7 @@ export default defineComponent({
})
const getpalletListDom = reactive({
})
const palletRef = ref(null)
watch(()=>palletData.color_,(newVal:any)=>{
if(!newVal?.rgba?.r)return
if(palletData.color?.gradient?.gradientShow){
@@ -221,7 +222,7 @@ export default defineComponent({
// this.selectColor = {rgba:gradientRgba,hex:hex} //顔色选择器默认颜色
let gradientWidth = (document.querySelector('.pallet .color_setting_operate_bg') as any).clientWidth
let gradientWidth = (palletRef.value.querySelector('.color_setting_operate_bg') as any).clientWidth
let position = {
x:event.clientX,
left:event.target.style.left?event.target.style.left.split('%')[0]:0
@@ -276,8 +277,8 @@ export default defineComponent({
// 点击外部区域关闭颜色选择器
const handleClickOutside = (event: Event) => {
const target = event.target as HTMLElement;
const colorSettingBlock = document.querySelector('.color_setting_block');
const palletColo = document.querySelector('.palletColo');
const colorSettingBlock = palletRef.value.querySelector('.color_setting_block');
const palletColo = palletRef.value.querySelector('.palletColo');
// 如果点击的是 .palletColo 或 .color_setting_block 内部,则不关闭
if (palletData.palletShow && colorSettingBlock &&
@@ -294,7 +295,7 @@ export default defineComponent({
nextTick().then(()=>{
const backIcon = document.createElement('div');
backIcon.classList.add('vc-sketch-color-wrap')
let dropperDom = document.getElementsByClassName("pallet")?.[0]?.getElementsByClassName('vc-chrome-fields-wrap')[0]
let dropperDom = palletRef.value.getElementsByClassName('vc-chrome-fields-wrap')[0]
dropperDom.appendChild(backIcon);
backIcon.addEventListener('click',async ()=>{
try {
@@ -322,7 +323,7 @@ export default defineComponent({
return{
...toRefs(palletData),
...toRefs(getpalletListDom),
palletRef,
openPallet,
selectImgItem,
setOperate,
@@ -614,6 +615,7 @@ export default defineComponent({
position: absolute;
content: "";
top: 0.2rem;
left: 0;
width: 1rem;
height: 1rem;
border-radius: 50%;

View File

@@ -1433,7 +1433,42 @@ export default {
selectTexture: '选择要使用的纹理',
DeleteTexture: '删除纹理',
TextureSettings: '纹理设置',
TextureSelector: '纹理选择'
TextureSelector: '纹理选择',
// 混合模式
CompositeNormal: '正常',
CompositeNormalTip: '正常:默认,新图形覆盖原内容',
CompositeDarken: '变暗',
CompositeDarkenTip: '变暗:取暗部颜色',
CompositeMultiply: '正片叠底',
CompositeMultiplyTip: '正片叠底:图像变暗',
CompositeColorBurn: '颜色加深',
CompositeColorBurnTip: '颜色加深:增加对比度,变暗底层颜色',
CompositeLighten: '变亮',
CompositeLightenTip: '变亮:取亮部颜色',
CompositeScreen: '滤色',
CompositeScreenTip: '滤色:图像变亮',
CompositeColorDodge: '颜色减淡',
CompositeColorDodgeTip: '颜色减淡:降低对比度,加亮底层颜色',
CompositeLighter: '颜色减淡(添加)',
CompositeLighterTip: '颜色减淡(添加):重叠部分亮度叠加',
CompositeOverlay: '叠加',
CompositeOverlayTip: '叠加:高光效果',
CompositeSoftLight: '柔光',
CompositeSoftLightTip: '柔光:混合效果',
CompositeHardLight: '强光',
CompositeHardLightTip: '强光:高光效果',
CompositeDifference: '差值',
CompositeDifferenceTip: '差值:取两图像颜色差',
CompositeExclusion: '排除',
CompositeExclusionTip: '排除:取两图像颜色差的绝对值',
CompositeHue: '色相',
CompositeHueTip: '色相:保留原图像颜色,改变新图像色相',
CompositeSaturation: '饱和度',
CompositeSaturationTip: '饱和度:保留原图像色相,改变新图像饱和度',
CompositeColor: '颜色',
CompositeColorTip: '颜色:保留原图像饱和度,改变新图像颜色',
CompositeLuminosity: '亮度',
CompositeLuminosityTip: '亮度:保留原图像颜色,改变新图像亮度',
},
speedList: {
High: '高级',

View File

@@ -1433,7 +1433,42 @@ export default {
selectTexture: 'Select the texture you want to use',
DeleteTexture: 'Delete Texture',
TextureSettings: 'Texture Settings',
TextureSelector: 'Texture Selector'
TextureSelector: 'Texture Selector',
// 混合模式
CompositeNormal: 'Normal',
CompositeNormalTip: 'Normal: Default, new graphics cover original content',
CompositeDarken: 'Darken',
CompositeDarkenTip: 'Darken: Take the darkest color',
CompositeMultiply: 'Multiply',
CompositeMultiplyTip: 'Multiply: Darken the image',
CompositeColorBurn: 'Color Burn',
CompositeColorBurnTip: 'Color Burn: Increase contrast and darken the bottom color',
CompositeLighten: 'Lighten',
CompositeLightenTip: 'Lighten: Take the brightest color',
CompositeScreen: 'Screen',
CompositeScreenTip: 'Screen: Lighten the image',
CompositeColorDodge: 'Color Dodge',
CompositeColorDodgeTip: 'Color Dodge: Reduce contrast and lighten the bottom color',
CompositeLighter: 'Color Dodge (Add)',
CompositeLighterTip: 'Color Dodge (Add): Add the brightness of the overlapping parts',
CompositeOverlay: 'Overlay',
CompositeOverlayTip: 'Overlay: Highlight effect',
CompositeSoftLight: 'Soft Light',
CompositeSoftLightTip: 'Soft Light: Blend effect',
CompositeHardLight: 'Hard Light',
CompositeHardLightTip: 'Hard Light: Highlight effect',
CompositeDifference: 'Difference',
CompositeDifferenceTip: 'Difference: Take the color difference between the two images',
CompositeExclusion: 'Exclusion',
CompositeExclusionTip: 'Exclusion: Take the absolute value of the color difference between the two images',
CompositeHue: 'Hue',
CompositeHueTip: 'Hue: Preserve the original image color and change the hue of the new image',
CompositeSaturation: 'Saturation',
CompositeSaturationTip: 'Saturation: Preserve the original image hue and change the saturation of the new image',
CompositeColor: 'Color',
CompositeColorTip: 'Color: Preserve the original image saturation and change the color of the new image',
CompositeLuminosity: 'Luminosity',
CompositeLuminosityTip: 'Luminosity: Preserve the original image color and change the luminosity of the new image',
},
speedList: {
High: 'High',