合并画布代码
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<transition name="brush-control-canel-fade">
|
||||
<div v-if="isVisible" class="brush-control-panel">
|
||||
<!-- 笔刷大小控制 -->
|
||||
<VerticalSlider
|
||||
@@ -55,80 +55,89 @@
|
||||
</template>
|
||||
</VerticalSlider>
|
||||
|
||||
<!-- 颜色选择器 - 仅在特定工具下显示 -->
|
||||
<div v-if="showColorPicker" class="color-picker-container">
|
||||
<label for="color-picker" class="current-color-label">
|
||||
<div
|
||||
class="current-color"
|
||||
:style="{ backgroundColor: brushColor }"
|
||||
></div>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="color-picker"
|
||||
class="system-color-picker"
|
||||
v-model="customColor"
|
||||
@input="setBrushColor(customColor)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 透明度控制 - 仅在特定工具下显示 -->
|
||||
<VerticalSlider
|
||||
v-if="showOpacitySlider"
|
||||
v-model="brushOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:presets="opacityPresets"
|
||||
:memorized-values="memorizedOpacities"
|
||||
:is-percentage="true"
|
||||
custom-class="opacity-slider"
|
||||
:active-threshold="0.01"
|
||||
:step="0.01"
|
||||
v-model:showTooltip="showOpacityTooltip"
|
||||
@slide-start="handleOpacitySlideStart"
|
||||
@slide-end="handleOpacitySlideEnd"
|
||||
@click="showOpacityTooltip = true"
|
||||
>
|
||||
<template #tooltip-content>
|
||||
<div class="tooltip-header">
|
||||
<div class="tooltip-title">Opacity</div>
|
||||
<div class="tooltip-close-btn" @click.stop="closeOpacityTooltip">
|
||||
<SvgIcon name="CClose" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="opacity-preview">
|
||||
<div class="opacity-checker"></div>
|
||||
<!-- 1.这里加上过渡动画 颜色选择器 - 仅在特定工具下显示 -->
|
||||
<transition name="color-picker-fade" mode="out-in">
|
||||
<div
|
||||
v-if="showColorPicker"
|
||||
class="color-picker-container"
|
||||
key="color-picker"
|
||||
>
|
||||
<label for="color-picker" class="current-color-label">
|
||||
<div
|
||||
class="opacity-color"
|
||||
:style="{
|
||||
backgroundColor: brushColor,
|
||||
opacity: brushOpacity,
|
||||
}"
|
||||
class="current-color"
|
||||
:style="{ backgroundColor: brushColor }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-text">
|
||||
{{ Math.round(brushOpacity * 100) }}%
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="color-picker"
|
||||
class="system-color-picker"
|
||||
v-model="customColor"
|
||||
@input="setBrushColor(customColor)"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 2.这里加上过渡动画 透明度控制 - 仅在特定工具下显示 -->
|
||||
<transition name="opacity-slider-fade" mode="out-in">
|
||||
<VerticalSlider
|
||||
v-if="showOpacitySlider"
|
||||
key="opacity-slider"
|
||||
v-model="brushOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:presets="opacityPresets"
|
||||
:memorized-values="memorizedOpacities"
|
||||
:is-percentage="true"
|
||||
custom-class="opacity-slider"
|
||||
:active-threshold="0.01"
|
||||
:step="0.01"
|
||||
v-model:showTooltip="showOpacityTooltip"
|
||||
@slide-start="handleOpacitySlideStart"
|
||||
@slide-end="handleOpacitySlideEnd"
|
||||
@click="showOpacityTooltip = true"
|
||||
>
|
||||
<template #tooltip-content>
|
||||
<div class="tooltip-header">
|
||||
<div class="tooltip-title">Opacity</div>
|
||||
<div class="tooltip-close-btn" @click.stop="closeOpacityTooltip">
|
||||
<SvgIcon name="CClose" size="20" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="tooltip-controls">
|
||||
<button
|
||||
class="control-btn add"
|
||||
v-if="!memorizedOpacities.includes(brushOpacity)"
|
||||
@click="memorizeOpacity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="control-btn remove"
|
||||
@click="removeMemorizedOpacity"
|
||||
v-if="canRemoveOpacity"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div class="opacity-preview">
|
||||
<div class="opacity-checker"></div>
|
||||
<div
|
||||
class="opacity-color"
|
||||
:style="{
|
||||
backgroundColor: brushColor,
|
||||
opacity: brushOpacity,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VerticalSlider>
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-text">
|
||||
{{ Math.round(brushOpacity * 100) }}%
|
||||
</div>
|
||||
<div class="tooltip-controls">
|
||||
<button
|
||||
class="control-btn add"
|
||||
v-if="!memorizedOpacities.includes(brushOpacity)"
|
||||
@click="memorizeOpacity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="control-btn remove"
|
||||
@click="removeMemorizedOpacity"
|
||||
v-if="canRemoveOpacity"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VerticalSlider>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -139,7 +148,6 @@ import { BrushStore } from "../store/BrushStore";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import { inject } from "vue";
|
||||
import VerticalSlider from "./VerticalSlider.vue";
|
||||
import SvgIcon from "@/component/Canvas/SvgIcon/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
activeTool: {
|
||||
@@ -462,6 +470,13 @@ watch(
|
||||
color: #333;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
// 添加高度过渡动画
|
||||
transition: height 0.3s ease-out, min-height 0.3s ease-out;
|
||||
// overflow: hidden;
|
||||
|
||||
transform: translate3d(0, -50%, 0); // 确保使用3D变换以提高性能
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 笔刷大小预览相关样式
|
||||
@@ -660,27 +675,49 @@ watch(
|
||||
}
|
||||
|
||||
// 淡入淡出动画
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
.brush-control-canel-fade-enter-active,
|
||||
.brush-control-canel-fade-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
.brush-control-canel-fade-enter-from,
|
||||
.brush-control-canel-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) translateY(-50%);
|
||||
}
|
||||
|
||||
// 颜色选择器过渡动画
|
||||
.color-picker-fade-enter-active,
|
||||
.color-picker-fade-leave-active {
|
||||
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
|
||||
}
|
||||
.color-picker-fade-enter-from,
|
||||
.color-picker-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
}
|
||||
|
||||
// 透明度滑块过渡动画
|
||||
.opacity-slider-fade-enter-active,
|
||||
.opacity-slider-fade-leave-active {
|
||||
transition: opacity 0.25s ease-out, transform 0.25s ease-out;
|
||||
}
|
||||
.opacity-slider-fade-enter-from,
|
||||
.opacity-slider-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-height: 600px) {
|
||||
.brush-control-panel {
|
||||
transform: translateY(-50%);
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brush-control-panel {
|
||||
left: 10px;
|
||||
// padding: 12px;
|
||||
padding: 12px 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,338 +1,350 @@
|
||||
<template>
|
||||
<div class="brush-panel">
|
||||
<div class="brush-panel-content">
|
||||
<!-- 笔刷类型选择 -->
|
||||
<div class="brush-section">
|
||||
<div class="section-header">
|
||||
<span>笔刷类型</span>
|
||||
</div>
|
||||
<div class="brush-type-grid">
|
||||
<div
|
||||
v-for="brush in brushStore.state.availableBrushes"
|
||||
:key="brush.id"
|
||||
@click="setBrushTypeWithCommand(brush.id)"
|
||||
:class="[
|
||||
'brush-type-item',
|
||||
{ active: brushStore.state.type === brush.id },
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="brush-preview"
|
||||
:style="getBrushPreviewStyle(brush)"
|
||||
></div>
|
||||
<span class="brush-name">{{ brush.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态渲染笔刷可配置属性 -->
|
||||
<template
|
||||
v-for="(properties, category) in propertiesByCategory"
|
||||
:key="category"
|
||||
>
|
||||
<div class="brush-panel" @click.stop="">
|
||||
<div class="brush-panel-wrapper">
|
||||
<div class="brush-panel-content">
|
||||
<!-- 笔刷类型选择 -->
|
||||
<div class="brush-section">
|
||||
<div class="section-header">
|
||||
<span>{{ category }}</span>
|
||||
<div class="section-actions" v-if="category === '材质'">
|
||||
<button class="action-btn" @click="showLibrary = !showLibrary">
|
||||
<i class="icon-library">📚</i> 材质库
|
||||
</button>
|
||||
<span>笔刷类型</span>
|
||||
</div>
|
||||
<div class="brush-type-grid">
|
||||
<div
|
||||
v-for="brush in brushStore.state.availableBrushes"
|
||||
:key="brush.id"
|
||||
@click="setBrushTypeWithCommand(brush.id)"
|
||||
:class="[
|
||||
'brush-type-item',
|
||||
{ active: brushStore.state.type === brush.id },
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="brush-preview"
|
||||
:style="getBrushPreviewStyle(brush)"
|
||||
></div>
|
||||
<span class="brush-name">{{ brush.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 针对每个属性,根据其类型渲染合适的控件 -->
|
||||
<div class="property-list">
|
||||
<div
|
||||
v-for="prop in properties"
|
||||
:key="prop.id"
|
||||
class="property-item"
|
||||
>
|
||||
<!-- 滑块控件 -->
|
||||
<template v-if="prop.type === 'slider'">
|
||||
<div class="slider-property">
|
||||
<div class="slider-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
<span class="slider-value">
|
||||
{{ formatPropertyValue(prop) }}
|
||||
</span>
|
||||
<!-- 动态渲染笔刷可配置属性 -->
|
||||
<template
|
||||
v-for="(properties, category) in propertiesByCategory"
|
||||
:key="category"
|
||||
>
|
||||
<div class="brush-section">
|
||||
<div class="section-header">
|
||||
<span>{{ category }}</span>
|
||||
<div class="section-actions" v-if="category === '材质'">
|
||||
<button class="action-btn" @click="showLibrary = !showLibrary">
|
||||
<i class="icon-library">📚</i> 材质库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 针对每个属性,根据其类型渲染合适的控件 -->
|
||||
<div class="property-list">
|
||||
<div
|
||||
v-for="prop in properties"
|
||||
:key="prop.id"
|
||||
class="property-item"
|
||||
>
|
||||
<!-- 滑块控件 -->
|
||||
<template v-if="prop.type === 'slider'">
|
||||
<div class="slider-property">
|
||||
<div class="slider-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
<span class="slider-value">
|
||||
{{ formatPropertyValue(prop) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
:value="prop.value"
|
||||
@input="
|
||||
(e) =>
|
||||
handlePropertyChange(
|
||||
prop.id,
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
"
|
||||
:min="prop.min || 0"
|
||||
:max="prop.max || 100"
|
||||
:step="prop.step || 1"
|
||||
class="property-slider"
|
||||
/>
|
||||
</div>
|
||||
<!-- 预设值按钮,如果定义了预设 -->
|
||||
<div v-if="prop.presets" class="property-presets">
|
||||
<button
|
||||
v-for="preset in prop.presets"
|
||||
:key="preset"
|
||||
@click="handlePropertyChange(prop.id, preset)"
|
||||
:class="{ active: Math.abs(prop.value - preset) < 0.1 }"
|
||||
>
|
||||
{{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
</template>
|
||||
|
||||
<!-- 颜色选择器 -->
|
||||
<template v-else-if="prop.type === 'color'">
|
||||
<div class="color-property">
|
||||
<div class="color-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
<div
|
||||
class="color-preview"
|
||||
:style="{ backgroundColor: prop.value }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="prop.value"
|
||||
@input="
|
||||
(e) => handlePropertyChange(prop.id, e.target.value)
|
||||
"
|
||||
class="color-picker"
|
||||
/>
|
||||
<!-- 如果是主颜色,显示最近使用的颜色 -->
|
||||
<div v-if="prop.id === 'color'" class="recent-colors">
|
||||
<div
|
||||
v-for="(color, index) in brushStore.state
|
||||
.recentColors"
|
||||
:key="index"
|
||||
class="color-item"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="handlePropertyChange(prop.id, color)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 复选框 -->
|
||||
<template v-else-if="prop.type === 'checkbox'">
|
||||
<div class="checkbox-property">
|
||||
<span>{{ prop.name }}</span>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="prop.value"
|
||||
@change="
|
||||
(e) => handlePropertyChange(prop.id, e.target.checked)
|
||||
"
|
||||
:id="`toggle-${prop.id}`"
|
||||
/>
|
||||
<label :for="`toggle-${prop.id}`"></label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 选择器 -->
|
||||
<template v-else-if="prop.type === 'select'">
|
||||
<div class="select-property">
|
||||
<span>{{ prop.name }}</span>
|
||||
<select
|
||||
:value="prop.value"
|
||||
@input="
|
||||
(e) =>
|
||||
handlePropertyChange(
|
||||
prop.id,
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
@change="
|
||||
(e) => handlePropertyChange(prop.id, e.target.value)
|
||||
"
|
||||
:min="prop.min || 0"
|
||||
:max="prop.max || 100"
|
||||
:step="prop.step || 1"
|
||||
class="property-slider"
|
||||
/>
|
||||
</div>
|
||||
<!-- 预设值按钮,如果定义了预设 -->
|
||||
<div v-if="prop.presets" class="property-presets">
|
||||
<button
|
||||
v-for="preset in prop.presets"
|
||||
:key="preset"
|
||||
@click="handlePropertyChange(prop.id, preset)"
|
||||
:class="{ active: Math.abs(prop.value - preset) < 0.1 }"
|
||||
class="property-select"
|
||||
>
|
||||
{{ preset }}
|
||||
</button>
|
||||
<option
|
||||
v-for="option in prop.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 颜色选择器 -->
|
||||
<template v-else-if="prop.type === 'color'">
|
||||
<div class="color-property">
|
||||
<div class="color-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
<!-- 文件选择器(用于材质) -->
|
||||
<template v-else-if="prop.type === 'file'">
|
||||
<div class="file-property">
|
||||
<div class="file-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="color-preview"
|
||||
:style="{ backgroundColor: prop.value }"
|
||||
></div>
|
||||
class="file-preview"
|
||||
@click="handleFileSelect(prop.id)"
|
||||
>
|
||||
<img v-if="prop.value" :src="prop.value" alt="材质预览" />
|
||||
<div v-else class="no-file">点击上传材质图片</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
class="select-file-btn"
|
||||
@click="handleFileSelect(prop.id)"
|
||||
>
|
||||
上传图片
|
||||
</button>
|
||||
<button
|
||||
class="clear-file-btn"
|
||||
@click="handlePropertyChange(prop.id, '')"
|
||||
v-if="prop.value"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-row">
|
||||
</template>
|
||||
|
||||
<!-- 材质网格选择器 -->
|
||||
<template v-else-if="prop.type === 'texture-grid'">
|
||||
<div class="texture-grid-property">
|
||||
<div class="texture-grid-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
</div>
|
||||
<div class="texture-grid">
|
||||
<div
|
||||
v-for="texture in prop.options"
|
||||
:key="texture.value"
|
||||
class="texture-item"
|
||||
:class="{ active: prop.value === texture.value }"
|
||||
@click="handleTextureSelect(texture.value)"
|
||||
>
|
||||
<img
|
||||
:src="texture.preview || texture.value"
|
||||
:alt="texture.label"
|
||||
class="texture-thumbnail"
|
||||
/>
|
||||
<span class="texture-label">{{ texture.label }}</span>
|
||||
</div>
|
||||
<!-- 自定义纹理上传按钮 -->
|
||||
<div
|
||||
class="texture-item upload-item"
|
||||
@click="triggerTextureUpload"
|
||||
>
|
||||
<div class="upload-icon">
|
||||
<span>+</span>
|
||||
</div>
|
||||
<span class="texture-label">上传纹理</span>
|
||||
</div>
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input
|
||||
ref="textureFileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleTextureUpload"
|
||||
style="display: none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他类型的属性 -->
|
||||
<template v-else>
|
||||
<div class="generic-property">
|
||||
<span>{{ prop.name }}</span>
|
||||
<input
|
||||
type="color"
|
||||
type="text"
|
||||
:value="prop.value"
|
||||
@input="
|
||||
(e) => handlePropertyChange(prop.id, e.target.value)
|
||||
"
|
||||
class="color-picker"
|
||||
/>
|
||||
<!-- 如果是主颜色,显示最近使用的颜色 -->
|
||||
<div v-if="prop.id === 'color'" class="recent-colors">
|
||||
<div
|
||||
v-for="(color, index) in brushStore.state.recentColors"
|
||||
:key="index"
|
||||
class="color-item"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="handlePropertyChange(prop.id, color)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 复选框 -->
|
||||
<template v-else-if="prop.type === 'checkbox'">
|
||||
<div class="checkbox-property">
|
||||
<span>{{ prop.name }}</span>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="prop.value"
|
||||
@change="
|
||||
(e) => handlePropertyChange(prop.id, e.target.checked)
|
||||
"
|
||||
:id="`toggle-${prop.id}`"
|
||||
/>
|
||||
<label :for="`toggle-${prop.id}`"></label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 选择器 -->
|
||||
<template v-else-if="prop.type === 'select'">
|
||||
<div class="select-property">
|
||||
<span>{{ prop.name }}</span>
|
||||
<select
|
||||
:value="prop.value"
|
||||
@change="
|
||||
(e) => handlePropertyChange(prop.id, e.target.value)
|
||||
"
|
||||
class="property-select"
|
||||
>
|
||||
<option
|
||||
v-for="option in prop.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件选择器(用于材质) -->
|
||||
<template v-else-if="prop.type === 'file'">
|
||||
<div class="file-property">
|
||||
<div class="file-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
</div>
|
||||
<div class="file-preview" @click="handleFileSelect(prop.id)">
|
||||
<img v-if="prop.value" :src="prop.value" alt="材质预览" />
|
||||
<div v-else class="no-file">点击上传材质图片</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<button
|
||||
class="select-file-btn"
|
||||
@click="handleFileSelect(prop.id)"
|
||||
>
|
||||
上传图片
|
||||
</button>
|
||||
<button
|
||||
class="clear-file-btn"
|
||||
@click="handlePropertyChange(prop.id, '')"
|
||||
v-if="prop.value"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 材质网格选择器 -->
|
||||
<template v-else-if="prop.type === 'texture-grid'">
|
||||
<div class="texture-grid-property">
|
||||
<div class="texture-grid-header">
|
||||
<span>{{ prop.name }}</span>
|
||||
</div>
|
||||
<div class="texture-grid">
|
||||
<div
|
||||
v-for="texture in prop.options"
|
||||
:key="texture.value"
|
||||
class="texture-item"
|
||||
:class="{ active: prop.value === texture.value }"
|
||||
@click="handleTextureSelect(texture.value)"
|
||||
>
|
||||
<img
|
||||
:src="texture.preview || texture.value"
|
||||
:alt="texture.label"
|
||||
class="texture-thumbnail"
|
||||
/>
|
||||
<span class="texture-label">{{ texture.label }}</span>
|
||||
</div>
|
||||
<!-- 自定义纹理上传按钮 -->
|
||||
<div class="texture-item upload-item" @click="triggerTextureUpload">
|
||||
<div class="upload-icon">
|
||||
<span>+</span>
|
||||
</div>
|
||||
<span class="texture-label">上传纹理</span>
|
||||
</div>
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input
|
||||
ref="textureFileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleTextureUpload"
|
||||
style="display: none;"
|
||||
class="property-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 其他类型的属性 -->
|
||||
<template v-else>
|
||||
<div class="generic-property">
|
||||
<span>{{ prop.name }}</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="prop.value"
|
||||
@input="
|
||||
(e) => handlePropertyChange(prop.id, e.target.value)
|
||||
"
|
||||
class="property-input"
|
||||
/>
|
||||
<!-- 属性描述提示 -->
|
||||
<div v-if="prop.description" class="property-description">
|
||||
{{ prop.description }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 属性描述提示 -->
|
||||
<div v-if="prop.description" class="property-description">
|
||||
{{ prop.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 材质库弹窗 -->
|
||||
<div
|
||||
v-if="showLibrary"
|
||||
class="texture-library-overlay"
|
||||
@click.self="showLibrary = false"
|
||||
>
|
||||
<div class="texture-library-modal">
|
||||
<div class="modal-header">
|
||||
<h3>材质库</h3>
|
||||
<button class="close-btn" @click="showLibrary = false">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-content">
|
||||
<div class="texture-categories">
|
||||
<button
|
||||
v-for="(category, index) in textureCategories"
|
||||
:key="index"
|
||||
:class="[
|
||||
'category-btn',
|
||||
{ active: selectedCategory === category },
|
||||
]"
|
||||
@click="selectedCategory = category"
|
||||
>
|
||||
{{ category }}
|
||||
<!-- 材质库弹窗 -->
|
||||
<div
|
||||
v-if="showLibrary"
|
||||
class="texture-library-overlay"
|
||||
@click.self="showLibrary = false"
|
||||
>
|
||||
<div class="texture-library-modal">
|
||||
<div class="modal-header">
|
||||
<h3>材质库</h3>
|
||||
<button class="close-btn" @click="showLibrary = false">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="texture-list">
|
||||
<div
|
||||
v-for="(texture, index) in filteredTextures"
|
||||
:key="index"
|
||||
class="library-texture-item"
|
||||
@click="selectLibraryTexture(texture.path)"
|
||||
>
|
||||
<img
|
||||
:src="texture.thumbnail"
|
||||
:alt="texture.name"
|
||||
class="texture-thumbnail"
|
||||
/>
|
||||
<span class="texture-name">{{ texture.name }}</span>
|
||||
<div class="modal-content">
|
||||
<div class="texture-categories">
|
||||
<button
|
||||
v-for="(category, index) in textureCategories"
|
||||
:key="index"
|
||||
:class="[
|
||||
'category-btn',
|
||||
{ active: selectedCategory === category },
|
||||
]"
|
||||
@click="selectedCategory = category"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="texture-list">
|
||||
<div
|
||||
v-for="(texture, index) in filteredTextures"
|
||||
:key="index"
|
||||
class="library-texture-item"
|
||||
@click="selectLibraryTexture(texture.path)"
|
||||
>
|
||||
<img
|
||||
:src="texture.thumbnail"
|
||||
:alt="texture.name"
|
||||
class="texture-thumbnail"
|
||||
/>
|
||||
<span class="texture-name">{{ texture.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
class="upload-btn"
|
||||
@click="handleFileSelect('texturePath')"
|
||||
>
|
||||
上传新材质
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="upload-btn" @click="handleFileSelect('texturePath')">
|
||||
上传新材质
|
||||
</div>
|
||||
|
||||
<!-- 笔刷预设 -->
|
||||
<div class="brush-section">
|
||||
<div class="section-header">
|
||||
<span>笔刷预设</span>
|
||||
<button
|
||||
class="save-preset-btn"
|
||||
@click="saveCurrentAsPreset"
|
||||
title="保存当前设置为预设"
|
||||
>
|
||||
<i class="save-icon">+</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 笔刷预设 -->
|
||||
<div class="brush-section">
|
||||
<div class="section-header">
|
||||
<span>笔刷预设</span>
|
||||
<button
|
||||
class="save-preset-btn"
|
||||
@click="saveCurrentAsPreset"
|
||||
title="保存当前设置为预设"
|
||||
>
|
||||
<i class="save-icon">+</i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="presets-container">
|
||||
<div
|
||||
v-for="(preset, index) in brushStore.state.presets"
|
||||
:key="index"
|
||||
class="preset-item"
|
||||
@click="applyPresetWithCommand(index)"
|
||||
>
|
||||
<div class="presets-container">
|
||||
<div
|
||||
class="preset-color"
|
||||
:style="{
|
||||
backgroundColor: preset.color,
|
||||
width: preset.size + 'px',
|
||||
height: preset.size + 'px',
|
||||
opacity: preset.opacity,
|
||||
}"
|
||||
></div>
|
||||
<span class="preset-name">{{ preset.name }}</span>
|
||||
v-for="(preset, index) in brushStore.state.presets"
|
||||
:key="index"
|
||||
class="preset-item"
|
||||
@click="applyPresetWithCommand(index)"
|
||||
>
|
||||
<div
|
||||
class="preset-color"
|
||||
:style="{
|
||||
backgroundColor: preset.color,
|
||||
width: preset.size + 'px',
|
||||
height: preset.size + 'px',
|
||||
opacity: preset.opacity,
|
||||
}"
|
||||
></div>
|
||||
<span class="preset-name">{{ preset.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,10 +460,7 @@ function handleTextureSelect(textureId) {
|
||||
|
||||
// 触发纹理上传文件选择
|
||||
function triggerTextureUpload() {
|
||||
const fileInput = textureFileInput.value;
|
||||
if (fileInput) {
|
||||
fileInput.click();
|
||||
}
|
||||
handleFileSelect("texturePath");
|
||||
}
|
||||
|
||||
// 处理纹理文件上传
|
||||
@@ -461,15 +470,15 @@ async function handleTextureUpload(event) {
|
||||
|
||||
try {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请选择图片文件');
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件大小 (限制为 5MB)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
alert('文件大小不能超过 5MB');
|
||||
alert("文件大小不能超过 5MB");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -478,8 +487,8 @@ async function handleTextureUpload(event) {
|
||||
const brushManager = toolManager?.brushManager;
|
||||
|
||||
if (!texturePresetManager || !brushManager) {
|
||||
console.error('缺少必要的管理器实例');
|
||||
alert('系统错误:无法上传纹理');
|
||||
console.error("缺少必要的管理器实例");
|
||||
alert("系统错误:无法上传纹理");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -493,12 +502,11 @@ async function handleTextureUpload(event) {
|
||||
});
|
||||
|
||||
await commandManager.execute(command);
|
||||
|
||||
|
||||
// 清空文件输入,允许重复上传同一文件
|
||||
event.target.value = '';
|
||||
|
||||
event.target.value = "";
|
||||
} catch (error) {
|
||||
console.error('纹理上传失败:', error);
|
||||
console.error("纹理上传失败:", error);
|
||||
alert(`上传失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -673,29 +681,44 @@ onMounted(() => {
|
||||
const brushStore = BrushStore;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.brush-panel {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 62px;
|
||||
padding: 0;
|
||||
background-color: rgba(255, 255, 255, 0.95); /* 改为白色背景 */
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
max-height: 85vh;
|
||||
width: 30%;
|
||||
/* overflow-y: auto; */
|
||||
min-width: 280px;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
position: relative;
|
||||
animation: panelFadeIn 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); /* 添加阴影效果 */
|
||||
backdrop-filter: blur(2px); /* 添加模糊效果 */
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
background-color: rgba(255, 255, 255, 0.95); /* 改为白色背景 */
|
||||
z-index: 1000; /* 确保面板在最上层 */
|
||||
|
||||
.brush-panel-wrapper {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-height: 85vh; /* 限制最大高度 */
|
||||
/*优化ios上的滚动效果*/
|
||||
-webkit-overflow-scrolling: touch;
|
||||
.brush-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加指向整个面板的倒三角 */
|
||||
.brush-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 20px;
|
||||
top: -9px;
|
||||
right: 58px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 10px solid transparent;
|
||||
@@ -716,12 +739,6 @@ const brushStore = BrushStore;
|
||||
}
|
||||
}
|
||||
|
||||
.brush-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brush-section {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
@@ -746,9 +763,10 @@ const brushStore = BrushStore;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
|
||||
}
|
||||
|
||||
.section-header::after {
|
||||
/* .section-header::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
@@ -757,18 +775,18 @@ const brushStore = BrushStore;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid #999; /* 更柔和的颜色 */
|
||||
border-top: 6px solid #999;
|
||||
transform: translateY(-50%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
} */
|
||||
|
||||
.section-header:hover {
|
||||
/* .section-header:hover {
|
||||
background-color: rgba(248, 249, 250, 1);
|
||||
}
|
||||
|
||||
.section-header:hover::after {
|
||||
border-top-color: #666;
|
||||
}
|
||||
} */
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
@@ -1114,6 +1132,12 @@ const brushStore = BrushStore;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.property-select {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.property-select:focus {
|
||||
border-color: #4285f4;
|
||||
outline: none;
|
||||
|
||||
@@ -17,6 +17,7 @@ provide("brushStore", BrushStore);
|
||||
|
||||
const toolManager = inject("toolManager");
|
||||
const layerManager = inject("layerManager");
|
||||
const isShowLayerPanel = inject("isShowLayerPanel", ref(false));
|
||||
|
||||
const props = defineProps({
|
||||
activeTool: String,
|
||||
@@ -24,6 +25,7 @@ const props = defineProps({
|
||||
canvasHeight: Number,
|
||||
canvasColor: String,
|
||||
brushSize: Number,
|
||||
enabledRedGreenMode: Boolean,
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -40,9 +42,9 @@ const showBrushPanel = ref(false);
|
||||
const brushPanelRef = ref(null);
|
||||
|
||||
// 计算属性
|
||||
const shouldShowBrushSettings = computed(() => {
|
||||
return props.activeTool === OperationType.DRAW;
|
||||
});
|
||||
// const shouldShowBrushSettings = computed(() => {
|
||||
// return props.activeTool === OperationType.DRAW;
|
||||
// });
|
||||
|
||||
function updateCanvasSize() {
|
||||
if (!layerManager) {
|
||||
@@ -86,6 +88,11 @@ function updateCanvasColor() {
|
||||
|
||||
// 切换笔刷面板显示状态
|
||||
function toggleBrushPanel() {
|
||||
// 如果笔刷没有激活 则激活笔刷工具
|
||||
if (toolManager?.activeTool !== OperationType.DRAW) {
|
||||
toolManager.setToolWithCommand(OperationType.DRAW);
|
||||
}
|
||||
|
||||
showBrushPanel.value = !showBrushPanel.value;
|
||||
}
|
||||
|
||||
@@ -176,16 +183,37 @@ function syncBrushStoreToManager() {
|
||||
|
||||
// 点击外部时关闭笔刷面板
|
||||
function handleClickOutside(event) {
|
||||
if (
|
||||
showBrushPanel.value &&
|
||||
brushPanelRef.value &&
|
||||
!brushPanelRef.value.contains(event.target) &&
|
||||
!event.target.closest(".brush-selector")
|
||||
) {
|
||||
showBrushPanel.value = false;
|
||||
// if (isShowLayerPanel.value) {
|
||||
// // 如果点击的是图层面板或其内部元素,则不关闭
|
||||
// if (event.target.closest(".layers-panel")) {
|
||||
// return;
|
||||
// }
|
||||
// // 关闭图层面板
|
||||
// isShowLayerPanel.value = false;
|
||||
// }
|
||||
|
||||
if (showBrushPanel.value) {
|
||||
// 检查是否点击了笔刷选择器按钮
|
||||
if (event.target.closest(".brush-selector")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否点击了笔刷面板或其内部元素
|
||||
if (event.target.closest(".brush-panel")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果都不是,则关闭面板
|
||||
if (showBrushPanel.value) {
|
||||
showBrushPanel.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showLayerPanel() {
|
||||
isShowLayerPanel.value = !isShowLayerPanel.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取工具管理器和笔刷管理器
|
||||
const brushManager = toolManager?.brushManager;
|
||||
@@ -229,94 +257,51 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="canvas-header">
|
||||
<span class="canvas-title">Canvas</span>
|
||||
|
||||
<!-- 默认设置 -->
|
||||
<div
|
||||
v-if="
|
||||
<div class="canvas-header-wrapper">
|
||||
<span class="canvas-title">Canvas</span>
|
||||
<!-- 默认设置 -->
|
||||
<!-- v-if="
|
||||
!activeTool ||
|
||||
activeTool === OperationType.SELECT ||
|
||||
activeTool === OperationType.PAN
|
||||
"
|
||||
class="canvas-settings"
|
||||
>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Width</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="canvasWidth"
|
||||
class="setting-input"
|
||||
@input="$emit('update:canvasWidth', Number($event.target.value))"
|
||||
@change="updateCanvasSize"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Height</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="canvasHeight"
|
||||
class="setting-input"
|
||||
@input="$emit('update:canvasHeight', Number($event.target.value))"
|
||||
@change="updateCanvasSize"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Color</span>
|
||||
<div class="color-picker-wrapper">
|
||||
" -->
|
||||
<div class="canvas-settings" v-if="!props.enabledRedGreenMode">
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Width</span>
|
||||
<input
|
||||
type="color"
|
||||
:value="canvasColor"
|
||||
class="color-picker"
|
||||
@input="$emit('update:canvasColor', $event.target.value)"
|
||||
@change="updateCanvasColor"
|
||||
type="text"
|
||||
:value="canvasWidth"
|
||||
class="setting-input"
|
||||
@input="$emit('update:canvasWidth', Number($event.target.value))"
|
||||
@change="updateCanvasSize"
|
||||
/>
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Height</span>
|
||||
<input
|
||||
type="text"
|
||||
:value="canvasHeight"
|
||||
class="setting-input"
|
||||
@input="$emit('update:canvasHeight', Number($event.target.value))"
|
||||
@change="updateCanvasSize"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Color</span>
|
||||
<div class="color-picker-wrapper">
|
||||
<input
|
||||
type="color"
|
||||
:value="canvasColor"
|
||||
class="color-picker"
|
||||
@input="$emit('update:canvasColor', $event.target.value)"
|
||||
@change="updateCanvasColor"
|
||||
/>
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 绘图工具设置 -->
|
||||
<div v-if="shouldShowBrushSettings" class="canvas-settings">
|
||||
<!-- 简化的笔刷控制UI -->
|
||||
<!-- <div class="setting-group">
|
||||
<span class="setting-label">大小:</span>
|
||||
<input
|
||||
type="range"
|
||||
:value="BrushStore.state.size"
|
||||
min="0.5"
|
||||
max="100"
|
||||
step="0.5"
|
||||
class="size-slider"
|
||||
@input="handleBrushSizeChange"
|
||||
/>
|
||||
<span class="size-value">{{ BrushStore.state.size }}px</span>
|
||||
</div> -->
|
||||
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">笔刷:</span>
|
||||
<div class="brush-selector" @click="toggleBrushPanel">
|
||||
<div
|
||||
class="brush-preview"
|
||||
:style="{
|
||||
backgroundColor: BrushStore.state.color,
|
||||
height: BrushStore.state.type === 'marker' ? '4px' : '2px',
|
||||
opacity: BrushStore.state.opacity,
|
||||
}"
|
||||
></div>
|
||||
<span class="brush-dropdown">▼</span>
|
||||
</div>
|
||||
|
||||
<!-- 笔刷面板 -->
|
||||
<div
|
||||
v-if="showBrushPanel"
|
||||
class="brush-panel-container"
|
||||
ref="brushPanelRef"
|
||||
>
|
||||
<BrushPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">颜色:</span>
|
||||
<div class="color-picker-wrapper">
|
||||
<input
|
||||
@@ -327,64 +312,71 @@ onMounted(() => {
|
||||
/>
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本工具设置 -->
|
||||
<div v-if="activeTool === OperationType.TEXT" class="canvas-settings">
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Font:</span>
|
||||
<select class="font-select">
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Size:</span>
|
||||
<input
|
||||
type="number"
|
||||
class="setting-input"
|
||||
value="16"
|
||||
min="8"
|
||||
max="72"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Color:</span>
|
||||
<div class="color-picker-wrapper">
|
||||
<input type="color" class="color-picker" value="#000000" />
|
||||
<span class="color-dropdown">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传工具设置 -->
|
||||
<div v-if="activeTool === OperationType.UPLOAD" class="canvas-settings">
|
||||
<div class="setting-group">
|
||||
<span class="setting-label">Upload Type:</span>
|
||||
<select class="setting-select">
|
||||
<option value="image">Image</option>
|
||||
<option value="vector">Vector Graphics</option>
|
||||
</select>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- 导出设置 -->
|
||||
<div class="setting-group export-group">
|
||||
<!-- <div class="setting-group export-group">
|
||||
<span class="export-model-select">exportModel.select:</span>
|
||||
<span class="export-model-dropdown">▼</span>
|
||||
</div> -->
|
||||
|
||||
<!-- 绘图工具设置 -->
|
||||
<div class="canvas-settings gap-20" v-if="!props.enabledRedGreenMode">
|
||||
<div
|
||||
class="btn"
|
||||
:class="{ active: showBrushPanel }"
|
||||
@click="toggleBrushPanel"
|
||||
>
|
||||
<!-- <span class="setting-label">笔刷:</span>/ -->
|
||||
<div class="brush-selector">
|
||||
<SvgIcon name="CBrushTop" size="22"></SvgIcon>
|
||||
<!-- <div
|
||||
class="brush-preview"
|
||||
:style="{
|
||||
backgroundColor: BrushStore.state.color,
|
||||
height: BrushStore.state.type === 'marker' ? '4px' : '2px',
|
||||
opacity: BrushStore.state.opacity,
|
||||
}"
|
||||
></div> -->
|
||||
<!-- <span class="brush-dropdown">▼</span> -->
|
||||
</div>
|
||||
<!-- 笔刷面板 -->
|
||||
<div
|
||||
v-if="showBrushPanel"
|
||||
class="brush-panel-container"
|
||||
ref="brushPanelRef"
|
||||
>
|
||||
<Teleport to="body">
|
||||
<BrushPanel />
|
||||
</Teleport>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="btn"
|
||||
:class="{ active: isShowLayerPanel }"
|
||||
@click="showLayerPanel"
|
||||
>
|
||||
<SvgIcon name="CLayout" size="26"></SvgIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.canvas-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
user-select: none;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
.canvas-header-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-title {
|
||||
@@ -393,19 +385,44 @@ onMounted(() => {
|
||||
margin-right: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.canvas-title::before {
|
||||
content: "⟳";
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
// &:before {
|
||||
// // /* content: "⟳";
|
||||
// // margin-right: 5px;
|
||||
// // font-size: 14px; */
|
||||
// }
|
||||
}
|
||||
|
||||
.canvas-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
color: #213547;
|
||||
.btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: #f0f0f0;
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
&:hover {
|
||||
background-color: #e6f7ff;
|
||||
// color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gap-20 {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.setting-group {
|
||||
@@ -476,15 +493,15 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.brush-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
width: 80px;
|
||||
justify-content: space-between;
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// border: 1px solid #ddd;
|
||||
// border-radius: 4px;
|
||||
// padding: 5px;
|
||||
// cursor: pointer;
|
||||
// background-color: white;
|
||||
// width: 80px;
|
||||
// justify-content: space-between;
|
||||
}
|
||||
|
||||
.brush-preview {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onMounted, onUnmounted } from "vue";
|
||||
import SvgIcon from "../../../SvgIcon/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
position: {
|
||||
type: Object,
|
||||
default: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "select"]);
|
||||
|
||||
const menuRef = ref(null);
|
||||
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||
const hoveredItem = ref(null);
|
||||
const submenuPositions = ref(new Map());
|
||||
const hideTimer = ref(null); // 添加隐藏定时器
|
||||
|
||||
// 计算菜单位置,处理边界问题
|
||||
const calculatePosition = () => {
|
||||
if (!menuRef.value || !props.visible) return;
|
||||
|
||||
const menu = menuRef.value;
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
let x = props.position.x;
|
||||
let y = props.position.y;
|
||||
|
||||
// 右边界检测
|
||||
if (x + menuRect.width > windowWidth - 10) {
|
||||
x = x - menuRect.width;
|
||||
}
|
||||
|
||||
// 底边界检测
|
||||
if (y + menuRect.height > windowHeight - 10) {
|
||||
y = windowHeight - menuRect.height - 10;
|
||||
}
|
||||
|
||||
// 左边界检测
|
||||
if (x < 10) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 顶边界检测
|
||||
if (y < 10) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
adjustedPosition.value = { x, y };
|
||||
};
|
||||
|
||||
// 计算子菜单位置
|
||||
const calculateSubmenuPosition = (itemElement, itemIndex) => {
|
||||
if (!itemElement || !menuRef.value) return { x: 0, y: 0, direction: "right" };
|
||||
|
||||
const itemRect = itemElement.getBoundingClientRect();
|
||||
const menuRect = menuRef.value.getBoundingClientRect();
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// 预估子菜单宽度(可以根据实际情况调整)
|
||||
const submenuWidth = 200;
|
||||
const submenuHeight = 300; // 预估高度
|
||||
|
||||
let x = itemRect.right + 4;
|
||||
// 直接使用菜单项相对于主菜单容器的偏移量
|
||||
let y = itemElement.offsetTop;
|
||||
let direction = "right";
|
||||
|
||||
// 右边界检测,如果右侧空间不足,显示在左侧
|
||||
if (x + submenuWidth > windowWidth - 10) {
|
||||
x = itemRect.left - submenuWidth - 4;
|
||||
direction = "left";
|
||||
}
|
||||
|
||||
// 底边界检测 - 基于子菜单的绝对位置检查
|
||||
const absoluteSubmenuBottom = itemRect.top + submenuHeight;
|
||||
if (absoluteSubmenuBottom > windowHeight - 10) {
|
||||
// 计算可用的最大Y位置(相对于主菜单)
|
||||
const maxAbsoluteY = windowHeight - submenuHeight - 10;
|
||||
const maxRelativeY = maxAbsoluteY - menuRect.top;
|
||||
y = Math.max(0, maxRelativeY);
|
||||
}
|
||||
|
||||
// 左边界检测
|
||||
if (x < 10) {
|
||||
x = 10;
|
||||
direction = "right";
|
||||
}
|
||||
|
||||
// 确保 y 不为负数
|
||||
if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
y = 0;
|
||||
|
||||
const position = { x, y, direction };
|
||||
submenuPositions.value.set(itemIndex, position);
|
||||
return position;
|
||||
};
|
||||
|
||||
// 清除隐藏定时器
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
hideTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 显示子菜单
|
||||
const showSubmenu = (item, index, element) => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
clearHideTimer();
|
||||
hoveredItem.value = index;
|
||||
nextTick(() => {
|
||||
calculateSubmenuPosition(element, index);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 隐藏子菜单(延迟)
|
||||
const hideSubmenu = (index) => {
|
||||
clearHideTimer();
|
||||
hideTimer.value = setTimeout(() => {
|
||||
if (hoveredItem.value === index) {
|
||||
hoveredItem.value = null;
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// 处理鼠标进入菜单项
|
||||
const handleItemMouseEnter = (item, index, event) => {
|
||||
const element = event.target.closest(".context-menu-item");
|
||||
showSubmenu(item, index, element);
|
||||
};
|
||||
|
||||
// 处理鼠标在菜单项内移动
|
||||
const handleItemMouseMove = (item, index, event) => {
|
||||
// 如果当前菜单项有子菜单但子菜单未显示,则显示子菜单
|
||||
if (
|
||||
item.children &&
|
||||
item.children.length > 0 &&
|
||||
hoveredItem.value !== index
|
||||
) {
|
||||
const element = event.target.closest(".context-menu-item");
|
||||
showSubmenu(item, index, element);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标离开菜单项
|
||||
const handleItemMouseLeave = (item, index) => {
|
||||
// 只有当有子菜单时才延迟隐藏
|
||||
if (item.children && item.children.length > 0) {
|
||||
hideSubmenu(index);
|
||||
} else {
|
||||
hoveredItem.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理鼠标进入子菜单
|
||||
const handleSubmenuMouseEnter = (index) => {
|
||||
clearHideTimer();
|
||||
hoveredItem.value = index;
|
||||
};
|
||||
|
||||
// 处理鼠标离开子菜单
|
||||
const handleSubmenuMouseLeave = (index) => {
|
||||
hideSubmenu(index);
|
||||
};
|
||||
|
||||
// 监听可见性和位置变化
|
||||
watch([() => props.visible, () => props.position], () => {
|
||||
if (props.visible) {
|
||||
nextTick(() => {
|
||||
calculatePosition();
|
||||
});
|
||||
} else {
|
||||
hoveredItem.value = null;
|
||||
submenuPositions.value.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// 处理菜单项点击
|
||||
const handleItemClick = (item, index) => {
|
||||
if (item.disabled || item.type === "divider") return;
|
||||
|
||||
// 如果有子菜单,不关闭菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("select", item, index);
|
||||
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// 处理子菜单项点击
|
||||
const handleSubItemClick = (subItem, parentIndex, subIndex) => {
|
||||
if (subItem.disabled || subItem.type === "divider") return;
|
||||
|
||||
emit("select", subItem, `${parentIndex}-${subIndex}`);
|
||||
|
||||
if (subItem.action) {
|
||||
subItem.action();
|
||||
}
|
||||
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// 处理外部点击关闭
|
||||
const handleOutsideClick = (event) => {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target)) {
|
||||
emit("close");
|
||||
}
|
||||
};
|
||||
|
||||
// 处理 ESC 键关闭
|
||||
const handleEscKey = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
emit("close");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleOutsideClick, true);
|
||||
document.addEventListener("contextmenu", handleOutsideClick, true);
|
||||
document.addEventListener("keydown", handleEscKey);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleOutsideClick, true);
|
||||
document.removeEventListener("contextmenu", handleOutsideClick, true);
|
||||
document.removeEventListener("keydown", handleEscKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="context-menu">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
top: `${adjustedPosition.y}px`,
|
||||
left: `${adjustedPosition.x}px`,
|
||||
}"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<!-- 分隔线 -->
|
||||
<div
|
||||
v-if="item.type === 'divider'"
|
||||
class="context-menu-divider"
|
||||
></div>
|
||||
|
||||
<!-- 菜单项 -->
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
:class="{
|
||||
disabled: item.disabled,
|
||||
danger: item.danger,
|
||||
'has-children': item.children && item.children.length > 0,
|
||||
hovered: hoveredItem === index,
|
||||
}"
|
||||
@click="handleItemClick(item, index)"
|
||||
@mouseenter="handleItemMouseEnter(item, index, $event)"
|
||||
@mousemove="handleItemMouseMove(item, index, $event)"
|
||||
@mouseleave="handleItemMouseLeave(item, index)"
|
||||
>
|
||||
<span class="context-menu-icon" v-if="item.icon">
|
||||
<SvgIcon
|
||||
:name="item.icon"
|
||||
size="14"
|
||||
:style="{
|
||||
transform: item.inverIcon ? `rotate(90deg)` : 'none',
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="context-menu-label">{{ item.label }}</span>
|
||||
<span class="context-menu-shortcut" v-if="item.shortcut">
|
||||
{{ item.shortcut }}
|
||||
</span>
|
||||
<span
|
||||
class="context-menu-arrow"
|
||||
v-if="item.children && item.children.length > 0"
|
||||
>
|
||||
<SvgIcon name="CRight" size="12" />
|
||||
</span>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<transition name="context-submenu">
|
||||
<div
|
||||
v-if="
|
||||
item.children &&
|
||||
item.children.length > 0 &&
|
||||
hoveredItem === index
|
||||
"
|
||||
class="context-submenu"
|
||||
:class="{
|
||||
'submenu-left':
|
||||
submenuPositions.get(index)?.direction === 'left',
|
||||
}"
|
||||
@mouseenter="handleSubmenuMouseEnter(index)"
|
||||
@mouseleave="handleSubmenuMouseLeave"
|
||||
>
|
||||
<template
|
||||
v-for="(subItem, subIndex) in item.children"
|
||||
:key="subIndex"
|
||||
>
|
||||
<!-- 子菜单分隔线 -->
|
||||
<div
|
||||
v-if="subItem.type === 'divider'"
|
||||
class="context-menu-divider"
|
||||
></div>
|
||||
|
||||
<!-- 子菜单项 -->
|
||||
<div
|
||||
v-else
|
||||
class="context-menu-item"
|
||||
:class="{
|
||||
disabled: subItem.disabled,
|
||||
danger: subItem.danger,
|
||||
}"
|
||||
@click="handleSubItemClick(subItem, index, subIndex)"
|
||||
>
|
||||
<span class="context-menu-icon" v-if="subItem.icon">
|
||||
<SvgIcon
|
||||
:name="subItem.icon"
|
||||
size="14"
|
||||
:style="{
|
||||
transform: subItem.inverIcon
|
||||
? `rotate(90deg)`
|
||||
: 'none',
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
<span class="context-menu-label">{{ subItem.label }}</span>
|
||||
<span class="context-menu-shortcut" v-if="subItem.shortcut">
|
||||
{{ subItem.shortcut }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import "./contextMenu.less";
|
||||
</style>
|
||||
@@ -0,0 +1,514 @@
|
||||
<script setup>
|
||||
import { ref, nextTick, computed, inject } from "vue";
|
||||
import { Checkbox } from "ant-design-vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import SvgIcon from "../../../SvgIcon/index.vue";
|
||||
import { isGroupLayer } from "../../utils/layerHelper";
|
||||
|
||||
// 设置组件名称,用于递归渲染
|
||||
defineOptions({
|
||||
name: "LayerItem",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
layer: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isChild: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMultiSelectMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
editingName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
canDelete: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
thumbnailUrl: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isHidenDragHandle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expandedGroupIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"click",
|
||||
"double-click",
|
||||
"context-menu",
|
||||
"checkbox-change",
|
||||
"toggle-visibility",
|
||||
"toggle-lock",
|
||||
"delete",
|
||||
"edit-confirm",
|
||||
"edit-cancel",
|
||||
"edit-keydown",
|
||||
"touch-start",
|
||||
"touch-move",
|
||||
"touch-end",
|
||||
"child-layers-sort",
|
||||
"update-child-layers",
|
||||
"toggle-group-expanded",
|
||||
// 新增子图层专用事件
|
||||
"toggle-child-visibility",
|
||||
"toggle-child-lock",
|
||||
"delete-child",
|
||||
"rename-child",
|
||||
// v-model相关事件
|
||||
"update:editingName",
|
||||
]);
|
||||
|
||||
const layerManager = inject("layerManager", null);
|
||||
|
||||
// 计算属性
|
||||
const isGroupLayerType = computed(() => {
|
||||
return isGroupLayer(props.layer);
|
||||
});
|
||||
|
||||
// 计算属性:检查组图层是否展开
|
||||
const isGroupExpanded = computed(() => {
|
||||
return props.expandedGroupIds.has(props.layer.id);
|
||||
});
|
||||
|
||||
// 获取子图层
|
||||
const childLayers = computed(() => {
|
||||
if (!isGroupLayerType.value) return [];
|
||||
|
||||
// 优先使用 layer.children 属性
|
||||
if (props.layer.children && Array.isArray(props.layer.children)) {
|
||||
return props.layer.children;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
// 切换组图层展开/收起状态
|
||||
const toggleGroupExpanded = () => {
|
||||
emit("toggle-group-expanded", props.layer.id);
|
||||
};
|
||||
|
||||
// 获取图层类型图标
|
||||
function getLayerTypeIcon(layer) {
|
||||
if (!layer) return "🖼️";
|
||||
|
||||
if (isGroupLayer(layer)) {
|
||||
return "📁";
|
||||
}
|
||||
|
||||
if (layer.fabricObject) {
|
||||
switch (layer.fabricObject.type) {
|
||||
case "image":
|
||||
return "🖼️";
|
||||
case "text":
|
||||
return "📝";
|
||||
case "rect":
|
||||
return "▢";
|
||||
case "circle":
|
||||
return "⬤";
|
||||
case "path":
|
||||
return "✎";
|
||||
default:
|
||||
return "⬤";
|
||||
}
|
||||
}
|
||||
|
||||
return "🖼️";
|
||||
}
|
||||
|
||||
function getLayerTypeText(layerType) {
|
||||
const typeMap = {
|
||||
EMPTY: "空图层",
|
||||
TEXT: "文本",
|
||||
IMAGE: "图片",
|
||||
SHAPE: "形状",
|
||||
GROUP: "组合",
|
||||
BACKGROUND: "背景",
|
||||
FIXED: "固定",
|
||||
};
|
||||
|
||||
return typeMap[layerType] || "未知";
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
function handleClick(event) {
|
||||
emit("click", props.layer, event);
|
||||
}
|
||||
|
||||
function handleDoubleClick(event) {
|
||||
emit("double-click", props.layer, event);
|
||||
}
|
||||
|
||||
function handleContextMenu(event) {
|
||||
emit("context-menu", event, props.layer);
|
||||
}
|
||||
|
||||
function handleCheckboxChange(event) {
|
||||
emit("checkbox-change", props.layer.id, event);
|
||||
}
|
||||
|
||||
function handleToggleVisibility() {
|
||||
if (props.isChild) {
|
||||
// 子图层需要传递父图层ID - 从父级组件获取
|
||||
const parentId = props.layer.parentId || findParentLayerId();
|
||||
emit("toggle-child-visibility", props.layer.id, parentId);
|
||||
} else {
|
||||
// 一级图层
|
||||
emit("toggle-visibility", props.layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleLock() {
|
||||
if (props.isChild) {
|
||||
// 子图层需要传递父图层ID - 从父级组件获取
|
||||
const parentId = props.layer.parentId || findParentLayerId();
|
||||
emit("toggle-child-lock", props.layer.id, parentId);
|
||||
} else {
|
||||
// 一级图层
|
||||
emit("toggle-lock", props.layer);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!props.canDelete) {
|
||||
console.warn("当前图层无法删除:", props.layer.id);
|
||||
return;
|
||||
}
|
||||
if (props.isChild) {
|
||||
// 子图层删除:需要传递父图层ID
|
||||
const parentId = props.layer.parentId || findParentLayerId();
|
||||
if (parentId) {
|
||||
emit("delete-child", props.layer.id, parentId);
|
||||
} else {
|
||||
console.warn("无法找到子图层的父图层ID:", props.layer.id);
|
||||
}
|
||||
} else {
|
||||
// 一级图层删除
|
||||
emit("delete", props.layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditConfirm() {
|
||||
if (props.isChild) {
|
||||
// 子图层重命名:需要传递父图层ID
|
||||
const parentId = props.layer.parentId || findParentLayerId();
|
||||
if (props.editingName && props.editingName.trim() && parentId) {
|
||||
emit("rename-child", props.layer.id, parentId, props.editingName.trim());
|
||||
} else if (!parentId) {
|
||||
console.warn("无法找到子图层的父图层ID:", props.layer.id);
|
||||
}
|
||||
// 发送编辑取消事件,清理编辑状态
|
||||
emit("edit-cancel");
|
||||
} else {
|
||||
// 一级图层重命名
|
||||
emit("edit-confirm");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
emit("edit-cancel");
|
||||
}
|
||||
|
||||
function handleEditKeydown(event) {
|
||||
emit("edit-keydown", event); // 修复事件名称,从 "edit-keyboard" 改为 "edit-keydown"
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
emit("touch-start", event, props.layer);
|
||||
}
|
||||
|
||||
function handleTouchMove(event) {
|
||||
emit("touch-move", event);
|
||||
}
|
||||
|
||||
function handleTouchEnd(event) {
|
||||
emit("touch-end", event);
|
||||
}
|
||||
|
||||
function handleUpdateChildLayers(newChildren) {
|
||||
// 更新当前组图层的children数组
|
||||
console.log(
|
||||
"更新子图层顺序:",
|
||||
"父图层ID:",
|
||||
props.layer.id,
|
||||
"新顺序:",
|
||||
newChildren
|
||||
);
|
||||
emit("update-child-layers", props.layer.id, newChildren);
|
||||
}
|
||||
|
||||
// 子图层递归事件处理
|
||||
function handleChildClick(childLayer, event) {
|
||||
emit("click", childLayer, event);
|
||||
}
|
||||
|
||||
function handleChildDoubleClick(childLayer, event) {
|
||||
emit("double-click", childLayer, event);
|
||||
}
|
||||
|
||||
function handleChildContextMenu(event, childLayer) {
|
||||
emit("context-menu", event, childLayer);
|
||||
}
|
||||
|
||||
function handleChildToggleVisibility(childLayerId) {
|
||||
emit("toggle-visibility", childLayerId);
|
||||
}
|
||||
|
||||
function handleChildToggleLock(childLayer) {
|
||||
emit("toggle-lock", childLayer);
|
||||
}
|
||||
|
||||
// 动画钩子函数
|
||||
function onEnter(el) {
|
||||
// 设置初始状态
|
||||
el.style.height = "0";
|
||||
el.style.opacity = "0";
|
||||
el.style.paddingTop = "0";
|
||||
el.style.paddingBottom = "0";
|
||||
el.style.marginTop = "0";
|
||||
el.style.marginBottom = "0";
|
||||
el.style.overflow = "hidden";
|
||||
|
||||
// 强制重排
|
||||
el.offsetHeight;
|
||||
|
||||
// 获取最终高度
|
||||
el.style.height = "auto";
|
||||
const finalHeight = el.scrollHeight;
|
||||
el.style.height = "0";
|
||||
|
||||
// 执行动画
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
el.style.height = finalHeight + "px";
|
||||
el.style.opacity = "1";
|
||||
el.style.paddingTop = "";
|
||||
el.style.paddingBottom = "";
|
||||
el.style.marginTop = "";
|
||||
el.style.marginBottom = "";
|
||||
});
|
||||
}
|
||||
|
||||
function onLeave(el) {
|
||||
// 设置当前高度
|
||||
el.style.height = el.scrollHeight + "px";
|
||||
el.style.overflow = "hidden";
|
||||
|
||||
// 强制重排
|
||||
el.offsetHeight;
|
||||
|
||||
// 执行收起动画
|
||||
el.style.transition = "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
el.style.height = "0";
|
||||
el.style.opacity = "0";
|
||||
el.style.paddingTop = "0";
|
||||
el.style.paddingBottom = "0";
|
||||
el.style.marginTop = "0";
|
||||
el.style.marginBottom = "0";
|
||||
}
|
||||
|
||||
// 查找父图层ID的辅助方法 - 增强版本
|
||||
function findParentLayerId() {
|
||||
// 首先检查 layer 对象是否已经有 parentId 属性
|
||||
if (props.layer.parentId) {
|
||||
return props.layer.parentId;
|
||||
}
|
||||
|
||||
// 如果没有,尝试从 layerManager 中查找
|
||||
if (layerManager && layerManager.layers) {
|
||||
for (const layer of layerManager.layers.value) {
|
||||
if (
|
||||
layer.children &&
|
||||
Array.isArray(layer.children) &&
|
||||
layer.children.some((child) => child.id === props.layer.id)
|
||||
) {
|
||||
return layer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn("无法找到图层的父图层:", props.layer.id);
|
||||
return null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 主图层项 -->
|
||||
<div
|
||||
:class="[
|
||||
'layer-item',
|
||||
{
|
||||
'child-layer': isChild,
|
||||
active: isActive,
|
||||
selected: isSelected,
|
||||
'group-layer': isGroupLayerType,
|
||||
editing: isEditing,
|
||||
'multi-select-mode': isMultiSelectMode,
|
||||
invisible: !layer.visible,
|
||||
locked: layer.locked,
|
||||
'fixed-layer': layer.isBackground || layer.isFixed,
|
||||
},
|
||||
]"
|
||||
@click="handleClick"
|
||||
@dblclick="handleDoubleClick"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="layer-drag-handle" :title="$t('拖拽排序')">
|
||||
<SvgIcon
|
||||
v-if="!isHidenDragHandle"
|
||||
:name="isChild ? 'CSort' : 'CSort'"
|
||||
:size="16"
|
||||
></SvgIcon>
|
||||
</div>
|
||||
|
||||
<!-- 图层头部 -->
|
||||
<div class="layer-header">
|
||||
<!-- 多选复选框 -->
|
||||
<div
|
||||
v-if="isMultiSelectMode && !isChild"
|
||||
class="layer-checkbox"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox :checked="isSelected" @change="handleCheckboxChange" />
|
||||
</div>
|
||||
|
||||
<!-- 图层预览图标 -->
|
||||
<div class="layer-review">
|
||||
<img
|
||||
v-if="thumbnailUrl"
|
||||
:src="thumbnailUrl"
|
||||
class="layer-thumbnail"
|
||||
:alt="$t('图层预览')"
|
||||
/>
|
||||
<span v-else class="layer-type-icon">{{
|
||||
getLayerTypeIcon(layer)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- 图层名称 -->
|
||||
<div class="layer-name-container" :title="layer.name">
|
||||
<div class="layer-name-wrapper">
|
||||
<span v-if="!isEditing" class="layer-name text-ellipsis">
|
||||
{{ layer.name }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
:value="editingName"
|
||||
:data-layer-id="layer.id"
|
||||
:data-child-layer-id="isChild ? layer.id : undefined"
|
||||
class="layer-name-input"
|
||||
@blur="handleEditConfirm"
|
||||
@keydown="handleEditKeydown"
|
||||
@click.stop
|
||||
@input="$emit('update:editingName', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图层操作按钮 -->
|
||||
<div class="layer-actions" v-if="!(isGroupLayerType && !isChild)">
|
||||
<!-- 可见性切换 -->
|
||||
<div
|
||||
class="visibility-btn"
|
||||
@click.stop="handleToggleVisibility"
|
||||
:title="$t('显示/隐藏图层')"
|
||||
>
|
||||
<SvgIcon v-if="layer.visible" name="CEye" :size="16"></SvgIcon>
|
||||
<SvgIcon v-else name="CUnEye" :size="16"></SvgIcon>
|
||||
</div>
|
||||
|
||||
<!-- 锁定状态 -->
|
||||
<span
|
||||
v-if="layer.locked"
|
||||
class="status-icon locked"
|
||||
:class="{ disabled: layer.isBackground || layer.isFixed }"
|
||||
:title="$t('锁定')"
|
||||
@click.stop="handleToggleLock"
|
||||
>
|
||||
<SvgIcon name="CLock" :size="18"></SvgIcon>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="status-icon"
|
||||
:title="$t('未锁定')"
|
||||
@click.stop="handleToggleLock"
|
||||
>
|
||||
<SvgIcon name="CUnLock" :size="18"></SvgIcon>
|
||||
</span>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<div
|
||||
class="delete-btn"
|
||||
:class="{ disabled: !canDelete }"
|
||||
:title="$t('删除图层')"
|
||||
@click.stop="handleDelete"
|
||||
>
|
||||
<SvgIcon name="CDelete" size="14"></SvgIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 组图层展开/收起图标 -->
|
||||
<div
|
||||
v-if="isGroupLayerType && !isChild"
|
||||
class="group-expand-icon"
|
||||
@click.stop="toggleGroupExpanded"
|
||||
@dblclick.stop=""
|
||||
:title="isGroupExpanded ? $t('收起组') : $t('展开组')"
|
||||
>
|
||||
<SvgIcon
|
||||
name="CRight"
|
||||
:size="12"
|
||||
:style="{
|
||||
transform: isGroupExpanded ? 'rotate(45deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图层状态指示器 -->
|
||||
<!-- <div v-if="!isChild" class="layer-status">
|
||||
<span
|
||||
v-if="isGroupLayerType"
|
||||
class="status-icon group"
|
||||
:title="$t('组图层')"
|
||||
>
|
||||
📁
|
||||
</span>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import "./layersPanel.less";
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup>
|
||||
import { computed, inject } from "vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import LayerItem from "./LayerItem.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "LayersList",
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
layers: Array,
|
||||
activeLayerId: String,
|
||||
sortableRootLayers: Array,
|
||||
selectedLayerIds: Array,
|
||||
isMultiSelectMode: Boolean,
|
||||
editingLayerId: String,
|
||||
editingLayerName: String,
|
||||
thumbnailManager: Object,
|
||||
groupName: String,
|
||||
expandedGroupIds: Set, // 新增:展开状态集合
|
||||
isChild: Boolean,
|
||||
parentLayerId: String, // 新增:父图层ID
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"layer-click",
|
||||
"layer-double-click",
|
||||
"context-menu",
|
||||
"checkbox-change",
|
||||
"toggle-visibility",
|
||||
"toggle-lock",
|
||||
"delete",
|
||||
"edit-confirm",
|
||||
"edit-cancel",
|
||||
"edit-keydown",
|
||||
"touch-start",
|
||||
"touch-move",
|
||||
"touch-end",
|
||||
"update:editing-name",
|
||||
"root-layers-sort",
|
||||
"child-layers-sort",
|
||||
"select-child-layer",
|
||||
"start-child-layer-edit",
|
||||
"child-context-menu",
|
||||
"finish-child-layer-edit",
|
||||
"cancel-child-layer-edit",
|
||||
"child-layer-edit-keydown",
|
||||
"toggle-group-expanded",
|
||||
// 新增子图层专用事件
|
||||
"toggle-child-visibility",
|
||||
"toggle-child-lock",
|
||||
"delete-child",
|
||||
"rename-child",
|
||||
]);
|
||||
|
||||
// 检查图层是否被选中
|
||||
const isLayerSelected = (layerId) => {
|
||||
return props.selectedLayerIds.includes(layerId);
|
||||
};
|
||||
|
||||
// 获取图层缩略图URL
|
||||
function getLayerThumbnail(layerId) {
|
||||
if (props.thumbnailManager) {
|
||||
return props.thumbnailManager.getLayerThumbnail(layerId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 事件转发方法
|
||||
const forwardEvent = (eventName, ...args) => {
|
||||
emit(eventName, ...args);
|
||||
};
|
||||
|
||||
// 处理根级图层拖拽排序
|
||||
const handleRootLayersSort = (event) => {
|
||||
if (props.isChild) {
|
||||
// 子图层事件处理
|
||||
// 确保排序只影响当前组图层的children,而不是全局layers
|
||||
emit(
|
||||
"child-layers-sort",
|
||||
event,
|
||||
props.sortableRootLayers,
|
||||
props.parentLayerId
|
||||
);
|
||||
} else {
|
||||
emit("root-layers-sort", event);
|
||||
}
|
||||
};
|
||||
|
||||
const canDeleteComputed = computed(() => {
|
||||
// 如果是子图层,检查父图层是否可以删除
|
||||
if (props.isChild) {
|
||||
const parentLayer = props.layers.find(
|
||||
(layer) => layer.id === props.parentLayerId
|
||||
);
|
||||
return parentLayer?.children?.length > 1;
|
||||
}
|
||||
// 否则直接返回根图层的可删除状态
|
||||
return props.layers.length > 3;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layers-list">
|
||||
<!-- 可排序的根级图层 -->
|
||||
<VueDraggable
|
||||
:model-value="sortableRootLayers"
|
||||
@end="handleRootLayersSort"
|
||||
class="sortable-layers"
|
||||
:animation="200"
|
||||
:disabled="false"
|
||||
handle=".layer-drag-handle"
|
||||
ghost-class="ghost"
|
||||
chosen-class="chosen"
|
||||
drag-class="drag"
|
||||
:group="groupName"
|
||||
>
|
||||
<!-- 遍历可排序的根级图层 -->
|
||||
<template v-for="(layer, index) in sortableRootLayers" :key="layer.id">
|
||||
<div class="layer-group">
|
||||
<!-- 使用 LayerItem 子组件 -->
|
||||
<LayerItem
|
||||
:layer="layer"
|
||||
:is-child="isChild"
|
||||
:is-active="layer.id === activeLayerId"
|
||||
:is-selected="isLayerSelected(layer.id)"
|
||||
:is-multi-select-mode="isMultiSelectMode"
|
||||
:is-editing="editingLayerId === layer.id"
|
||||
:editing-name="editingLayerName"
|
||||
:can-delete="
|
||||
canDeleteComputed &&
|
||||
!layer.isBackground &&
|
||||
!layer.isFixed &&
|
||||
!layer.locked
|
||||
"
|
||||
:thumbnail-url="getLayerThumbnail(layer.id)"
|
||||
:expanded-group-ids="expandedGroupIds"
|
||||
@click="(...args) => forwardEvent('layer-click', ...args)"
|
||||
@double-click="
|
||||
(...args) => forwardEvent('layer-double-click', ...args)
|
||||
"
|
||||
@context-menu="(...args) => forwardEvent('context-menu', ...args)"
|
||||
@checkbox-change="
|
||||
(...args) => forwardEvent('checkbox-change', ...args)
|
||||
"
|
||||
@toggle-visibility="
|
||||
(...args) => forwardEvent('toggle-visibility', ...args)
|
||||
"
|
||||
@toggle-lock="(...args) => forwardEvent('toggle-lock', ...args)"
|
||||
@delete="(...args) => forwardEvent('delete', ...args)"
|
||||
@edit-confirm="(...args) => forwardEvent('edit-confirm', ...args)"
|
||||
@edit-cancel="(...args) => forwardEvent('edit-cancel', ...args)"
|
||||
@edit-keydown="(...args) => forwardEvent('edit-keydown', ...args)"
|
||||
@touch-start="(...args) => forwardEvent('touch-start', ...args)"
|
||||
@touch-move="(...args) => forwardEvent('touch-move', ...args)"
|
||||
@touch-end="(...args) => forwardEvent('touch-end', ...args)"
|
||||
@update:editing-name="
|
||||
(...args) => forwardEvent('update:editing-name', ...args)
|
||||
"
|
||||
@toggle-group-expanded="
|
||||
(...args) => forwardEvent('toggle-group-expanded', ...args)
|
||||
"
|
||||
@toggle-child-visibility="
|
||||
(...args) => forwardEvent('toggle-child-visibility', ...args)
|
||||
"
|
||||
@toggle-child-lock="
|
||||
(...args) => forwardEvent('toggle-child-lock', ...args)
|
||||
"
|
||||
@delete-child="(...args) => forwardEvent('delete-child', ...args)"
|
||||
@rename-child="(...args) => forwardEvent('rename-child', ...args)"
|
||||
/>
|
||||
|
||||
<!-- 子图层列表 (递归渲染) -->
|
||||
<div
|
||||
v-if="
|
||||
layer?.children?.length > 0 &&
|
||||
!layer.isBackground &&
|
||||
!layer.isFixed &&
|
||||
expandedGroupIds?.has(layer.id)
|
||||
"
|
||||
class="child-layers"
|
||||
>
|
||||
<LayersList
|
||||
:layers="layers"
|
||||
:sortableRootLayers="layer.children"
|
||||
:active-layer-id="activeLayerId"
|
||||
:selected-layer-ids="selectedLayerIds"
|
||||
:is-multi-select-mode="isMultiSelectMode"
|
||||
:editing-layer-id="editingLayerId"
|
||||
:editing-layer-name="editingLayerName"
|
||||
:thumbnail-manager="thumbnailManager"
|
||||
:expanded-group-ids="expandedGroupIds"
|
||||
:isChild="true"
|
||||
:parentLayerId="layer.id"
|
||||
group-name="layers-child"
|
||||
@layer-click="(...args) => forwardEvent('layer-click', ...args)"
|
||||
@layer-double-click="
|
||||
(...args) => forwardEvent('layer-double-click', ...args)
|
||||
"
|
||||
@context-menu="(...args) => forwardEvent('context-menu', ...args)"
|
||||
@checkbox-change="
|
||||
(...args) => forwardEvent('checkbox-change', ...args)
|
||||
"
|
||||
@toggle-visibility="
|
||||
(...args) => forwardEvent('toggle-visibility', ...args)
|
||||
"
|
||||
@toggle-lock="(...args) => forwardEvent('toggle-lock', ...args)"
|
||||
@delete="(...args) => forwardEvent('delete', ...args)"
|
||||
@edit-confirm="(...args) => forwardEvent('edit-confirm', ...args)"
|
||||
@edit-cancel="(...args) => forwardEvent('edit-cancel', ...args)"
|
||||
@edit-keydown="(...args) => forwardEvent('edit-keydown', ...args)"
|
||||
@touch-start="(...args) => forwardEvent('touch-start', ...args)"
|
||||
@touch-move="(...args) => forwardEvent('touch-move', ...args)"
|
||||
@touch-end="(...args) => forwardEvent('touch-end', ...args)"
|
||||
@update:editing-name="
|
||||
(...args) => forwardEvent('update:editing-name', ...args)
|
||||
"
|
||||
@root-layers-sort="
|
||||
(...args) => forwardEvent('root-layers-sort', ...args)
|
||||
"
|
||||
@child-layers-sort="
|
||||
(...args) => forwardEvent('child-layers-sort', ...args)
|
||||
"
|
||||
@select-child-layer="
|
||||
(...args) => forwardEvent('select-child-layer', ...args)
|
||||
"
|
||||
@start-child-layer-edit="
|
||||
(...args) => forwardEvent('start-child-layer-edit', ...args)
|
||||
"
|
||||
@child-context-menu="
|
||||
(...args) => forwardEvent('child-context-menu', ...args)
|
||||
"
|
||||
@toggle-child-visibility="
|
||||
(...args) => forwardEvent('toggle-child-visibility', ...args)
|
||||
"
|
||||
@toggle-child-lock="
|
||||
(...args) => forwardEvent('toggle-child-lock', ...args)
|
||||
"
|
||||
@finish-child-layer-edit="
|
||||
(...args) => forwardEvent('finish-child-layer-edit', ...args)
|
||||
"
|
||||
@cancel-child-layer-edit="
|
||||
(...args) => forwardEvent('cancel-child-layer-edit', ...args)
|
||||
"
|
||||
@child-layer-edit-keydown="
|
||||
(...args) => forwardEvent('child-layer-edit-keydown', ...args)
|
||||
"
|
||||
@toggle-group-expanded="
|
||||
(...args) => forwardEvent('toggle-group-expanded', ...args)
|
||||
"
|
||||
@delete-child="(...args) => forwardEvent('delete-child', ...args)"
|
||||
@rename-child="(...args) => forwardEvent('rename-child', ...args)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
// 从父组件的样式文件中继承相关样式
|
||||
.layers-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.sortable-layers {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
// .layer-group {
|
||||
// // margin-bottom: 1px;
|
||||
// }
|
||||
|
||||
.child-layers {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 20px;
|
||||
background-color: #e0e0e0;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-layers {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
// 拖拽状态样式
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.chosen {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.drag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.layers-list {
|
||||
.child-layers {
|
||||
padding-left: 25px;
|
||||
&::after {
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
// 右键菜单样式
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background-color: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
z-index: 1000;
|
||||
max-width: 280px;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
// overflow: hidden;
|
||||
top: 60px; // 默认位置,可根据实际需要调整
|
||||
// 动画相关
|
||||
&.context-menu-enter-active,
|
||||
&.context-menu-leave-active {
|
||||
transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1),opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
&.context-menu-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&.context-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
white-space: nowrap;
|
||||
min-height: 32px;
|
||||
position: relative;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
cursor: not-allowed;
|
||||
|
||||
.context-menu-icon {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: #fff2f0;
|
||||
color: #ff7875;
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-children {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.hovered {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-menu-label {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.context-menu-shortcut {
|
||||
margin-left: 16px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.context-menu-arrow {
|
||||
margin-left: 8px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0;
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
// 子菜单样式
|
||||
.context-submenu {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
background-color: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
z-index: 1001;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
// overflow: hidden;
|
||||
|
||||
&.submenu-left {
|
||||
left: auto;
|
||||
right: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 子菜单动画
|
||||
.context-submenu-enter-active,
|
||||
.context-submenu-leave-active {
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.context-submenu-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-8px);
|
||||
}
|
||||
|
||||
.context-submenu-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
|
||||
// 响应式优化
|
||||
@media (max-width: 768px) {
|
||||
.context-menu {
|
||||
min-width: 140px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.context-menu-shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
min-width: 140px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色主题支持
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.context-menu {
|
||||
background-color: #1f1f1f;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.48),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.32),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&.hovered {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.context-menu-shortcut {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.context-menu-arrow {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.context-submenu {
|
||||
background-color: #1f1f1f;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.48),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.32),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
// 文本省略样式
|
||||
.text-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 主容器样式
|
||||
.layers-panel-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
z-index: 6;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-height: 85vh;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// 头部样式
|
||||
.layers-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-actions-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.normal-actions,
|
||||
.multi-select-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮样式
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #40a9ff;
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background-color: #f5f5f5;
|
||||
color: #bfbfbf;
|
||||
border-color: #e6e6e6;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #bfbfbf;
|
||||
border-color: #e6e6e6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊按钮样式
|
||||
.group-btn {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
background-color: #bae7ff;
|
||||
border-color: #69c0ff;
|
||||
}
|
||||
|
||||
&.disabled{
|
||||
background-color: #f0f5ff;
|
||||
border-color: #d9ecff;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
}
|
||||
|
||||
.ungroup-btn {
|
||||
background-color: #fff2e8;
|
||||
border-color: #ffbb96;
|
||||
color: #fa8c16;
|
||||
|
||||
&:hover {
|
||||
background-color: #ffd8bf;
|
||||
border-color: #ff9c6e;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
&.disabled{
|
||||
background-color: #f0f5ff;
|
||||
border-color: #d9ecff;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-selected-btn {
|
||||
background-color: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
color: #ff4d4f;
|
||||
|
||||
&:hover {
|
||||
background-color: #ffebe6;
|
||||
border-color: #ff7875;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-selection-btn {
|
||||
background-color: #f6f6f6;
|
||||
border-color: #d9d9d9;
|
||||
color: #595959;
|
||||
|
||||
&:hover {
|
||||
background-color: #e6e6e6;
|
||||
border-color: #bfbfbf;
|
||||
color: #595959;
|
||||
}
|
||||
}
|
||||
|
||||
// 多选信息提示
|
||||
.multi-select-info {
|
||||
padding: 10px 6px;
|
||||
// background-color: #e6f7ff;
|
||||
background-color: rgba(238, 238, 238,0.4);
|
||||
border-bottom: 1px solid #91d5ff;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
// 图层列表
|
||||
.layers-list {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
// 图层项样式
|
||||
.layer-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid #f5f2f2;
|
||||
padding-left: 30px;
|
||||
padding-right: 10px;
|
||||
|
||||
&.group-layer {
|
||||
background-color: rgba(240, 248, 255, 0.3);
|
||||
border-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #e0e0e0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: #bae7ff;
|
||||
border-color: #91d5ff;
|
||||
// box-shadow: 0 0 0 1px #1890ff;
|
||||
}
|
||||
|
||||
&.editing {
|
||||
background-color: #fff7e6;
|
||||
border-color: #ffd666;
|
||||
}
|
||||
|
||||
// &.multi-select-mode {
|
||||
// // padding-left: 30px;
|
||||
// }
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图层头部
|
||||
.layer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
// 图层预览
|
||||
.layer-review {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
background: repeating-conic-gradient(#f5f5f5 0% 25%, #ffffff 0% 50%) 50% /
|
||||
10px 10px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.layer-type-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// 可见性按钮
|
||||
.visibility-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.hidden {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 图层名称
|
||||
.layer-name-container {
|
||||
flex: 1;
|
||||
margin: 0 6px;
|
||||
overflow: hidden;
|
||||
// max-width: 204px;
|
||||
.layer-name-wrapper{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layer-name-input {
|
||||
width: 100%;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #1890ff;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: #40a9ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 图层状态
|
||||
.layer-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 12px;
|
||||
|
||||
&.locked {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.group {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// 图层操作
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
&.disabled{
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
// pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽手柄
|
||||
.layer-drag-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
cursor: move;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
margin-right: 4px;
|
||||
background: #eee;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, 0.2);
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
// 复选框
|
||||
.layer-checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 0;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// input[type="checkbox"] {
|
||||
// width: 16px;
|
||||
// height: 16px;
|
||||
// cursor: pointer;
|
||||
// accent-color: #1890ff;
|
||||
// }
|
||||
}
|
||||
|
||||
// 子图层样式
|
||||
.child-layers {
|
||||
}
|
||||
|
||||
.child-layer {
|
||||
padding: 8px 20px 8px 32px;
|
||||
background-color: rgba(240, 240, 240, 0.3);
|
||||
border-left: 2px solid #e0e0e0;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(224, 224, 224, 0.5);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: rgba(230, 247, 255, 0.5);
|
||||
border-left: 2px solid #1890ff;
|
||||
}
|
||||
|
||||
&.editing {
|
||||
background-color: rgba(255, 247, 230, 0.5);
|
||||
border-left: 2px solid #ffd666;
|
||||
}
|
||||
|
||||
.layer-actions {
|
||||
position: static;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layer-indent {
|
||||
width: 20px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.layer-info {
|
||||
flex: 1;
|
||||
margin: 0 8px;
|
||||
|
||||
.layer-name {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.layer-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layer-type {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.child-drag-handle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: move;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
margin-right: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
// 固定图层样式
|
||||
.fixed-layers {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
// background-color: #fafafa;
|
||||
|
||||
background-color: rgba(238, 238, 238,0.4);
|
||||
.layer-drag-handle{
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-layer {
|
||||
background-color: #fafafa;
|
||||
// border-left: 3px solid #1890ff;
|
||||
|
||||
// &:hover {
|
||||
// background-color: #e6f7ff;
|
||||
// }
|
||||
}
|
||||
|
||||
.fixed-layer-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #1890ff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.background-indicator,
|
||||
.fixed-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.background-icon,
|
||||
.fixed-icon {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 拖拽样式
|
||||
.sortable-layers {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
background-color: #f0f0f0;
|
||||
border: 2px dashed #1890ff;
|
||||
}
|
||||
|
||||
.chosen {
|
||||
background-color: #e6f7ff;
|
||||
border: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.drag {
|
||||
opacity: 0.8;
|
||||
transform: rotate(5deg);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// 子图层拖拽样式
|
||||
.child-layers {
|
||||
.ghost {
|
||||
opacity: 0.4;
|
||||
background-color: #fff7e6;
|
||||
border: 2px dashed #faad14;
|
||||
}
|
||||
|
||||
.chosen {
|
||||
background-color: #fff7e6;
|
||||
border: 1px solid #faad14;
|
||||
}
|
||||
|
||||
.drag {
|
||||
opacity: 0.7;
|
||||
transform: rotate(3deg);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.layers-panel-inner {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
padding: 12px;
|
||||
padding-left: 35px;
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.layer-drag-handle,
|
||||
.visibility-btn {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.layer-review {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.multi-select-info {
|
||||
// padding: 12px;
|
||||
|
||||
small {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
// .layer-name-container {
|
||||
// // max-width: 182px;
|
||||
// }
|
||||
}
|
||||
|
||||
// iPad 优化
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
.layer-item {
|
||||
padding: 10px;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.layer-drag-handle:hover,
|
||||
.visibility-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸设备优化
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.layer-item {
|
||||
padding-left: 30px;
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
border-color: #d9d9d9;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组图层展开/收起图标样式
|
||||
.group-expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 4px;
|
||||
|
||||
// &:hover {
|
||||
// background-color: rgba(0, 0, 0, 0.1);
|
||||
// }
|
||||
|
||||
// 展开/收起图标的过渡动画
|
||||
.svg-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 组图层样式
|
||||
.group-layer {
|
||||
.layer-type-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
// 子图层缩进和连接线
|
||||
.child-layers {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-indent {
|
||||
width: 16px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
// 子图层展开/收起动画样式
|
||||
.child-layers-expand-enter-active,
|
||||
.child-layers-expand-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.child-layers-expand-enter-from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.child-layers-expand-leave-to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// 展开图标旋转动画优化
|
||||
.group-expand-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
margin-right: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
// 展开/收起图标的过渡动画
|
||||
.svg-icon {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 子图层展开时的额外样式
|
||||
.child-layers {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, rgba(240, 248, 255, 0.1) 0%, rgba(240, 248, 255, 0.05) 100%);
|
||||
// border-radius: 4px;
|
||||
// margin-top: 2px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, #e0e0e0 0%, rgba(224, 224, 224, 0.3) 100%);
|
||||
}
|
||||
|
||||
// 子图层项动画
|
||||
.layer-item {
|
||||
animation: slideInRight 0.2s ease-out;
|
||||
animation-fill-mode: both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0.05s; }
|
||||
&:nth-child(2) { animation-delay: 0.1s; }
|
||||
&:nth-child(3) { animation-delay: 0.15s; }
|
||||
&:nth-child(4) { animation-delay: 0.2s; }
|
||||
&:nth-child(5) { animation-delay: 0.25s; }
|
||||
}
|
||||
}
|
||||
|
||||
// 子图层项进入动画
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端动画优化
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.child-layers-expand-enter-active,
|
||||
.child-layers-expand-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.group-expand-icon {
|
||||
&:hover {
|
||||
transform: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.child-layers .layer-item {
|
||||
animation-duration: 0.15s;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, inject, computed, onMounted, onUnmounted } from "vue";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import SvgIcon from "@/component/Canvas/SvgIcon/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
activeTool: String,
|
||||
@@ -156,7 +155,7 @@ const redGreenToolsList = ref([
|
||||
{
|
||||
id: OperationType.RED_BRUSH,
|
||||
title: "Red Brush (R)",
|
||||
action: () => selectTool(OperationType.RED_BRUSH),
|
||||
action: () => selectTool(OperationType.RED_BRUSH, true),
|
||||
icon: { name: "CBrush", size: "24" },
|
||||
class: "red-brush-btn",
|
||||
style: { color: "#FF0000" },
|
||||
@@ -164,7 +163,7 @@ const redGreenToolsList = ref([
|
||||
{
|
||||
id: OperationType.GREEN_BRUSH,
|
||||
title: "Green Brush (G)",
|
||||
action: () => selectTool(OperationType.GREEN_BRUSH),
|
||||
action: () => selectTool(OperationType.GREEN_BRUSH, true),
|
||||
icon: { name: "CBrush", size: "24" },
|
||||
class: "green-brush-btn",
|
||||
style: { color: "#00AA00" },
|
||||
@@ -172,7 +171,7 @@ const redGreenToolsList = ref([
|
||||
{
|
||||
id: OperationType.ERASER,
|
||||
title: "Eraser (E)",
|
||||
action: () => selectTool(OperationType.ERASER),
|
||||
action: () => selectTool(OperationType.ERASER, true),
|
||||
icon: { name: "CEraser", size: "22" },
|
||||
class: "eraser-btn",
|
||||
},
|
||||
@@ -197,8 +196,8 @@ const toolsList = computed(() => {
|
||||
return props.isRedGreenMode ? redGreenToolsList.value : normalToolsList.value;
|
||||
});
|
||||
|
||||
function selectTool(tool) {
|
||||
emit("tool-selected", tool);
|
||||
function selectTool(tool, isRedGreenMode = false) {
|
||||
emit("tool-selected", tool, isRedGreenMode);
|
||||
}
|
||||
|
||||
function triggerImageUpload() {
|
||||
@@ -261,15 +260,15 @@ function handleKeyDown(event) {
|
||||
|
||||
switch (key) {
|
||||
case "R":
|
||||
selectTool(OperationType.RED_BRUSH);
|
||||
selectTool(OperationType.RED_BRUSH, true);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "G":
|
||||
selectTool(OperationType.GREEN_BRUSH);
|
||||
selectTool(OperationType.GREEN_BRUSH, true);
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "E":
|
||||
selectTool(OperationType.ERASER);
|
||||
selectTool(OperationType.ERASER, true);
|
||||
event.preventDefault();
|
||||
break;
|
||||
}
|
||||
@@ -323,6 +322,8 @@ onUnmounted(() => {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
background-color: #ffffff;
|
||||
user-select: none;
|
||||
min-width: 58px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
|
||||
Reference in New Issue
Block a user