画布增加的新功能
This commit is contained in:
@@ -54,6 +54,24 @@
|
||||
<div class="content unicode" style="display: block;">
|
||||
<ul class="icon_lists dib-box">
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">混合模式</div>
|
||||
<div class="code-name">&#xe7a4;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">更多</div>
|
||||
<div class="code-name">&#xe60f;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">平铺</div>
|
||||
<div class="code-name">&#xe8d7;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></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>
|
||||
|
||||
@@ -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
@@ -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.
@@ -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;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class FillLayerBackgroundCommand extends Command {
|
||||
layer.clippingMask,
|
||||
this.canvas
|
||||
);
|
||||
clippingMaskFabricObject.clipPath = null;
|
||||
// clippingMaskFabricObject.clipPath = null;
|
||||
|
||||
clippingMaskFabricObject.set({
|
||||
// 设置绝对定位
|
||||
|
||||
301
src/component/Canvas/CanvasEditor/commands/FillRepeatCommand.js
Normal file
301
src/component/Canvas/CanvasEditor/commands/FillRepeatCommand.js
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
50
src/component/Canvas/CanvasEditor/commands/ObjectCommands.js
Normal file
50
src/component/Canvas/CanvasEditor/commands/ObjectCommands.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
// 标记为脏对象
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ export class UpdateGroupMaskPositionCommand extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
clippingMaskFabricObject.clipPath = null;
|
||||
// clippingMaskFabricObject.clipPath = null;
|
||||
clippingMaskFabricObject.set({
|
||||
absolutePositioned: true,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
<!-- 遍历固定层 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
// 计算按压持续时间
|
||||
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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:"变暗:重叠部分颜色减淡" },
|
||||
]);
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
121
src/component/Canvas/CanvasEditor/components/tools/AngleTool.vue
Normal file
121
src/component/Canvas/CanvasEditor/components/tools/AngleTool.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
160
src/component/Canvas/CanvasEditor/components/tools/Slider.vue
Normal file
160
src/component/Canvas/CanvasEditor/components/tools/Slider.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -688,7 +688,6 @@ export class CanvasEventManager {
|
||||
this.layerManager.commandManager.execute(transformCmd, {
|
||||
name: "对象修改",
|
||||
});
|
||||
|
||||
// 清除临时状态记录
|
||||
delete activeObj._initialTransformState;
|
||||
}
|
||||
|
||||
@@ -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 " ":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -310,7 +310,7 @@ export class LiquifyWebGLManager {
|
||||
this.isHolding = true;
|
||||
|
||||
// 启动持续效果定时器
|
||||
this.startContinuousEffect();
|
||||
// this.startContinuousEffect();
|
||||
|
||||
console.log(`WebGL液化开始,初始点: (${x}, ${y})`);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
37
src/component/Canvas/CanvasEditor/utils/event.js
Normal file
37
src/component/Canvas/CanvasEditor/utils/event.js
Normal 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();
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -53,7 +53,7 @@ const changeFixedImage = () => {
|
||||
canvasEditor.value.changeFixedImage(changeImageUrl);
|
||||
};
|
||||
const frontBackChange = (value) =>{
|
||||
console.log(value)
|
||||
console.log("==========红绿图导出url", value)
|
||||
}
|
||||
|
||||
// 组件挂载时绑定键盘事件
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 深色模式适配 */
|
||||
|
||||
@@ -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 ()=>{
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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: '高级',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user