Files
aida_front/src/component/Canvas/CanvasEditor/components/BrushPanel.vue

1985 lines
52 KiB
Vue
Raw Normal View History

2025-06-09 10:25:54 +08:00
<template>
2025-06-18 11:05:23 +08:00
<div class="brush-panel" @click.stop="">
<div class="brush-panel-wrapper">
<div class="brush-panel-content">
<!-- 笔刷类型选择 -->
<div class="brush-section">
<div class="section-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t('Canvas.BrushType') }}</span>
2025-06-18 11:05:23 +08:00
</div>
<div class="brush-type-grid">
2025-06-09 10:25:54 +08:00
<div
2025-06-18 11:05:23 +08:00
v-for="brush in brushStore.state.availableBrushes"
:key="brush.id"
@click="setBrushTypeWithCommand(brush.id)"
:class="['brush-type-item', { active: brushStore.state.type === brush.id }]"
2025-06-18 11:05:23 +08:00
>
2025-09-16 13:57:15 +08:00
<!-- <div class="brush-preview" :style="getBrushPreviewStyle(brush)"></div> -->
<img class="brush-preview" :src="brush.imgUrl" :title="brush.name" alt="">
2025-06-18 11:05:23 +08:00
<span class="brush-name">{{ brush.name }}</span>
</div>
2025-06-09 10:25:54 +08:00
</div>
</div>
2025-06-18 11:05:23 +08:00
<!-- 动态渲染笔刷可配置属性 -->
<template v-for="(properties, category) in propertiesByCategory" :key="category">
2025-06-18 11:05:23 +08:00
<div class="brush-section">
<div class="section-header">
<span>{{ category }}</span>
<!-- <div class="section-actions" v-if="category === '纹理'">
2025-06-18 11:05:23 +08:00
<button class="action-btn" @click="showLibrary = !showLibrary">
<i class="icon-library">📚</i> 材质库
</button>
</div> -->
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
<!-- 针对每个属性根据其类型渲染合适的控件 -->
<div class="property-list">
<div v-for="prop in properties" :key="prop.id" class="property-item">
2025-06-18 11:05:23 +08:00
<!-- 滑块控件 -->
<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))"
2025-06-18 11:05:23 +08:00
: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>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
</template>
2025-06-09 10:25:54 +08:00
2025-06-18 11:05:23 +08:00
<!-- 颜色选择器 -->
<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>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
<div class="color-row">
<input
type="color"
:value="prop.value"
@input="(e) => handlePropertyChange(prop.id, e.target.value)"
2025-06-18 11:05:23 +08:00
class="color-picker"
/>
<!-- 如果是主颜色显示最近使用的颜色 -->
<div v-if="prop.id === 'color'" class="recent-colors">
<div
v-for="(color, index) in brushStore.state.recentColors"
2025-06-18 11:05:23 +08:00
:key="index"
class="color-item"
:style="{ backgroundColor: color }"
@click="handlePropertyChange(prop.id, color)"
></div>
</div>
</div>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
</template>
2025-06-09 10:25:54 +08:00
2025-06-18 11:05:23 +08:00
<!-- 复选框 -->
<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)"
2025-06-18 11:05:23 +08:00
:id="`toggle-${prop.id}`"
/>
<label :for="`toggle-${prop.id}`"></label>
</div>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
</template>
2025-06-09 10:25:54 +08:00
2025-06-18 11:05:23 +08:00
<!-- 选择器 -->
<template v-else-if="prop.type === 'select'">
<div class="select-property">
2025-06-09 10:25:54 +08:00
<span>{{ prop.name }}</span>
2025-06-18 11:05:23 +08:00
<select
:value="prop.value"
@change="(e) => handlePropertyChange(prop.id, e.target.value)"
2025-06-18 11:05:23 +08:00
class="property-select"
2025-06-09 10:25:54 +08:00
>
2025-06-18 11:05:23 +08:00
<option
v-for="option in prop.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
</template>
2025-06-09 10:25:54 +08:00
2025-06-18 11:05:23 +08:00
<!-- 文件选择器用于材质 -->
<!-- <template v-else-if="prop.type === 'file'">
2025-06-18 11:05:23 +08:00
<div class="file-property">
<div class="file-header">
<span>{{ prop.name }}</span>
</div>
2025-06-09 10:25:54 +08:00
<div
2025-06-18 11:05:23 +08:00
class="file-preview"
@click="handleFileSelect(prop.id)"
2025-06-09 10:25:54 +08:00
>
2025-06-18 11:05:23 +08:00
<img v-if="prop.value" :src="prop.value" alt="材质预览" />
<div v-else class="no-file">点击上传材质图片</div>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
<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> -->
2025-06-18 11:05:23 +08:00
<!-- 材质网格选择器 -->
<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">
<!-- 预设纹理 -->
2025-06-18 11:05:23 +08:00
<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>
<div class="uploaded-textures-section">
<div class="uploaded-textures-divider">
2025-09-24 16:26:40 +08:00
<span>{{ $t("Canvas.UploadedTexture") }}</span>
2025-06-09 10:25:54 +08:00
</div>
</div>
<div class="texture-grid">
<!-- 上传的纹理缓存区域 -->
2025-06-18 11:05:23 +08:00
<!-- 自定义纹理上传按钮 -->
<div class="texture-item upload-item" @click="triggerTextureUpload">
2025-06-18 11:05:23 +08:00
<div class="upload-icon">
<span>+</span>
</div>
2025-09-24 16:26:40 +08:00
<span class="texture-label">{{ $t("Canvas.UploadTexture") }}</span>
2025-06-18 11:05:23 +08:00
</div>
<div
v-for="textureId in brushStore.state.uploadedTextures"
:key="textureId"
class="texture-item upload-item"
:class="{ active: prop.value === textureId }"
@click="handleTextureSelect(textureId)"
>
<img
:src="getUploadedTexturePreview(textureId)"
:alt="getUploadedTextureName(textureId)"
class="texture-thumbnail"
/>
<!-- 删除按钮 -->
<div
class="texture-remove-btn"
@click.stop="removeUploadedTexture(textureId)"
2025-09-24 16:26:40 +08:00
:title="$t('Canvas.DeleteTexture')"
>
<span>×</span>
</div>
</div>
2025-06-18 11:05:23 +08:00
<!-- 隐藏的文件输入 -->
<input
ref="textureFileInput"
type="file"
accept="image/*"
@change="handleTextureUpload"
style="display: none"
/>
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
</div>
</template>
<!-- 其他类型的属性 -->
<template v-else>
<div class="generic-property">
<span>{{ prop.name }}</span>
2025-06-09 10:25:54 +08:00
<input
2025-06-18 11:05:23 +08:00
type="text"
:value="prop.value"
@input="(e) => handlePropertyChange(prop.id, e.target.value)"
2025-06-18 11:05:23 +08:00
class="property-input"
2025-06-09 10:25:54 +08:00
/>
</div>
2025-06-18 11:05:23 +08:00
</template>
<!-- 属性描述提示 -->
<div v-if="prop.description" class="property-description">
{{ prop.description }}
2025-06-09 10:25:54 +08:00
</div>
2025-06-18 11:05:23 +08:00
</div>
</div>
</div>
</template>
<!-- 材质库弹窗 -->
<!-- <div
2025-06-18 11:05:23 +08:00
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">
&times;
</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 }}
</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"
2025-06-09 10:25:54 +08:00
/>
2025-06-18 11:05:23 +08:00
<span class="texture-name">{{ texture.name }}</span>
2025-06-09 10:25:54 +08:00
</div>
</div>
</div>
2025-06-18 11:05:23 +08:00
<div class="modal-footer">
2025-06-09 10:25:54 +08:00
<button
2025-06-18 11:05:23 +08:00
class="upload-btn"
@click="handleFileSelect('texturePath')"
2025-06-09 10:25:54 +08:00
>
2025-06-18 11:05:23 +08:00
上传新材质
2025-06-09 10:25:54 +08:00
</button>
</div>
</div>
</div> -->
2025-06-29 23:29:47 +08:00
<!-- 阴影设置 -->
<div class="brush-section">
<div class="section-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t("Canvas.ShadowSettings") }}</span>
2025-06-29 23:29:47 +08:00
</div>
<div class="property-list">
<!-- 阴影开关 -->
<div class="property-item">
<div class="checkbox-property">
2025-08-22 10:27:48 +08:00
<span>{{ $t("Canvas.EnableShadows") }}</span>
2025-06-29 23:29:47 +08:00
<div class="toggle-switch">
<input
type="checkbox"
:checked="brushStore.state.shadowEnabled"
@change="(e) => handleShadowPropertyChange('shadowEnabled', e.target.checked)"
2025-06-29 23:29:47 +08:00
id="shadow-enabled"
/>
<label for="shadow-enabled"></label>
</div>
</div>
</div>
<!-- 阴影控制组 - 仅在启用阴影时显示 -->
<template v-if="brushStore.state.shadowEnabled">
<!-- 阴影颜色 -->
<div class="property-item">
<div class="color-property">
<div class="color-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t("Canvas.ShadowColor") }}</span>
2025-06-29 23:29:47 +08:00
<div
class="color-preview"
:style="{ backgroundColor: brushStore.state.shadowColor }"
></div>
</div>
<div class="color-row">
<input
type="color"
:value="brushStore.state.shadowColor"
@input="(e) => handleShadowPropertyChange('shadowColor', e.target.value)"
2025-06-29 23:29:47 +08:00
class="color-picker"
/>
</div>
</div>
</div>
<!-- 阴影宽度 -->
<div class="property-item">
<div class="slider-property">
<div class="slider-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t("Canvas.ShadowWidth") }}</span>
<span class="slider-value">{{ brushStore.state.shadowWidth }}px</span>
2025-06-29 23:29:47 +08:00
</div>
<div class="slider-container">
<input
type="range"
:value="brushStore.state.shadowWidth"
@input="
(e) => handleShadowPropertyChange('shadowWidth', parseFloat(e.target.value))
2025-06-29 23:29:47 +08:00
"
:min="0"
:max="50"
:step="1"
class="property-slider"
/>
</div>
<div class="property-presets">
<button
v-for="preset in [0, 5, 10, 15, 20]"
:key="preset"
@click="handleShadowPropertyChange('shadowWidth', preset)"
:class="{
active: Math.abs(brushStore.state.shadowWidth - preset) < 0.1,
2025-06-29 23:29:47 +08:00
}"
>
{{ preset }}
</button>
</div>
</div>
</div>
<!-- 阴影X偏移 -->
<div class="property-item">
<div class="slider-property">
<div class="slider-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t("Canvas.ShadowOffsetX") }}</span>
<span class="slider-value">{{ brushStore.state.shadowOffsetX }}px</span>
2025-06-29 23:29:47 +08:00
</div>
<div class="slider-container">
<input
type="range"
:value="brushStore.state.shadowOffsetX"
@input="
(e) =>
handleShadowPropertyChange('shadowOffsetX', parseFloat(e.target.value))
2025-06-29 23:29:47 +08:00
"
:min="-50"
:max="50"
:step="1"
class="property-slider"
/>
</div>
<div class="property-presets">
<button
v-for="preset in [-10, -5, 0, 5, 10]"
:key="preset"
@click="handleShadowPropertyChange('shadowOffsetX', preset)"
2025-06-29 23:29:47 +08:00
:class="{
active: Math.abs(brushStore.state.shadowOffsetX - preset) < 0.1,
2025-06-29 23:29:47 +08:00
}"
>
{{ preset }}
</button>
</div>
</div>
</div>
<!-- 阴影Y偏移 -->
<div class="property-item">
<div class="slider-property">
<div class="slider-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t("Canvas.ShadowOffsetY") }}</span>
<span class="slider-value">{{ brushStore.state.shadowOffsetY }}px</span>
2025-06-29 23:29:47 +08:00
</div>
<div class="slider-container">
<input
type="range"
:value="brushStore.state.shadowOffsetY"
@input="
(e) =>
handleShadowPropertyChange('shadowOffsetY', parseFloat(e.target.value))
2025-06-29 23:29:47 +08:00
"
:min="-50"
:max="50"
:step="1"
class="property-slider"
/>
</div>
<div class="property-presets">
<button
v-for="preset in [-10, -5, 0, 5, 10]"
:key="preset"
@click="handleShadowPropertyChange('shadowOffsetY', preset)"
2025-06-29 23:29:47 +08:00
:class="{
active: Math.abs(brushStore.state.shadowOffsetY - preset) < 0.1,
2025-06-29 23:29:47 +08:00
}"
>
{{ preset }}
</button>
</div>
</div>
</div>
2025-06-09 10:25:54 +08:00
2025-06-29 23:29:47 +08:00
<!-- 阴影预览 -->
<div class="property-item">
<div class="shadow-preview-container">
2025-08-22 10:27:48 +08:00
<div class="shadow-preview-title">{{ $t("Canvas.ShadowPreview") }}</div>
2025-06-29 23:29:47 +08:00
<div class="shadow-preview-box">
<div
class="shadow-preview-element"
:style="{
backgroundColor: brushStore.state.color,
width: `${Math.max(20, Math.min(60, brushStore.state.size))}px`,
height: `${Math.max(20, Math.min(60, brushStore.state.size))}px`,
2025-06-29 23:29:47 +08:00
boxShadow: brushStore.state.shadowEnabled
? `${brushStore.state.shadowOffsetX}px ${brushStore.state.shadowOffsetY}px ${brushStore.state.shadowWidth}px ${brushStore.state.shadowColor}`
: 'none',
}"
></div>
</div>
</div>
</div>
</template>
</div>
</div>
2025-06-18 11:05:23 +08:00
<!-- 笔刷预设 -->
<div class="brush-section">
<div class="section-header">
2025-08-22 10:27:48 +08:00
<span>{{ $t('Canvas.BrushPreset') }}</span>
<button class="save-preset-btn" @click="saveCurrentAsPreset" title="保存当前设置为预设">
2025-06-18 11:05:23 +08:00
<i class="save-icon">+</i>
</button>
</div>
2025-06-09 10:25:54 +08:00
2025-06-18 11:05:23 +08:00
<div class="presets-container">
2025-06-09 10:25:54 +08:00
<div
2025-06-18 11:05:23 +08:00
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>
2025-06-09 10:25:54 +08:00
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, inject, onUnmounted, computed } from "vue";
import { BrushStore } from "../store/BrushStore";
import {
BrushSizeCommand,
BrushColorCommand,
BrushOpacityCommand,
BrushTypeCommand,
TextureCommand,
BrushPresetCommand,
BrushPropertyCommand,
TextureUploadCommand,
} from "../commands/BrushCommands";
import { debounce } from "lodash-es";
2025-09-24 16:26:40 +08:00
import { useI18n } from "vue-i18n";
const { t } = useI18n();
2025-06-09 10:25:54 +08:00
// 从工具管理器获取可用笔刷类型
const toolManager = inject("toolManager");
// 命令管理器
const commandManager = inject("commandManager");
// 注入纹理预设管理器
const texturePresetManager = inject("texturePresetManager");
2025-06-09 10:25:54 +08:00
// 计算属性:按分类获取当前笔刷可配置属性
const propertiesByCategory = computed(() => {
return BrushStore.getPropertiesByCategory();
});
// 材质库状态
const showLibrary = ref(false);
const selectedCategory = ref("全部");
const textureFileInput = ref(null);
const textureCategories = ["全部", "纹理", "笔触", "图案", "特效"];
// 材质库中的材质实际项目中可以从API获取
const textureLibrary = [
{
name: "水彩1",
path: "/textures/watercolor1.png",
thumbnail: "/textures/watercolor1_thumb.png",
category: "笔触",
},
{
name: "水彩2",
path: "/textures/watercolor2.png",
thumbnail: "/textures/watercolor2_thumb.png",
category: "笔触",
},
{
name: "粉笔",
path: "/textures/chalk.png",
thumbnail: "/textures/chalk_thumb.png",
category: "笔触",
},
{
name: "木纹",
path: "/textures/wood.png",
thumbnail: "/textures/wood_thumb.png",
category: "纹理",
},
{
name: "皮革",
path: "/textures/leather.png",
thumbnail: "/textures/leather_thumb.png",
category: "纹理",
},
{
name: "点阵",
path: "/textures/dots.png",
thumbnail: "/textures/dots_thumb.png",
category: "图案",
},
{
name: "线条",
path: "/textures/lines.png",
thumbnail: "/textures/lines_thumb.png",
category: "图案",
},
{
name: "星星",
path: "/textures/stars.png",
thumbnail: "/textures/stars_thumb.png",
category: "特效",
},
// 可以添加更多材质...
];
// 根据选择的类别过滤材质
const filteredTextures = computed(() => {
if (selectedCategory.value === "全部") {
return textureLibrary;
}
return textureLibrary.filter((texture) => texture.category === selectedCategory.value);
2025-06-09 10:25:54 +08:00
});
// 从材质库选择材质
function selectLibraryTexture(path) {
handlePropertyChange("texturePath", path);
showLibrary.value = false;
}
// 处理材质选择
function handleTextureSelect(textureId) {
handlePropertyChange("textureSelector", textureId);
}
// 触发纹理上传文件选择
function triggerTextureUpload() {
if (textureFileInput.value.length) {
textureFileInput.value[0].click();
return;
}
textureFileInput.value.click();
2025-06-09 10:25:54 +08:00
}
// 处理纹理文件上传
async function handleTextureUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
try {
// 验证文件类型
2025-06-18 11:05:23 +08:00
if (!file.type.startsWith("image/")) {
alert("请选择图片文件");
2025-06-09 10:25:54 +08:00
return;
}
// 验证文件大小 (限制为 5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
2025-06-18 11:05:23 +08:00
alert("文件大小不能超过 5MB");
2025-06-09 10:25:54 +08:00
return;
}
// 获取笔刷管理器
2025-06-09 10:25:54 +08:00
const brushManager = toolManager?.brushManager;
if (!texturePresetManager || !brushManager) {
2025-06-18 11:05:23 +08:00
console.error("缺少必要的管理器实例");
alert("系统错误:无法上传纹理");
2025-06-09 10:25:54 +08:00
return;
}
// 创建并执行纹理上传命令
const command = new TextureUploadCommand({
file: file,
name: file.name.replace(/\.[^/.]+$/, ""), // 移除文件扩展名
category: "自定义材质",
texturePresetManager: texturePresetManager,
brushManager: brushManager,
});
const result = await commandManager.execute(command);
// 上传成功后将纹理ID添加到缓存列表
if (result && result.textureId) {
brushStore.cacheUploadedTexture(result.textureId);
// 自动选择新上传的纹理
handleTextureSelect(result.textureId);
}
2025-06-18 11:05:23 +08:00
2025-06-09 10:25:54 +08:00
// 清空文件输入,允许重复上传同一文件
2025-06-18 11:05:23 +08:00
event.target.value = "";
2025-06-09 10:25:54 +08:00
} catch (error) {
2025-06-18 11:05:23 +08:00
console.error("纹理上传失败:", error);
2025-06-09 10:25:54 +08:00
alert(`上传失败: ${error.message}`);
}
}
// 获取上传纹理的预览图
function getUploadedTexturePreview(textureId) {
const texture = texturePresetManager?.getTextureById?.(textureId);
return texture?.preview || texture?.path || "/placeholder-texture.png";
}
// 获取上传纹理的名称
function getUploadedTextureName(textureId) {
const texture = texturePresetManager?.getTextureById?.(textureId);
return texture?.name || "未知纹理";
}
// 删除上传的纹理
function removeUploadedTexture(textureId) {
try {
// 从缓存中移除
brushStore.removeCachedTexture(textureId);
// 从纹理预设管理器中删除
if (texturePresetManager?.removeCustomTexture) {
texturePresetManager.removeCustomTexture(textureId);
}
// 如果当前选中的是被删除的纹理,清空选择
const currentProperty = propertiesByCategory.value?.["纹理"]?.find(
(p) => p.id === "textureSelector"
);
if (currentProperty?.value === textureId) {
handlePropertyChange("textureSelector", null);
}
} catch (error) {
console.error("删除纹理失败:", error);
alert(`删除失败: ${error.message}`);
}
}
2025-06-09 10:25:54 +08:00
// 格式化属性值显示
function formatPropertyValue(prop) {
if (prop.type === "slider") {
// 如果是透明度,显示为百分比
if (prop.id === "opacity" || prop.id === "textureOpacity") {
return `${Math.round(prop.value * 100)}%`;
}
// 如果有单位,添加单位
if (prop.unit) {
return `${prop.value}${prop.unit}`;
}
// 根据step确定小数位数
const decimalPlaces = prop.step.toString().includes(".")
? prop.step.toString().split(".")[1].length
: 0;
return prop.value.toFixed(decimalPlaces);
}
return prop.value;
}
// 防抖处理函数,优化性能
const debouncedPropertyCommand = debounce((propId, value) => {
// 创建并执行属性更新命令
const command = new BrushPropertyCommand({
propertyId: propId,
value: value,
});
commandManager.execute(command, { nonUndoable: true });
}, 200);
2025-06-29 23:29:47 +08:00
// 处理阴影属性变化的防抖函数
const debouncedShadowCommand = debounce((propId, value) => {
// 通知工具管理器更新阴影
if (toolManager?.brushManager) {
toolManager.brushManager.updateShadow();
}
}, 100);
2025-06-09 10:25:54 +08:00
// 处理属性变化
function handlePropertyChange(propId, value) {
// 直接更新UI通过防抖函数延迟创建命令
BrushStore.updatePropertyValue(propId, value);
debouncedPropertyCommand(propId, value);
}
2025-06-29 23:29:47 +08:00
// 处理阴影属性变化
function handleShadowPropertyChange(propId, value) {
// 更新BrushStore中的阴影属性
if (propId === "shadowEnabled") {
BrushStore.setShadowEnabled(value);
} else if (propId === "shadowColor") {
BrushStore.setShadowColor(value);
} else if (propId === "shadowWidth") {
BrushStore.setShadowWidth(value);
} else if (propId === "shadowOffsetX") {
BrushStore.setShadowOffsetX(value);
} else if (propId === "shadowOffsetY") {
BrushStore.setShadowOffsetY(value);
}
// 通知笔刷管理器更新阴影设置
debouncedShadowCommand(propId, value);
}
2025-06-09 10:25:54 +08:00
// 使用命令设置笔刷类型
function setBrushTypeWithCommand(type) {
const command = new BrushTypeCommand({
brushType: type,
brushManager: toolManager?.brushManager,
});
commandManager.execute(command, { nonUndoable: true }); // 不需要撤销
}
// 获取笔刷预览样式
function getBrushPreviewStyle(brush) {
const baseStyle = {
backgroundColor: brush.id === "eraser" ? "#ffffff" : brushStore.state.color,
};
// 根据不同笔刷类型设置不同样式
switch (brush.id) {
case "pencil":
return {
...baseStyle,
// height: "2px",
borderRadius: "1px",
};
case "marker":
return {
...baseStyle,
// height: "4px",
opacity: 0.7,
borderRadius: "2px",
};
case "spray":
return {
...baseStyle,
backgroundImage: "radial-gradient(circle, currentColor 1px, transparent 1px)",
2025-06-09 10:25:54 +08:00
backgroundSize: "4px 4px",
backgroundPosition: "center",
opacity: 0.8,
};
case "eraser":
return {
...baseStyle,
border: "1px dashed #999",
backgroundColor: "transparent",
};
case "neon":
return {
...baseStyle,
// height: "3px",
borderRadius: "1px",
boxShadow: `0 0 5px 2px ${baseStyle.backgroundColor}`,
};
case "rainbow":
return {
...baseStyle,
background: "linear-gradient(90deg, red, orange, yellow, green, blue, indigo, violet)",
2025-06-09 10:25:54 +08:00
height: "3px",
};
case "texture":
return {
...baseStyle,
backgroundImage:
"url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4IiBoZWlnaHQ9IjgiPjxyZWN0IHdpZHRoPSI4IiBoZWlnaHQ9IjgiIGZpbGw9IiNmZmYiLz48cGF0aCBkPSJNMCAwTDggOFpNOCAwTDAgOFoiIHN0cm9rZS13aWR0aD0iMSIgc3Ryb2tlPSIjMDAwIj48L3BhdGg+PC9zdmc+')",
backgroundSize: "8px 8px",
// height: "12px",
};
default:
return baseStyle;
}
}
// 应用预设
function applyPresetWithCommand(presetIndex) {
const command = new BrushPresetCommand({ preset: presetIndex });
commandManager.execute(command, { nonUndoable: true });
}
// 保存当前设置为预设
function saveCurrentAsPreset() {
// 简单实现,可以后续优化为弹窗输入名称
2025-09-24 16:26:40 +08:00
const name = prompt(t('Canvas.presetNamePrompt'), `${t('Canvas.preset')} ${BrushStore.state.presets.length + 1}`);
2025-06-09 10:25:54 +08:00
if (name) {
const presetIndex = BrushStore.saveCurrentAsPreset(name);
// 应用新创建的预设(可选)
// applyPresetWithCommand(presetIndex);
}
}
// 初始化可用笔刷类型
onMounted(() => {
if (toolManager?.brushManager) {
const availableBrushes = toolManager.brushManager
.getBrushTypes()
?.filter((brush) => brush.id !== "eraser");
2025-09-16 13:57:15 +08:00
console.log(availableBrushes)
2025-06-09 10:25:54 +08:00
BrushStore.setAvailableBrushes(availableBrushes);
}
});
// 导出笔刷存储供模板使用
const brushStore = BrushStore;
</script>
2025-06-18 11:05:23 +08:00
<style scoped lang="less">
2025-06-09 10:25:54 +08:00
.brush-panel {
2025-06-18 11:05:23 +08:00
position: absolute;
right: 20px;
top: 62px;
2025-06-09 10:25:54 +08:00
padding: 0;
2025-06-18 11:05:23 +08:00
width: 30%;
/* overflow-y: auto; */
2025-06-09 10:25:54 +08:00
min-width: 280px;
border-left: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
2025-08-22 10:27:48 +08:00
font-family: pingfang_medium, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2025-06-09 10:25:54 +08:00
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);
2025-06-18 11:05:23 +08:00
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;
}
}
2025-06-09 10:25:54 +08:00
}
/* 添加指向整个面板的倒三角 */
.brush-panel::before {
content: "";
position: absolute;
2025-06-18 11:05:23 +08:00
top: -9px;
right: 58px;
2025-06-09 10:25:54 +08:00
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid rgba(255, 255, 255, 0.95); /* 与面板背景色一致 */
filter: drop-shadow(0 -1px 1px rgba(0, 0, 0, 0.05));
z-index: 1;
}
@keyframes panelFadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.brush-section {
padding: 0;
background-color: transparent;
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
box-shadow: none;
transition: all 0.25s ease;
margin-bottom: 1px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: rgba(255, 255, 255, 0.8); /* 略微透明的白色背景 */
padding: 10px 16px;
font-weight: 600;
font-size: 15px;
color: #333; /* 更深的文字颜色 */
cursor: pointer;
user-select: none;
position: relative;
transition: background-color 0.2s ease;
2025-06-18 11:05:23 +08:00
border-bottom: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
2025-06-09 10:25:54 +08:00
}
2025-06-18 11:05:23 +08:00
/* .section-header::after {
2025-06-09 10:25:54 +08:00
content: "";
position: absolute;
right: 16px;
top: 50%;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
2025-06-18 11:05:23 +08:00
border-top: 6px solid #999;
2025-06-09 10:25:54 +08:00
transform: translateY(-50%);
transition: transform 0.25s ease;
2025-06-18 11:05:23 +08:00
} */
2025-06-09 10:25:54 +08:00
2025-06-18 11:05:23 +08:00
/* .section-header:hover {
2025-06-09 10:25:54 +08:00
background-color: rgba(248, 249, 250, 1);
}
.section-header:hover::after {
border-top-color: #666;
2025-06-18 11:05:23 +08:00
} */
2025-06-09 10:25:54 +08:00
.section-actions {
display: flex;
gap: 8px;
margin-right: 28px; /* 为三角形留出空间 */
}
.action-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
background-color: rgba(66, 133, 244, 0.1); /* 淡蓝色背景 */
border: 1px solid rgba(66, 133, 244, 0.2);
border-radius: 4px;
color: #4285f4; /* 蓝色文字 */
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background-color: rgba(66, 133, 244, 0.15);
transform: translateY(-1px);
}
.property-list {
display: flex;
flex-direction: column;
padding: 14px 16px;
gap: 16px;
background-color: #fff; /* 纯白色背景 */
}
.property-item {
margin-bottom: 5px;
}
/* 滑块控件样式 */
.slider-property {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.slider-header span:first-child {
font-weight: 500;
color: #333; /* 更深的文字颜色 */
font-size: 14px;
}
.slider-value {
font-size: 14px;
color: #666; /* 更深的文字颜色 */
min-width: 45px;
text-align: right;
background-color: rgba(0, 0, 0, 0.05);
padding: 3px 7px;
border-radius: 4px;
2025-08-22 10:27:48 +08:00
font-family: pingfang_medium, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2025-06-09 10:25:54 +08:00
}
.slider-container {
width: 100%;
padding: 0;
}
.property-slider {
width: 100%;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
outline: none;
}
.property-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #4285f4; /* 蓝色滑块 */
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.property-slider::-webkit-slider-thumb:hover {
background: #3b77db;
transform: scale(1.1);
}
.property-presets {
display: flex;
justify-content: flex-start;
margin-top: 10px;
flex-wrap: wrap;
gap: 7px;
}
.property-presets button {
min-width: 32px;
height: 22px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: #fff;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
padding: 0 7px;
transition: all 0.2s;
}
.property-presets button.active {
background-color: rgba(66, 133, 244, 0.1);
border-color: rgba(66, 133, 244, 0.2);
color: #4285f4;
font-weight: 500;
}
.property-presets button:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.03);
transform: translateY(-1px);
}
/* 笔刷类型网格 */
.brush-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
padding: 14px 16px;
gap: 10px;
background-color: #fff; /* 纯白色背景 */
}
.brush-type-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s;
background-color: #fff;
justify-content: center;
}
.brush-type-item:hover {
background-color: rgba(0, 0, 0, 0.02);
border-color: rgba(66, 133, 244, 0.2);
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
}
.brush-type-item.active {
background-color: rgba(66, 133, 244, 0.1);
border-color: rgba(66, 133, 244, 0.2);
}
/* 统一笔刷预览大小 */
.brush-preview {
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.02);
2025-09-16 13:57:15 +08:00
object-fit: contain;
background-color: #fff;
2025-06-09 10:25:54 +08:00
}
/* 保持笔刷预览内容样式一致 */
.brush-preview::before {
content: "";
width: 75%;
height: 3px;
background-color: currentColor;
}
.brush-name {
font-size: 14px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
color: #333; /* 更深的文字颜色 */
}
/* 颜色选择器样式 */
.color-property .color-header,
.file-property .file-header,
.texture-grid-property .texture-grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.color-property .color-header span,
.file-property .file-header span,
.texture-grid-property .texture-grid-header span {
font-weight: 500;
color: #333; /* 更深的文字颜色 */
font-size: 14px;
}
.color-row {
display: flex;
align-items: center;
gap: 12px;
}
.color-preview {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
}
.color-picker {
width: 40px;
height: 40px;
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 0;
background: none;
cursor: pointer;
border-radius: 4px;
overflow: hidden;
}
.recent-colors {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.color-item {
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s;
}
.color-item:hover {
transform: scale(1.08);
border-color: rgba(66, 133, 244, 0.2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
/* 复选框样式 */
.checkbox-property,
.select-property,
.generic-property {
display: flex;
justify-content: space-between;
align-items: center;
height: 32px;
}
.checkbox-property span,
.select-property span,
.generic-property span {
font-weight: 500;
color: #333; /* 更深的文字颜色 */
font-size: 14px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 22px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-switch label {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
border-radius: 22px;
transition: 0.25s;
}
.toggle-switch label:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: 0.25s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.toggle-switch input:checked + label {
background-color: #4285f4; /* 蓝色 */
}
.toggle-switch input:checked + label:before {
transform: translateX(22px);
}
/* 选择框样式 */
.property-select {
width: 150px;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.05);
background-color: #fff;
color: #333; /* 更深的文字颜色 */
font-size: 14px;
cursor: pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath fill='%23666' d='M0 0h8L4 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
-webkit-appearance: none;
appearance: none;
padding-right: 30px;
height: 34px;
}
2025-06-18 11:05:23 +08:00
@media screen and (max-width: 1024px) {
.property-select {
width: 120px;
}
}
2025-06-09 10:25:54 +08:00
.property-select:focus {
border-color: #4285f4;
outline: none;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.1);
}
/* 文件选择器样式 */
.file-property {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-preview {
width: 100%;
height: 110px;
border: 1px dashed rgba(0, 0, 0, 0.05);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.02);
cursor: pointer;
transition: all 0.2s;
}
.file-preview:hover {
border-color: rgba(66, 133, 244, 0.2);
background-color: rgba(66, 133, 244, 0.05);
transform: translateY(-1px);
}
.file-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.no-file {
font-size: 14px;
color: #666; /* 更深的文字颜色 */
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.no-file:before {
content: "📁";
font-size: 22px;
}
.file-actions {
display: flex;
justify-content: space-between;
gap: 10px;
}
.select-file-btn,
.clear-file-btn {
padding: 7px 14px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
}
.select-file-btn {
background-color: rgba(66, 133, 244, 0.1);
border: 1px solid rgba(66, 133, 244, 0.2);
color: #4285f4;
}
.select-file-btn:hover {
background-color: rgba(66, 133, 244, 0.15);
transform: translateY(-1px);
}
.clear-file-btn {
background-color: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.2);
color: #f44336;
}
.clear-file-btn:hover {
background-color: rgba(244, 67, 54, 0.15);
transform: translateY(-1px);
}
.property-description {
font-size: 13px;
color: #666; /* 更深的文字颜色 */
margin-top: 5px;
line-height: 1.4;
font-style: italic;
}
/* 文本输入框样式 */
.property-input {
width: 150px;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.05);
font-size: 14px;
color: #333; /* 更深的文字颜色 */
height: 34px;
}
/* 预设容器样式 */
.presets-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
padding: 14px 16px;
gap: 10px;
background-color: #fff; /* 纯白色背景 */
}
.preset-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 6px;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
cursor: pointer;
background-color: white;
transition: all 0.2s;
height: 90px;
justify-content: center;
}
.preset-item:hover {
border-color: rgba(66, 133, 244, 0.2);
background-color: rgba(0, 0, 0, 0.02);
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
}
.preset-color {
border-radius: 50%;
margin-bottom: 10px;
min-width: 18px;
min-height: 18px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
.preset-name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
color: #333; /* 更深的文字颜色 */
}
.save-preset-btn {
width: 24px;
height: 24px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(66, 133, 244, 0.2);
border-radius: 4px;
background-color: rgba(66, 133, 244, 0.1);
color: #4285f4;
cursor: pointer;
padding: 0;
line-height: 0;
transition: all 0.2s;
}
.save-preset-btn:hover {
background-color: rgba(66, 133, 244, 0.15);
transform: translateY(-1px);
}
.save-icon {
font-style: normal;
}
/* 材质网格样式 */
.texture-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
gap: 10px;
margin-top: 10px;
}
.texture-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
height: 90px;
justify-content: center;
}
.texture-item:hover {
border-color: rgba(66, 133, 244, 0.2);
transform: translateY(-2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
}
.texture-item.active {
background-color: rgba(66, 133, 244, 0.1);
border-color: rgba(66, 133, 244, 0.2);
}
.texture-thumbnail {
width: 100%;
height: 100%;
2025-06-09 10:25:54 +08:00
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
.texture-label {
font-size: 14px;
color: #333; /* 更深的文字颜色 */
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
/* 上传纹理按钮样式 */
.texture-item.upload-item {
border: 2px dashed rgba(66, 133, 244, 0.3);
background-color: rgba(66, 133, 244, 0.05);
}
.texture-item.upload-item:hover {
border-color: rgba(66, 133, 244, 0.5);
background-color: rgba(66, 133, 244, 0.1);
}
.upload-icon {
width: 100%;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
border-radius: 4px;
background-color: rgba(66, 133, 244, 0.1);
}
.upload-icon span {
font-size: 24px;
color: #4285f4;
font-weight: bold;
}
/* 材质库样式 */
.texture-library-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.5); /* 白色半透明背景 */
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.texture-library-modal {
background-color: #fff; /* 白色背景 */
border-radius: 12px;
width: 80%;
max-width: 700px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.05);
2025-06-09 10:25:54 +08:00
animation: modalSlideUp 0.3s ease;
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 14px 18px;
background-color: rgba(255, 255, 255, 0.8); /* 略微透明的白色背景 */
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 17px;
color: #333; /* 更深的文字颜色 */
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: #666; /* 更深的文字颜色 */
padding: 0;
line-height: 1;
opacity: 0.7;
transition: all 0.2s;
}
.close-btn:hover {
opacity: 1;
color: #333; /* 更深的文字颜色 */
transform: scale(1.1);
}
.modal-content {
padding: 18px;
overflow-y: auto;
max-height: calc(80vh - 110px);
display: flex;
flex-direction: column;
gap: 18px;
background-color: #fff; /* 纯白色背景 */
}
.texture-categories {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 10px;
flex-wrap: wrap;
scrollbar-width: thin;
}
.texture-categories::-webkit-scrollbar {
height: 4px;
}
.texture-categories::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
border-radius: 4px;
}
.category-btn {
padding: 6px 12px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.05);
color: #333; /* 更深的文字颜色 */
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: all 0.2s;
height: 34px;
display: flex;
align-items: center;
}
.category-btn.active {
background-color: rgba(66, 133, 244, 0.1);
border-color: rgba(66, 133, 244, 0.2);
color: #4285f4;
}
.category-btn:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
transform: translateY(-1px);
}
.texture-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 12px;
}
.library-texture-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
background-color: #fff;
height: 110px;
justify-content: center;
}
.library-texture-item:hover {
border-color: rgba(66, 133, 244, 0.2);
background-color: rgba(0, 0, 0, 0.02);
transform: translateY(-2px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.library-texture-item img {
width: 100%;
height: 65px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 8px;
}
.texture-name {
font-size: 14px;
color: #333; /* 更深的文字颜色 */
text-align: center;
}
.modal-footer {
padding: 14px 18px;
background-color: rgba(255, 255, 255, 0.8); /* 略微透明的白色背景 */
border-top: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: flex-end;
}
.upload-btn {
padding: 7px 16px;
background-color: #4285f4; /* 蓝色 */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 15px;
transition: all 0.2s;
height: 36px;
display: flex;
align-items: center;
}
.upload-btn:hover {
background-color: #3b77db;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 上传纹理缓存区域样式 */
.uploaded-textures-section {
width: 100%;
margin-top: 15px;
}
.uploaded-textures-divider {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 0 4px;
}
.uploaded-textures-divider::before,
.uploaded-textures-divider::after {
content: "";
flex: 1;
height: 1px;
background: linear-gradient(to right, transparent, rgba(0, 0, 0, 0.08), transparent);
}
.uploaded-textures-divider span {
padding: 0 12px;
font-size: 13px;
color: #666;
background-color: #fff;
white-space: nowrap;
font-weight: 500;
}
.uploaded-textures-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
gap: 10px;
margin-bottom: 15px;
}
.uploaded-texture-item {
position: relative;
border: 1px solid rgba(108, 117, 125, 0.2);
background-color: rgba(248, 249, 250, 0.8);
}
.uploaded-texture-item:hover {
border-color: rgba(66, 133, 244, 0.3);
background-color: rgba(66, 133, 244, 0.05);
}
.uploaded-texture-item.active {
background-color: rgba(66, 133, 244, 0.15);
border-color: rgba(66, 133, 244, 0.4);
}
.texture-remove-btn {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
background-color: rgba(244, 67, 54, 0.9);
border: 1px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
z-index: 10;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.texture-remove-btn span {
color: #fff;
font-size: 12px;
font-weight: bold;
line-height: 1;
}
.uploaded-texture-item:hover .texture-remove-btn {
opacity: 1;
transform: scale(1.1);
}
.texture-remove-btn:hover {
background-color: rgba(244, 67, 54, 1);
transform: scale(1.2) !important;
2025-06-09 10:25:54 +08:00
}
2025-06-29 23:29:47 +08:00
/* 阴影设置样式 */
.shadow-preview-container {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.shadow-preview-title {
font-weight: 500;
color: #333;
font-size: 14px;
text-align: center;
}
.shadow-preview-box {
2025-11-03 16:52:43 +08:00
background: repeating-conic-gradient(#F9FAFA 0% 25%, #ffffff 0% 50%) 50% / 10px 10px;
2025-06-29 23:29:47 +08:00
border-radius: 8px;
padding: 30px;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
border: 1px solid rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.shadow-preview-element {
border-radius: 50%;
transition: all 0.3s ease;
position: relative;
z-index: 1;
background-clip: padding-box;
}
/* 响应式设计 */
@media (max-width: 768px) {
.brush-panel {
width: 95%;
right: 2.5%;
max-height: 75vh;
}
.shadow-preview-box {
min-height: 100px;
padding: 20px;
}
.property-presets {
justify-content: center;
}
.brush-type-grid,
.presets-container,
.texture-grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
}
}
2025-06-09 10:25:54 +08:00
</style>