平铺元素ui更改

This commit is contained in:
李志鹏
2026-01-13 14:41:20 +08:00
parent e1ca896764
commit 6eda04a81e
18 changed files with 1544 additions and 233 deletions

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="103.000000pt" height="92.000000pt" viewBox="0 0 103.000000 92.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,92.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M365 895 c-5 -2 -36 -6 -67 -10 -45 -5 -58 -10 -59 -23 0 -11 -2 -12
-6 -4 -7 17 -32 15 -40 -4 -4 -11 -8 -12 -13 -4 -5 8 -13 9 -21 4 -7 -4 -22
-9 -34 -10 -11 -1 -41 -11 -66 -21 l-47 -18 20 -63 c11 -38 23 -60 30 -56 6 4
8 -1 3 -15 -3 -11 -3 -21 2 -21 4 0 9 -12 9 -26 1 -14 5 -28 8 -32 4 -3 33 3
66 15 33 11 61 19 62 18 2 -2 1 -139 -2 -304 l-5 -301 309 2 309 3 -2 108 c-2
77 2 113 11 125 10 12 10 14 1 8 -7 -4 -13 -2 -13 4 0 6 10 8 23 5 18 -5 26
-1 36 17 9 17 10 18 6 3 -5 -16 -4 -18 6 -7 18 17 -1 34 -40 37 l-32 2 -2 151
c-1 82 0 147 3 144 9 -12 44 -11 52 1 5 8 8 7 8 -4 0 -14 26 -28 52 -29 11 0
39 70 33 80 -3 5 1 11 7 13 10 4 9 8 -2 16 -13 10 -8 15 12 12 8 -1 38 91 32
98 -2 2 -10 -2 -18 -8 -9 -7 -17 -8 -20 -2 -3 5 0 11 6 14 7 2 -18 14 -56 26
-38 12 -71 19 -74 16 -3 -3 -11 0 -18 6 -8 6 -20 9 -28 6 -8 -3 -16 -2 -18 3
-5 15 -154 30 -286 29 -70 0 -131 -2 -137 -4z m21 -30 c-6 -18 3 -47 14 -40 4
2 18 -7 30 -20 27 -29 17 -34 -14 -7 -20 16 -20 16 -7 -1 23 -29 66 -47 112
-47 34 0 40 3 35 16 -5 14 -4 15 9 4 13 -11 19 -9 36 6 12 11 29 35 38 54 15
31 20 35 58 35 24 0 43 -2 43 -5 0 -11 115 -27 121 -18 3 5 9 2 13 -7 4 -13
14 -16 34 -12 15 2 36 1 47 -4 16 -7 14 -8 -10 -5 l-30 5 32 -14 c39 -18 39
-19 14 -83 -15 -38 -17 -52 -8 -61 9 -9 8 -11 -7 -5 -16 6 -18 4 -12 -19 3
-14 2 -29 -4 -32 -6 -3 -7 1 -4 9 6 16 -9 20 -73 19 -12 0 -20 4 -17 8 3 5 -4
9 -15 9 -20 0 -21 -5 -21 -151 l0 -151 -27 8 c-16 4 -38 7 -50 7 -19 -1 -21 2
-13 17 15 28 12 57 -6 57 -11 0 -15 -8 -12 -27 4 -24 1 -27 -23 -26 -15 1 -25
4 -22 9 2 4 -2 7 -10 7 -9 0 -14 -10 -14 -25 0 -13 3 -22 8 -19 5 3 6 -1 3 -9
-2 -7 0 -25 5 -40 8 -20 7 -25 -3 -21 -7 3 -13 -2 -13 -11 0 -12 7 -15 26 -11
14 3 21 3 14 0 -7 -3 -9 -12 -6 -20 3 -7 10 -11 16 -7 5 3 7 1 4 -4 -9 -14 3
-74 12 -68 4 2 7 -6 7 -18 -1 -32 32 -34 44 -3 5 14 7 34 5 46 -3 13 0 18 7
14 8 -5 9 -1 5 10 -4 9 -3 15 2 12 5 -3 12 1 15 10 3 8 2 12 -4 9 -6 -3 -10
-1 -10 4 0 13 3 13 24 5 13 -5 16 -24 16 -103 0 -63 4 -102 13 -111 10 -12 9
-12 -4 -2 -13 10 -88 12 -300 10 l-284 -3 3 303 3 302 -26 0 c-14 0 -25 -4
-25 -10 0 -5 -7 -6 -17 -3 -9 4 -14 2 -10 -3 4 -6 -6 -9 -24 -6 -28 4 -41 -11
-19 -21 6 -3 5 -4 -2 -3 -7 2 -13 11 -13 22 -1 32 -25 104 -35 104 -5 0 -6 7
-3 17 4 10 2 14 -5 9 -7 -4 -10 2 -8 17 3 29 14 47 29 47 7 0 3 -8 -8 -17
l-20 -16 20 8 c11 4 28 10 37 12 10 3 18 9 18 13 0 4 6 7 12 7 22 -2 158 26
158 32 0 3 20 6 45 5 25 0 45 3 45 8 0 4 3 8 6 8 3 0 4 -7 0 -15z m237 -5 c-3
-9 1 -8 11 4 9 11 16 15 16 9 0 -6 -7 -16 -15 -23 -8 -7 -15 -9 -15 -4 0 10
-29 -28 -30 -40 0 -4 8 -5 17 -2 15 6 15 4 -2 -14 -22 -24 -37 -26 -28 -4 5
14 3 15 -15 5 -26 -14 -77 -14 -103 -1 -10 6 -27 27 -38 48 l-19 37 113 0 c95
0 112 -2 108 -15z"/>
<path d="M346 641 c-3 -5 1 -12 10 -15 23 -9 36 -7 29 4 -3 6 1 7 9 4 9 -3 16
-1 16 5 0 13 -56 15 -64 2z"/>
<path d="M440 640 c0 -16 33 -26 38 -12 2 7 8 10 13 6 5 -3 9 0 9 5 0 6 -13
11 -30 11 -16 0 -30 -5 -30 -10z"/>
<path d="M530 641 c0 -12 37 -24 50 -16 20 12 10 25 -20 25 -16 0 -30 -4 -30
-9z"/>
<path d="M620 641 c0 -12 37 -24 50 -16 20 12 10 25 -20 25 -16 0 -30 -4 -30
-9z"/>
<path d="M310 593 c0 -20 5 -30 16 -30 10 0 14 8 12 25 -4 37 -28 40 -28 5z"/>
<path d="M697 613 c-13 -13 -7 -50 8 -50 10 0 15 10 15 29 0 27 -9 35 -23 21z"/>
<path d="M317 534 c-4 -4 -7 -20 -7 -36 0 -35 23 -34 28 1 4 25 -10 46 -21 35z"/>
<path d="M692 503 c4 -39 28 -42 28 -4 0 21 -5 31 -16 31 -11 0 -14 -8 -12
-27z"/>
<path d="M312 415 c4 -33 22 -33 26 0 2 18 -1 25 -13 25 -12 0 -15 -7 -13 -25z"/>
<path d="M312 329 c2 -19 8 -33 13 -31 15 3 12 55 -3 60 -10 3 -13 -5 -10 -29z"/>
<path d="M342 278 c3 -7 19 -14 37 -16 24 -3 32 0 29 10 -3 7 -19 14 -37 16
-24 3 -32 0 -29 -10z"/>
<path d="M443 275 c0 -10 10 -15 29 -15 18 0 28 5 28 15 0 10 -10 15 -28 15
-19 0 -29 -5 -29 -15z"/>
<path d="M530 275 c0 -10 10 -15 33 -15 22 0 28 3 18 9 -11 7 -11 9 0 14 8 3
-1 6 -18 6 -23 1 -33 -4 -33 -14z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,855 @@
<template>
<transition name="fade">
<div class="part-selector-toolbar" v-if="visible" :class="{active:!closePanel}">
<div class="btn" @click="setClosePanel"><i class="fi fi-br-angle-left"></i></div>
<!-- 顶部选区类型工具栏 -->
<div class="toolbar-section">
<div class="toolbar-header">
<div class="header-title">{{ t("Canvas.GarmentPartSelector") }}</div>
<!-- 移除关闭按钮完全通过工具切换控制显示隐藏 -->
</div>
<div class="tool-types">
<div
:class="[
'tool-btn',
{ active: selectionType === OperationType.LASSO },
]"
@click="setSelectionType(OperationType.LASSO)"
>
<svg-icon name="CFree" size="20" />
<span>{{ $t("Canvas.freehandSketching") }}</span>
</div>
<div
:class="[
'tool-btn',
{ active: selectionType === OperationType.LASSO_RECTANGLE },
]"
@click="setSelectionType(OperationType.LASSO_RECTANGLE)"
>
<svg-icon name="CRectangle" size="26" />
<span>{{ $t("Canvas.rectangle") }}</span>
</div>
<div
:class="[
'tool-btn',
{ active: selectionType === OperationType.LASSO_ELLIPSE },
]"
@click="setSelectionType(OperationType.LASSO_ELLIPSE)"
>
<svg-icon name="CEllipse" size="24" />
<span>{{ $t("Canvas.ellipse") }}</span>
</div>
</div>
<!-- 分割线 -->
<div class="toolbar-divider"></div>
<!-- 底部选区操作工具栏 -->
<div class="tool-actions">
<div class="action-btn" @click="copySelectionToNewLayer">
<svg-icon name="CPaste" size="16" />
<span class="btn-text">{{ $t("Canvas.creation") }}</span>
</div>
<div class="action-btn" @click="cutSelectionToNewLayer">
<svg-icon name="CCut" size="26" />
<span class="btn-text">{{ $t("Canvas.CreateAndCopy") }}</span>
</div>
<div class="action-btn" @click="clearSelectionContent">
<svg-icon name="CClear" size="18" />
<span class="btn-text">{{ $t("Canvas.TheClearlySelectedContent") }}</span>
</div>
<!-- <button
class="action-btn"
@click="addSelection"
:disabled="!hasSelection"
title="添加"
>
<svg-icon name="plus" />
<span class="btn-text">{{ $t("添加") }}</span>
</button>
<button
class="action-btn"
@click="removeSelection"
:disabled="!hasSelection"
title="移除"
>
<svg-icon name="minus" />
<span class="btn-text">{{ $t("移除") }}</span>
</button>
<button
class="action-btn"
@click="invertSelection"
:disabled="!hasSelection"
title="反转"
>
<svg-icon name="flip-horizontal" />
<span class="btn-text">{{ $t("反转") }}</span>
</button> -->
<!-- <button
class="action-btn"
@click="copySelectionToNewLayer"
:disabled="!hasSelection"
title="拷贝并粘贴"
>
<svg-icon name="copy" />
<span class="btn-text">{{ $t("拷贝并粘贴") }}</span>
</button>
<button
class="action-btn"
@click="openFeatherDialog"
:disabled="!hasSelection"
title="羽化"
>
<svg-icon name="feather" />
<span class="btn-text">{{ $t("羽化") }}</span>
</button>
<button
class="action-btn"
@click="fillSelection"
:disabled="!hasSelection"
title="颜色填充"
>
<svg-icon name="fill-color" />
<span class="btn-text">{{ $t("颜色填充") }}</span>
</button>
<button
class="action-btn"
@click="clearSelection"
:disabled="!hasSelection"
title="清除"
>
<svg-icon name="trash" />
<span class="btn-text">{{ $t("清除") }}</span>
</button> -->
</div>
</div>
<!-- 羽化设置弹窗 -->
<div v-if="showFeatherDialog" class="dialog-overlay">
<div class="dialog-container">
<div class="dialog-header">
<h3>{{ $t("羽化") }}</h3>
<button class="close-dialog-btn" @click="cancelFeather">×</button>
</div>
<div class="dialog-content">
<div class="feather-control">
<input
type="range"
min="0"
max="50"
v-model.number="featherAmount"
class="slider-control"
/>
<div class="feather-value">{{ featherAmount }}px</div>
</div>
<div class="dialog-buttons">
<button class="cancel-btn" @click="cancelFeather">
{{ $t("Canvas.close") }}
</button>
<button class="confirm-btn" @click="applyFeather">
{{ $t("Canvas.confirmEdit") }}
</button>
</div>
</div>
</div>
</div>
<!-- 颜色选择器 -->
<div v-if="showColorPicker" class="dialog-overlay">
<div class="dialog-container">
<div class="dialog-header">
<h3>{{ $t("Canvas.SelectFillColor") }}</h3>
<button class="close-dialog-btn" @click="cancelColorPicker">
×
</button>
</div>
<div class="dialog-content">
<input type="color" v-model="fillColor" class="color-picker" />
<div class="dialog-buttons">
<button class="cancel-btn" @click="cancelColorPicker">
{{ $t("Canvas.close") }}
</button>
<button class="confirm-btn" @click="confirmColorPicker">
{{ $t("Canvas.confirmEdit") }}
</button>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
CreateSelectionCommand,
InvertSelectionCommand,
FeatherSelectionCommand,
FillSelectionCommand,
// CopySelectionToNewLayerCommand,
// ClearSelectionContentCommand,
} from "../commands/SelectionCommands";
import { ToolCommand } from "../commands/ToolCommands";
import {
LassoCutoutCommand,
ClearSelectionCommand,
// CutSelectionToNewLayerCommand,
} from "../commands/LassoCutoutCommand";
import { OperationType } from "../utils/layerHelper";
import { ClearSelectionContentCommand } from "../commands/ClearSelectionContentCommand";
import { CutSelectionToNewLayerCommand } from "../commands/CutSelectionToNewLayerCommand";
const props = defineProps({
canvas: {
type: Object,
required: true,
},
commandManager: {
type: Object,
required: true,
},
selectionManager: {
type: Object,
required: true,
},
layerManager: {
type: Object,
required: true,
},
toolManager: {
type: Object,
required: true,
},
activeTool: {
type: String,
required: false,
default: null,
},
});
// 响应式数据
const visible = ref(false);
const selectionType = ref("rectangle");
const featherAmount = ref(0);
const fillColor = ref("#000000");
const hasSelection = ref(false);
const showFeatherDialog = ref(false);
const showColorPicker = ref(false);
//打开隐藏操作面板
const closePanel = ref(false)
const setClosePanel = ()=>{
closePanel.value = !closePanel.value
}
// 国际化
const { t } = useI18n();
onMounted(() => {
// 为选区管理器添加监听,以便在选区变化时更新状态
if (props.selectionManager) {
// 在选区管理器中添加选区变化的监听
checkSelectionStatus();
// 设置选区状态变化的回调
// eslint-disable-next-line vue/no-mutating-props
props.selectionManager.onSelectionChanged = () => {
checkSelectionStatus();
};
}
});
// 监听 activeTool 变化
watch(
() => props.activeTool,
(newTool) => {
// 当工具为LASSO或AREA类型时显示选区面板
const selectionTools = [
OperationType.PART,
OperationType.PART_RECTANGLE,
OperationType.PART_BRUSH,
OperationType.PART_ERASER
];
if (selectionTools.includes(newTool)) {
show();
// 根据工具类型设置选区类型
selectionType.value = newTool;
// 更新选区管理器的选区类型
if (props.selectionManager) {
props.selectionManager.setSelectionType(selectionType.value);
props.selectionManager.setupSelectionEvents();
}
} else {
close();
}
},
{ immediate: true }
);
/**
* 显示面板
*/
function show() {
visible.value = true;
closePanel.value = true
checkSelectionStatus();
}
/**
* 关闭面板
*/
function close() {
visible.value = false;
}
/**
* 设置选区类型
*/
function setSelectionType(type) {
selectionType.value = type;
// 通过 ToolManager 切换工具,这会自动通知 SelectionManager
if (props.toolManager) {
props.toolManager.setToolWithCommand(type);
}
// 备用方案:如果没有 toolManager直接更新 selectionManager
else if (props.selectionManager) {
props.selectionManager.setSelectionType(type);
props.selectionManager.setupSelectionEvents();
}
}
/**
* 检查选区状态
*/
function checkSelectionStatus() {
hasSelection.value =
props.selectionManager &&
props.selectionManager.getSelectionObject() !== null;
// 同步羽化值
if (hasSelection.value) {
featherAmount.value = props.selectionManager.getFeatherAmount();
}
}
/**
* 添加选区
*/
function addSelection() {
// TODO: 实现添加选区功能
console.log("添加选区功能尚未实现");
}
/**
* 移除选区
*/
function removeSelection() {
// TODO: 实现移除选区功能
console.log("移除选区功能尚未实现");
}
/**
* 反转选区
*/
function invertSelection() {
if (!hasSelection.value) return;
props.commandManager.execute(
new InvertSelectionCommand({
canvas: props.canvas,
selectionManager: props.selectionManager,
})
);
checkSelectionStatus();
}
/**
* 清除选区
*/
function clearSelection() {
if (!hasSelection.value) return;
props.commandManager.execute(
new ClearSelectionCommand({
selectionManager: props.selectionManager,
})
);
checkSelectionStatus();
}
/**
* 应用羽化效果
*/
function applyFeather() {
if (!hasSelection.value) return;
props.commandManager.execute(
new FeatherSelectionCommand({
selectionManager: props.selectionManager,
featherAmount: featherAmount.value,
})
);
}
/**
* 填充选区
*/
function fillSelection() {
if (!hasSelection.value) return;
showColorPicker.value = true;
}
/**
* 套索抠图到新图层
*/
function copySelectionToNewLayer() {
if (!hasSelection.value) return;
props.commandManager.execute(
new LassoCutoutCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
toolManager: props.toolManager,
})
);
checkSelectionStatus();
}
/**
* 剪切选区到新图层
*/
function cutSelectionToNewLayer() {
if (!hasSelection.value) return;
props.commandManager.execute(
new CutSelectionToNewLayerCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
toolManager: props.toolManager,
})
);
checkSelectionStatus();
}
/**
* 清除选区内容
*/
function clearSelectionContent() {
if (!hasSelection.value) return;
props.commandManager.execute(
new ClearSelectionContentCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
})
);
checkSelectionStatus();
}
/**
* 打开羽化设置弹窗
*/
function openFeatherDialog() {
showFeatherDialog.value = true;
}
/**
* 取消羽化设置
*/
function cancelFeather() {
showFeatherDialog.value = false;
}
/**
* 确认羽化设置
*/
function confirmFeather() {
applyFeather();
showFeatherDialog.value = false;
}
/**
* 取消颜色选择
*/
function cancelColorPicker() {
showColorPicker.value = false;
}
/**
* 确认颜色选择
*/
function confirmColorPicker() {
if (!hasSelection.value) return;
props.commandManager.execute(
new FillSelectionCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
color: fillColor.value,
})
);
checkSelectionStatus();
showColorPicker.value = false;
}
</script>
<style scoped lang="less">
.part-selector-toolbar {
position: absolute;
bottom: 22px;
left: 20px;
right: 20px;
max-width: min(90vw, 640px);
margin: 0 auto;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 8px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
z-index: 1000;
color: #333;
border: 1px solid rgba(0, 0, 0, 0.05);
user-select: none;
&.active{
transform: translateY(100%);
> .btn{
> i{
transform: rotate(90deg);
}
}
}
> .btn{
width: 100%;
text-align: center;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
height: 22px;
> i{
font-size: 1.4rem;
transform: rotate(270deg);
}
}
}
/* 平板和手机适配 */
@media screen and (max-width: 768px) {
.part-selector-toolbar {
bottom: 15px;
left: 15px;
right: 15px;
max-width: calc(100vw - 30px);
border-radius: 6px;
}
}
@media screen and (max-width: 480px) {
.part-selector-toolbar {
bottom: 10px;
left: 10px;
right: 10px;
max-width: calc(100vw - 20px);
}
}
.part-selector-toolbar.is-active {
transform: translateY(0);
}
.toolbar-header {
// display: flex;
// justify-content: center;
// align-items: center;
padding: 8px 0;
// border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background-color: rgba(255, 255, 255, 0.8);
border-radius: 8px 8px 0 0;
}
.header-title {
font-size: 13px;
font-weight: 500;
color: #333;
text-align: left;
}
.header-btn {
background: none;
border: none;
color: #333;
font-size: 12px;
cursor: pointer;
padding: 3px 0;
border-radius: 3px;
transition: background-color 0.2s ease;
min-width: 32px;
}
.header-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.close-btn {
color: #666;
}
.toolbar-section {
padding: 0 3rem 1.2rem;
}
.tool-types {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 10px 0;
}
.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;
}
.toolbar-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.05);
margin-bottom: 5px;
}
.tool-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 0 10px;
}
/* 平板适配 - 每行4个按钮 */
@media screen and (max-width: 768px) {
.tool-actions {
grid-template-columns: repeat(3, 1fr);
gap: 8px 6px;
padding: 0 8px;
}
}
/* 手机适配 - 每行3个按钮 */
@media screen and (max-width: 480px) {
.tool-actions {
grid-template-columns: repeat(3, 1fr);
gap: 6px 4px;
padding: 0 6px;
}
.header-btn {
font-size: 11px;
padding: 2px 4px;
min-width: 28px;
}
}
.action-btn {
display: flex;
// flex-direction: column;
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 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.dialog-header h3 {
margin: 0;
font-size: 15px;
color: #333;
font-weight: 500;
}
.close-dialog-btn {
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.dialog-content {
padding: 15px;
}
.feather-control {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.slider-control {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
-webkit-appearance: none;
appearance: none;
margin-right: 10px;
}
.slider-control::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #007aff;
cursor: pointer;
}
.feather-value {
font-size: 14px;
color: #333;
min-width: 40px;
text-align: right;
}
.dialog-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 15px;
}
.cancel-btn,
.confirm-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
border: none;
}
.cancel-btn {
background-color: rgba(0, 0, 0, 0.05);
color: #333;
}
.confirm-btn {
background-color: #007aff;
color: white;
}
.color-picker {
width: 100%;
height: 40px;
border: none;
border-radius: 6px;
cursor: pointer;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(30px);
}
</style>

View File

@@ -1,14 +1,15 @@
<template> <template>
<div class="repeat-setting"> <div class="repeat-setting">
<div class="title">{{ t("Canvas.repeatSetting") }}</div>
<div class="repeat-setting-item"> <div class="repeat-setting-item">
<span class="label">{{ t("Canvas.angle") }}</span> <span class="label">{{ t("Canvas.angle") }}</span>
<angle-tool <angle-tool
:angle="angle" :angle="angle"
@input="(e) => emit('inputFillAngle', e)" @input="(e) => emit('inputFillAngle', e)"
@change="(e) => emit('changeFillAngle', e)" @change="(e) => emit('changeFillAngle', e)"
style-type="2"
/> />
</div> </div>
<p></p>
<div class="repeat-setting-item"> <div class="repeat-setting-item">
<span class="label">{{ t("Canvas.scale") }}</span> <span class="label">{{ t("Canvas.scale") }}</span>
<slider <slider
@@ -22,7 +23,6 @@
@change="changeFillScale" @change="changeFillScale"
/> />
</div> </div>
<p></p>
<div class="repeat-setting-item"> <div class="repeat-setting-item">
<span class="label">Gap X</span> <span class="label">Gap X</span>
<slider <slider
@@ -36,7 +36,6 @@
@change="(e) => emit('changeFill_Gap', e, gapY)" @change="(e) => emit('changeFill_Gap', e, gapY)"
/> />
</div> </div>
<p></p>
<div class="repeat-setting-item"> <div class="repeat-setting-item">
<span class="label">Gap Y</span> <span class="label">Gap Y</span>
<slider <slider
@@ -50,14 +49,23 @@
@change="(e) => emit('changeFill_Gap', gapX, e)" @change="(e) => emit('changeFill_Gap', gapX, e)"
/> />
</div> </div>
<p></p>
<div class="repeat-setting-item"> <div class="repeat-setting-item">
<span class="label">{{ t("Canvas.offset") }}</span> <span class="label">{{ t("Canvas.offset") }}</span>
<offset-tool <offset-tool
:top="(props.object.fill?.offsetY / props.object.height) * 100" :left="offsetX"
:left="(props.object.fill?.offsetX / props.object.width) * 100" :top="offsetY"
@input="(e) => emit('inputFillOffset', e)" @input="(e) => emit('inputFillOffset', e)"
@change="(e) => emit('changeFillOffset', e)" @change="(e) => emit('changeFillOffset', e)"
:show-dish="false"
/>
</div>
<div class="repeat-setting-item offset">
<offset-tool
:left="offsetX"
:top="offsetY"
@input="(e) => emit('inputFillOffset', e)"
@change="(e) => emit('changeFillOffset', e)"
:show-input="false"
/> />
</div> </div>
</div> </div>
@@ -88,6 +96,12 @@
}); });
const gapX = computed(() => props.object.fill_?.gapX || 0); const gapX = computed(() => props.object.fill_?.gapX || 0);
const gapY = computed(() => props.object.fill_?.gapY || 0); const gapY = computed(() => props.object.fill_?.gapY || 0);
const offsetX = computed(
() => (props.object.fill?.offsetX / props.object.width) * 100
);
const offsetY = computed(
() => (props.object.fill?.offsetY / props.object.height) * 100
);
const emit = defineEmits([ const emit = defineEmits([
"inputFillAngle", "inputFillAngle",
"changeFillAngle", "changeFillAngle",
@@ -111,23 +125,36 @@
<style scoped lang="less"> <style scoped lang="less">
.repeat-setting { .repeat-setting {
user-select: none; user-select: none;
width: 228px;
> .title {
line-height: 35px;
font-size: 14px;
text-align: center;
margin-top: -12px;
margin-bottom: 3px;
}
> .repeat-setting-item { > .repeat-setting-item {
display: flex; display: flex;
align-items: center; align-items: center;
//虚线 margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
&.offset {
justify-content: center;
}
> .label { > .label {
min-width: 50px; min-width: 68px;
font-size: 14px; font-size: 12px;
} }
> .angle-tool { &:not(.offset) > div {
width: 120px; width: 120px;
flex: 1;
}
> .slider {
--slider-thumb-color1: #000;
--slider-thumb-color2: #eee;
} }
}
> p {
margin: 10px 0;
width: 100%;
height: 0;
border-bottom: 1px dashed #e5e5e5;
} }
} }
</style> </style>

View File

@@ -132,7 +132,6 @@
v-if="v.type === 'rect'" v-if="v.type === 'rect'"
trigger="click" trigger="click"
destroyTooltipOnHide destroyTooltipOnHide
:title="t('Canvas.repeatSetting')"
> >
<template #content> <template #content>
<repeat-setting <repeat-setting
@@ -784,6 +783,7 @@
} }
> .list { > .list {
display: flex; display: flex;
> div { > div {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -166,6 +166,19 @@ const normalToolsList = ref([
icon: { name: "CFont", size: "20" }, icon: { name: "CFont", size: "20" },
class: "text-btn", class: "text-btn",
}, },
{
id: OperationType.PART,
title: t("Canvas.GarmentPartSelector"),
action: () => selectTool(OperationType.PART),
icon: { name: "CPart", size: "28" },
class: "part-btn",
activeList: [
OperationType.PART,
OperationType.PART_RECTANGLE,
OperationType.PART_BRUSH,
OperationType.PART_ERASER,
],
},
{ {
id: "help", id: "help",
title: t("Canvas.help"), title: t("Canvas.help"),

View File

@@ -1,32 +1,53 @@
<template> <template>
<div class="angle-tool" :disabled="disabled"> <div class="angle-tool" :disabled="disabled">
<div <template v-if="styleType === '1'">
ref="dishRef" <div
class="dish" ref="dishRef"
@mousedown.stop="mousedown" class="dish"
@touchmove.stop="mousedown" @mousedown.stop="mousedown"
> @touchmove.stop="mousedown"
<div class="pointer" :style="{ transform: `rotate(${angle}deg)` }"> >
<span></span> <div
class="pointer"
:style="{ transform: `rotate(${angle}deg)` }"
>
<span></span>
</div>
</div> </div>
</div> <div class="input">
<div class="input"> <input
<input type="number"
type="number" v-model="angle"
v-model="angle" @input="onInput"
@input="onInput" @change="onChange"
@change="onChange" :disabled="disabled"
:disabled="disabled" />
/> </div>
</div> </template>
<my-input
v-if="styleType === '2'"
v-model="angle"
@input="onInput"
@change="onChange"
:disabled="disabled"
type="number"
after="°"
icon="icon-angle"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue"; import { ref, defineProps, defineEmits, watch } from "vue";
import { calculateAngle } from "../../utils/helper"; import { calculateAngle } from "../../utils/helper";
import MyInput from "./MyInput.vue";
// Props // Props
const props = defineProps({ const props = defineProps({
styleType: {
type: String,
default: "1",
},
angle: { angle: {
type: Number, type: Number,
default: 0, default: 0,
@@ -139,5 +160,8 @@
outline: none; outline: none;
} }
} }
> .my-input {
flex: 1;
}
} }
</style> </style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="my-input">
<span class="decorate"></span>
<span v-show="icon" :class="['iconfont', icon]"></span>
<span v-show="before" class="before">{{ before }}</span>
<input v-bind="$attrs" :value="modelValue" @input="onInput" />
<span v-show="after" class="after">{{ after }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
const props = defineProps({
modelValue: { type: Number, default: 0 },
icon: { default: "", type: String },
before: { default: "", type: String },
after: { default: "", type: String },
});
const emit = defineEmits(["update:modelValue", "input"]);
const onInput = (e) => {
const value = e.target.value;
emit("update:modelValue", value);
emit("input", value);
};
</script>
<style scoped lang="less">
.my-input {
display: flex;
align-items: center;
width: 100%;
border: 1px solid rgba(230, 230, 231, 1);
border-radius: 3px;
height: 20px;
padding: 0 4px 0 2px;
> .decorate {
width: 2px;
background-color: rgba(230, 230, 231, 1);
border-radius: 3px;
height: 85%;
margin-right: 4px;
}
> .iconfont {
font-size: 10px;
color: #000;
margin-right: 2px;
}
> .before {
font-size: 12px;
color: #000;
margin-right: 2px;
}
> .after {
font-size: 12px;
color: #000;
}
> input {
font-size: 12px;
width: 0;
flex: 1;
text-align: right;
outline: none;
border: none;
background-color: transparent;
padding: 0;
}
}
</style>

View File

@@ -1,84 +1,100 @@
<template> <template>
<div class="offset-tool"> <div class="offset-tool">
<div class="input" v-show="showInput">
<my-input
v-model="left"
@input="onInput"
@change="onChange"
type="number"
before="X"
after="%"
:min="-100"
:max="100"
/>
<my-input
v-model="top"
@input="onInput"
@change="onChange"
type="number"
before="Y"
after="%"
:min="-100"
:max="100"
/>
</div>
<div <div
class="dish" class="dish"
@mousedown="mousedown" @mousedown="mousedown"
@touchstart="mousedown" @touchstart="mousedown"
ref="dishRef" ref="dishRef"
v-show="showDish"
> >
<span <img src="/src/assets/images/icon/xyz.png" />
:style="{ top: data.top + '%', left: data.left + '%' }" <span class="ball" :style="ballStyle"></span>
></span> <span class="tip x">X: {{ left }}%</span>
<span class="tip y">Y: {{ top }}%</span>
<span class="line x"></span>
<span class="line y"></span>
<span class="line z" :style="lineZStyle"></span>
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue"; import { ref, defineProps, defineEmits, watch, computed } from "vue";
import MyInput from "./MyInput.vue";
const props = defineProps({ const props = defineProps({
top: {
type: Number,
default: 50,
},
left: { left: {
type: Number, type: Number,
default: 50, default: 0,
},
top: {
type: Number,
default: 0,
},
showInput: {
type: Boolean,
default: true,
},
showDish: {
type: Boolean,
default: true,
}, },
}); });
const tofix = (v: number | string) => Number(Number(v).toFixed(1));
const emit = defineEmits(["change", "input"]); const emit = defineEmits(["change", "input"]);
const data = reactive({ // 工具的实际坐标 -100 ~ 100
top: tofix(props.top), const top = ref(Math.round(props.top));
left: tofix(props.left), const left = ref(Math.round(props.left));
});
watch( // 原点的坐标 0 ~ 100
() => props.top, const ballStyle = computed(() => ({
(v) => (data.top = tofix(v)) top: 50 + Number(top.value) / 2 + "%",
); left: 50 + Number(left.value) / 2 + "%",
}));
watch( watch(
() => props.left, () => props.left,
(v) => (data.left = tofix(v)) (v) => (left.value = Math.round(v))
);
watch(
() => props.top,
(v) => (top.value = Math.round(v))
); );
const dishRef = ref<HTMLDivElement>(); const dishRef = ref<HTMLDivElement>();
const mousedown = (e: MouseEvent | TouchEvent) => { const mousedown = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return; if (!dishRef.value) return;
const mousemove = (e: MouseEvent | TouchEvent) => { const mousemove = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return; if (!dishRef.value) return;
const { left, top, width, height } = const rect = dishRef.value.getBoundingClientRect();
dishRef.value.getBoundingClientRect();
const X = e.clientX || (e as TouchEvent).touches[0].clientX; const X = e.clientX || (e as TouchEvent).touches[0].clientX;
const Y = e.clientY || (e as TouchEvent).touches[0].clientY; const Y = e.clientY || (e as TouchEvent).touches[0].clientY;
var x = ((X - left) / width) * 100; var x = ((X - rect.left) / rect.width) * 100;
var y = ((Y - top) / height) * 100; var y = ((Y - rect.top) / rect.height) * 100;
if (x < 0) x = 0; if (x < 0) x = 0;
if (x > 100) x = 100; if (x > 100) x = 100;
if (y < 0) y = 0; if (y < 0) y = 0;
if (y > 100) y = 100; if (y > 100) y = 100;
data.left = tofix(x); left.value = Math.round((x - 50) * 2);
data.top = tofix(y); top.value = Math.round((y - 50) * 2);
onInput(); onInput();
}; };
mousemove(e); mousemove(e);
@@ -94,96 +110,125 @@
document.addEventListener("mouseup", mouseup); document.addEventListener("mouseup", mouseup);
document.addEventListener("touchend", mouseup); document.addEventListener("touchend", mouseup);
}; };
const onInput = () => emit("input", { ...data }); const onInput = () => {
emit("input", { left: left.value, top: top.value });
};
var changeTime: any = null; var changeTime: any = null;
const onChange = () => { const onChange = () => {
clearTimeout(changeTime); clearTimeout(changeTime);
changeTime = setTimeout(() => emit("change", { ...data }), 500); changeTime = setTimeout(() => {
emit("change", {
left: left.value,
top: top.value,
});
}, 500);
}; };
// var offsetTime = null; const lineZStyle = computed(() => ({
// watch(data, (v) => { "--rotateZ": calculateAngle(0, 0, left.value, top.value) + "deg",
// const obj = { ...v }; width: calculateDistance(0, 0, left.value, top.value) / 2 + "%",
// emit("input", obj); }));
// clearTimeout(offsetTime); // 计算角度
// offsetTime = setTimeout(() => emit("change", obj), 50); function calculateAngle(x1: number, y1: number, x2: number, y2: number) {
// }); const deltaX = x2 - x1;
const deltaY = y1 - y2;
// defineExpose({ let angle = Math.atan2(deltaX, deltaY) * (180 / Math.PI) - 90;
// open, return angle;
// close, }
// }); // 计算距离
function calculateDistance(x1: number, y1: number, x2: number, y2: number) {
const deltaX = x2 - x1;
const deltaY = y2 - y1;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
return distance;
}
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.offset-tool { .offset-tool {
width: 125px;
height: 125px;
display: flex;
position: relative; position: relative;
overflow: hidden; > .input {
--gap: 15px; display: flex;
align-items: center;
justify-content: center;
> * {
flex: 1;
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
}
> .dish { > .dish {
margin: var(--gap) 0 0 var(--gap); width: 135px;
flex: 1; height: 135px;
border: 1px solid #000; border: 1px solid #eaeaea;
border-radius: 5px; border-radius: 4px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
background-color: #fff; background-color: #f6f6f6;
> span { margin-top: 24px;
> * {
position: absolute;
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
position: absolute; }
top: 0%; > img {
left: 0%; width: 15px;
height: 15px;
bottom: 4px;
right: 4px;
}
> .ball {
top: 50%;
left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 8px; width: 10px;
height: 8px; height: 10px;
background-color: #000; border: 1px solid #fff;
background-color: #333;
border-radius: 50%; border-radius: 50%;
box-shadow: 0px 0.68px 1.7px 0px rgba(0, 0, 0, 0.26);
} }
} > .tip {
> .tip { font-size: 10px;
position: absolute; color: #000;
right: 4px; line-height: 24px;
bottom: 0; &.x {
font-size: 10px; top: 50%;
pointer-events: none; right: 0%;
user-select: none; transform: translate(100%, -50%);
color: #666; padding-left: 6px;
} }
> input.left { &.y {
right: 0; top: 0%;
} left: 50%;
> input.top { transform: translate(-50%, -100%);
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 { > .line {
background: #3b77db; border-color: #d9d9d9;
transform: scale(1.1); border-style: dashed;
border-width: 0;
width: 0;
height: 0;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.x {
width: 100%;
border-top-width: 1px;
}
&.y {
height: 100%;
border-left-width: 1px;
}
&.z {
width: 50%;
border-top-width: 1px;
border-color: #454754;
transform: translate(0%, -50%) rotateZ(var(--rotateZ));
transform-origin: left center;
}
} }
} }
} }

View File

@@ -1,13 +1,12 @@
<template> <template>
<div class="slider" :disabled="disabled"> <div class="slider" :disabled="disabled">
<div class="input-range"> <div
<span class="input-range"
class="tip" :style="{
:style="{ '--progress': (value - props.min) / (props.max - props.min),
'--progress': (value - props.min) / (props.max - props.min), }"
}" >
>{{ props.tipFormatter(value) }}</span <span class="tip">{{ props.tipFormatter(value) }}</span>
>
<input <input
type="range" type="range"
v-model="value" v-model="value"
@@ -20,8 +19,7 @@
/> />
</div> </div>
<div class="input" v-show="isInput"> <div class="input" v-show="isInput">
<input <my-input
type="number"
v-model="value" v-model="value"
:min="props.min" :min="props.min"
:max="props.max" :max="props.max"
@@ -29,6 +27,7 @@
@input="onInput" @input="onInput"
@change="onChange" @change="onChange"
:disabled="disabled" :disabled="disabled"
type="number"
/> />
</div> </div>
</div> </div>
@@ -36,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue"; import { ref, defineProps, defineEmits, watch } from "vue";
import MyInput from "./MyInput.vue";
const props = defineProps({ const props = defineProps({
disabled: { disabled: {
type: Boolean, type: Boolean,
@@ -86,9 +86,10 @@
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
--input-thumb-size: 12px;
width: 150px; width: 150px;
// &:focus-within, --input-thumb-size: 10px;
--backcolor1: var(--slider-thumb-color1, #4285f4);
--backcolor2: var(--slider-thumb-color2, rgba(0, 0, 0, 0.1));
&:hover { &:hover {
> .input-range > .tip { > .input-range > .tip {
display: block; display: block;
@@ -103,21 +104,26 @@
appearance: none; appearance: none;
height: 5px; height: 5px;
border-radius: 5px; border-radius: 5px;
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
outline: none; outline: none;
background: linear-gradient(
to right,
var(--backcolor1) 0%,
var(--backcolor1) calc(var(--progress) * 100%),
var(--backcolor2) calc(var(--progress) * 100%),
var(--backcolor2) 100%
);
&::-webkit-slider-thumb { &::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: var(--input-thumb-size); width: var(--input-thumb-size);
height: var(--input-thumb-size); height: var(--input-thumb-size);
border-radius: 50%; border-radius: 50%;
background: #4285f4; /* 蓝色滑块 */ background: var(--backcolor1); /* 蓝色滑块 */
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
} }
&::-webkit-slider-thumb:hover { &::-webkit-slider-thumb:hover {
background: #3b77db;
transform: scale(1.1); transform: scale(1.1);
} }
} }

View File

@@ -38,6 +38,7 @@ import LiquifyPanel from "./components/LiquifyPanel.vue"; // 引入液化编辑
import PalletPanel from "./components/PalletPanel/index.vue"; import PalletPanel from "./components/PalletPanel/index.vue";
import SelectMenuPanel from "./components/SelectMenuPanel/index.vue"; // 引入选择工具菜单组件 import SelectMenuPanel from "./components/SelectMenuPanel/index.vue"; // 引入选择工具菜单组件
import SelectionPanel from "./components/SelectionPanel.vue"; // 引入选区面板 import SelectionPanel from "./components/SelectionPanel.vue"; // 引入选区面板
import PartSelectorPanel from "./components/PartSelectorPanel.vue"; // 引入部件选取面板
import { LayerType, OperationType } from "./utils/layerHelper.js"; import { LayerType, OperationType } from "./utils/layerHelper.js";
import { ToolManager } from "./managers/ToolManager.js"; import { ToolManager } from "./managers/ToolManager.js";
import { fabric } from "fabric-with-all"; import { fabric } from "fabric-with-all";
@@ -1248,6 +1249,18 @@ defineExpose({
:activeTool="activeTool" :activeTool="activeTool"
/> />
<!-- 部件选取面板 -->
<PartSelectorPanel
v-if="canvasManagerLoaded && !enabledRedGreenMode"
:canvas="canvasManager && canvasManager.canvas"
:commandManager="commandManager"
:selectionManager="selectionManager"
:layerManager="layerManager"
:canvasManager="canvasManager"
:toolManager="toolManager"
:activeTool="activeTool"
/>
<!-- 文本编辑面板 --> <!-- 文本编辑面板 -->
<TextEditorPanel <TextEditorPanel
v-if="canvasManagerLoaded && !enabledRedGreenMode" v-if="canvasManagerLoaded && !enabledRedGreenMode"

View File

@@ -67,6 +67,12 @@ export class ToolManager {
// 工具列表 - 与OperationType保持一致 // 工具列表 - 与OperationType保持一致
this.tools = { this.tools = {
// 禁用工具
[OperationType.DISABLED]: {
name: "禁用工具",
icon: "disabled",
cursor: "not-allowed",
},
// 基础工具 // 基础工具
[OperationType.SELECT]: { [OperationType.SELECT]: {
name: "选择工具", name: "选择工具",
@@ -83,6 +89,7 @@ export class ToolManager {
shortcut: "B", shortcut: "B",
setup: this.setupBrushTool.bind(this), setup: this.setupBrushTool.bind(this),
allowedInRedGreen: false, allowedInRedGreen: false,
specialLayerDisabled: true,
}, },
[OperationType.ERASER]: { [OperationType.ERASER]: {
name: "橡皮擦", name: "橡皮擦",
@@ -91,6 +98,7 @@ export class ToolManager {
shortcut: "E", shortcut: "E",
setup: this.setupEraserTool.bind(this), setup: this.setupEraserTool.bind(this),
allowedInRedGreen: true, // 红绿图模式允许橡皮擦 allowedInRedGreen: true, // 红绿图模式允许橡皮擦
specialLayerDisabled: true,
}, },
[OperationType.EYEDROPPER]: { [OperationType.EYEDROPPER]: {
name: "吸色工具", name: "吸色工具",
@@ -117,6 +125,7 @@ export class ToolManager {
shortcut: "L", shortcut: "L",
setup: this.setupLassoTool.bind(this), setup: this.setupLassoTool.bind(this),
allowedInRedGreen: false, allowedInRedGreen: false,
specialLayerDisabled: true,
}, },
[OperationType.LASSO_RECTANGLE]: { [OperationType.LASSO_RECTANGLE]: {
name: "矩形套索工具", name: "矩形套索工具",
@@ -126,6 +135,7 @@ export class ToolManager {
altKey: true, altKey: true,
setup: this.setupRectangleLassoTool.bind(this), setup: this.setupRectangleLassoTool.bind(this),
allowedInRedGreen: false, allowedInRedGreen: false,
specialLayerDisabled: true,
}, },
[OperationType.LASSO_ELLIPSE]: { [OperationType.LASSO_ELLIPSE]: {
name: "椭圆形套索工具", name: "椭圆形套索工具",
@@ -135,6 +145,7 @@ export class ToolManager {
altKey: true, altKey: true,
setup: this.setupEllipseLassoTool.bind(this), setup: this.setupEllipseLassoTool.bind(this),
allowedInRedGreen: false, allowedInRedGreen: false,
specialLayerDisabled: true,
}, },
// 选区工具 - 只需要矩形选区 // 选区工具 - 只需要矩形选区
@@ -164,6 +175,7 @@ export class ToolManager {
shortcut: "J", shortcut: "J",
setup: this.setupLiquifyTool.bind(this), setup: this.setupLiquifyTool.bind(this),
allowedInRedGreen: false, // 红绿图模式不允许液化 allowedInRedGreen: false, // 红绿图模式不允许液化
specialLayerDisabled: true,
}, },
[OperationType.TEXT]: { [OperationType.TEXT]: {
name: "文本工具", name: "文本工具",
@@ -174,6 +186,36 @@ export class ToolManager {
allowedInRedGreen: false, // 红绿图模式不允许文本 allowedInRedGreen: false, // 红绿图模式不允许文本
}, },
// 部件选取工具
[OperationType.PART]: {
name: "部件选取工具",
icon: "part",
cursor: "crosshair",
// setup: this.setupLassoTool.bind(this),
specialLayerDisabled: true,
},
[OperationType.PART_RECTANGLE]: {
name: "部件选取工具-矩形",
icon: "part",
cursor: "crosshair",
// setup: this.setupRectangleLassoTool.bind(this),
specialLayerDisabled: true,
},
[OperationType.PART_BRUSH]: {
name: "部件选取工具-画笔",
icon: "part",
cursor: "crosshair",
// setup: this.setupEllipseLassoTool.bind(this),
specialLayerDisabled: true,
},
[OperationType.PART_ERASER]: {
name: "部件选取工具-橡皮擦",
icon: "part",
cursor: "crosshair",
// setup: this.setupEllipseLassoTool.bind(this),
specialLayerDisabled: true,
},
// 红绿图模式专用工具 // 红绿图模式专用工具
[OperationType.RED_BRUSH]: { [OperationType.RED_BRUSH]: {
name: "红色笔刷", name: "红色笔刷",
@@ -331,8 +373,9 @@ export class ToolManager {
* @param {String} toolId 工具ID * @param {String} toolId 工具ID
*/ */
setTool(toolId) { setTool(toolId) {
const tool = this.tools[toolId];
// 检查工具是否存在 // 检查工具是否存在
if (!this.tools[toolId]) { if (!tool) {
console.error(`工具 '${toolId}' 不存在`); console.error(`工具 '${toolId}' 不存在`);
return; return;
} }
@@ -348,15 +391,20 @@ export class ToolManager {
console.warn(`工具 '${toolId}' 只能在红绿图模式下使用`); console.warn(`工具 '${toolId}' 只能在红绿图模式下使用`);
return; return;
} }
if(tool?.specialLayerDisabled && this.checkToolCanOperateSelectedObject()){
console.warn(`工具 '${toolId}' 不能在当前选中对象上操作`);
toolId = OperationType.DISABLED;
}
// 保存先前的工具 // 保存先前的工具
this.previousTool = this.activeTool.value; this.previousTool = this.activeTool.value;
// 取消画布的选中状态 // 取消画布的选中状态
this.canvas?.discardActiveObject(); if(toolId !== OperationType.DISABLED){
this.canvasManager?.layerManager?.updateLayersObjectsInteractivity?.(); this.canvas?.discardActiveObject();
this.canvas?.renderAll(); this.canvasManager?.layerManager?.updateLayersObjectsInteractivity?.();
this.canvas?.renderAll();
}
// 隐藏文本编辑面板 // 隐藏文本编辑面板
this.hideTextEditor(); this.hideTextEditor();
@@ -374,7 +422,6 @@ export class ToolManager {
} }
// 设置工具特定的状态 // 设置工具特定的状态
const tool = this.tools[toolId];
if (tool && typeof tool.setup === "function") { if (tool && typeof tool.setup === "function") {
console.log(`画布切换工具:${tool.name}(${toolId})`) console.log(`画布切换工具:${tool.name}(${toolId})`)
this.canvas.toolId = toolId; this.canvas.toolId = toolId;
@@ -424,7 +471,7 @@ export class ToolManager {
const currentTool = this.activeTool.value; const currentTool = this.activeTool.value;
const tool = this.tools[currentTool]; const tool = this.tools[currentTool];
if(tool?.specialLayerDisabled && this.checkToolCanOperateSelectedObject()) return;
// 根据当前工具设置selection状态 // 根据当前工具设置selection状态
if (currentTool === OperationType.SELECT) { if (currentTool === OperationType.SELECT) {
this.canvas.selection = true; this.canvas.selection = true;
@@ -460,19 +507,15 @@ export class ToolManager {
/** /**
* 检查当前工具是否禁止操作当前选中的对象 * 检查当前工具是否禁止操作当前选中的对象
* @param {Boolean} isBrushTool 是否为画笔工具
* @returns {Boolean} 是否可以切换 * @returns {Boolean} 是否可以切换
*/ */
checkToolCanOperateSelectedObject(isBrushTool = false) { checkToolCanOperateSelectedObject() {
const layer = this.layerManager?.getActiveLayer(); const layer = this.layerManager?.getActiveLayer();
const isSpecialLayer = !!layer?.specialType; const isSpecialLayer = !!layer?.specialType;
if (isSpecialLayer) { if (isSpecialLayer) {
if(isBrushTool){ this._disableBrushIndicator();
this._disableBrushIndicator();
}
this.canvas.defaultCursor = "not-allowed"; this.canvas.defaultCursor = "not-allowed";
} }
console.log("===========",isSpecialLayer, this.canvas.defaultCursor);
return isSpecialLayer; return isSpecialLayer;
} }
@@ -482,7 +525,7 @@ export class ToolManager {
*/ */
setupBrushTool() { setupBrushTool() {
if (!this.canvas) return; if (!this.canvas) return;
if (this.checkToolCanOperateSelectedObject(true)) return; if (this.checkToolCanOperateSelectedObject()) return;
this.canvas.isDrawingMode = true; this.canvas.isDrawingMode = true;
this.canvas.selection = false; this.canvas.selection = false;
@@ -526,7 +569,7 @@ export class ToolManager {
*/ */
setupEraserTool() { setupEraserTool() {
if (!this.canvas) return; if (!this.canvas) return;
if (this.checkToolCanOperateSelectedObject(true)) return; if (this.checkToolCanOperateSelectedObject()) return;
this.canvas.isDrawingMode = true; this.canvas.isDrawingMode = true;
this.canvas.selection = false; this.canvas.selection = false;
@@ -580,6 +623,7 @@ export class ToolManager {
*/ */
setupLassoTool() { setupLassoTool() {
if (!this.canvas) return; if (!this.canvas) return;
if (this.checkToolCanOperateSelectedObject()) return;
this.canvas.isDrawingMode = false; this.canvas.isDrawingMode = false;
this.canvas.selection = false; this.canvas.selection = false;
@@ -676,7 +720,7 @@ export class ToolManager {
*/ */
setupLiquifyTool() { setupLiquifyTool() {
if (!this.canvas || !this.layerManager) return; if (!this.canvas || !this.layerManager) return;
if (this.checkToolCanOperateSelectedObject(true)) return; if (this.checkToolCanOperateSelectedObject()) return;
this.canvas.isDrawingMode = false; this.canvas.isDrawingMode = false;
this.canvas.selection = false; this.canvas.selection = false;

View File

@@ -1005,3 +1005,92 @@ export async function base64ToCanvas(base64, scale = 1, sr = false) {
image.onerror = reject; image.onerror = reject;
}); });
} }
/**
* 图片边界跟踪算法(透明底)
* @param {HTMLCanvasElement} canvas - canvas元素
* @returns {Array} 边界点数组 [{x, y}, ...]
*/
export function traceImageContour(canvas) {
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// 查找起始点(第一个不透明像素)
let startX = -1;
let startY = -1;
outer: for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
if (data[index + 3] > 0) {
startX = x;
startY = y;
break outer;
}
}
}
if (startX === -1) return []; // 没有不透明像素
// Moore-Neighbor边界跟踪算法
const contour = [];
const visited = new Set();
const directions = [
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
];
let currentX = startX;
let currentY = startY;
let backtrackDir = 4; // 起始方向:右
do {
const pointKey = `${currentX},${currentY}`;
if (!visited.has(pointKey)) {
contour.push({ x: currentX, y: currentY });
visited.add(pointKey);
}
// 从右方向开始顺时针查找
let found = false;
for (let i = 0; i < 8; i++) {
const dir = (backtrackDir + i) % 8;
const dx = directions[dir][0];
const dy = directions[dir][1];
const checkX = currentX + dx;
const checkY = currentY + dy;
if (
checkX >= 0 &&
checkX < width &&
checkY >= 0 &&
checkY < height
) {
const index = (checkY * width + checkX) * 4;
if (data[index + 3] > 0) {
currentX = checkX;
currentY = checkY;
backtrackDir = (dir + 5) % 8; // 下一个开始查找的方向
found = true;
break;
}
}
}
if (!found) break;
} while (
!(currentX === startX && currentY === startY) &&
visited.size < width * height
);
return contour;
}

View File

@@ -44,6 +44,7 @@ export const SpecialType = {
*/ */
export const OperationType = { export const OperationType = {
// 编辑器模式 // 编辑器模式
DISABLED: "disabled", // 禁用
DRAW: "draw", // 绘画模式 DRAW: "draw", // 绘画模式
ERASER: "eraser", // 橡皮擦模式 ERASER: "eraser", // 橡皮擦模式
SELECT: "select", // 选择模式 SELECT: "select", // 选择模式
@@ -76,6 +77,12 @@ export const OperationType = {
RED_BRUSH: "red_brush", // 红色笔刷 RED_BRUSH: "red_brush", // 红色笔刷
GREEN_BRUSH: "green_brush", // 绿色笔刷 GREEN_BRUSH: "green_brush", // 绿色笔刷
// 部件选取工具
PART: "part", // 部件选取工具模式 - 点选模式
PART_RECTANGLE: "part_rectangle", // 部件选取工具模式 - 矩形模式
PART_BRUSH: "part_brush", // 部件选取工具模式 - 笔刷模式
PART_ERASER: "part_eraser", // 部件选取工具模式 - 橡皮擦模式
// SHAPE: "shape", // 形状模式 // SHAPE: "shape", // 形状模式
// 可以根据需要添加更多工具 // 可以根据需要添加更多工具
}; };

View File

@@ -345,7 +345,7 @@ const otherData = {
// angle: 0, // angle: 0,
// }, // },
{ {
ifSingle: true, ifSingle: false,
level2Type: "Pattern", level2Type: "Pattern",
designType: "Library", designType: "Library",
path: "/src/assets/images/canvas/yinhua1.jpg", path: "/src/assets/images/canvas/yinhua1.jpg",

View File

@@ -1,8 +1,8 @@
<template> <template>
<div class="test" ref="testRef"> <div class="test" ref="testRef">
<!-- <div class="canvas-container"> <div class="canvas-container">
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
</div> --> </div>
</div> </div>
</template> </template>
@@ -35,53 +35,104 @@
canvas1.height = height; canvas1.height = height;
const ctx1 = canvas1.getContext("2d"); const ctx1 = canvas1.getContext("2d");
ctx1.drawImage(image, 0, 0, width, height); ctx1.drawImage(image, 0, 0, width, height);
const data = ctx1.getImageData(0, 0, width, height); const arr = traceImageContour(canvas1);
testRef.value.appendChild(canvas1); const str = arr.map((v) => `${v.x} ${v.y}`).join(" L ");
const testData = test(data); const path = new fabric.Path(`M ${str} z`);
const canvas2 = document.createElement("canvas"); path.set({
canvas2.width = width; fill: "rgba(127, 255, 127, 0.3)",
canvas2.height = height; stroke: "#2AA81B",
const ctx2 = canvas2.getContext("2d"); strokeWidth: 2,
ctx2.putImageData(testData, 0, 0); strokeDashArray: [8, 4],
testRef.value.appendChild(canvas2); strokeLineCap: "round",// 折线端点样式
strokeLineJoin: "bevel", // 折线连接样式
strokeUniform: true, // 保持描边宽度不随缩放改变
});
canvas.add(path);
}; };
}); });
// 获取图片轮廓点位 // 边界追踪
function test(data) { function traceImageContour(canvas) {
// 找过的点位 const ctx = canvas.getContext("2d");
const visited = []; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 轮廓点位 const data = imageData.data;
const contours = []; const width = canvas.width;
const { width, height } = data; const height = canvas.height;
function cd(x, y) {
const arr = [ // 查找起始点(第一个不透明像素)
[x, y], // 当前 let startX = -1;
[x, y - 1], // 上 let startY = -1;
[x + 1, y], // 右
[x, y + 1], // 下 outer: for (let y = 0; y < height; y++) {
[x - 1, y], // 左 for (let x = 0; x < width; x++) {
]; const index = (y * width + x) * 4;
for (let i = 0; i < arr.length; i++) { if (data[index + 3] > 0) {
let [x1, y1] = arr[i]; startX = x;
if (x1 < 0 || x1 >= width || y1 < 0 || y1 >= height) continue; startY = y;
let key = `${x1},${y1}`; break outer;
if (visited.includes(key)) continue;
visited.push(key);
let index = (y1 * width + x1) * 4;
let r = data.data[index];
let g = data.data[index + 1];
let b = data.data[index + 2];
let a = data.data[index + 3];
if ((r || g || b) && a) {
contours.push({ x: x1, y: y1 });
} else {
if (i > 0) cd(x1, y1);
} }
} }
} }
cd(0, 0);
console.log(contours); if (startX === -1) return []; // 没有不透明像素
return data;
// Moore-Neighbor边界跟踪算法
const contour = [];
const visited = new Set();
const directions = [
[-1, 0],
[-1, -1],
[0, -1],
[1, -1],
[1, 0],
[1, 1],
[0, 1],
[-1, 1],
];
let currentX = startX;
let currentY = startY;
let backtrackDir = 4; // 起始方向:右
do {
const pointKey = `${currentX},${currentY}`;
if (!visited.has(pointKey)) {
contour.push({ x: currentX, y: currentY });
visited.add(pointKey);
}
// 从右方向开始顺时针查找
let found = false;
for (let i = 0; i < 8; i++) {
const dir = (backtrackDir + i) % 8;
const dx = directions[dir][0];
const dy = directions[dir][1];
const checkX = currentX + dx;
const checkY = currentY + dy;
if (
checkX >= 0 &&
checkX < width &&
checkY >= 0 &&
checkY < height
) {
const index = (checkY * width + checkX) * 4;
if (data[index + 3] > 0) {
currentX = checkX;
currentY = checkY;
backtrackDir = (dir + 5) % 8; // 下一个开始查找的方向
found = true;
break;
}
}
}
if (!found) break;
} while (
!(currentX === startX && currentY === startY) &&
visited.size < width * height
);
return contour;
} }
</script> </script>

View File

@@ -1497,6 +1497,7 @@ export default {
CompositeColorTip: '颜色:保留原图像饱和度,改变新图像颜色', CompositeColorTip: '颜色:保留原图像饱和度,改变新图像颜色',
CompositeLuminosity: '亮度', CompositeLuminosity: '亮度',
CompositeLuminosityTip: '亮度:保留原图像颜色,改变新图像亮度', CompositeLuminosityTip: '亮度:保留原图像颜色,改变新图像亮度',
GarmentPartSelector: '服装部件选取',
}, },
speedList: { speedList: {
High: '高级', High: '高级',

View File

@@ -1547,7 +1547,8 @@ export default {
'Color: Preserve the original image saturation and change the color of the new image', 'Color: Preserve the original image saturation and change the color of the new image',
CompositeLuminosity: 'Luminosity', CompositeLuminosity: 'Luminosity',
CompositeLuminosityTip: CompositeLuminosityTip:
'Luminosity: Preserve the original image color and change the luminosity of the new image' 'Luminosity: Preserve the original image color and change the luminosity of the new image',
GarmentPartSelector: 'Garment Part Selector',
}, },
speedList: { speedList: {
High: 'High', High: 'High',