1985 lines
52 KiB
Vue
1985 lines
52 KiB
Vue
<template>
|
||
<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>{{ $t('Canvas.BrushType') }}</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> -->
|
||
<img class="brush-preview" :src="brush.imgUrl" :title="brush.name" alt="">
|
||
<span class="brush-name">{{ brush.name }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 动态渲染笔刷可配置属性 -->
|
||
<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>
|
||
</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"
|
||
@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>
|
||
<div class="uploaded-textures-section">
|
||
<div class="uploaded-textures-divider">
|
||
<span>{{ $t("Canvas.UploadedTexture") }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="texture-grid">
|
||
<!-- 上传的纹理缓存区域 -->
|
||
|
||
<!-- 自定义纹理上传按钮 -->
|
||
<div class="texture-item upload-item" @click="triggerTextureUpload">
|
||
<div class="upload-icon">
|
||
<span>+</span>
|
||
</div>
|
||
<span class="texture-label">{{ $t("Canvas.UploadTexture") }}</span>
|
||
</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)"
|
||
:title="$t('Canvas.DeleteTexture')"
|
||
>
|
||
<span>×</span>
|
||
</div>
|
||
</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="text"
|
||
:value="prop.value"
|
||
@input="(e) => handlePropertyChange(prop.id, e.target.value)"
|
||
class="property-input"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 属性描述提示 -->
|
||
<div v-if="prop.description" class="property-description">
|
||
{{ prop.description }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 }}
|
||
</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> -->
|
||
<!-- 阴影设置 -->
|
||
<div class="brush-section">
|
||
<div class="section-header">
|
||
<span>{{ $t("Canvas.ShadowSettings") }}</span>
|
||
</div>
|
||
|
||
<div class="property-list">
|
||
<!-- 阴影开关 -->
|
||
<div class="property-item">
|
||
<div class="checkbox-property">
|
||
<span>{{ $t("Canvas.EnableShadows") }}</span>
|
||
<div class="toggle-switch">
|
||
<input
|
||
type="checkbox"
|
||
:checked="brushStore.state.shadowEnabled"
|
||
@change="(e) => handleShadowPropertyChange('shadowEnabled', e.target.checked)"
|
||
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">
|
||
<span>{{ $t("Canvas.ShadowColor") }}</span>
|
||
<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)"
|
||
class="color-picker"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 阴影宽度 -->
|
||
<div class="property-item">
|
||
<div class="slider-property">
|
||
<div class="slider-header">
|
||
<span>{{ $t("Canvas.ShadowWidth") }}</span>
|
||
<span class="slider-value">{{ brushStore.state.shadowWidth }}px</span>
|
||
</div>
|
||
<div class="slider-container">
|
||
<input
|
||
type="range"
|
||
:value="brushStore.state.shadowWidth"
|
||
@input="
|
||
(e) => handleShadowPropertyChange('shadowWidth', parseFloat(e.target.value))
|
||
"
|
||
: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,
|
||
}"
|
||
>
|
||
{{ preset }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 阴影X偏移 -->
|
||
<div class="property-item">
|
||
<div class="slider-property">
|
||
<div class="slider-header">
|
||
<span>{{ $t("Canvas.ShadowOffsetX") }}</span>
|
||
<span class="slider-value">{{ brushStore.state.shadowOffsetX }}px</span>
|
||
</div>
|
||
<div class="slider-container">
|
||
<input
|
||
type="range"
|
||
:value="brushStore.state.shadowOffsetX"
|
||
@input="
|
||
(e) =>
|
||
handleShadowPropertyChange('shadowOffsetX', parseFloat(e.target.value))
|
||
"
|
||
: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)"
|
||
:class="{
|
||
active: Math.abs(brushStore.state.shadowOffsetX - preset) < 0.1,
|
||
}"
|
||
>
|
||
{{ preset }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 阴影Y偏移 -->
|
||
<div class="property-item">
|
||
<div class="slider-property">
|
||
<div class="slider-header">
|
||
<span>{{ $t("Canvas.ShadowOffsetY") }}</span>
|
||
<span class="slider-value">{{ brushStore.state.shadowOffsetY }}px</span>
|
||
</div>
|
||
<div class="slider-container">
|
||
<input
|
||
type="range"
|
||
:value="brushStore.state.shadowOffsetY"
|
||
@input="
|
||
(e) =>
|
||
handleShadowPropertyChange('shadowOffsetY', parseFloat(e.target.value))
|
||
"
|
||
: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)"
|
||
:class="{
|
||
active: Math.abs(brushStore.state.shadowOffsetY - preset) < 0.1,
|
||
}"
|
||
>
|
||
{{ preset }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 阴影预览 -->
|
||
<div class="property-item">
|
||
<div class="shadow-preview-container">
|
||
<div class="shadow-preview-title">{{ $t("Canvas.ShadowPreview") }}</div>
|
||
<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`,
|
||
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>
|
||
<!-- 笔刷预设 -->
|
||
<div class="brush-section">
|
||
<div class="section-header">
|
||
<span>{{ $t('Canvas.BrushPreset') }}</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="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>
|
||
</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";
|
||
import { useI18n } from "vue-i18n";
|
||
const { t } = useI18n();
|
||
|
||
|
||
// 从工具管理器获取可用笔刷类型
|
||
const toolManager = inject("toolManager");
|
||
|
||
// 命令管理器
|
||
const commandManager = inject("commandManager");
|
||
|
||
// 注入纹理预设管理器
|
||
const texturePresetManager = inject("texturePresetManager");
|
||
|
||
// 计算属性:按分类获取当前笔刷可配置属性
|
||
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);
|
||
});
|
||
|
||
// 从材质库选择材质
|
||
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();
|
||
}
|
||
|
||
// 处理纹理文件上传
|
||
async function handleTextureUpload(event) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
// 验证文件类型
|
||
if (!file.type.startsWith("image/")) {
|
||
alert("请选择图片文件");
|
||
return;
|
||
}
|
||
|
||
// 验证文件大小 (限制为 5MB)
|
||
const maxSize = 5 * 1024 * 1024;
|
||
if (file.size > maxSize) {
|
||
alert("文件大小不能超过 5MB");
|
||
return;
|
||
}
|
||
|
||
// 获取笔刷管理器
|
||
const brushManager = toolManager?.brushManager;
|
||
|
||
if (!texturePresetManager || !brushManager) {
|
||
console.error("缺少必要的管理器实例");
|
||
alert("系统错误:无法上传纹理");
|
||
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);
|
||
}
|
||
|
||
// 清空文件输入,允许重复上传同一文件
|
||
event.target.value = "";
|
||
} catch (error) {
|
||
console.error("纹理上传失败:", error);
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
// 格式化属性值显示
|
||
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);
|
||
|
||
// 处理阴影属性变化的防抖函数
|
||
const debouncedShadowCommand = debounce((propId, value) => {
|
||
// 通知工具管理器更新阴影
|
||
if (toolManager?.brushManager) {
|
||
toolManager.brushManager.updateShadow();
|
||
}
|
||
}, 100);
|
||
|
||
// 处理属性变化
|
||
function handlePropertyChange(propId, value) {
|
||
// 直接更新UI,通过防抖函数延迟创建命令
|
||
BrushStore.updatePropertyValue(propId, value);
|
||
debouncedPropertyCommand(propId, value);
|
||
}
|
||
|
||
// 处理阴影属性变化
|
||
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);
|
||
}
|
||
|
||
// 使用命令设置笔刷类型
|
||
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)",
|
||
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)",
|
||
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() {
|
||
// 简单实现,可以后续优化为弹窗输入名称
|
||
const name = prompt(t('Canvas.presetNamePrompt'), `${t('Canvas.preset')} ${BrushStore.state.presets.length + 1}`);
|
||
if (name) {
|
||
const presetIndex = BrushStore.saveCurrentAsPreset(name);
|
||
// 应用新创建的预设(可选)
|
||
// applyPresetWithCommand(presetIndex);
|
||
}
|
||
}
|
||
|
||
// 初始化可用笔刷类型
|
||
onMounted(() => {
|
||
if (toolManager?.brushManager) {
|
||
const availableBrushes = toolManager.brushManager
|
||
.getBrushTypes()
|
||
?.filter((brush) => brush.id !== "eraser");
|
||
console.log(availableBrushes)
|
||
BrushStore.setAvailableBrushes(availableBrushes);
|
||
}
|
||
});
|
||
|
||
// 导出笔刷存储供模板使用
|
||
const brushStore = BrushStore;
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.brush-panel {
|
||
position: absolute;
|
||
right: 20px;
|
||
top: 62px;
|
||
padding: 0;
|
||
width: 30%;
|
||
/* overflow-y: auto; */
|
||
min-width: 280px;
|
||
border-left: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
|
||
font-family: pingfang_medium, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
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: -9px;
|
||
right: 58px;
|
||
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;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.05); /* 更柔和的边框 */
|
||
}
|
||
|
||
/* .section-header::after {
|
||
content: "";
|
||
position: absolute;
|
||
right: 16px;
|
||
top: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 6px solid transparent;
|
||
border-right: 6px solid transparent;
|
||
border-top: 6px solid #999;
|
||
transform: translateY(-50%);
|
||
transition: transform 0.25s ease;
|
||
} */
|
||
|
||
/* .section-header:hover {
|
||
background-color: rgba(248, 249, 250, 1);
|
||
}
|
||
|
||
.section-header:hover::after {
|
||
border-top-color: #666;
|
||
} */
|
||
|
||
.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;
|
||
font-family: pingfang_medium, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
}
|
||
|
||
.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);
|
||
object-fit: contain;
|
||
background-color: #fff;
|
||
}
|
||
|
||
/* 保持笔刷预览内容样式一致 */
|
||
.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;
|
||
}
|
||
|
||
@media screen and (max-width: 1024px) {
|
||
.property-select {
|
||
width: 120px;
|
||
}
|
||
}
|
||
|
||
.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%;
|
||
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);
|
||
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;
|
||
}
|
||
|
||
/* 阴影设置样式 */
|
||
.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 {
|
||
background: repeating-conic-gradient(#F9FAFA 0% 25%, #ffffff 0% 50%) 50% / 10px 10px;
|
||
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));
|
||
}
|
||
}
|
||
</style>
|