Files
aida_front/src/component/Canvas/CanvasEditor/components/BrushPanel.vue
2025-11-03 16:52:43 +08:00

1985 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">
&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"
/>
<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>